Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support multi-person collaborative editing #1274

Merged
merged 80 commits into from
Sep 19, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
80 commits
Select commit Hold shift + click to select a range
f12a8dd
feat: init collaboration module
situ2001 Jul 5, 2022
d0fe352
chore: clean up
situ2001 Jul 6, 2022
a504eb9
chore: add deps
situ2001 Jul 7, 2022
07dd789
feat: basic textmodel binding
situ2001 Jul 8, 2022
0770cfb
chore: clean up codes
situ2001 Jul 8, 2022
523b6f6
refactor: fix type error and refactor binding
situ2001 Jul 8, 2022
42ddf9e
refactor: rename
situ2001 Jul 8, 2022
ea1e8d6
feat: store bindings
situ2001 Jul 8, 2022
654e620
feat: undo manager
situ2001 Jul 8, 2022
e9b3ee8
fix: only support CodeEditor
situ2001 Jul 8, 2022
94db44f
fix: add missing dependency
situ2001 Jul 8, 2022
04b1d2f
feat: binding now supports multiple editor
situ2001 Jul 9, 2022
5a5e5c0
feat: support multiple editor
situ2001 Jul 9, 2022
4468a98
fix: support multiple editors
situ2001 Jul 9, 2022
4106c89
refactor: refine logic
situ2001 Jul 10, 2022
f7c5c8d
refactor: refine logic
situ2001 Jul 10, 2022
31df2a1
feat: force save
situ2001 Jul 10, 2022
e076677
feat: node-side init
situ2001 Jul 13, 2022
386eb58
feat: init backservice
situ2001 Jul 13, 2022
f118bea
feat: set init content by server
situ2001 Jul 13, 2022
b1037ac
feat: delete YText obj by server on file deletion
situ2001 Jul 13, 2022
c9e0af5
fix: add missing dep
situ2001 Jul 13, 2022
c82d27d
refactor: make commands into constants
situ2001 Jul 17, 2022
4a56924
fix: wont destroy bindings when app is stopped
situ2001 Jul 17, 2022
8ad8993
refactor: use `ROOM_NAME` constant
situ2001 Jul 17, 2022
3386f0a
refactor: apply di to `TextModelBinding`
situ2001 Jul 17, 2022
2269629
chore: remove unused log
situ2001 Jul 17, 2022
86c3154
test: create test for `CollaborationModule`
situ2001 Jul 17, 2022
1a93355
test: create test for `CollaborationContribution`
situ2001 Jul 17, 2022
c1d4636
refactor: clean codes and add comments
situ2001 Jul 18, 2022
7161517
refactor: use `EventBus` for `FilesChangeEvent`
situ2001 Jul 19, 2022
e18f1e5
refactor: modify class field accessibility
situ2001 Jul 19, 2022
e4ceb9d
refactor: rename methods
situ2001 Jul 19, 2022
9e1209a
test: basic test for `CollaborationService`
situ2001 Jul 19, 2022
bd23945
test: apply modifies to descriptions
situ2001 Jul 19, 2022
074b7a9
chore: update deps
situ2001 Jul 20, 2022
c5361b4
test: create test for `TextModelBinding`
situ2001 Jul 22, 2022
cdf51c4
test: completed test for `TextModelBinding`
situ2001 Jul 24, 2022
9761d4f
test: create test for node side of collaboration
situ2001 Jul 24, 2022
5777218
Merge pull request #1 from situ2001/dev/collaboration
situ2001 Jul 24, 2022
301393e
feat: handle fs event and resolve content in node
situ2001 Jul 25, 2022
b13539d
fix: destroy undoManager when destroying binding
situ2001 Jul 25, 2022
0a5a3f1
Merge pull request #2 from situ2001/collaboration/migrate-fs-event-to…
situ2001 Jul 25, 2022
d79b24e
feat(collaboration): init user info, cursor widget
situ2001 Aug 6, 2022
9e7042e
fix: should set content before registering listener
situ2001 Aug 11, 2022
05daf57
Merge pull request #11 from situ2001/collaboration/fix-binding-init
situ2001 Aug 11, 2022
b581551
Merge branch 'feat/collaboration' into collaboration/user-model
situ2001 Aug 12, 2022
a523a52
feat: add user info and indicator
situ2001 Aug 12, 2022
d039585
refactor: refactor constants and interface
situ2001 Aug 12, 2022
fa84f06
test: update
situ2001 Aug 12, 2022
31d9b11
Merge pull request #12 from situ2001/collaboration/user-model
situ2001 Aug 12, 2022
a5394c1
feat: use `Deferred` to handle events
situ2001 Aug 15, 2022
a3a6806
test: remove
situ2001 Aug 15, 2022
601cee5
test: update
situ2001 Aug 15, 2022
fc05bcf
test: update
situ2001 Aug 15, 2022
3e4a5da
Merge pull request #13 from situ2001/collaboration/enhance
situ2001 Aug 15, 2022
7a02d67
refactor: move listener and handler from backService
situ2001 Aug 23, 2022
fc0089a
refactor: rename
situ2001 Aug 23, 2022
e14f154
test: rename
situ2001 Aug 23, 2022
32d899e
refactor: remove methods from interface
situ2001 Aug 23, 2022
46fd3af
Merge pull request #16 from situ2001/collaboration/refactor-node
situ2001 Aug 23, 2022
ed6a9e4
refactor: classified types and constants
situ2001 Aug 23, 2022
573e4c2
refactor: remove default contribution
situ2001 Aug 23, 2022
9666a76
refactor: rename to `CollaborationModuleContribution`
situ2001 Aug 23, 2022
46e1a4d
refactor: remove `logger.debug` statements
situ2001 Aug 23, 2022
0f9d08e
fix: do not remove styles when awareness removed
situ2001 Aug 23, 2022
4eaeac9
refactor: renamed textmodel-binding
situ2001 Aug 23, 2022
cd22513
refactor: refine TextModelBinding
situ2001 Aug 23, 2022
7a7467c
refactor: rename interface methods
situ2001 Aug 23, 2022
bd3798e
refactor: rename interface methods
situ2001 Aug 23, 2022
5f426c7
test: add missing token of di provider
situ2001 Aug 23, 2022
d7d995b
refactor: removed unused methods
situ2001 Aug 23, 2022
e0577d7
Merge pull request #17 from situ2001/collaboration/refactor-browser
situ2001 Aug 23, 2022
214aeec
feat: enable autosave on collaboration module did start
situ2001 Aug 23, 2022
e43469e
docs: init README
situ2001 Aug 23, 2022
dc7f38d
feat: get ws hostname from AppConfig
situ2001 Aug 23, 2022
124726a
refactor: move constants
situ2001 Aug 23, 2022
9c55566
test: add mock `AppConfig`
situ2001 Aug 26, 2022
e24d06c
Merge branch 'main' into feat/collaboration
erha19 Sep 14, 2022
b1c84bf
chore: bump version to 2.20.1
situ2001 Sep 16, 2022
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions configs/ts/references/tsconfig.collaboration.json
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__"]
}
3 changes: 3 additions & 0 deletions configs/ts/tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,9 @@
{
"path": "./references/tsconfig.markdown.json"
},
{
"path": "./references/tsconfig.collaboration.json"
},
{
"path": "./references/tsconfig.task.json"
},
Expand Down
2 changes: 2 additions & 0 deletions configs/ts/tsconfig.resolve.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
23 changes: 23 additions & 0 deletions packages/collaboration/README.md
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)
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 packages/collaboration/__tests__/browser/collaboration-service.test.ts
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();
});
});
Loading