diff --git a/configs/ts/references/tsconfig.collaboration.json b/configs/ts/references/tsconfig.collaboration.json new file mode 100644 index 0000000000..7beeb43960 --- /dev/null +++ b/configs/ts/references/tsconfig.collaboration.json @@ -0,0 +1,9 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "rootDir": "../../../packages/collaboration/src", + "outDir": "../../../packages/collaboration/lib" + }, + "include": ["../../../packages/collaboration/src"], + "exclude": ["../../../packages/collaboration/__mocks__"] +} diff --git a/configs/ts/tsconfig.build.json b/configs/ts/tsconfig.build.json index 8941e97456..239ecc729a 100644 --- a/configs/ts/tsconfig.build.json +++ b/configs/ts/tsconfig.build.json @@ -141,6 +141,9 @@ { "path": "./references/tsconfig.markdown.json" }, + { + "path": "./references/tsconfig.collaboration.json" + }, { "path": "./references/tsconfig.task.json" }, diff --git a/configs/ts/tsconfig.resolve.json b/configs/ts/tsconfig.resolve.json index 2fba6559b3..ab9f12cbd6 100644 --- a/configs/ts/tsconfig.resolve.json +++ b/configs/ts/tsconfig.resolve.json @@ -7,6 +7,8 @@ "@opensumi/ide-utils/lib/*": ["../packages/utils/src/*"], "@opensumi/ide-addons": ["../packages/addons/src/index.ts"], "@opensumi/ide-addons/lib/*": ["../packages/addons/src/*"], + "@opensumi/ide-collaboration": ["../packages/collaboration/src/index.ts"], + "@opensumi/ide-collaboration/lib/*": ["../packages/collaboration/src/*"], "@opensumi/ide-comments": ["../packages/comments/src/index.ts"], "@opensumi/ide-comments/lib/*": ["../packages/comments/src/*"], "@opensumi/ide-components": ["../packages/components/src/index.ts"], diff --git a/packages/collaboration/README.md b/packages/collaboration/README.md new file mode 100644 index 0000000000..eed07b5672 --- /dev/null +++ b/packages/collaboration/README.md @@ -0,0 +1,23 @@ +# Collaboration Module 协同编辑模块 + +> Make OpenSumi Collaborative + +## 如何使用 + +只需在 browser 与 node app 的 startup 上添加该模块,并实现`CollaborationModuleContribution`即可使用。 + +## 平台支持 + +目前的实现只支持 Cloud IDE 场景 (Browser + Node) + +## 当前限制 + +- 仅支持了 IDE **编辑器部分**的协同编辑功能 +- 暂时仅支持在编辑器编辑文件,未处理外部对 IDE 工作区文件的修改(如 `git pull`, 用其他软件修改了文件内容) +- 未支持 browser-only 与 electron 平台 + +## Thanks + +[yjs -- Shared data types for building collaborative software](https://github.com/yjs/yjs) + +[y-websocket -- Websocket Connector for Yjs](https://github.com/yjs/y-websocket) diff --git a/packages/collaboration/__tests__/browser/collaboration-contribution.test.ts b/packages/collaboration/__tests__/browser/collaboration-contribution.test.ts new file mode 100644 index 0000000000..fd6f69d95f --- /dev/null +++ b/packages/collaboration/__tests__/browser/collaboration-contribution.test.ts @@ -0,0 +1,86 @@ +import { KeybindingRegistry, KeybindingWeight, PreferenceService } from '@opensumi/ide-core-browser'; +import { CommandRegistry, CommandRegistryImpl, IDisposable, ILogger } from '@opensumi/ide-core-common'; +import { createBrowserInjector } from '@opensumi/ide-dev-tool/src/injector-helper'; +import { MockInjector } from '@opensumi/ide-dev-tool/src/mock-injector'; +import { AUTO_SAVE_MODE } from '@opensumi/ide-editor'; +import { IFileService, IFileServiceClient } from '@opensumi/ide-file-service'; + +import { + CollaborationServiceForClientPath, + ICollaborationService, + IYWebsocketServer, + CollaborationModuleContribution, +} from '../../src'; +import { CollaborationContribution } from '../../src/browser/collaboration.contribution'; +import { CollaborationService } from '../../src/browser/collaboration.service'; +import { REDO, UNDO } from '../../src/common/commands'; +import { CollaborationServiceForClient } from '../../src/node/collaboration.service'; +import { YWebsocketServerImpl } from '../../src/node/y-websocket-server'; + +describe('CollaborationContribution test', () => { + let injector: MockInjector; + let contribution: CollaborationContribution; + let collaborationService: ICollaborationService; + let preferenceService: PreferenceService; + + beforeAll(() => { + injector = createBrowserInjector([]); + injector.mockService(ILogger); + injector.mockService(PreferenceService); + injector.mockService(IFileServiceClient); + injector.mockService(IFileService); + injector.mockService(KeybindingRegistry); + injector.addProviders( + CollaborationContribution, + + { + token: ICollaborationService, + useClass: CollaborationService, + }, + { + token: CollaborationServiceForClientPath, + useClass: CollaborationServiceForClient, + }, + { + token: IYWebsocketServer, + useClass: YWebsocketServerImpl, + }, + { + token: CommandRegistry, + useClass: CommandRegistryImpl, + }, + ); + + injector.addProviders({ + token: CollaborationModuleContribution, + useValue: { getContributions: () => [] }, + }); + + contribution = injector.get(CollaborationContribution); + collaborationService = injector.get(ICollaborationService); + preferenceService = injector.get(PreferenceService); + }); + + it('should register key bindings with correct id, priority and when clause', () => { + const registry: KeybindingRegistry = injector.get(KeybindingRegistry); + const commandIds = [UNDO.id, REDO.id]; + + jest.spyOn(registry, 'registerKeybinding').mockImplementation((binding) => { + const { command, when, priority } = binding; + expect(commandIds.includes(command)).toBe(true); + expect(when).toBe('editorFocus'); + expect(priority).toBe(KeybindingWeight.EditorContrib); + return undefined as any as IDisposable; // just ignore type error + }); + + contribution.registerKeybindings(registry); + }); + + it('should register commands', () => { + const registry: CommandRegistryImpl = injector.get(CommandRegistry); + + contribution.registerCommands(registry); + + expect(registry.getCommands()).toEqual([UNDO, REDO]); + }); +}); diff --git a/packages/collaboration/__tests__/browser/collaboration-service.test.ts b/packages/collaboration/__tests__/browser/collaboration-service.test.ts new file mode 100644 index 0000000000..1639a18553 --- /dev/null +++ b/packages/collaboration/__tests__/browser/collaboration-service.test.ts @@ -0,0 +1,184 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import * as Y from 'yjs'; + +import { Injectable, Autowired } from '@opensumi/di'; +import { AppConfig } from '@opensumi/ide-core-browser'; +import { EventBusImpl, IEventBus, ILogger, URI } from '@opensumi/ide-core-common'; +import { INodeLogger } from '@opensumi/ide-core-node'; +import { createBrowserInjector } from '@opensumi/ide-dev-tool/src/injector-helper'; +import { MockInjector } from '@opensumi/ide-dev-tool/src/mock-injector'; +import { WorkbenchEditorService } from '@opensumi/ide-editor'; +import { + EditorDocumentModelCreationEvent, + EditorDocumentModelRemovalEvent, + IEditorDocumentModelService, +} from '@opensumi/ide-editor/lib/browser'; +import { IFileService } from '@opensumi/ide-file-service'; +import { ITextModel } from '@opensumi/ide-monaco'; +import { ICSSStyleService } from '@opensumi/ide-theme'; +import * as monaco from '@opensumi/monaco-editor-core/esm/vs/editor/editor.api'; + +import { CollaborationService } from '../../src/browser/collaboration.service'; +import { TextModelBinding } from '../../src/browser/textmodel-binding'; +import { CollaborationServiceForClientPath, ICollaborationService, IYWebsocketServer } from '../../src/common'; +import { CollaborationServiceForClient } from '../../src/node/collaboration.service'; +import { YWebsocketServerImpl } from '../../src/node/y-websocket-server'; + +@Injectable() +class MockWorkbenchEditorService { + uri: URI; + + get currentResource() { + return { + uri: this.uri, + }; + } +} + +@Injectable() +class MockDocModelService { + @Autowired(WorkbenchEditorService) + private workbenchService: MockDocModelService; + + private textModel: ITextModel; + + getModelReference(uri: string) { + return { + dispose() {}, + instance: { + getMonacoModel() { + return monaco.editor.createModel(''); + }, + }, + }; + } +} + +describe('CollaborationService basic routines', () => { + let injector: MockInjector; + let service: CollaborationService; + let server: YWebsocketServerImpl; + let eventBus: IEventBus; + let workbenchEditorService: MockWorkbenchEditorService; + + beforeAll(() => { + injector = createBrowserInjector([]); + injector.mockService(ILogger); + injector.mockService(INodeLogger); + injector.mockService(IFileService); + injector.mockService(ICSSStyleService); + injector.addProviders( + { + token: ICollaborationService, + useClass: CollaborationService, + }, + { + token: IYWebsocketServer, + useClass: YWebsocketServerImpl, + }, + { + token: CollaborationServiceForClientPath, + useClass: CollaborationServiceForClient, + }, + ); + injector.addProviders({ + token: IEventBus, + useClass: EventBusImpl, + }); + injector.addProviders({ + token: AppConfig, + useValue: { + wsPath: { toString: () => 'ws://127.0.0.1:8080' }, + }, + }); + + injector.addProviders({ + token: WorkbenchEditorService, + useClass: MockWorkbenchEditorService, + }); + workbenchEditorService = injector.get(WorkbenchEditorService); + const uriString = 'file://home/situ2001/114514/1919810'; + workbenchEditorService.uri = new URI(uriString); + + injector.addProviders({ + token: IEditorDocumentModelService, + useClass: MockDocModelService, + }); + + server = injector.get(IYWebsocketServer); + eventBus = injector.get(IEventBus); + service = injector.get(ICollaborationService); + + // mock impl, because origin impl comes with nodejs + jest.spyOn(server, 'requestInitContent').mockImplementation(async (uri: string) => { + if (!server['yMap'].has(uri)) { + server['yMap'].set(uri, new Y.Text('init content')); + } + }); + + // start server + server.initialize(); + }); + + it('should successfully initialize', () => { + const spy = jest.spyOn(service, 'initialize'); + service.initialize(); + expect(spy).toBeCalled(); + }); + + it('should create a new binding when all things are ready', async () => { + const event = new EditorDocumentModelCreationEvent({ + uri: new URI(workbenchEditorService.uri.toString()), + } as any); + await eventBus.fireAndAwait(event); + expect(service['bindingMap'].has(workbenchEditorService.uri.toString())).toBeTruthy(); + }); + + it('should call undo and redo on current binding', () => { + const targetBinding = service['getBinding'](workbenchEditorService.uri.toString()) as TextModelBinding; + expect(targetBinding).toBeInstanceOf(TextModelBinding); + const undoSpy = jest.spyOn(targetBinding, 'undo'); + const redoSpy = jest.spyOn(targetBinding, 'redo'); + service.undoOnFocusedTextModel(); + service.redoOnFocusedTextModel(); + expect(undoSpy).toBeCalled(); + expect(redoSpy).toBeCalled(); + }); + + it('should change Y.Text when remote Y.Text was changed', async () => { + // simulate Y.Text delete and add + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const binding = service['bindingMap'].get(workbenchEditorService.uri.toString())!; + expect(binding).toBeInstanceOf(TextModelBinding); + expect(binding['yText'].toJSON()).toBeTruthy(); + + const spy = jest.spyOn(binding, 'changeYText'); + const { yMapReady } = service['getDeferred'](workbenchEditorService.uri.toString()); + + service['yTextMap'].delete(workbenchEditorService.uri.toString()); + service['yTextMap'].set(workbenchEditorService.uri.toString(), new Y.Text('1919810')); + + await yMapReady.promise; + + expect(spy).toBeCalled(); + expect(binding['yText'].toJSON()).toBe('1919810'); + }); + + it('should remove binding on EditorDocumentModelRemovalEvent', async () => { + const event = new EditorDocumentModelRemovalEvent({ + codeUri: new URI(workbenchEditorService.uri.toString()), + } as any); + await eventBus.fireAndAwait(event); + expect(service['bindingMap'].has(workbenchEditorService.uri.toString())).toBeFalsy(); + }); + + it('should successfully destroy', () => { + const spy = jest.spyOn(service, 'destroy'); + service.destroy(); + expect(spy).toBeCalled(); + }); + + afterAll(() => { + server.destroy(); + }); +}); diff --git a/packages/collaboration/__tests__/browser/textmodel-binding.test.ts b/packages/collaboration/__tests__/browser/textmodel-binding.test.ts new file mode 100644 index 0000000000..71733de12b --- /dev/null +++ b/packages/collaboration/__tests__/browser/textmodel-binding.test.ts @@ -0,0 +1,322 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { Awareness } from 'y-protocols/awareness'; +import { WebsocketProvider } from 'y-websocket'; +import * as Y from 'yjs'; + +import { Injector } from '@opensumi/di'; +import { uuid } from '@opensumi/ide-core-common'; +import { ICodeEditor } from '@opensumi/ide-monaco'; +import * as monaco from '@opensumi/monaco-editor-core/esm/vs/editor/editor.api'; + +import { TextModelBinding } from '../../src/browser/textmodel-binding'; +import { ICollaborationService } from '../../src/common'; + +const injector = new Injector(); + +injector.addProviders({ + token: ICollaborationService, + useValue: { + getCursorWidgetRegistry: jest.fn(), + }, +}); + +const createBindingWithTextModel = (doc: Y.Doc, awareness: Awareness) => { + const textModel = monaco.editor.createModel(''); + const yText = doc.getText('test'); + // const binding = new TextModelBinding(yText, textModel, awareness); + const binding = injector.get(TextModelBinding, [yText, textModel, awareness]); + return { + textModel, + binding, + yText, + }; +}; + +describe('TextModelBinding test for yText and TextModel', () => { + let doc: Y.Doc; + let user1: ReturnType; + let user2: ReturnType; + let wsProvider: WebsocketProvider; + + beforeEach(() => { + doc = new Y.Doc(); + wsProvider = new WebsocketProvider('ws://127.0.0.1:12345', 'test', doc, { connect: false }); // we don't use wsProvider here + user1 = createBindingWithTextModel(doc, wsProvider.awareness); + user2 = createBindingWithTextModel(doc, wsProvider.awareness); + jest.mock('@opensumi/di'); + }); + + afterEach(() => { + user1.binding.destroy(); + user2.binding.destroy(); + doc.destroy(); + }); + + it('should initialize properly', () => { + const yText = doc.getText('test'); + + expect(user1.binding['undoManger']).toBeTruthy(); + expect(user2.binding['undoManger']).toBeTruthy(); + expect(user1.binding['textModel'] === user1.textModel).toBeTruthy(); + expect(user2.binding['textModel'] === user2.textModel).toBeTruthy(); + expect(user1.binding['yText'] === yText).toBeTruthy(); + expect(user2.binding['yText'] === yText).toBeTruthy(); + expect(user1.binding['doc'] === doc).toBeTruthy(); + expect(user2.binding['doc'] === doc).toBeTruthy(); + }); + + it('should fire event onDidChangeContent when yText is modified or text model content is changed', () => { + const f1 = jest.fn(); + const disposable1 = user1.textModel.onDidChangeContent(f1); + const f2 = jest.fn(); + const disposable2 = user2.textModel.onDidChangeContent(f2); + + user1.yText.insert(0, '810'); + user2.yText.insert(0, '1919'); + const pos = user1.textModel.getPositionAt(0); + const range = new monaco.Range(pos.lineNumber, pos.column, pos.lineNumber, pos.column); + user1.textModel.applyEdits([{ range, text: '514' }]); + user2.textModel.applyEdits([{ range, text: '114' }]); + + expect(user1.yText.toString() === user2.yText.toString()).toBeTruthy(); + expect(user1.textModel.getValue() === user2.textModel.getValue()).toBeTruthy(); + expect(user1.yText.toString() === '1145141919810').toBeTruthy(); + expect(user2.yText.toString() === '1145141919810').toBeTruthy(); + expect(user1.textModel.getValue() === '1145141919810').toBeTruthy(); + expect(user2.textModel.getValue() === '1145141919810').toBeTruthy(); + + expect(f1).toBeCalled(); + expect(f2).toBeCalled(); + + disposable1.dispose(); + disposable2.dispose(); + }); + + it('should set value of TextModel when current content of TextModel is not the same with Y.Text', () => { + user1.yText.insert(0, '1145141919810'); + + const model = monaco.editor.createModel('114514'); + const modelSpy = jest.spyOn(model, 'setValue'); + const binding = new TextModelBinding(doc.getText('test'), model, wsProvider.awareness); + + expect(modelSpy).toBeCalled(); + expect(model.getValue()).toBe('1145141919810'); + expect(user1.textModel.getValue()).toBe('1145141919810'); + + model.dispose(); + binding.destroy(); + }); + + it('should correctly handle Y.Text event', () => { + // insertion + const textModel = user2.textModel; + const insertionSpy = jest.spyOn(textModel, 'applyEdits'); + user1.yText.insert(0, 'insert'); + expect(insertionSpy).toBeCalled(); + expect(user2.textModel.getValue()).toBe('insert'); + // deletion + const deletionSpy = jest.spyOn(textModel, 'applyEdits'); + user2.yText.delete(0, 3); + expect(deletionSpy).toBeCalled(); + expect(user2.textModel.getValue()).toBe('ert'); + }); + + it('should mutex on two events mentioned above', () => { + let mutex = user1.binding['mutex']; + let yTextEventFn = jest.fn(); + let TextModelEventFn = jest.fn(); + + // editing on yText will trigger yText event + // and onDidChangeContent will be triggered in yText observer + // but wont execute fn in event onDidChangeContent while executing yText observer + user1.yText.observe(() => mutex(() => yTextEventFn())); + user1.textModel.onDidChangeContent(() => mutex(() => TextModelEventFn())); + + user1.yText.insert(0, 'foo'); + expect(yTextEventFn).toBeCalledTimes(1); + expect(TextModelEventFn).toBeCalledTimes(0); + + // the same + mutex = user2.binding['mutex']; + yTextEventFn = jest.fn(); + TextModelEventFn = jest.fn(); + + user2.yText.observe(() => mutex(() => yTextEventFn())); + user2.textModel.onDidChangeContent(() => mutex(() => TextModelEventFn())); + + const pos = user2.textModel.getPositionAt(0); + const range = new monaco.Range(pos.lineNumber, pos.column, pos.lineNumber, pos.column); + user2.textModel.applyEdits([{ range, text: 'bar' }]); + + expect(yTextEventFn).toBeCalledTimes(0); + expect(TextModelEventFn).toBeCalledTimes(1); + }); + + it('should undo and redo correctly', () => { + // now here is simple undo and redo test + let pos = user1.textModel.getPositionAt(0); + let range = new monaco.Range(pos.lineNumber, pos.column, pos.lineNumber, pos.column); + user1.textModel.applyEdits([{ range, text: '114514' }]); + pos = user2.textModel.getPositionAt(3); + range = new monaco.Range(pos.lineNumber, pos.column, pos.lineNumber, pos.column); + user2.textModel.applyEdits([{ range, text: '1919810' }]); + expect(user1.textModel.getValue()).toBe('1141919810514'); + user1.binding.undo(); + expect(user1.textModel.getValue()).toBe('1919810'); + user2.binding.undo(); + expect(user1.textModel.getValue()).toBe(''); + user1.binding.redo(); + expect(user1.textModel.getValue()).toBe('114514'); + user2.binding.redo(); + expect(user1.textModel.getValue()).toBe('1141919810514'); + }); + + it('should randomly edit many times', () => { + // just randomly insert or delete + for (let i = 0; i < 100; i++) { + const userCurrentTurn = Math.random() > 0.5 ? user2 : user1; + const insert = Math.random() <= 0.5 ? true : false; + const len = userCurrentTurn.textModel.getValueLength(); + if (insert) { + const pos = userCurrentTurn.textModel.getPositionAt(Math.ceil(Math.random() * len)); + const range = new monaco.Range(pos.lineNumber, pos.column, pos.lineNumber, pos.column); + userCurrentTurn.textModel.applyEdits([{ range, text: uuid() }]); + } else { + const startPos = userCurrentTurn.textModel.getPositionAt(Math.ceil(Math.random() * len)); + const deletionLen = Math.ceil(Math.random() * 10); + const endPos = startPos.delta(0, deletionLen); + const range = new monaco.Range(startPos.lineNumber, startPos.column, endPos.lineNumber, endPos.column); + userCurrentTurn.textModel.applyEdits([{ range, text: '' }]); + } + expect(user1.textModel.getValue() === user2.textModel.getValue()).toBeTruthy(); + expect(user1.yText.toString() === user2.yText.toString()).toBeTruthy(); + expect(user1.textModel.getValue() === user2.yText.toString()).toBeTruthy(); + expect(user1.yText.toString() === user2.textModel.getValue()).toBeTruthy(); + } + }); +}); + +describe('TextModelBinding test for editor', () => { + let editor: ICodeEditor; + let doc: Y.Doc; + let binding: TextModelBinding; + let yText: Y.Text; + let textModel: monaco.editor.ITextModel; + + beforeAll(() => { + doc = new Y.Doc(); + const wsProvider = new WebsocketProvider('ws://127.0.0.1:12345', 'test', doc, { connect: false }); + + const { + binding: _binding, + textModel: _textModel, + yText: _yText, + } = createBindingWithTextModel(doc, wsProvider.awareness); + binding = _binding; + yText = _yText; + textModel = _textModel; + + // FIXME correct this type + editor = monaco.editor.create(document.createElement('div'), { value: '' }) as any as ICodeEditor; + + editor.setModel(textModel); + + expect(editor.getModel() === textModel).toBeTruthy(); + }); + + it('should add editor and register corresponding events', () => { + const setSpy = jest.spyOn(binding, 'addEditor'); + const registerSpy = jest.spyOn(editor, 'onDidChangeCursorSelection'); + binding.addEditor(editor); + expect(setSpy).toBeCalledTimes(1); + expect(registerSpy).toBeCalledTimes(1); + }); + + it('should fire relevant events after changing selection', () => { + // insert some text + textModel.applyEdits([{ range: new monaco.Range(1, 1, 1, 1), text: '114514' }]); + + const probeFnForAwareness = jest.fn(); + binding['awareness'].on('change', probeFnForAwareness); + const probeFnForEditor = jest.fn(); + const disposable = editor.onDidChangeCursorSelection(probeFnForEditor); + + // events are onDidChangeCursorSelection, awareness-related + const range = new monaco.Range(1, 1, 1, 4); // text => 114 + // will fire events + editor.setSelection(range); + + expect(probeFnForAwareness).toBeCalled(); + expect(probeFnForEditor).toBeCalled(); + + const state = binding['awareness'].getLocalState()!; + expect('selection' in state).toBeTruthy(); + // check correctness of awareness field + const selectionField: { + anchor: Y.RelativePosition; + head: Y.RelativePosition; + } = state['selection']!; + expect(selectionField).toBeTruthy(); + + { + const relStart = selectionField.anchor; + const relEnd = selectionField.head; + expect(relStart).toBeInstanceOf(Y.RelativePosition); + expect(relEnd).toBeInstanceOf(Y.RelativePosition); + // convert back to abs position + const absStart = Y.createAbsolutePositionFromRelativePosition(relStart, doc)!; + const absEnd = Y.createAbsolutePositionFromRelativePosition(relEnd, doc)!; + expect(absStart !== null).toBeTruthy(); + expect(absEnd !== undefined).toBeTruthy(); + // create range from relative position + const start = textModel.getPositionAt(absStart.index); + const end = textModel.getPositionAt(absEnd.index); + const rangeFromYRelativePosition = new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column); + expect(rangeFromYRelativePosition.equalsRange(range)).toBeTruthy(); + } + + // dust + binding['awareness'].off('change', probeFnForAwareness); + disposable.dispose(); + }); + + it('should save current selections before Y transaction and restore current selections after Y.Text was changed', () => { + // init value + textModel.setValue(''); + textModel.applyEdits([{ range: new monaco.Range(1, 1, 1, 1), text: '114514' }]); + + const probeFnForYDocBeforeAllTransaction = jest.fn(); + doc.on('beforeAllTransactions', probeFnForYDocBeforeAllTransaction); + + // first set selection + editor.setSelection(new monaco.Range(1, 1, 1, 4)); // => 114 + // then apply edits to editor + yText.insert(1, '1919810'); // simulate edit from other person + + expect(probeFnForYDocBeforeAllTransaction).toBeCalled(); + + // check selection backup result, check if it can be restored correctly + expect(binding['savedSelections'].has(editor)).toBeTruthy(); + { + const savedSelection = binding['savedSelections'].get(editor)!; + const absStart = Y.createAbsolutePositionFromRelativePosition(savedSelection.start, doc)!; + const absEnd = Y.createAbsolutePositionFromRelativePosition(savedSelection.end, doc)!; + expect(absStart).toBeInstanceOf(Y.AbsolutePosition); + expect(absEnd).toBeInstanceOf(Y.AbsolutePosition); + // construct range + const start = textModel.getPositionAt(absStart.index); + const end = textModel.getPositionAt(absEnd.index); + const range = new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column); + const currentSelection = editor.getSelection()!; + expect(currentSelection !== null).toBeTruthy(); + expect(currentSelection.equalsRange(range)).toBeTruthy(); + expect(currentSelection.equalsRange(new monaco.Range(1, 1, 1, 11))).toBeTruthy(); + } + + doc.off('beforeAllTransactions', probeFnForYDocBeforeAllTransaction); + }); + + afterAll(() => { + binding.destroy(); + }); +}); diff --git a/packages/collaboration/__tests__/node/service.test.ts b/packages/collaboration/__tests__/node/service.test.ts new file mode 100644 index 0000000000..aef0ea7ced --- /dev/null +++ b/packages/collaboration/__tests__/node/service.test.ts @@ -0,0 +1,80 @@ +import * as Y from 'yjs'; + +import { INodeLogger } from '@opensumi/ide-core-node'; +import { createNodeInjector } from '@opensumi/ide-dev-tool/src/injector-helper'; +import { MockInjector } from '@opensumi/ide-dev-tool/src/mock-injector'; +import { FileStat, IFileService } from '@opensumi/ide-file-service'; +import { FileService } from '@opensumi/ide-file-service/src/node'; + +import { ICollaborationServiceForClient, IYWebsocketServer, ROOM_NAME } from '../../src'; +import { CollaborationServiceForClient } from '../../src/node/collaboration.service'; +import { YWebsocketServerImpl } from '../../src/node/y-websocket-server'; + +describe('Collaboration node ws server test', () => { + let injector: MockInjector; + let server: YWebsocketServerImpl; + let service: CollaborationServiceForClient; + let yDoc: Y.Doc; + + const MOCK_CONTENT = 'init mock content'; + + beforeAll(() => { + injector = createNodeInjector([]); + injector.mockService(INodeLogger); + injector.mockService(IFileService); + injector.addProviders( + { + token: IYWebsocketServer, + useClass: YWebsocketServerImpl, + }, + { + token: ICollaborationServiceForClient, + useClass: CollaborationServiceForClient, + }, + ); + + const fileService: FileService = injector.get(IFileService); + jest.spyOn(fileService, 'resolveContent').mockImplementation(async () => ({ content: MOCK_CONTENT } as any)); + + server = injector.get(IYWebsocketServer); + service = injector.get(ICollaborationServiceForClient); + }); + + it('should correctly initialize', () => { + const spy = jest.spyOn(server, 'initialize'); + server.initialize(); + expect(spy).toBeCalled(); + }); + + it('should get Y.Doc', () => { + yDoc = server.getYDoc(ROOM_NAME); + expect(yDoc).toBeInstanceOf(Y.Doc); + }); + + const TEST_URI = 'file://foo'; + + it('should set init content correctly', async () => { + await service.requestInitContent(TEST_URI); + const yMap: Y.Map = yDoc.getMap(); + expect(yMap.has(TEST_URI)).toBeTruthy(); + expect(yMap.get(TEST_URI)).toBeInstanceOf(Y.Text); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + expect(yMap.get(TEST_URI)!.toString()).toBe(MOCK_CONTENT); + }); + + it('should remove Y.Text', () => { + server.removeYText(TEST_URI); + const yMap = yDoc.getMap(); + expect(yMap.has(TEST_URI)).toBeFalsy(); + }); + + it('should correctly dispose', () => { + const spy = jest.spyOn(server, 'destroy'); + server.destroy(); + expect(spy).toBeCalled(); + }); + + afterAll(() => { + yDoc.destroy(); + }); +}); diff --git a/packages/collaboration/package.json b/packages/collaboration/package.json new file mode 100644 index 0000000000..768097eb71 --- /dev/null +++ b/packages/collaboration/package.json @@ -0,0 +1,35 @@ +{ + "name": "@opensumi/ide-collaboration", + "version": "2.20.1", + "files": [ + "lib" + ], + "license": "MIT", + "main": "lib/index.js", + "typings": "lib/index.d.ts", + "scripts": { + "prepublishOnly": "npm run build", + "build": "tsc --build ../../configs/ts/references/tsconfig.collaboration.json" + }, + "repository": { + "type": "git", + "url": "git@github.com:opensumi/core.git" + }, + "dependencies": { + "@opensumi/ide-core-common": "^2.20.1", + "@opensumi/ide-core-node": "^2.20.1", + "@opensumi/ide-file-service": "^2.20.1", + "y-websocket": "^1.3.9", + "yjs": "^13.5.39", + "lib0": "^0.2.51", + "ws": "^8.8.0" + }, + "devDependencies": { + "@opensumi/ide-core-browser": "^2.20.1", + "@opensumi/ide-editor": "2.20.1", + "@opensumi/ide-monaco": "2.20.1", + "@opensumi/ide-theme": "2.20.1", + "@opensumi/ide-dev-tool": "^1.3.1", + "y-protocols": "^1.0.2" + } +} diff --git a/packages/collaboration/src/browser/collaboration.contribution.ts b/packages/collaboration/src/browser/collaboration.contribution.ts new file mode 100644 index 0000000000..4a4d3323a9 --- /dev/null +++ b/packages/collaboration/src/browser/collaboration.contribution.ts @@ -0,0 +1,90 @@ +import { Autowired } from '@opensumi/di'; +import { + ClientAppContribution, + KeybindingContribution, + KeybindingRegistry, + KeybindingWeight, + PreferenceService, +} from '@opensumi/ide-core-browser'; +import { CommandContribution, CommandRegistry, ContributionProvider, Domain } from '@opensumi/ide-core-common'; +import { AUTO_SAVE_MODE } from '@opensumi/ide-editor'; + +import { ICollaborationService, CollaborationModuleContribution } from '../common'; +import { REDO, UNDO } from '../common/commands'; + +@Domain(ClientAppContribution, KeybindingContribution, CommandContribution) +export class CollaborationContribution implements ClientAppContribution, KeybindingContribution, CommandContribution { + @Autowired(ICollaborationService) + private collaborationService: ICollaborationService; + + @Autowired(PreferenceService) + private preferenceService: PreferenceService; + + @Autowired(CollaborationModuleContribution) + private readonly contributionProvider: ContributionProvider; + + private prevSetAskIfDiff: boolean; + private prevSetAutoChange: string; + + onDidStart() { + if (this.preferenceService.get('editor.askIfDiff') === true) { + this.prevSetAskIfDiff = true; + this.preferenceService.set('editor.askIfDiff', false); + } + + if (this.preferenceService.get('editor.autoSave') !== AUTO_SAVE_MODE.AFTER_DELAY) { + this.prevSetAutoChange = this.preferenceService.get('editor.autoSave') as string; + this.preferenceService.set('editor.autoSave', AUTO_SAVE_MODE.AFTER_DELAY); + } + + // before init + const providers = this.contributionProvider.getContributions(); + for (const provider of providers) { + this.collaborationService.registerContribution(provider); + } + + this.collaborationService.initialize(); + } + + onStop() { + if (this.prevSetAskIfDiff !== undefined) { + this.preferenceService.set('editor.askIfDiff', this.prevSetAskIfDiff); + } + + if (this.prevSetAutoChange !== undefined) { + this.preferenceService.set('editor.autoSave', this.prevSetAutoChange); + } + + this.collaborationService.destroy(); + } + + registerKeybindings(keybindings: KeybindingRegistry): void { + keybindings.registerKeybinding({ + command: UNDO.id, + keybinding: 'ctrlcmd+z', + when: 'editorFocus', + priority: KeybindingWeight.EditorContrib, + }); + + keybindings.registerKeybinding({ + command: REDO.id, + keybinding: 'shift+ctrlcmd+z', + when: 'editorFocus', + priority: KeybindingWeight.EditorContrib, + }); + } + + registerCommands(commands: CommandRegistry): void { + commands.registerCommand(UNDO, { + execute: () => { + this.collaborationService.undoOnFocusedTextModel(); + }, + }); + + commands.registerCommand(REDO, { + execute: () => { + this.collaborationService.redoOnFocusedTextModel(); + }, + }); + } +} diff --git a/packages/collaboration/src/browser/collaboration.service.ts b/packages/collaboration/src/browser/collaboration.service.ts new file mode 100644 index 0000000000..7efb43653d --- /dev/null +++ b/packages/collaboration/src/browser/collaboration.service.ts @@ -0,0 +1,313 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { WebsocketProvider } from 'y-websocket'; +import * as Y from 'yjs'; + +import { Injectable, Autowired, Inject, INJECTOR_TOKEN, Injector } from '@opensumi/di'; +import { AppConfig } from '@opensumi/ide-core-browser'; +import { Deferred, ILogger, OnEvent, uuid, WithEventBus } from '@opensumi/ide-core-common'; +import { WorkbenchEditorService } from '@opensumi/ide-editor'; +import { + EditorDocumentModelCreationEvent, + EditorDocumentModelRemovalEvent, + EditorGroupCloseEvent, + EditorGroupOpenEvent, + IEditorDocumentModelService, +} from '@opensumi/ide-editor/lib/browser'; +import { WorkbenchEditorServiceImpl } from '@opensumi/ide-editor/lib/browser/workbench-editor.service'; +import { ITextModel, ICodeEditor } from '@opensumi/ide-monaco'; +import { ICSSStyleService } from '@opensumi/ide-theme'; + +import { + CollaborationServiceForClientPath, + ICollaborationService, + ICollaborationServiceForClient, + ROOM_NAME, + UserInfo, + CollaborationModuleContribution, + Y_REMOTE_SELECTION, + Y_REMOTE_SELECTION_HEAD, + COLLABORATION_PORT, +} from '../common'; + +import { getColorByClientID } from './color'; +import { CursorWidgetRegistry } from './cursor-widget'; +import { TextModelBinding } from './textmodel-binding'; + +import './styles.less'; + +@Injectable() +export class CollaborationService extends WithEventBus implements ICollaborationService { + @Autowired(INJECTOR_TOKEN) + private injector: Injector; + + @Autowired(ILogger) + private logger: ILogger; + + @Autowired(WorkbenchEditorService) + private workbenchEditorService: WorkbenchEditorServiceImpl; + + @Autowired(ICSSStyleService) + private cssManager: ICSSStyleService; + + @Autowired(IEditorDocumentModelService) + private docModelManager: IEditorDocumentModelService; + + @Autowired(AppConfig) + private appConfig: AppConfig; + + private clientIDStyleAddedSet: Set = new Set(); + + private cursorRegistryMap: Map = new Map(); + + private userInfo: UserInfo; + + private yDoc: Y.Doc; + + private yWebSocketProvider: WebsocketProvider; + + private yTextMap: Y.Map; + + private bindingMap: Map = new Map(); + + private yMapReadyMap: Map> = new Map(); + + private bindingReadyMap: Map> = new Map(); + + private yMapObserver = (event: Y.YMapEvent) => { + const changes = event.changes.keys; + changes.forEach((change, key) => { + if (change.action === 'add') { + const { yMapReady } = this.getDeferred(key); + const binding = this.getBinding(key); + if (binding) { + const text = this.yTextMap.get(key)!; + binding.changeYText(text); + } + yMapReady.resolve(); + } else if (change.action === 'delete') { + this.resetDeferredYMapKey(key); + } + }); + }; + + constructor(@Inject(CollaborationServiceForClientPath) private readonly backService: ICollaborationServiceForClient) { + super(); + } + + initialize() { + this.yDoc = new Y.Doc(); + this.yTextMap = this.yDoc.getMap(); + + // transform url + const wsPath = new URL(this.appConfig.wsPath.toString()); + wsPath.port = String(COLLABORATION_PORT); + this.yWebSocketProvider = new WebsocketProvider(wsPath.toString(), ROOM_NAME, this.yDoc); + + this.yTextMap.observe(this.yMapObserver); + + if (this.userInfo === undefined) { + // fallback + this.userInfo = { + id: uuid().slice(0, 4), + nickname: `${uuid().slice(0, 4)}`, + }; + } + // add userInfo to awareness field + this.yWebSocketProvider.awareness.setLocalStateField('user-info', this.userInfo); + + this.yWebSocketProvider.awareness.on('update', this.updateCSSManagerWhenAwarenessUpdated); + } + + destroy() { + this.yWebSocketProvider.awareness.off('update', this.updateCSSManagerWhenAwarenessUpdated); + this.clientIDStyleAddedSet.forEach((clientID) => { + this.cssManager.removeClass(`${Y_REMOTE_SELECTION}-${clientID}`); + this.cssManager.removeClass(`${Y_REMOTE_SELECTION_HEAD}-${clientID}`); + this.cssManager.removeClass(`${Y_REMOTE_SELECTION_HEAD}-${clientID}::after`); + }); + this.yTextMap.unobserve(this.yMapObserver); + this.yWebSocketProvider.disconnect(); + this.bindingMap.forEach((binding) => binding.destroy()); + } + + registerContribution(contribution: CollaborationModuleContribution) { + if (this.userInfo) { + throw new Error('User info is already registered'); + } + + if (contribution.info) { + this.userInfo = contribution.info; + } + } + + undoOnFocusedTextModel() { + const uri = this.workbenchEditorService.currentResource?.uri.toString(); + if (uri && this.bindingMap.has(uri)) { + this.bindingMap.get(uri)!.undo(); + } + } + + redoOnFocusedTextModel() { + const uri = this.workbenchEditorService.currentResource?.uri.toString(); + if (uri && this.bindingMap.has(uri)) { + this.bindingMap.get(uri)!.redo(); + } + } + + private getDeferred(uri: string) { + if (!this.bindingReadyMap.has(uri)) { + this.bindingReadyMap.set(uri, new Deferred()); + } + if (!this.yMapReadyMap.has(uri)) { + this.yMapReadyMap.set(uri, new Deferred()); + } + + const bindingReady = this.bindingReadyMap.get(uri)!; + const yMapReady = this.yMapReadyMap.get(uri)!; + + return { bindingReady, yMapReady }; + } + + private resetDeferredYMapKey(uri: string) { + if (this.yMapReadyMap.has(uri)) { + this.yMapReadyMap.set(uri, new Deferred()); + } + } + + private resetDeferredBinding(uri: string) { + if (this.bindingReadyMap.has(uri)) { + this.bindingReadyMap.set(uri, new Deferred()); + } + } + + private createAndSetBinding(uri: string, model: ITextModel): TextModelBinding { + const cond = this.bindingMap.has(uri); + + if (!cond) { + const binding = this.injector.get(TextModelBinding, [ + this.yTextMap.get(uri)!, // only be called when entry of yMap is ready + model, + this.yWebSocketProvider.awareness, + ]); + this.bindingMap.set(uri, binding); + return binding; + } else { + return this.bindingMap.get(uri)!; + } + } + + private getBinding(uri: string) { + const cond = this.bindingMap.has(uri); + + if (cond) { + return this.bindingMap.get(uri)!; + } else { + return null; + } + } + + private removeBinding(uri: string) { + const binding = this.bindingMap.get(uri); + + if (binding) { + binding.destroy(); + this.bindingMap.delete(uri); + } + } + + public getCursorWidgetRegistry(editor: ICodeEditor) { + return this.cursorRegistryMap.get(editor); + } + + private updateCSSManagerWhenAwarenessUpdated = (changes: { + added: number[]; + updated: number[]; + removed: number[]; + }) => { + if (changes.added.length > 0) { + changes.added.forEach((clientID) => { + if (!this.clientIDStyleAddedSet.has(clientID)) { + const color = getColorByClientID(clientID); + this.cssManager.addClass(`${Y_REMOTE_SELECTION}-${clientID}`, { + backgroundColor: color, + opacity: '0.25', + }); + this.cssManager.addClass(`${Y_REMOTE_SELECTION_HEAD}-${clientID}`, { + position: 'absolute', + borderLeft: `${color} solid 2px`, + borderBottom: `${color} solid 2px`, + borderTop: `${color} solid 2px`, + height: '100%', + boxSizing: 'border-box', + }); + this.cssManager.addClass(`${Y_REMOTE_SELECTION_HEAD}-${clientID}::after`, { + position: 'absolute', + content: ' ', + border: `3px solid ${color}`, + left: '-4px', + top: '-5px', + }); + this.clientIDStyleAddedSet.add(clientID); + } + }); + } + }; + + @OnEvent(EditorDocumentModelCreationEvent) + private async editorDocumentModelCreationHandler(e: EditorDocumentModelCreationEvent) { + const uriString = e.payload.uri.toString(); + const { bindingReady, yMapReady } = this.getDeferred(uriString); + await this.backService.requestInitContent(uriString); + await yMapReady.promise; + // get monaco model from model ref by uri + const ref = this.docModelManager.getModelReference(e.payload.uri); + const monacoModel = ref?.instance.getMonacoModel(); + ref?.dispose(); + if (monacoModel) { + this.createAndSetBinding(uriString, monacoModel); + } + bindingReady.resolve(); + } + + @OnEvent(EditorDocumentModelRemovalEvent) + private async editorDocumentModelRemovalHandler(e: EditorDocumentModelRemovalEvent) { + const uriString = e.payload.codeUri.toString(); + const { bindingReady } = this.getDeferred(uriString); + await bindingReady.promise; + this.removeBinding(uriString); + this.resetDeferredBinding(uriString); + } + + @OnEvent(EditorGroupOpenEvent) + private async groupOpenHandler(e: EditorGroupOpenEvent) { + const uriString = e.payload.resource.uri.toString(); + const { bindingReady } = this.getDeferred(uriString); + await bindingReady.promise; + const binding = this.getBinding(uriString); + if (binding) { + binding.addEditor(e.payload.group.codeEditor.monacoEditor); + } + // create content widget registry + // check if editor has its widgetRegistry + const monacoEditor = e.payload.group.codeEditor.monacoEditor; + if (!this.cursorRegistryMap.has(monacoEditor) && monacoEditor) { + const registry = this.injector.get(CursorWidgetRegistry, [monacoEditor, this.yWebSocketProvider.awareness]); + this.cursorRegistryMap.set(monacoEditor, registry); + monacoEditor.onDidDispose(() => { + this.cursorRegistryMap.delete(monacoEditor); + registry.destroy(); + }); + } + } + + @OnEvent(EditorGroupCloseEvent) + private async groupCloseHandler(e: EditorGroupCloseEvent) { + const uriString = e.payload.resource.uri.toString(); + const { bindingReady } = this.getDeferred(uriString); + await bindingReady.promise; + const binding = this.getBinding(uriString); + if (binding) { + binding.removeEditor(e.payload.group.codeEditor.monacoEditor); + } + } +} diff --git a/packages/collaboration/src/browser/color.ts b/packages/collaboration/src/browser/color.ts new file mode 100644 index 0000000000..48bd6f6436 --- /dev/null +++ b/packages/collaboration/src/browser/color.ts @@ -0,0 +1,4 @@ +const color = ['red', 'orange', 'yellow', 'green', 'blue', 'indigo', 'violent']; + +// get color by clientID +export const getColorByClientID = (id: number): string => color[id % color.length]; diff --git a/packages/collaboration/src/browser/cursor-widget.ts b/packages/collaboration/src/browser/cursor-widget.ts new file mode 100644 index 0000000000..f9b4288c4f --- /dev/null +++ b/packages/collaboration/src/browser/cursor-widget.ts @@ -0,0 +1,146 @@ +import { Awareness } from 'y-protocols/awareness'; + +import { Autowired, Injectable, Injector, INJECTOR_TOKEN } from '@opensumi/di'; +import { IDisposable } from '@opensumi/ide-core-common'; +import { ICodeEditor } from '@opensumi/ide-monaco'; +import { monaco } from '@opensumi/ide-monaco/lib/browser/monaco-api'; +import { + IContentWidget, + IContentWidgetPosition, +} from '@opensumi/monaco-editor-core/esm/vs/editor/browser/editorBrowser'; + +import { ICursorWidgetRegistry, UserInfo, Y_REMOTE_SELECTION } from '../common'; + +const createPositionFrom = (lineNumber: number, column: number): IContentWidgetPosition => ({ + position: { lineNumber, column }, + preference: [ + monaco.editor.ContentWidgetPositionPreference.ABOVE, + monaco.editor.ContentWidgetPositionPreference.BELOW, + ], +}); + +/** + * one editor holds one CursorWidgetRegistry + */ +@Injectable({ multiple: true }) +export class CursorWidgetRegistry implements ICursorWidgetRegistry { + @Autowired(INJECTOR_TOKEN) + private readonly injector: Injector; + + /** + * store all widgets here, and widgets will be automatically added or removed from this registry + * + * clientID => widget + */ + widgets: Map = new Map(); + + // target editor + editor: ICodeEditor; + + awareness: Awareness; + + disposable: IDisposable; + + constructor(editor: ICodeEditor, awareness: Awareness) { + this.editor = editor; + this.awareness = awareness; + this.disposable = editor.onDidDispose(() => this.destroy()); + awareness.on('update', this.onAwarenessStateChange); + + this.getWidgetFromRegistry(); + } + + private getWidgetFromRegistry() { + // create widget from awareness + this.awareness.getStates().forEach((state, clientID) => { + const info: UserInfo = state['user-info']; + if (info) { + this.createWidget(clientID, info.nickname); + } + }); + } + + updatePositionOf(clientID: number, lineNumber: number, column: number) { + const widget = this.widgets.get(clientID); + if (widget) { + widget.position = createPositionFrom(lineNumber, column); + } + } + + removeAllPositions() { + this.widgets.forEach((widget) => { + widget.position = null; + }); + } + + layoutAllWidgets() { + this.widgets.forEach((widget) => { + this.editor.layoutContentWidget(widget); + }); + } + + destroy() { + // remove all from editor + this.widgets.forEach((widget) => { + this.editor.removeContentWidget(widget); + }); + this.awareness.off('update', this.onAwarenessStateChange); + if (this.disposable) { + this.disposable.dispose(); + } + } + + private createWidget(clientID: number, nickname: string) { + if (!this.widgets.has(clientID)) { + const widget = this.injector.get(CursorWidget, [nickname, clientID]); + this.editor.addContentWidget(widget); + this.widgets.set(clientID, widget); + } + } + + private deleteWidget(clientID: number) { + const widget = this.widgets.get(clientID); + if (widget) { + this.editor.removeContentWidget(widget); + this.widgets.delete(clientID); + } + } + + private onAwarenessStateChange = (changes: { added: number[]; updated: number[]; removed: number[] }) => { + if (changes.added.length > 0) { + this.getWidgetFromRegistry(); + } + }; +} + +@Injectable({ multiple: true }) +export class CursorWidget implements IContentWidget { + private domNode: HTMLElement; + + private id: string; + + position: IContentWidgetPosition | null = null; + + constructor(nickname: string, clientID: string) { + // init dom node + this.domNode = document.createElement('div'); + this.domNode.innerHTML = nickname; + this.domNode.style.opacity = '1'; + this.domNode.style.whiteSpace = 'nowrap'; + this.domNode.className = `${Y_REMOTE_SELECTION}-${clientID}`; + // set id + this.id = `cursor-widget-${nickname}`; + } + + getId(): string { + return this.id; + } + + getDomNode(): HTMLElement { + return this.domNode; + } + + getPosition(): IContentWidgetPosition | null { + return this.position; + } +} diff --git a/packages/collaboration/src/browser/index.ts b/packages/collaboration/src/browser/index.ts new file mode 100644 index 0000000000..9fe3c3b1a8 --- /dev/null +++ b/packages/collaboration/src/browser/index.ts @@ -0,0 +1,25 @@ +import { Provider, Injectable, Domain } from '@opensumi/di'; +import { BrowserModule } from '@opensumi/ide-core-browser'; + +import { ICollaborationService, CollaborationServiceForClientPath, CollaborationModuleContribution } from '../common'; + +import { CollaborationContribution } from './collaboration.contribution'; +import { CollaborationService } from './collaboration.service'; + +@Injectable() +export class CollaborationModule extends BrowserModule { + contributionProvider: Domain | Domain[] = [CollaborationModuleContribution]; + providers: Provider[] = [ + CollaborationContribution, + { + token: ICollaborationService, + useClass: CollaborationService, + }, + ]; + + backServices = [ + { + servicePath: CollaborationServiceForClientPath, + }, + ]; +} diff --git a/packages/collaboration/src/browser/styles.less b/packages/collaboration/src/browser/styles.less new file mode 100644 index 0000000000..8b551a6b82 --- /dev/null +++ b/packages/collaboration/src/browser/styles.less @@ -0,0 +1,22 @@ +// fallback style + +.yRemoteSelection { + background-color: rgba(255, 192, 203, 0.25); +} + +.yRemoteSelectionHead { + position: absolute; + border-left: pink solid 2px; + border-top: pink solid 2px; + border-bottom: pink solid 2px; + height: 100%; + box-sizing: border-box; +} + +.yRemoteSelectionHead::after { + position: absolute; + content: ' '; + border: 3px solid pink; + left: -4px; + top: -5px; +} diff --git a/packages/collaboration/src/browser/textmodel-binding.ts b/packages/collaboration/src/browser/textmodel-binding.ts new file mode 100644 index 0000000000..f92ceecf82 --- /dev/null +++ b/packages/collaboration/src/browser/textmodel-binding.ts @@ -0,0 +1,334 @@ +import { createMutex } from 'lib0/mutex'; +import { Awareness } from 'y-protocols/awareness'; +import * as Y from 'yjs'; + +import { Injectable, Autowired } from '@opensumi/di'; +import { ITextModel, ICodeEditor, Position } from '@opensumi/ide-monaco'; +import { IModelDeltaDecoration } from '@opensumi/ide-monaco/lib/browser/monaco-api/editor'; +import { + editor, + SelectionDirection, + Selection, + Range, + IDisposable, +} from '@opensumi/monaco-editor-core/esm/vs/editor/editor.api'; + +import { ICollaborationService, ITextModelBinding, Y_REMOTE_SELECTION, Y_REMOTE_SELECTION_HEAD } from '../common'; + +import { CollaborationService } from './collaboration.service'; + +@Injectable({ multiple: true }) +export class TextModelBinding implements ITextModelBinding { + @Autowired(ICollaborationService) + private collaborationService: CollaborationService; + + private savedSelections: Map = new Map(); + + private mutex = createMutex(); + + private doc: Y.Doc; + + private disposableContentChangeHandler: IDisposable; + + private decorations: Map = new Map(); + + private editors: Set; + + private disposables: Map = new Map(); + + private undoManger: Y.UndoManager; + + constructor( + private yText: Y.Text, + private textModel: ITextModel, + private awareness: Awareness, + editor?: ICodeEditor, + ) { + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + this.doc = yText.doc!; + this.editors = new Set(); + if (editor) { + this.editors.add(editor); + } + this.initialize(); + } + + /** + * Render decorations + */ + private renderDecorations = () => { + this.editors.forEach((editor) => { + if (editor.getModel() === this.textModel) { + const currentDecorations = this.decorations.get(editor) ?? []; + const newDecorations: IModelDeltaDecoration[] = []; // re-populate decorations + + const cursorWidgetRegistry = this.collaborationService.getCursorWidgetRegistry(editor); + + // set position of CursorWidget to null + cursorWidgetRegistry?.removeAllPositions(); + + this.awareness.getStates().forEach((state, clientID) => { + // if clientID is not mine, and selection from this client is not empty + if ( + clientID !== this.doc.clientID && + state.selection != null && + state.selection.anchor != null && + state.selection.head != null + ) { + const anchorAbs = Y.createAbsolutePositionFromRelativePosition(state.selection.anchor, this.doc); + const headAbs = Y.createAbsolutePositionFromRelativePosition(state.selection.head, this.doc); + + if ( + anchorAbs !== null && + headAbs !== null && + // ensure that the client is in the same Y.Text with mine + anchorAbs.type === this.yText && + headAbs.type === this.yText + ) { + let start: Position; + let end: Position; + let afterContentClassName: string | null; + let beforeContentClassName: string | null; + + // check if LTR or RTL + if (anchorAbs.index < headAbs.index) { + start = this.textModel.getPositionAt(anchorAbs.index); + end = this.textModel.getPositionAt(headAbs.index); + afterContentClassName = `${Y_REMOTE_SELECTION} ${Y_REMOTE_SELECTION_HEAD}-${clientID}`; + beforeContentClassName = null; + } else { + start = this.textModel.getPositionAt(headAbs.index); + end = this.textModel.getPositionAt(anchorAbs.index); + afterContentClassName = null; + beforeContentClassName = `${Y_REMOTE_SELECTION_HEAD} ${Y_REMOTE_SELECTION_HEAD}-${clientID}`; + } + + newDecorations.push({ + range: new Range(start.lineNumber, start.column, end.lineNumber, end.column), + options: { + description: 'yjs decoration ' + clientID, + className: `${Y_REMOTE_SELECTION} ${Y_REMOTE_SELECTION}-${clientID}`, + afterContentClassName, + beforeContentClassName, + }, + }); + + // update position + cursorWidgetRegistry?.updatePositionOf(clientID, end.lineNumber, end.column); + } + } + }); + + // invoke layoutWidget method to update update all cursor widgets + cursorWidgetRegistry?.layoutAllWidgets(); + + // delta update decorations + this.decorations.set(editor, editor.deltaDecorations(currentDecorations, newDecorations)); + } else { + // remove all decoration, when current active TextModel of this editor is not this.textModel + this.decorations.delete(editor); + } + }); + }; + + private yTextObserver = (event: Y.YTextEvent) => { + this.mutex(() => { + // fixme line seq issue + let index = 0; + event.delta.forEach((op) => { + if (op.retain !== undefined) { + index += op.retain; + } else if (op.insert !== undefined) { + const pos = this.textModel.getPositionAt(index); + const range = new Selection(pos.lineNumber, pos.column, pos.lineNumber, pos.column); + this.textModel.applyEdits([{ range, text: op.insert as string }]); + index += (op.insert as string).length; + } else if (op.delete !== undefined) { + const pos = this.textModel.getPositionAt(index); + const endPos = this.textModel.getPositionAt(index + op.delete); + const range = new Selection(pos.lineNumber, pos.column, endPos.lineNumber, endPos.column); + this.textModel.applyEdits([{ range, text: '' }]); + } else { + throw new Error('Unexpected error'); + } + }); + this.savedSelections.forEach((relSelection, editor) => { + // restore self-saved selection + const sel = this.createMonacoSelectionFromRelativeSelection(relSelection); + if (sel !== null) { + editor.setSelection(sel); + } + }); + }); + this.renderDecorations(); + }; + + private beforeAllTransactionsHandler = () => { + this.mutex(() => { + this.savedSelections = new Map(); + this.editors.forEach((editor) => { + if (editor.getModel() === this.textModel) { + const relSelection = this.createRelativeSelection(editor); + if (relSelection !== null) { + this.savedSelections.set(editor, relSelection); + } + } + }); + }); + }; + + private createRelativeSelection(editor: ICodeEditor) { + const sel = editor.getSelection(); + const monacoModel = this.textModel; + const type = this.yText; + if (sel !== null) { + const startPos = sel.getStartPosition(); + const endPos = sel.getEndPosition(); + const start = Y.createRelativePositionFromTypeIndex(type, monacoModel.getOffsetAt(startPos)); + const end = Y.createRelativePositionFromTypeIndex(type, monacoModel.getOffsetAt(endPos)); + return new RelativeSelection(start, end, sel.getDirection()); + } + return null; + } + + private createMonacoSelectionFromRelativeSelection(relSel: RelativeSelection) { + const doc = this.doc; + const start = Y.createAbsolutePositionFromRelativePosition(relSel.start, doc); + const end = Y.createAbsolutePositionFromRelativePosition(relSel.end, doc); + const type = this.yText; + const model = this.textModel; + if (start !== null && end !== null && start.type === type && end.type === type) { + const startPos = model.getPositionAt(start.index); + const endPos = model.getPositionAt(end.index); + return Selection.createWithDirection( + startPos.lineNumber, + startPos.column, + endPos.lineNumber, + endPos.column, + relSel.direction, + ); + } + return null; + } + + private textModelOnDidChangeContentHandler = (event: editor.IModelContentChangedEvent) => { + // apply changes from right to left + this.mutex(() => { + this.doc.transact(() => { + event.changes + .sort((change1, change2) => change2.rangeOffset - change1.rangeOffset) + .forEach((change) => { + // it will trigger y.text event + this.yText.delete(change.rangeOffset, change.rangeLength); + this.yText.insert(change.rangeOffset, change.text); + }); + }, this); + }); + }; + + private onDidChangeCursorSelectionHandler = (editor: ICodeEditor) => () => { + if (editor.getModel() === this.textModel) { + const sel = editor.getSelection(); + if (sel === null) { + return; + } + let anchor = this.textModel.getOffsetAt(sel.getStartPosition()); + let head = this.textModel.getOffsetAt(sel.getEndPosition()); + if (sel.getDirection() === SelectionDirection.RTL) { + const tmp = anchor; + anchor = head; + head = tmp; + } + this.awareness.setLocalStateField('selection', { + anchor: Y.createRelativePositionFromTypeIndex(this.yText, anchor), + head: Y.createRelativePositionFromTypeIndex(this.yText, head), + }); + } + }; + + private setModelContent() { + const yTextValue = this.yText.toString(); + if (this.textModel.getValue() !== yTextValue) { + this.textModel.setValue(yTextValue); + } + } + + initialize() { + this.undoManger = new Y.UndoManager(this.yText, { + trackedOrigins: new Set([this]), + }); + + this.setModelContent(); + + // save current selections + this.yText.doc?.on('beforeAllTransactions', this.beforeAllTransactionsHandler); + + // yText observer + this.yText.observe(this.yTextObserver); + + this.disposableContentChangeHandler = this.textModel.onDidChangeContent(this.textModelOnDidChangeContentHandler); + + // register awareness + this.editors.forEach((editor) => { + this.disposables.set(editor, editor.onDidChangeCursorSelection(this.onDidChangeCursorSelectionHandler(editor))); + }); + + // when awareness changed, render decorations again + this.awareness.on('change', this.renderDecorations); + } + + changeYText(newText: Y.Text) { + this.destroy(); + this.yText = newText; + this.initialize(); + } + + undo() { + this.undoManger.undo(); + } + + redo() { + this.undoManger.redo(); + } + + addEditor(editor: ICodeEditor) { + if (!this.editors.has(editor)) { + this.disposables.set(editor, editor.onDidChangeCursorSelection(this.onDidChangeCursorSelectionHandler(editor))); + this.editors.add(editor); + } + this.renderDecorations(); + } + + removeEditor(editor: ICodeEditor) { + if (this.editors.has(editor)) { + this.disposables.get(editor)?.dispose(); + this.disposables.delete(editor); + this.editors.delete(editor); + } + this.renderDecorations(); + } + + /** + * Stop listening to all events + */ + destroy() { + this.undoManger.destroy(); + this.disposables.forEach((disposable) => disposable.dispose()); + this.disposableContentChangeHandler.dispose(); + this.doc.off('beforeAllTransactions', this.beforeAllTransactionsHandler); + this.yText.unobserve(this.yTextObserver); + this.awareness.off('change', this.renderDecorations); + } +} + +class RelativeSelection { + public start: Y.RelativePosition; + public end: Y.RelativePosition; + public direction: SelectionDirection; + + constructor(start: Y.RelativePosition, end: Y.RelativePosition, direction: SelectionDirection) { + this.start = start; + this.end = end; + this.direction = direction; + } +} diff --git a/packages/collaboration/src/common/commands.ts b/packages/collaboration/src/common/commands.ts new file mode 100644 index 0000000000..24de03b531 --- /dev/null +++ b/packages/collaboration/src/common/commands.ts @@ -0,0 +1,15 @@ +import { Command } from '@opensumi/ide-core-common'; + +const COMMAND_CATEGORY = 'Collaboration'; + +export const UNDO: Command = { + id: 'collaboration.undo', + label: 'collaboration.undo', // TODO i18n + category: COMMAND_CATEGORY, +}; + +export const REDO: Command = { + id: 'collaboration.redo', + label: 'collaboration.redo', // TODO i18n + category: COMMAND_CATEGORY, +}; diff --git a/packages/collaboration/src/common/constants.ts b/packages/collaboration/src/common/constants.ts new file mode 100644 index 0000000000..a6428da54d --- /dev/null +++ b/packages/collaboration/src/common/constants.ts @@ -0,0 +1,4 @@ +export const COLLABORATION_PORT = 12345; + +export const Y_REMOTE_SELECTION = 'yRemoteSelection'; +export const Y_REMOTE_SELECTION_HEAD = 'yRemoteSelectionHead'; diff --git a/packages/collaboration/src/common/index.ts b/packages/collaboration/src/common/index.ts new file mode 100644 index 0000000000..20c9749702 --- /dev/null +++ b/packages/collaboration/src/common/index.ts @@ -0,0 +1,3 @@ +export * from './commands'; +export * from './types'; +export * from './constants'; diff --git a/packages/collaboration/src/common/types.ts b/packages/collaboration/src/common/types.ts new file mode 100644 index 0000000000..802728b651 --- /dev/null +++ b/packages/collaboration/src/common/types.ts @@ -0,0 +1,72 @@ +import { YText } from 'yjs/dist/src/internals'; + +import { ICodeEditor } from '@opensumi/ide-monaco'; + +export const ICollaborationService = Symbol('ICollaborationService'); + +export interface ICollaborationService { + initialize(): void; + destroy(): void; + undoOnFocusedTextModel(): void; + redoOnFocusedTextModel(): void; + registerContribution(contribution: CollaborationModuleContribution): void; +} + +export interface ITextModelBinding { + initialize(): void; + changeYText(newText: YText): void; + undo(): void; + redo(): void; + addEditor(editor: ICodeEditor): void; + removeEditor(editor: ICodeEditor): void; + destroy(): void; +} + +export const IYWebsocketServer = Symbol('IYWebsocketServer'); + +export interface IYWebsocketServer { + requestInitContent(uri: string): Promise; +} + +export const CollaborationServiceForClientPath = 'CollaborationServiceForClientPath'; + +export const ICollaborationServiceForClient = Symbol('ICollaborationServiceForClient'); + +export interface ICollaborationServiceForClient { + requestInitContent(uri: string): Promise; +} + +export const ROOM_NAME = 'y-room-opensumi'; + +// user model for collaboration module +export const CollaborationModuleContribution = Symbol('CollaborationModuleContribution'); + +export interface CollaborationModuleContribution { + info: UserInfo; +} + +export interface UserInfo { + id: string; // unique id + nickname: string; // will be displayed on live cursor + // may be more data fields +} + +export interface ICursorWidgetRegistry { + /** + * update specified position of widget, but not invoke `layoutWidget` + */ + updatePositionOf(clientID: number, lineNumber: number, column: number): void; + /** + * set all position of widget to null + * @param editor + */ + removeAllPositions(editor: ICodeEditor): void; + /** + * update all position of widget, `layoutWidget` is invoked + */ + layoutAllWidgets(): void; + /** + * destroy this registry and all its widgets + */ + destroy(): void; +} diff --git a/packages/collaboration/src/index.ts b/packages/collaboration/src/index.ts new file mode 100644 index 0000000000..d0b9323665 --- /dev/null +++ b/packages/collaboration/src/index.ts @@ -0,0 +1 @@ +export * from './common'; diff --git a/packages/collaboration/src/node/collaboration.contribution.ts b/packages/collaboration/src/node/collaboration.contribution.ts new file mode 100644 index 0000000000..a820346835 --- /dev/null +++ b/packages/collaboration/src/node/collaboration.contribution.ts @@ -0,0 +1,23 @@ +import { Autowired } from '@opensumi/di'; +import { Domain, INodeLogger, ServerAppContribution } from '@opensumi/ide-core-node'; + +import { IYWebsocketServer } from '../common'; + +import { YWebsocketServerImpl } from './y-websocket-server'; + +@Domain(ServerAppContribution) +export class CollaborationNodeContribution implements ServerAppContribution { + @Autowired(IYWebsocketServer) + private server: YWebsocketServerImpl; + + @Autowired(INodeLogger) + private logger: INodeLogger; + + initialize() { + this.server.initialize(); + } + + onStop() { + this.server.destroy(); + } +} diff --git a/packages/collaboration/src/node/collaboration.service.ts b/packages/collaboration/src/node/collaboration.service.ts new file mode 100644 index 0000000000..40306f30de --- /dev/null +++ b/packages/collaboration/src/node/collaboration.service.ts @@ -0,0 +1,17 @@ +import { Autowired, Injectable } from '@opensumi/di'; +import { INodeLogger } from '@opensumi/ide-core-node'; + +import { ICollaborationServiceForClient, IYWebsocketServer } from '../common'; + +@Injectable() +export class CollaborationServiceForClient implements ICollaborationServiceForClient { + @Autowired(INodeLogger) + private logger: INodeLogger; + + @Autowired(IYWebsocketServer) + private server: IYWebsocketServer; + + async requestInitContent(uri: string): Promise { + await this.server.requestInitContent(uri); + } +} diff --git a/packages/collaboration/src/node/index.ts b/packages/collaboration/src/node/index.ts new file mode 100644 index 0000000000..295e81878b --- /dev/null +++ b/packages/collaboration/src/node/index.ts @@ -0,0 +1,30 @@ +import { Injectable, Provider } from '@opensumi/di'; +import { NodeModule } from '@opensumi/ide-core-node'; + +import { CollaborationServiceForClientPath, ICollaborationServiceForClient, IYWebsocketServer } from '../common'; + +import { CollaborationNodeContribution } from './collaboration.contribution'; +import { CollaborationServiceForClient } from './collaboration.service'; +import { YWebsocketServerImpl } from './y-websocket-server'; + +@Injectable() +export class CollaborationModule extends NodeModule { + providers: Provider[] = [ + CollaborationNodeContribution, + { + token: IYWebsocketServer, + useClass: YWebsocketServerImpl, + }, + { + token: ICollaborationServiceForClient, + useClass: CollaborationServiceForClient, + }, + ]; + + backServices = [ + { + servicePath: CollaborationServiceForClientPath, + token: ICollaborationServiceForClient, + }, + ]; +} diff --git a/packages/collaboration/src/node/y-websocket-server.ts b/packages/collaboration/src/node/y-websocket-server.ts new file mode 100644 index 0000000000..e87f4e9775 --- /dev/null +++ b/packages/collaboration/src/node/y-websocket-server.ts @@ -0,0 +1,116 @@ +import http from 'http'; + +import ws from 'ws'; +import utils from 'y-websocket/bin/utils'; +import * as Y from 'yjs'; + +import { Injectable, Autowired } from '@opensumi/di'; +import { INodeLogger } from '@opensumi/ide-core-node'; +import { FileChangeType, IFileService } from '@opensumi/ide-file-service'; +import { FileService } from '@opensumi/ide-file-service/lib/node'; + +import { IYWebsocketServer, ROOM_NAME } from '../common'; + +@Injectable() +export class YWebsocketServerImpl implements IYWebsocketServer { + @Autowired(INodeLogger) + private logger: INodeLogger; + + @Autowired(IFileService) + private fileService: FileService; + + private yDoc: Y.Doc; + + private yMap: Y.Map; + + private websocketServer: ws.Server; + + private server: http.Server; + + initialize() { + this.logger.debug('init y-websocket server'); + + this.server = http.createServer((req, res) => { + res.writeHead(200); + res.end('hello'); + }); + + const { setupWSConnection } = utils; // todo add typing? + + this.websocketServer = new ws.Server({ noServer: true }); + + this.websocketServer.on('connection', setupWSConnection); + + this.server.on('upgrade', (req, socket, head) => { + const handleAuth = (ws) => { + this.websocketServer.emit('connection', ws, req); + }; + this.websocketServer.handleUpgrade(req, socket, head, handleAuth); + }); + + this.server.listen(12345, () => { + this.logger.log('y-websocket server listening on port 12345'); + }); + + // init + this.yDoc = this.getYDoc(ROOM_NAME); + this.yMap = this.yDoc.getMap(); + + this.yMap.observe((e) => { + e.changes.keys.forEach((change, key) => { + this.logger.debug(`[Collaboration] operation ${change.action} occurs on key ${key}`); + }); + }); + + this.fileService.onFilesChanged((e) => { + e.changes + .filter((e) => e.type === FileChangeType.DELETED) + .forEach((e) => { + if (e.type === FileChangeType.DELETED) { + this.logger.debug('on file event deleted', e); + this.removeYText(e.uri); + this.logger.debug('removed Y.Text of', e.uri); + } + }); + + e.changes + .filter((e) => e.type === FileChangeType.ADDED) + .forEach((e) => { + this.logger.debug('on file event added', e); + this.requestInitContent(e.uri); + }); + }); + } + + removeYText(uri: string) { + this.logger.debug('trying to remove uri', uri); + if (this.yMap.has(uri)) { + this.yMap.delete(uri); + this.logger.debug('removed', uri); + } + } + + async requestInitContent(uri: string): Promise { + try { + // load content from disk, not client + const { content } = await this.fileService.resolveContent(uri); + this.logger.debug('resolved content', content.substring(0, 20), 'from', uri); + if (!this.yMap.has(uri)) { + const yText = new Y.Text(content); // create yText with initial content + this.yMap.set(uri, yText); + } + } catch (e) { + this.logger.error(e); + } + } + + destroy() { + this.websocketServer.close(); + this.server.close(); + } + + getYDoc(room: string): Y.Doc { + const { getYDoc } = utils; + return getYDoc(room); + } +} diff --git a/packages/startup/entry/web/app.tsx b/packages/startup/entry/web/app.tsx index c84f85f850..2351baa9c3 100644 --- a/packages/startup/entry/web/app.tsx +++ b/packages/startup/entry/web/app.tsx @@ -6,6 +6,7 @@ setLocale('en-US'); import '@opensumi/ide-i18n'; import '@opensumi/ide-core-browser/lib/style/index.less'; +import { CollaborationModule } from '@opensumi/ide-collaboration/lib/browser'; import { SlotLocation } from '@opensumi/ide-core-browser'; import { ExpressFileServerModule } from '@opensumi/ide-express-file-server/lib/browser'; import { defaultConfig } from '@opensumi/ide-main-layout/lib/browser/default-config'; @@ -19,7 +20,7 @@ import { renderApp } from './render-app'; import '../styles.less'; renderApp({ - modules: [...CommonBrowserModules, ExpressFileServerModule, SampleModule, RemoteOpenerModule], + modules: [...CommonBrowserModules, ExpressFileServerModule, SampleModule, RemoteOpenerModule, CollaborationModule], layoutConfig: { ...defaultConfig, ...{ diff --git a/packages/startup/entry/web/server.ts b/packages/startup/entry/web/server.ts index f3691d79fe..a226dcd19d 100644 --- a/packages/startup/entry/web/server.ts +++ b/packages/startup/entry/web/server.ts @@ -1,9 +1,11 @@ import { startServer } from '@opensumi/ide-dev-tool/src/server'; +// eslint-disable-next-line import/order +import { CollaborationModule } from '@opensumi/ide-collaboration/lib/node'; import { ExpressFileServerModule } from '@opensumi/ide-express-file-server/lib/node'; import { OpenerModule } from '@opensumi/ide-remote-opener/lib/node'; import { CommonNodeModules } from '../../src/node/common-modules'; startServer({ - modules: [...CommonNodeModules, ExpressFileServerModule, OpenerModule], + modules: [...CommonNodeModules, ExpressFileServerModule, OpenerModule, CollaborationModule], }); diff --git a/packages/startup/package.json b/packages/startup/package.json index d802a9c60a..a68c757d68 100644 --- a/packages/startup/package.json +++ b/packages/startup/package.json @@ -59,6 +59,7 @@ }, "dependencies": { "@opensumi/ide-addons": "2.20.1", + "@opensumi/ide-collaboration": "2.20.1", "@opensumi/ide-comments": "2.20.1", "@opensumi/ide-core-browser": "2.20.1", "@opensumi/ide-core-common": "2.20.1",