UNPKG

open-collaboration-monaco

Version:

Connect a single Monaco Editor to an Open Collaboration Tools session

660 lines (587 loc) 26.1 kB
// ****************************************************************************** // Copyright 2024 TypeFox GmbH // This program and the accompanying materials are made available under the // terms of the MIT License, which is available in the project root. // ****************************************************************************** import { Deferred, DisposableCollection, ProtocolBroadcastConnection } from 'open-collaboration-protocol'; import * as Y from 'yjs'; import * as monaco from 'monaco-editor'; import * as awarenessProtocol from 'y-protocols/awareness'; import * as types from 'open-collaboration-protocol'; import { LOCAL_ORIGIN, OpenCollaborationYjsProvider, YTextChange, YTextChangeDelta } from 'open-collaboration-yjs'; import { createMutex } from 'lib0/mutex'; import { debounce } from 'lodash'; import { MonacoCollabCallbacks } from './monaco-api.js'; import { DisposablePeer } from './collaboration-peer.js'; export type UsersChangeEvent = () => void; export type FileNameChangeEvent = (fileName: string) => void; export interface Disposable { dispose(): void; } export interface CollaborationInstanceOptions { connection: ProtocolBroadcastConnection; host: boolean; callbacks: MonacoCollabCallbacks; editor?: monaco.editor.IStandaloneCodeEditor; roomClaim: types.CreateRoomResponse | types.JoinRoomResponse; } export class CollaborationInstance implements Disposable { protected readonly yjs: Y.Doc = new Y.Doc(); protected readonly yjsAwareness: awarenessProtocol.Awareness; protected readonly yjsProvider: OpenCollaborationYjsProvider; protected readonly yjsMutex = createMutex(); protected readonly identity = new Deferred<types.Peer>(); protected readonly documentDisposables = new Map<string, DisposableCollection>(); protected readonly peers = new Map<string, DisposablePeer>(); protected readonly throttles = new Map<string, () => void>(); protected readonly decorations = new Map<DisposablePeer, monaco.editor.IEditorDecorationsCollection>(); protected readonly usersChangedCallbacks: UsersChangeEvent[] = []; protected readonly fileNameChangeCallbacks: FileNameChangeEvent[] = []; protected currentPath?: string; protected stopPropagation = false; protected _following?: string; protected _fileName: string; protected previousFileName?: string; protected _workspaceName: string; protected connection: ProtocolBroadcastConnection; get following(): string | undefined { return this._following; } get connectedUsers(): DisposablePeer[] { return Array.from(this.peers.values()); } get ownUserData(): Promise<types.Peer> { return this.identity.promise; } get isHost(): boolean { return this.options.host; } get host(): types.Peer | undefined { return 'host' in this.options.roomClaim ? this.options.roomClaim.host : undefined; } get roomId(): string { return this.options.roomClaim.roomId; } get fileName(): string { return this._fileName; } get workspaceName(): string { return this._workspaceName; } set workspaceName(_workspaceName: string) { this._workspaceName = _workspaceName; } /** * access token for the room. allow to join or reconnect as host */ get roomToken(): string { return this.options.roomClaim.roomToken; } onUsersChanged(callback: UsersChangeEvent) { this.usersChangedCallbacks.push(callback); } onFileNameChange(callback: FileNameChangeEvent) { this.fileNameChangeCallbacks.push(callback); } constructor(protected options: CollaborationInstanceOptions) { this.connection = options.connection; this.yjsAwareness = new awarenessProtocol.Awareness(this.yjs); this.yjsProvider = new OpenCollaborationYjsProvider(this.options.connection, this.yjs, this.yjsAwareness, { resyncTimer: 10_000 }); this.yjsProvider.connect(); this._fileName = 'myFile.txt'; this._workspaceName = this.roomId; this.setupConnectionHandlers(); this.setupFileSystemHandlers(); this.options.editor && this.registerEditorEvents(); } private setupConnectionHandlers(): void { this.connection.peer.onJoinRequest(async (_, user) => { const result = await this.options.callbacks.onUserRequestsAccess(user); return result ? { workspace: { name: this.workspaceName, folders: [this.workspaceName] } } : undefined; }); this.connection.room.onJoin(async (_, peer) => { this.peers.set(peer.id, new DisposablePeer(this.yjsAwareness, peer)); const initData: types.InitData = { protocol: '0.0.1', host: await this.identity.promise, guests: Array.from(this.peers.values()).map(e => e.peer), capabilities: {}, permissions: { readonly: false }, workspace: { name: this.workspaceName, folders: [this.workspaceName] } }; this.connection.peer.init(peer.id, initData); this.notifyUsersChanged(); }); this.connection.room.onLeave(async (_, peer) => { const disposable = this.peers.get(peer.id); if (disposable) { this.peers.delete(peer.id); this.notifyUsersChanged(); } this.rerenderPresence(); }); this.connection.peer.onInfo((_, peer) => { this.yjsAwareness.setLocalStateField('peer', peer.id); this.identity.resolve(peer); }); this.connection.peer.onInit(async (_, initData) => { await this.initialize(initData); }); } private setupFileSystemHandlers(): void { this.connection.fs.onReadFile(this.handleReadFile.bind(this)); this.connection.fs.onStat(this.handleStat.bind(this)); this.connection.fs.onReaddir(this.handleReaddir.bind(this)); this.connection.fs.onChange(this.handleFileChange.bind(this)); } private async handleReadFile(_: unknown, path: string): Promise<{ content: Uint8Array }> { if (path === this._fileName && this.options.editor) { const text = this.options.editor.getModel()?.getValue(); const encoder = new TextEncoder(); const content = encoder.encode(text); return { content }; } throw new Error('Could not read file'); } private async handleStat(_: unknown, path: string): Promise<{ type: types.FileType; mtime: number; ctime: number; size: number }> { return { type: path === this.workspaceName ? types.FileType.Directory : types.FileType.File, mtime: 0, ctime: 0, size: 0 }; } private async handleReaddir(_: unknown, path: string): Promise<Record<string, types.FileType>> { const uri = this.getResourceUri(path); if (uri) { return { [this._fileName]: types.FileType.File }; } throw new Error('Could not read directory'); } private handleFileChange(_: unknown, change: types.FileChangeEvent): void { const deleteChange = change.changes.find(c => c.type === types.FileChangeEventType.Delete); const createChange = change.changes.find(c => c.type === types.FileChangeEventType.Create); if (deleteChange && createChange) { this._fileName = createChange.path; const model = this.options.editor?.getModel(); if (model) { this.registerTextDocument(model); } } } private notifyUsersChanged(): void { this.usersChangedCallbacks.forEach(callback => callback()); } private notifyFileNameChanged(fileName: string): void { this.fileNameChangeCallbacks.forEach(callback => callback(fileName)); } setEditor(editor: monaco.editor.IStandaloneCodeEditor): void { this.options.editor = editor; this.registerEditorEvents(); } async setFileName(fileName: string): Promise<void> { const oldFileName = this._fileName; this._fileName = fileName; const model = this.options.editor?.getModel(); if (model) { await this.registerTextDocument(model); this.connection.fs.change({ changes: [ { type: types.FileChangeEventType.Create, path: fileName }, { type: types.FileChangeEventType.Delete, path: oldFileName } ] }); } } dispose() { this.peers.clear(); this.documentDisposables.forEach(e => e.dispose()); this.documentDisposables.clear(); } leaveRoom() { this.options.connection.room.leave(); } getCurrentConnection(): ProtocolBroadcastConnection { return this.options.connection; } protected pushDocumentDisposable(path: string, disposable: Disposable) { let disposables = this.documentDisposables.get(path); if (!disposables) { disposables = new DisposableCollection(); this.documentDisposables.set(path, disposables); } disposables.push(disposable); } protected registerEditorEvents(): void { if (!this.options.editor) { return; } const text = this.options.editor.getModel(); if (text) { this.registerTextDocument(text); } this.options.editor.onDidChangeModelContent(event => { if (text && !this.stopPropagation) { this.updateTextDocument(event, text); } }); this.options.editor.onDidChangeCursorSelection(() => { if (this.options.editor && !this.stopPropagation) { this.updateTextSelection(this.options.editor); } }); const awarenessDebounce = debounce(() => { this.rerenderPresence(); }, 2000); this.yjsAwareness.on('change', async (_: unknown, origin: string) => { if (origin !== LOCAL_ORIGIN) { this.updateFollow(); this.rerenderPresence(); awarenessDebounce(); } }); } followUser(id?: string) { this._following = id; if (id) { this.updateFollow(); } } protected updateFollow(): void { if (this._following) { let userState: types.ClientAwareness | undefined = undefined; const states = this.yjsAwareness.getStates() as Map<number, types.ClientAwareness>; for (const state of states.values()) { const peer = this.peers.get(state.peer); if (peer?.peer.id === this._following) { userState = state; } } if (userState) { if (types.ClientTextSelection.is(userState.selection)) { this.followSelection(userState.selection); } } } } protected async followSelection(selection: types.ClientTextSelection): Promise<void> { if (!this.options.editor) { return; } const uri = this.getResourceUri(selection.path); const text = this.yjs.getText(selection.path); const prevPath = this.currentPath; this.currentPath = selection.path; if (prevPath !== selection.path) { this.stopPropagation = true; this.options.editor.setValue(text.toString()); this.stopPropagation = false; } const filename = this.getHostPath(selection.path); if (this._fileName !== filename) { this._fileName = filename; this.previousFileName = filename; this.notifyFileNameChanged(this._fileName); } this.registerTextObserver(selection.path, this.options.editor.getModel()!, text); if (uri && selection.visibleRanges && selection.visibleRanges.length > 0) { const visibleRange = selection.visibleRanges[0]; const range = new monaco.Range(visibleRange.start.line, visibleRange.start.character, visibleRange.end.line, visibleRange.end.character); this.options.editor && this.options.editor.revealRange(range); } } protected updateTextSelection(editor: monaco.editor.IStandaloneCodeEditor): void { const document = editor.getModel(); const selections = editor.getSelections(); if (!document || !selections) { return; } const path = this.currentPath; if (path) { const ytext = this.yjs.getText(path); const textSelections: types.RelativeTextSelection[] = []; for (const selection of selections) { const start = document.getOffsetAt(selection.getStartPosition()); const end = document.getOffsetAt(selection.getEndPosition()); const direction = selection.getDirection() === monaco.SelectionDirection.RTL ? types.SelectionDirection.RightToLeft : types.SelectionDirection.LeftToRight; const editorSelection: types.RelativeTextSelection = { start: Y.createRelativePositionFromTypeIndex(ytext, start), end: Y.createRelativePositionFromTypeIndex(ytext, end), direction }; textSelections.push(editorSelection); } const textSelection: types.ClientTextSelection = { path, textSelections, visibleRanges: editor.getVisibleRanges().map(range => ({ start: { line: range.startLineNumber, character: range.startColumn }, end: { line: range.endLineNumber, character: range.endColumn } })) }; this.setSharedSelection(textSelection); } } protected async registerTextDocument(document: monaco.editor.ITextModel): Promise<void> { const uri = this.getResourceUri(`${this._workspaceName}/${this._fileName}`); const path = this.getProtocolPath(uri); if (!this.currentPath || this.currentPath !== path) { this.currentPath = path; } if (path) { const text = document.getValue(); const yjsText = this.yjs.getText(path); let ytextContent = ''; if (this.isHost) { this.yjs.transact(() => { yjsText.delete(0, yjsText.length); yjsText.insert(0, text); }); ytextContent = yjsText.toString(); } else { ytextContent = await this.readFile(); if (this._fileName !== this.previousFileName) { this.previousFileName = this._fileName; this.notifyFileNameChanged(this._fileName); } } if (text !== ytextContent) { this.yjsMutex(() => { document.setValue(ytextContent); }); } this.registerTextObserver(path, document, yjsText); } } protected registerTextObserver(path: string, document: monaco.editor.ITextModel, yjsText: Y.Text): void { const textObserver = this.documentDisposables.get(path); if (textObserver) { textObserver.dispose(); } const resyncThrottle = this.getOrCreateThrottle(path, document); const observer = (textEvent: Y.YTextEvent) => { this.yjsMutex(async () => { if (this.options.editor) { const changes = YTextChangeDelta.toChanges(textEvent.delta); const edits = this.createEditsFromTextEvent(changes, document); this.updateDocument(document, edits); resyncThrottle(); } }); }; yjsText.observe(observer); this.pushDocumentDisposable(path, { dispose: () => yjsText.unobserve(observer) }); } protected updateDocument(document: monaco.editor.ITextModel, edits: monaco.editor.IIdentifiedSingleEditOperation[]): void { document.pushStackElement(); document.pushEditOperations(null, edits, () => null); document.pushStackElement(); } private createEditsFromTextEvent(changes: YTextChange[], document: monaco.editor.ITextModel): monaco.editor.IIdentifiedSingleEditOperation[] { const edits: monaco.editor.IIdentifiedSingleEditOperation[] = []; changes.forEach(change => { const start = document.getPositionAt(change.start); const end = document.getPositionAt(change.end); edits.push({ range: new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column), text: change.text }); }); return edits; } protected updateTextDocument(event: monaco.editor.IModelContentChangedEvent, document: monaco.editor.ITextModel): void { const path = this.currentPath; if (path) { this.yjsMutex(() => { const ytext = this.yjs.getText(path); this.yjs.transact(() => { for (const change of event.changes) { ytext.delete(change.rangeOffset, change.rangeLength); ytext.insert(change.rangeOffset, change.text); } }); this.getOrCreateThrottle(path, document)(); }); } } protected getOrCreateThrottle(path: string, document: monaco.editor.ITextModel): () => void { let value = this.throttles.get(path); if (!value) { value = debounce(() => { this.yjsMutex(() => { const yjsText = this.yjs.getText(path); const newContent = yjsText.toString(); if (newContent !== document.getValue()) { this.updateDocumentContent(document, newContent); } }); }, 100, { leading: false, trailing: true }); this.throttles.set(path, value); } return value; } private updateDocumentContent(document: monaco.editor.ITextModel, newContent: string): void { this.yjsMutex(() => { if (this.options.editor) { const edits: monaco.editor.IIdentifiedSingleEditOperation[] = [{ range: document.getFullModelRange(), text: newContent }]; this.updateDocument(document, edits); } }); } protected rerenderPresence() { const states = this.yjsAwareness.getStates() as Map<number, types.ClientAwareness>; for (const [clientID, state] of states.entries()) { if (clientID === this.yjs.clientID) { // Ignore own awareness state continue; } const peerId = state.peer; const peer = this.peers.get(peerId); if (!state.selection || !peer) { continue; } if (!types.ClientTextSelection.is(state.selection)) { continue; } const { path, textSelections } = state.selection; const selection = textSelections[0]; if (!selection) { continue; } const uri = this.getResourceUri(path); if (uri && this.options.editor) { const model = this.options.editor.getModel(); const forward = selection.direction === 1; let startIndex = Y.createAbsolutePositionFromRelativePosition(selection.start, this.yjs); let endIndex = Y.createAbsolutePositionFromRelativePosition(selection.end, this.yjs); if (model && startIndex && endIndex) { if (startIndex.index > endIndex.index) { [startIndex, endIndex] = [endIndex, startIndex]; } const start = model.getPositionAt(startIndex.index); const end = model.getPositionAt(endIndex.index); const inverted = (forward && end.lineNumber === 1) || (!forward && start.lineNumber === 1); const range: monaco.IRange = { startLineNumber: start.lineNumber, startColumn: start.column, endLineNumber: end.lineNumber, endColumn: end.column }; const contentClassNames: string[] = [peer.decoration.cursorClassName]; if (inverted) { contentClassNames.push(peer.decoration.cursorInvertedClassName); } this.setDecorations(peer, [{ range, options: { className: peer.decoration.selectionClassName, beforeContentClassName: !forward ? contentClassNames.join(' ') : undefined, afterContentClassName: forward ? contentClassNames.join(' ') : undefined, stickiness: monaco.editor.TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges } }]); } } } } protected setDecorations(peer: DisposablePeer, decorations: monaco.editor.IModelDeltaDecoration[]): void { if (this.decorations.has(peer)) { this.decorations.get(peer)?.set(decorations); } else if (this.options.editor) { this.decorations.set(peer, this.options.editor.createDecorationsCollection(decorations)); } } protected setSharedSelection(selection?: types.ClientSelection): void { this.yjsAwareness.setLocalStateField('selection', selection); } protected updateSelectionPath(newPath: string): void { const currentState = this.yjsAwareness.getLocalState() as types.ClientAwareness; if (currentState?.selection && types.ClientTextSelection.is(currentState.selection)) { const newSelection: types.ClientTextSelection = { ...currentState.selection, path: newPath }; this.setSharedSelection(newSelection); } } protected createSelectionFromRelative(selection: types.RelativeTextSelection, model: monaco.editor.ITextModel): monaco.Selection | undefined { const start = Y.createAbsolutePositionFromRelativePosition(selection.start, this.yjs); const end = Y.createAbsolutePositionFromRelativePosition(selection.end, this.yjs); if (start && end) { let anchor = model.getPositionAt(start.index); let head = model.getPositionAt(end.index); if (selection.direction === types.SelectionDirection.RightToLeft) { [anchor, head] = [head, anchor]; } return new monaco.Selection(anchor.lineNumber, anchor.column, head.lineNumber, head.column); } return undefined; } protected getHostPath(path: string): string { // When creating a URI as a guest, we always prepend it with the name of the workspace // This just removes the workspace name from the path to get the path expected by the protocol const subpath = path.substring(1).split('/'); return subpath.slice(1).join('/'); } async initialize(data: types.InitData): Promise<void> { for (const peer of [data.host, ...data.guests]) { this.peers.set(peer.id, new DisposablePeer(this.yjsAwareness, peer)); } this.notifyUsersChanged(); } getProtocolPath(uri?: monaco.Uri): string | undefined { if (!uri) { return undefined; } return uri.path.startsWith('/') ? uri.path.substring(1) : uri.path; } getResourceUri(path?: string): monaco.Uri | undefined { return new monaco.Uri().with({ path }); } async readFile(): Promise<string> { if (!this.currentPath) { return ''; } const path = this.getHostPath(this.currentPath); if (this.yjs.share.has(path)) { const stringValue = this.yjs.getText(path); return stringValue.toString(); } else { const file = await this.connection.fs.readFile(this.host?.id, path); const decoder = new TextDecoder(); return decoder.decode(file.content); } } }