-
Notifications
You must be signed in to change notification settings - Fork 393
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: support multi-person collaborative editing (#1274)
* feat: init collaboration module * chore: clean up * chore: add deps * feat: basic textmodel binding * chore: clean up codes * refactor: fix type error and refactor binding * refactor: rename * feat: store bindings * feat: undo manager * fix: only support CodeEditor * fix: add missing dependency * feat: binding now supports multiple editor * feat: support multiple editor * fix: support multiple editors * refactor: refine logic * refactor: refine logic * feat: force save * feat: node-side init * feat: init backservice * feat: set init content by server * feat: delete YText obj by server on file deletion * fix: add missing dep * refactor: make commands into constants * fix: wont destroy bindings when app is stopped * refactor: use `ROOM_NAME` constant * refactor: apply di to `TextModelBinding` * chore: remove unused log * test: create test for `CollaborationModule` * test: create test for `CollaborationContribution` * refactor: clean codes and add comments * refactor: use `EventBus` for `FilesChangeEvent` * refactor: modify class field accessibility * refactor: rename methods * test: basic test for `CollaborationService` * test: apply modifies to descriptions * chore: update deps * test: create test for `TextModelBinding` * test: completed test for `TextModelBinding` * test: create test for node side of collaboration * feat: handle fs event and resolve content in node * fix: destroy undoManager when destroying binding * feat(collaboration): init user info, cursor widget * fix: should set content before registering listener * feat: add user info and indicator * refactor: refactor constants and interface * test: update * feat: use `Deferred` to handle events * test: remove * test: update * test: update * refactor: move listener and handler from backService * refactor: rename * test: rename * refactor: remove methods from interface * refactor: classified types and constants * refactor: remove default contribution * refactor: rename to `CollaborationModuleContribution` * refactor: remove `logger.debug` statements * fix: do not remove styles when awareness removed * refactor: renamed textmodel-binding * refactor: refine TextModelBinding * refactor: rename interface methods * refactor: rename interface methods * test: add missing token of di provider * refactor: removed unused methods * feat: enable autosave on collaboration module did start * docs: init README * feat: get ws hostname from AppConfig * refactor: move constants * test: add mock `AppConfig` * chore: bump version to 2.20.1 Co-authored-by: Dan <[email protected]>
- Loading branch information
Showing
27 changed files
with
1,964 additions
and
2 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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__"] | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
86 changes: 86 additions & 0 deletions
86
packages/collaboration/__tests__/browser/collaboration-contribution.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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]); | ||
}); | ||
}); |
184 changes: 184 additions & 0 deletions
184
packages/collaboration/__tests__/browser/collaboration-service.test.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<MockWorkbenchEditorService>(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(); | ||
}); | ||
}); |
Oops, something went wrong.