Skip to content

Commit

Permalink
feat: support multi-person collaborative editing (#1274)
Browse files Browse the repository at this point in the history
* 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
2 people authored and bytemain committed Sep 27, 2022
1 parent 7a6f9fe commit f6544a2
Show file tree
Hide file tree
Showing 27 changed files with 1,964 additions and 2 deletions.
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

0 comments on commit f6544a2

Please sign in to comment.