feat(java): brought the plugin to a broadly usable state

This commit is contained in:
zaaarf 2024-09-16 03:01:14 +02:00
parent 44d5c77383
commit bbc2eb447a
No known key found for this signature in database
GPG key ID: C91CFF9E2262BBA1
20 changed files with 632 additions and 465 deletions

5
.gitignore vendored
View file

@ -47,8 +47,11 @@ Cargo.lock
.cargo
target
#IntelliJ test client run config
# IntelliJ test client run config
.run/
# Stuff from IntellIJ plugin
.intellijPlatform
# Do not include generated code
src/main/java/com/codemp/intellij/jni

View file

@ -1,6 +1,7 @@
[![codemp](https://code.mp/static/banner.png)](https://code.mp)
> `codemp` is a **collaborative** text editing solution to work remotely.
> [`codemp`](https://github.com/hexedtech/codemp) is a **collaborative** text editing solution to work
> remotely.
It seamlessly integrates in your editor providing remote cursors and instant text synchronization,
as well as a remote virtual workspace for you and your team.

View file

@ -60,4 +60,7 @@ tasks {
}
}
instrumentedJar.dependsOn shadowJar //TODO: instrumentedJar should use fatjar as input
// useful for debugging
runIde.doFirst { environment 'RUST_BACKTRACE', 'full' }
build.dependsOn shadowJar

View file

@ -1,22 +1,33 @@
package mp.code.intellij;
import com.google.common.collect.BiMap;
import com.google.common.collect.HashBiMap;
import com.google.common.collect.Maps;
import com.intellij.openapi.editor.markup.RangeHighlighter;
import mp.code.Extensions;
import mp.code.Workspace;
import mp.code.exceptions.ConnectionException;
import mp.code.intellij.exceptions.ide.NotConnectedException;
import mp.code.intellij.workspace.IJWorkspace;
import mp.code.Client;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.nio.file.Path;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
public class CodeMP {
public static Logger LOGGER = LoggerFactory.getLogger(CodeMP.class);
public static final Map<String, IJWorkspace> ACTIVE_WORKSPACES = new ConcurrentHashMap<>();
private static Client CLIENT = null;
private static String ACTIVE_WORKSPACE_ID = null;
public static void connect(String url, String username, String password) throws ConnectionException {
CLIENT = Client.connect(url, username, password);
// TODO this sucks
public static BiMap<Path, String> BUFFER_MAPPER = Maps.synchronizedBiMap(HashBiMap.create());
public static final Map<String, RangeHighlighter> HIGHLIGHTER_MAP = new ConcurrentHashMap<>();
public static void connect(String username, String password) throws ConnectionException {
CLIENT = Client.connectToServer(username, password, "api.codemp.dev", 50053, false); // TODO don't hardcode
new Thread(() -> Extensions.drive(true)).start();
}
public static void disconnect() {
@ -27,4 +38,27 @@ public class CodeMP {
if(CLIENT == null) throw new NotConnectedException(reason);
return CLIENT;
}
public static boolean isConnected() {
return CLIENT != null;
}
public static boolean isInWorkspace() {
return ACTIVE_WORKSPACE_ID != null;
}
public static Workspace getActiveWorkspace() {
return CodeMP.getClient("get workspace").getWorkspace(ACTIVE_WORKSPACE_ID)
.orElseThrow(IllegalStateException::new);
}
public static void joinWorkspace(String workspaceId) throws ConnectionException {
CodeMP.getClient("join workspace").joinWorkspace(workspaceId);
ACTIVE_WORKSPACE_ID = workspaceId;
}
public static void leaveWorkspace() {
CodeMP.getClient("leave workspace").leaveWorkspace(ACTIVE_WORKSPACE_ID);
ACTIVE_WORKSPACE_ID = null;
}
}

View file

@ -1,39 +1,13 @@
package mp.code.intellij.actions;
import com.intellij.credentialStore.Credentials;
import mp.code.exceptions.ConnectionException;
import mp.code.intellij.CodeMP;
import mp.code.intellij.settings.CodeMPSettings;
import mp.code.intellij.util.ActionUtil;
import com.intellij.openapi.actionSystem.AnAction;
import com.intellij.openapi.actionSystem.AnActionEvent;
import mp.code.intellij.util.InteractionUtil;
import org.jetbrains.annotations.NotNull;
import java.util.Objects;
public class ConnectAction extends AnAction {
public static void connect(AnActionEvent e, boolean silent) throws NullPointerException, ConnectionException {
CodeMPSettings.State state = Objects.requireNonNull(CodeMPSettings.getInstance().getState());
Credentials creds = Objects.requireNonNull(state.getCredentials());
CodeMP.connect(
Objects.requireNonNull(state.getServerUrl()),
Objects.requireNonNull(creds.getUserName()),
Objects.requireNonNull(creds.getPasswordAsString())
);
if(!silent) ActionUtil.notify(e,
"Success", String.format("Connected to %s!", state.getServerUrl()));
CodeMP.LOGGER.debug("Connected to {}!", state.getServerUrl());
}
@Override
public void actionPerformed(@NotNull AnActionEvent e) {
try {
connect(e, false);
} catch(NullPointerException ex) {
ActionUtil.notifyError(e, "Invalid credentials!", "Please configure your credentials before connecting.");
} catch(Exception exception) {
ActionUtil.notifyError(e, "Failed to connect to server!", exception);
}
InteractionUtil.connect(e.getProject(), null);
}
}

View file

@ -1,26 +1,25 @@
package mp.code.intellij.actions;
import mp.code.intellij.CodeMP;
import mp.code.intellij.util.ActionUtil;
import com.intellij.openapi.actionSystem.ActionUpdateThread;
import com.intellij.openapi.actionSystem.AnAction;
import com.intellij.openapi.actionSystem.AnActionEvent;
import mp.code.intellij.CodeMP;
import mp.code.intellij.util.InteractionUtil;
import org.jetbrains.annotations.NotNull;
public class DisconnectAction extends AnAction {
public static void disconnect(AnActionEvent e, boolean silent) {
String url = CodeMP.getClient("disconnect").getUrl();
CodeMP.disconnect();
if(!silent) ActionUtil.notify(e,
"Success", String.format("Disconnected from %s!", url));
CodeMP.LOGGER.debug("Connected to {}!", url);
@Override
public void actionPerformed(@NotNull AnActionEvent e) {
InteractionUtil.disconnect(e.getProject());
}
@Override
public void actionPerformed(@NotNull AnActionEvent e) {
try {
disconnect(e, false);
} catch(Exception ex) {
ActionUtil.notifyError(e, "Failed to disconnect!", ex);
}
public void update(@NotNull AnActionEvent e) {
e.getPresentation().setEnabled(CodeMP.isConnected());
}
@Override
public @NotNull ActionUpdateThread getActionUpdateThread() {
return ActionUpdateThread.EDT;
}
}

View file

@ -1,55 +1,43 @@
package mp.code.intellij.actions.workspace;
import com.intellij.openapi.module.Module;
import com.intellij.openapi.module.ModuleManager;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.roots.ModuleRootModificationUtil;
import com.intellij.openapi.vfs.VirtualFileManager;
import mp.code.exceptions.ConnectionException;
import com.intellij.openapi.actionSystem.ActionUpdateThread;
import mp.code.intellij.CodeMP;
import mp.code.intellij.util.ActionUtil;
import mp.code.intellij.vfs.CodeMPPath;
import mp.code.intellij.vfs.CodeMPFileSystem;
import mp.code.intellij.vfs.CodeMPFolder;
import mp.code.intellij.util.InteractionUtil;
import com.intellij.openapi.actionSystem.AnAction;
import com.intellij.openapi.actionSystem.AnActionEvent;
import com.intellij.openapi.ui.Messages;
import org.apache.logging.log4j.util.Strings;
import org.jetbrains.annotations.NotNull;
import java.io.IOException;
public class WorkspaceJoinAction extends AnAction {
public static void join(AnActionEvent e, String workspaceId, boolean silent) throws ConnectionException, IOException {
CodeMP.getClient("join workspace").joinWorkspace(workspaceId);
CodeMPFileSystem fs = (CodeMPFileSystem) VirtualFileManager.getInstance().getFileSystem(CodeMPFileSystem.PROTOCOL);
CodeMPFolder root = new CodeMPFolder(fs, new CodeMPPath(workspaceId, Strings.EMPTY));
@Override
public void actionPerformed(@NotNull AnActionEvent e) {
String[] availableWorkspaces = InteractionUtil.listWorkspaces(e.getProject());
if(availableWorkspaces.length == 0) {
Messages.showErrorDialog(
"There are no available workspaces. Ensure you have rights to access at least one!",
"CodeMP Join Workspace"
);
}
Project proj = e.getProject();
int choice = Messages.showDialog( // TODO NOT THE ONE
e.getProject(),
"Please choose a workspace to join:",
"CodeMP Join Workspace",
availableWorkspaces,
0,
Messages.getQuestionIcon()
);
assert proj != null;
Module someModule = ModuleManager.getInstance(proj).getModules()[0];
ModuleRootModificationUtil.addContentRoot(someModule, root);
if(!silent) ActionUtil.notify(e,
"Success", String.format("Joined workspace %s!", workspaceId));
CodeMP.LOGGER.debug("Joined workspace {}!", workspaceId);
InteractionUtil.joinWorkspace(e.getProject(), availableWorkspaces[choice], null);
}
@Override
public void actionPerformed(@NotNull AnActionEvent e) {
String workspaceId = Messages.showInputDialog(
"Workspace to connect to:",
"CodeMP Join",
Messages.getQuestionIcon());
public void update(@NotNull AnActionEvent e) {
e.getPresentation().setEnabled(CodeMP.isConnected());
}
try {
join(e, workspaceId, false);
} catch(Exception ex) {
ActionUtil.notifyError(e, String.format(
"Failed to join workspace %s!",
workspaceId), ex);
}
@Override
public @NotNull ActionUpdateThread getActionUpdateThread() {
return ActionUpdateThread.EDT;
}
}

View file

@ -1,22 +1,14 @@
package mp.code.intellij.actions.workspace;
import mp.code.intellij.CodeMP;
import mp.code.intellij.util.ActionUtil;
import com.intellij.openapi.actionSystem.ActionUpdateThread;
import com.intellij.openapi.actionSystem.AnAction;
import com.intellij.openapi.actionSystem.AnActionEvent;
import com.intellij.openapi.ui.Messages;
import com.intellij.openapi.util.Disposer;
import mp.code.intellij.CodeMP;
import mp.code.intellij.util.InteractionUtil;
import org.jetbrains.annotations.NotNull;
public class WorkspaceLeaveAction extends AnAction {
public static void leave(AnActionEvent e, String workspaceId, boolean silent) {
CodeMP.getClient("leave workspace").leaveWorkspace(workspaceId);
Disposer.dispose(CodeMP.ACTIVE_WORKSPACES.remove(workspaceId));
if(!silent) ActionUtil.notify(e, "Success", String.format("Left workspace %s!", workspaceId));
CodeMP.LOGGER.debug("Left workspace!");
}
@Override
public void actionPerformed(@NotNull AnActionEvent e) {
String workspaceId = Messages.showInputDialog(
@ -24,10 +16,16 @@ public class WorkspaceLeaveAction extends AnAction {
"CodeMP Workspace Leave",
Messages.getQuestionIcon());
try {
leave(e, workspaceId, false);
} catch(Exception ex) {
ActionUtil.notifyError(e, "Failed to leave workspace!", ex);
}
InteractionUtil.leaveWorkspace(e.getProject(), workspaceId);
}
@Override
public void update(@NotNull AnActionEvent e) {
e.getPresentation().setEnabled(CodeMP.isConnected());
}
@Override
public @NotNull ActionUpdateThread getActionUpdateThread() {
return ActionUpdateThread.EDT;
}
}

View file

@ -1,24 +1,22 @@
package mp.code.intellij.listeners;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.editor.EditorFactory;
import com.intellij.openapi.vfs.VirtualFile;
import lombok.SneakyThrows;
import mp.code.exceptions.ControllerException;
import mp.code.intellij.CodeMP;
import com.intellij.openapi.command.CommandProcessor;
import com.intellij.openapi.editor.event.DocumentEvent;
import com.intellij.openapi.editor.event.DocumentListener;
import mp.code.BufferController;
import mp.code.data.TextChange;
import org.jetbrains.annotations.NotNull;
import java.util.Objects;
import java.util.OptionalLong;
public class BufferEventListener implements DocumentListener {
private final BufferController controller;
public BufferEventListener(BufferController controller) {
this.controller = controller;
}
@Override
@SneakyThrows
public void documentChanged(@NotNull DocumentEvent event) {
@ -30,14 +28,28 @@ public class BufferEventListener implements DocumentListener {
if(groupString.startsWith("codemp-buffer-receive") || groupString.startsWith("codemp-buffer-sync"))
return;
//TODO move actions break
int changeOffset = event.getOffset();
CharSequence newFragment = event.getNewFragment();
this.controller.send(new TextChange(
changeOffset,
changeOffset + event.getOldFragment().length(),
newFragment.toString(),
OptionalLong.empty()
));
VirtualFile file = EditorFactory.getInstance().editors(event.getDocument())
.map(Editor::getVirtualFile)
.filter(Objects::nonNull)
.filter(vf -> vf.getFileSystem().getNioPath(vf) != null)
.findFirst()
.orElse(null);
if(file == null) return;
CodeMP.getActiveWorkspace().getBuffer(CodeMP.BUFFER_MAPPER.get(file.toNioPath())).ifPresent(controller -> {
int changeOffset = event.getOffset();
CharSequence newFragment = event.getNewFragment();
try {
controller.send(new TextChange(
changeOffset,
changeOffset + event.getOldFragment().length(),
newFragment.toString(),
OptionalLong.empty()
));
} catch(ControllerException e) {
throw new RuntimeException(e);
}
});
}
}

View file

@ -1,6 +1,11 @@
package mp.code.intellij.listeners;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.application.CoroutinesKt;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.util.SlowOperations;
import lombok.SneakyThrows;
import mp.code.exceptions.ControllerException;
import mp.code.intellij.CodeMP;
import mp.code.intellij.util.FileUtil;
import com.intellij.openapi.editor.Caret;
@ -8,18 +13,11 @@ import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.editor.VisualPosition;
import com.intellij.openapi.editor.event.CaretEvent;
import com.intellij.openapi.editor.event.CaretListener;
import mp.code.CursorController;
import mp.code.data.Cursor;
import org.jetbrains.annotations.NotNull;
public class CursorEventListener implements CaretListener {
private final CursorController controller;
public CursorEventListener(CursorController controller) {
this.controller = controller;
}
@Override
@SneakyThrows
public void caretPositionChanged(@NotNull CaretEvent event) {
@ -27,20 +25,33 @@ public class CursorEventListener implements CaretListener {
if(caret == null)
return;
VirtualFile file = event.getEditor().getVirtualFile();
if(file == null) return;
if(CodeMP.getActiveWorkspace().getBuffer(CodeMP.BUFFER_MAPPER.get(file.toNioPath())).isEmpty()) return;
VisualPosition startPos = caret.getSelectionStartPosition();
VisualPosition endPos = caret.getSelectionEndPosition();
CodeMP.LOGGER.debug("Caret moved from {}x {}y to {}x {}y",
startPos.line, startPos.column, endPos.line, endPos.column
);
Editor editor = event.getEditor();
this.controller.send(new Cursor(
startPos.line,
startPos.column,
endPos.line,
endPos.column,
FileUtil.getRelativePath(editor.getProject(), editor.getVirtualFile()),
null
));
new Thread(() -> { // kys
ApplicationManager.getApplication().runReadAction(() -> {
Editor editor = event.getEditor();
try {
CodeMP.getActiveWorkspace().getCursor().send(new Cursor(
startPos.line,
startPos.column,
endPos.line,
endPos.column,
FileUtil.getRelativePath(editor.getProject(), editor.getVirtualFile()),
null
));
} catch(ControllerException e) {
throw new RuntimeException(e);
}
});
}).start();
}
}

View file

@ -1,33 +0,0 @@
package mp.code.intellij.listeners;
import mp.code.intellij.task.BufferEventAwaiterTask;
import mp.code.intellij.util.FileUtil;
import com.intellij.openapi.Disposable;
import com.intellij.openapi.fileEditor.FileEditorManager;
import com.intellij.openapi.fileEditor.FileEditorManagerListener;
import com.intellij.openapi.util.Disposer;
import com.intellij.openapi.vfs.VirtualFile;
import mp.code.Workspace;
import org.jetbrains.annotations.NotNull;
public class WorkspaceFileClosedListener implements FileEditorManagerListener.Before {
private final Workspace handler;
private final BufferEventAwaiterTask task;
public WorkspaceFileClosedListener(Workspace handler, BufferEventAwaiterTask task) {
this.handler = handler;
this.task = task;
}
@Override
public void beforeFileClosed(@NotNull FileEditorManager source, @NotNull VirtualFile file) {
String path = FileUtil.getRelativePath(source.getProject(), file);
if(path == null) return;
Disposable disp = this.task.activeBuffers.remove(path);
if(disp == null) return;
this.handler.detachFromBuffer(path);
Disposer.dispose(disp);
}
}

View file

@ -1,68 +0,0 @@
package mp.code.intellij.listeners;
import mp.code.exceptions.ConnectionException;
import mp.code.intellij.task.BufferEventAwaiterTask;
import mp.code.intellij.util.FileUtil;
import com.intellij.openapi.Disposable;
import com.intellij.openapi.fileEditor.FileEditorManager;
import com.intellij.openapi.fileEditor.FileOpenedSyncListener;
import com.intellij.openapi.fileEditor.TextEditor;
import com.intellij.openapi.fileEditor.ex.FileEditorWithProvider;
import com.intellij.openapi.util.Disposer;
import com.intellij.openapi.vfs.VirtualFile;
import mp.code.BufferController;
import mp.code.Workspace;
import org.jetbrains.annotations.NotNull;
import java.util.List;
public class WorkspaceFileOpenedListener implements FileOpenedSyncListener {
private final Workspace handler;
private final BufferEventAwaiterTask task;
public WorkspaceFileOpenedListener(Workspace handler, BufferEventAwaiterTask task) {
this.handler = handler;
this.task = task;
}
@Override
public void fileOpenedSync(@NotNull FileEditorManager source,
@NotNull VirtualFile file,
@NotNull List<FileEditorWithProvider> editorsWithProviders) {
editorsWithProviders
.stream()
.map(FileEditorWithProvider::component1)
.filter(fe -> fe instanceof TextEditor)
.map(fe -> (TextEditor) fe)
.map(TextEditor::getEditor)
.forEach(editor -> {
String path = FileUtil.getRelativePath(editor.getProject(), file);
if(path == null) return;
BufferController bufferController = this.getBufferForPath(path);
Disposable disp = Disposer.newDisposable(String.format("codemp-buffer-%s", path));
editor.getDocument().addDocumentListener(new BufferEventListener(bufferController), disp);
editor.getDocument().setText(""); //empty it so we can start receiving
this.task.activeBuffers.put(path, disp);
});
}
/**
* Attach to a buffer or, if it does not exist, implicitly create it.
* @param path the buffer's name (which is the path relative to project root)
* @return the {@link BufferController} for it
*/
private BufferController getBufferForPath(String path) {
try {
return this.handler.attachToBuffer(path);
} catch (ConnectionException ignored) {
try {
this.handler.createBuffer(path);
return this.handler.attachToBuffer(path);
} catch(ConnectionException e) {
throw new RuntimeException(e);
}
}
}
}

View file

@ -1,83 +0,0 @@
package mp.code.intellij.task;
import lombok.SneakyThrows;
import mp.code.intellij.CodeMP;
import mp.code.intellij.util.FileUtil;
import com.intellij.openapi.Disposable;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.command.CommandProcessor;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.progress.ProgressIndicator;
import com.intellij.openapi.progress.Task;
import com.intellij.openapi.project.Project;
import mp.code.BufferController;
import mp.code.Workspace;
import mp.code.data.TextChange;
import org.jetbrains.annotations.NotNull;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
public class BufferEventAwaiterTask extends Task.Backgroundable implements Disposable {
public final Map<String, Disposable> activeBuffers;
private final Workspace handler;
public BufferEventAwaiterTask(@NotNull Project project, @NotNull Workspace handler) {
super(project, "Awaiting CodeMP buffer events", false);
this.activeBuffers = new ConcurrentHashMap<>();
this.handler = handler;
}
@Override
@SneakyThrows
@SuppressWarnings("InfiniteLoopStatement")
public void run(@NotNull ProgressIndicator indicator) {
while(true) {
Optional<BufferController> bufferOptional;
bufferOptional = this.handler.selectBuffer(100L);
if(bufferOptional.isEmpty())
continue;
BufferController buffer = bufferOptional.get();
List<TextChange> changeList = new ArrayList<>();
while(true) {
Optional<TextChange> changeOptional;
try {
changeOptional = buffer.tryRecv();
} catch(Exception e) {
throw new RuntimeException(e);
}
if(changeOptional.isEmpty())
break;
TextChange change = changeOptional.get();
CodeMP.LOGGER.debug("Received text change {} from offset {} to {}!",
change.content, change.start, change.end);
changeList.add(change);
}
Editor bufferEditor = FileUtil.getActiveEditorByPath(this.myProject, buffer.getName());
ApplicationManager.getApplication().invokeLaterOnWriteThread(() ->
ApplicationManager.getApplication().runWriteAction(() ->
CommandProcessor.getInstance().executeCommand(
this.myProject,
() -> changeList.forEach((change) ->
bufferEditor.getDocument().replaceString(
(int) change.start, (int) change.end, change.content)
),
"CodeMPBufferReceive",
"codemp-buffer-receive", //TODO: mark this with the name
bufferEditor.getDocument()
)));
}
}
@Override
public void dispose() {
this.activeBuffers.values().forEach(Disposable::dispose);
this.activeBuffers.clear();
}
}

View file

@ -1,97 +0,0 @@
package mp.code.intellij.task;
import lombok.SneakyThrows;
import mp.code.intellij.CodeMP;
import mp.code.intellij.util.ColorUtil;
import mp.code.intellij.util.FileUtil;
import com.intellij.openapi.Disposable;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.editor.RangeMarker;
import com.intellij.openapi.editor.markup.HighlighterLayer;
import com.intellij.openapi.editor.markup.HighlighterTargetArea;
import com.intellij.openapi.editor.markup.RangeHighlighter;
import com.intellij.openapi.editor.markup.TextAttributes;
import com.intellij.openapi.progress.ProgressIndicator;
import com.intellij.openapi.progress.Task;
import com.intellij.openapi.project.Project;
import mp.code.CursorController;
import mp.code.data.Cursor;
import org.jetbrains.annotations.NotNull;
import java.awt.*;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
//TODO this is janky as it shows a progress bar it doesn't use
//implements disposable so i can use it as lifetime ig
public class CursorEventAwaiterTask extends Task.Backgroundable implements Disposable {
private final CursorController handler;
private final Map<String, RangeHighlighter> highlighterMap = new ConcurrentHashMap<>();
public CursorEventAwaiterTask(@NotNull Project project, @NotNull CursorController handler) {
super(project, "Awaiting CodeMP cursor events", false);
this.handler = handler;
}
@Override
@SneakyThrows
@SuppressWarnings("InfiniteLoopStatement")
public void run(@NotNull ProgressIndicator indicator) {
while(true) {
Cursor event;
event = this.handler.recv();
Editor editor = FileUtil.getActiveEditorByPath(this.myProject, event.buffer);
if(editor == null)
continue;
CodeMP.LOGGER.debug(
"Cursor moved by user {}! Start pos: {}x {}y; end pos: {}x {}y in buffer {}!",
event.user,
event.startCol, event.startRow,
event.endCol, event.endRow,
event.buffer
);
try {
int startOffset = editor.getDocument()
.getLineStartOffset(event.startRow) + event.startCol;
int endOffset = editor.getDocument()
.getLineStartOffset(event.startRow) + event.startCol;
ApplicationManager.getApplication().invokeLater(() -> {
int documentLength = editor.getDocument().getTextLength();
if(startOffset > documentLength || endOffset > documentLength) {
CodeMP.LOGGER.debug(
"Out of bounds cursor: start was {}, end was {}, document length was {}!",
startOffset, endOffset, documentLength);
return;
}
RangeHighlighter previous = this.highlighterMap.put(event.user, editor
.getMarkupModel()
.addRangeHighlighter(
startOffset,
endOffset,
HighlighterLayer.SELECTION,
new TextAttributes(
null,
ColorUtil.hashColor(event.user),
null,
null,
Font.PLAIN
), HighlighterTargetArea.EXACT_RANGE
));
if(previous != null)
previous.dispose();
});
} catch(IndexOutOfBoundsException ignored) {}
}
}
@Override
public void dispose() {
this.highlighterMap.values().forEach(RangeMarker::dispose);
}
}

View file

@ -0,0 +1,181 @@
package mp.code.intellij.ui;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.command.CommandProcessor;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.project.DumbAware;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.wm.ToolWindow;
import com.intellij.openapi.wm.ToolWindowFactory;
import com.intellij.ui.content.Content;
import com.intellij.ui.content.ContentFactory;
import com.intellij.ui.treeStructure.Tree;
import mp.code.data.TextChange;
import mp.code.exceptions.ControllerException;
import mp.code.intellij.CodeMP;
import mp.code.intellij.util.FileUtil;
import mp.code.intellij.util.InteractionUtil;
import org.jetbrains.annotations.NotNull;
import javax.swing.*;
import javax.swing.tree.DefaultMutableTreeNode;
import javax.swing.tree.TreePath;
import java.awt.*;
import java.awt.event.ActionEvent;
import java.awt.event.MouseEvent;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
public class CodeMPToolWindowFactory implements ToolWindowFactory, DumbAware {
@Override
public void createToolWindowContent(
@NotNull Project project,
@NotNull ToolWindow toolWindow
) {
ContentFactory contentFactory = ContentFactory.getInstance();
Content content = contentFactory.createContent(
new CodeMPToolWindow(project),
"",
false
);
toolWindow.getContentManager().addContent(content);
}
@Override
public boolean shouldBeAvailable(@NotNull Project project) {
return true;
}
public static WindowState getWindowState() {
if(!CodeMP.isConnected()) return WindowState.DISCONNECTED;
if(!CodeMP.isInWorkspace()) return WindowState.CONNECTED;
return WindowState.JOINED;
}
public enum WindowState {
DISCONNECTED,
CONNECTED,
JOINED
}
public static class CodeMPToolWindow extends JPanel {
public CodeMPToolWindow(Project project) {
this.draw(project);
}
private void redraw(Project project) {
this.draw(project);
this.repaint();
}
private void draw(Project project) {
this.removeAll();
switch(getWindowState()) {
case DISCONNECTED -> {
JButton connectButton = new JButton(new AbstractAction("Connect...") {
@Override
public void actionPerformed(ActionEvent e) {
InteractionUtil.connect(project, () -> CodeMPToolWindow.this.redraw(project));
}
});
this.add(connectButton);
}
case CONNECTED -> {
this.setLayout(new GridLayout(0, 1));
JTree tree = drawTree(InteractionUtil.listWorkspaces(project));
tree.addMouseListener(new SimpleMouseListener() {
@Override
public void mouseClicked(MouseEvent e) {
if(e.getClickCount() < 2) return;
TreePath path = tree.getPathForLocation(e.getX(), e.getY());
if(path == null) return;
String workspaceName = path.getLastPathComponent().toString();
InteractionUtil.joinWorkspace(
project,
workspaceName,
() -> CodeMPToolWindow.this.redraw(project)
);
}
});
this.add(tree);
}
case JOINED -> {
JTree tree = drawTree(CodeMP.getActiveWorkspace().getFileTree(Optional.empty(), false));
tree.addMouseListener(new SimpleMouseListener() {
@Override
public void mouseClicked(MouseEvent e) {
if(e.getClickCount() < 2) return;
TreePath path = tree.getPathForLocation(e.getX(), e.getY());
if(path == null) return;
InteractionUtil.bufferAttach(
project,
CodeMP.getActiveWorkspace(),
path.getLastPathComponent().toString()
).ifPresent(controller -> {
try {
Thread.sleep(1000); // TODO: this sucks
} catch(InterruptedException ignored) {}
ApplicationManager.getApplication().runWriteAction(() -> {
try {
FileUtil.getAndRegisterBufferEquivalent(this, project, controller);
} catch(Exception ex) {
throw new RuntimeException(ex);
}
});
controller.callback(bufferController -> {
ApplicationManager.getApplication().runReadAction(() -> {
Editor editor = FileUtil.getActiveEditorByPath(project, bufferController.getName());
ApplicationManager.getApplication().invokeLaterOnWriteThread(() -> {
List<TextChange> changeList = new ArrayList<>();
while(true) {
Optional<TextChange> changeOptional;
try {
changeOptional = bufferController.tryRecv();
} catch(ControllerException ex) {
throw new RuntimeException(ex);
}
if(changeOptional.isEmpty())
break;
TextChange change = changeOptional.get();
CodeMP.LOGGER.debug("Received text change {} from offset {} to {}!",
change.content, change.start, change.end);
changeList.add(change);
}
ApplicationManager.getApplication().runWriteAction(() ->
CommandProcessor.getInstance().executeCommand(
project,
() -> changeList.forEach((change) ->
editor.getDocument().replaceString(
(int) change.start, (int) change.end, change.content)
),
"CodeMPBufferReceive",
"codemp-buffer-receive",
editor.getDocument()
)
);
});
});
});
});
}
});
this.add(tree);
}
}
}
private JTree drawTree(String[] contents) {
DefaultMutableTreeNode root = new DefaultMutableTreeNode();
for(String content : contents) {
root.add(new DefaultMutableTreeNode(content));
}
return new Tree(root);
}
}
}

View file

@ -0,0 +1,30 @@
package mp.code.intellij.ui;
import java.awt.event.MouseEvent;
import java.awt.event.MouseListener;
/**
* Allows usage of {@link MouseListener} without implementing
* all methods.
*/
class SimpleMouseListener implements MouseListener {
@Override
public void mouseClicked(MouseEvent e) {
}
@Override
public void mousePressed(MouseEvent e) {
}
@Override
public void mouseReleased(MouseEvent e) {
}
@Override
public void mouseEntered(MouseEvent e) {
}
@Override
public void mouseExited(MouseEvent e) {
}
}

View file

@ -1,49 +0,0 @@
package mp.code.intellij.util;
import mp.code.intellij.CodeMP;
import mp.code.intellij.exceptions.ide.BadActionEventStateException;
import com.intellij.notification.Notification;
import com.intellij.notification.NotificationType;
import com.intellij.notification.Notifications;
import com.intellij.openapi.actionSystem.AnActionEvent;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.fileEditor.FileEditorManager;
import com.intellij.openapi.project.Project;
public class ActionUtil {
public static Project getCurrentProject(AnActionEvent event) {
Project project = event.getProject();
if(project == null)
throw new BadActionEventStateException("Project was null!");
return project;
}
public static Editor getCurrentEditor(AnActionEvent event) {
Editor editor = FileEditorManager.getInstance(getCurrentProject(event))
.getSelectedTextEditor();
if(editor == null)
throw new BadActionEventStateException("Editor was null!");
return editor;
}
public static void notify(AnActionEvent event, String title, String msg) {
Notifications.Bus.notify(new Notification(
"CodeMP", title, msg, NotificationType.INFORMATION
), event.getProject());
}
public static void notifyError(AnActionEvent event, String title, String msg) {
Notifications.Bus.notify(new Notification(
"CodeMP", title, msg, NotificationType.ERROR
), event.getProject());
}
public static void notifyError(AnActionEvent event, String title, Throwable t) {
Notifications.Bus.notify(new Notification(
"CodeMP", title,
String.format("%s: %s", t.getClass().getCanonicalName(), t.getMessage()),
NotificationType.ERROR
), event.getProject());
CodeMP.LOGGER.error(title, t);
}
}

View file

@ -1,7 +1,9 @@
package mp.code.intellij.util;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.fileEditor.FileEditor;
import com.intellij.openapi.fileEditor.FileEditorManager;
import com.intellij.openapi.fileEditor.OpenFileDescriptor;
import com.intellij.openapi.fileEditor.TextEditor;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.roots.ProjectRootManager;
@ -9,9 +11,11 @@ import com.intellij.openapi.vfs.VfsUtilCore;
import com.intellij.openapi.vfs.VirtualFile;
import mp.code.BufferController;
import mp.code.exceptions.ConnectionException;
import mp.code.exceptions.ControllerException;
import mp.code.intellij.CodeMP;
import mp.code.intellij.vfs.CodeMPPath;
import java.io.IOException;
import java.util.Arrays;
import java.util.Optional;
@ -35,6 +39,29 @@ public class FileUtil {
.orElse(null);
}
public static FileEditor getAndRegisterBufferEquivalent(Object requestor, Project project, BufferController buffer) throws ControllerException, IOException {
VirtualFile contentRoot = ProjectRootManager.getInstance(project).getContentRoots()[0];
String bufferName = buffer.getName();
VirtualFile found = contentRoot.findFileByRelativePath(bufferName);
if(found == null) {
VirtualFile lastParent = contentRoot;
String[] path = bufferName.split("/");
for(int i = 0; i < path.length - 1; i++)
lastParent = lastParent.createChildDirectory(requestor, path[i]);
found = lastParent.createChildData(requestor, path[path.length - 1]);
}
found.setBinaryContent(buffer.getContent().getBytes());
CodeMP.BUFFER_MAPPER.put(found.toNioPath(), bufferName);
return FileEditorManager.getInstance(project).openEditor(
new OpenFileDescriptor(project, found, 0),
true
).get(0);
}
/**
* Will first check if such a buffer exists.
* If it does, it will try to get the relevant controller and,

View file

@ -0,0 +1,231 @@
package mp.code.intellij.util;
import com.intellij.credentialStore.Credentials;
import com.intellij.notification.Notification;
import com.intellij.notification.NotificationType;
import com.intellij.notification.Notifications;
import com.intellij.openapi.application.ApplicationManager;
import com.intellij.openapi.editor.Editor;
import com.intellij.openapi.editor.EditorFactory;
import com.intellij.openapi.editor.event.EditorEventMulticaster;
import com.intellij.openapi.editor.markup.HighlighterLayer;
import com.intellij.openapi.editor.markup.HighlighterTargetArea;
import com.intellij.openapi.editor.markup.RangeHighlighter;
import com.intellij.openapi.editor.markup.TextAttributes;
import com.intellij.openapi.progress.ProgressIndicator;
import com.intellij.openapi.progress.ProgressManager;
import com.intellij.openapi.progress.Task;
import com.intellij.openapi.project.Project;
import mp.code.BufferController;
import mp.code.Client;
import mp.code.Workspace;
import mp.code.data.Cursor;
import mp.code.exceptions.ConnectionException;
import mp.code.exceptions.ConnectionRemoteException;
import mp.code.exceptions.ControllerException;
import mp.code.intellij.CodeMP;
import mp.code.intellij.listeners.BufferEventListener;
import mp.code.intellij.listeners.CursorEventListener;
import mp.code.intellij.settings.CodeMPSettings;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import java.awt.*;
import java.util.Objects;
import java.util.Optional;
/**
* Contains utility classes for interacting with CodeMP in contexts
* where the user should be shown the output, taking care of things
* like notifications and error handling.
*/
public class InteractionUtil {
public static void connect(@Nullable Project project, @Nullable Runnable after) {
ProgressManager.getInstance().run(new Task.Backgroundable(project, "Connecting to CodeMP server...") {
@Override
public void run(@NotNull ProgressIndicator indicator) {
try {
CodeMPSettings.State state = Objects.requireNonNull(CodeMPSettings.getInstance().getState());
Credentials credentials = Objects.requireNonNull(state.getCredentials());
CodeMP.connect(
Objects.requireNonNull(credentials.getUserName()),
Objects.requireNonNull(credentials.getPasswordAsString())
);
if(after != null) after.run();
notifyInfo(
project,
"Success",
String.format("Connected to %s!", state.getServerUrl())
);
} catch(NullPointerException e) {
Notifications.Bus.notify(new Notification(
"CodeMP",
"Invalid credentials!",
"Please configure your credentials before connecting.",
NotificationType.ERROR
), project);
} catch(ConnectionException e) {
notifyError(project, "Failed to leave workspace!", e);
}
}
});
}
public static void disconnect(@Nullable Project project) {
CodeMP.disconnect();
notifyInfo(project, "Success", "Disconnected from server!");
}
public static void joinWorkspace(Project project, @NotNull String workspaceId, @Nullable Runnable after) {
ProgressManager.getInstance().run(new Task.Backgroundable(project, String.format("Joining workspace %s...", workspaceId)) {
@Override
public void run(@NotNull ProgressIndicator indicator) {
if(project == null) {
Notifications.Bus.notify(new Notification(
"CodeMP",
"No project found",
"Please ensure that you have an open project before attempting to join a workspace.",
NotificationType.ERROR
), null);
return;
}
try {
CodeMP.joinWorkspace(workspaceId);
} catch(ConnectionException e) {
InteractionUtil.notifyError(project, String.format(
"Failed to join workspace %s!",
workspaceId
), e);
return;
}
EditorEventMulticaster eventMulticaster = EditorFactory.getInstance().getEventMulticaster();
eventMulticaster.addDocumentListener(new BufferEventListener()); // TODO disposable
eventMulticaster.addCaretListener(new CursorEventListener()); // TODO disposable
CodeMP.getActiveWorkspace().getCursor().callback(controller -> {
ApplicationManager.getApplication().invokeLater(() -> {
try {
while(true) {
Optional<Cursor> c = controller.tryRecv();
if(c.isEmpty()) break;
Cursor event = c.get();
CodeMP.LOGGER.debug(
"Cursor moved by user {}! Start pos: {}x {}y; end pos: {}x {}y in buffer {}!",
event.user,
event.startCol, event.startRow,
event.endCol, event.endRow,
event.buffer
);
try {
ApplicationManager.getApplication().invokeLater(() -> {
Editor editor = FileUtil.getActiveEditorByPath(this.myProject, event.buffer);
if(editor == null) return;
int startOffset = editor.getDocument()
.getLineStartOffset(event.startRow) + event.startCol;
int endOffset = editor.getDocument()
.getLineStartOffset(event.startRow) + event.startCol;
int documentLength = editor.getDocument().getTextLength();
if(startOffset > documentLength || endOffset > documentLength) {
CodeMP.LOGGER.debug(
"Out of bounds cursor: start was {}, end was {}, document length was {}!",
startOffset, endOffset, documentLength);
return;
}
RangeHighlighter previous = CodeMP.HIGHLIGHTER_MAP.put(
event.user,
editor.getMarkupModel().addRangeHighlighter(
startOffset,
endOffset,
HighlighterLayer.SELECTION,
new TextAttributes(
null,
ColorUtil.hashColor(event.user),
null,
null,
Font.PLAIN
), HighlighterTargetArea.EXACT_RANGE
)
);
if(previous != null)
previous.dispose();
});
} catch(IndexOutOfBoundsException ignored) {}
}
} catch(ControllerException ex) {
notifyError(project, "Error receiving change", ex);
}
});
});
if(after != null) after.run();
notifyInfo(
project,
"Success",
String.format("Joined workspace %s!", workspaceId)
);
}
});
}
public static void leaveWorkspace(Project project, String workspaceId) {
CodeMP.leaveWorkspace();
notifyInfo(
project,
"Success",
String.format("Left workspace %s!", workspaceId)
);
}
public static String[] listWorkspaces(Project project) {
try {
Client client = CodeMP.getClient("drawActiveWorkspaces");
return client.listWorkspaces(true, true);
} catch(ConnectionRemoteException exception) {
notifyError(project, "Failed to list workspaces!", exception);
return new String[0];
}
}
public static Optional<BufferController> bufferAttach(Project project, Workspace workspace, String path) {
try {
BufferController controller = workspace.attachToBuffer(path);
notifyInfo(project, "Success!", String.format(
"Successfully attached to buffer %s on workspace %s!",
path,
workspace.getWorkspaceId())
);
return Optional.of(controller);
} catch(ConnectionException e) {
notifyError(project, "Failed to attach to buffer!", e);
return Optional.empty();
}
}
private static void notifyInfo(Project project, String title, String msg) {
Notifications.Bus.notify(new Notification(
"CodeMP", title, msg, NotificationType.INFORMATION
), project);
}
private static void notifyError(Project project, String title, Throwable t) {
Notifications.Bus.notify(new Notification(
"CodeMP", title,
String.format("%s: %s", t.getClass().getCanonicalName(), t.getMessage()),
NotificationType.ERROR
), project);
CodeMP.LOGGER.error(title, t);
}
}

View file

@ -33,5 +33,10 @@
id="mp.code.intellij.settings.CodeMPSettingsConfigurable"
displayName="CodeMP"/>
<workspaceModel.fileIndexContributor implementation="mp.code.intellij.vfs.CodeMPFileIndexContributor" />
<toolWindow
id="CodeMP"
factoryClass="mp.code.intellij.ui.CodeMPToolWindowFactory"
anchor="right"
doNotActivateOnStart="false" />
</extensions>
</idea-plugin>