UNPKG

open-collaboration-monaco

Version:

Connect a single Monaco Editor to an Open Collaboration Tools session

580 lines 22.3 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 } 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, YTextChangeDelta } from 'open-collaboration-yjs'; import { createMutex } from 'lib0/mutex'; import { debounce } from 'lodash'; import { DisposablePeer } from './collaboration-peer.js'; export class CollaborationInstance { options; yjs = new Y.Doc(); yjsAwareness; yjsProvider; yjsMutex = createMutex(); identity = new Deferred(); documentDisposables = new Map(); peers = new Map(); throttles = new Map(); decorations = new Map(); usersChangedCallbacks = []; fileNameChangeCallbacks = []; currentPath; stopPropagation = false; _following; _fileName; previousFileName; _workspaceName; connection; get following() { return this._following; } get connectedUsers() { return Array.from(this.peers.values()); } get ownUserData() { return this.identity.promise; } get isHost() { return this.options.host; } get host() { return 'host' in this.options.roomClaim ? this.options.roomClaim.host : undefined; } get roomId() { return this.options.roomClaim.roomId; } get fileName() { return this._fileName; } get workspaceName() { return this._workspaceName; } set workspaceName(_workspaceName) { this._workspaceName = _workspaceName; } /** * access token for the room. allow to join or reconnect as host */ get roomToken() { return this.options.roomClaim.roomToken; } onUsersChanged(callback) { this.usersChangedCallbacks.push(callback); } onFileNameChange(callback) { this.fileNameChangeCallbacks.push(callback); } constructor(options) { this.options = options; 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(); } setupConnectionHandlers() { 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 = { 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); }); } setupFileSystemHandlers() { 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)); } async handleReadFile(_, path) { 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'); } async handleStat(_, path) { return { type: path === this.workspaceName ? types.FileType.Directory : types.FileType.File, mtime: 0, ctime: 0, size: 0 }; } async handleReaddir(_, path) { const uri = this.getResourceUri(path); if (uri) { return { [this._fileName]: types.FileType.File }; } throw new Error('Could not read directory'); } handleFileChange(_, change) { 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); } } } notifyUsersChanged() { this.usersChangedCallbacks.forEach(callback => callback()); } notifyFileNameChanged(fileName) { this.fileNameChangeCallbacks.forEach(callback => callback(fileName)); } setEditor(editor) { this.options.editor = editor; this.registerEditorEvents(); } async setFileName(fileName) { 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() { return this.options.connection; } pushDocumentDisposable(path, disposable) { let disposables = this.documentDisposables.get(path); if (!disposables) { disposables = new DisposableCollection(); this.documentDisposables.set(path, disposables); } disposables.push(disposable); } registerEditorEvents() { 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 (_, origin) => { if (origin !== LOCAL_ORIGIN) { this.updateFollow(); this.rerenderPresence(); awarenessDebounce(); } }); } followUser(id) { this._following = id; if (id) { this.updateFollow(); } } updateFollow() { if (this._following) { let userState = undefined; const states = this.yjsAwareness.getStates(); 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); } } } } async followSelection(selection) { 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); } } updateTextSelection(editor) { 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 = []; 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 = { start: Y.createRelativePositionFromTypeIndex(ytext, start), end: Y.createRelativePositionFromTypeIndex(ytext, end), direction }; textSelections.push(editorSelection); } const textSelection = { path, textSelections, visibleRanges: editor.getVisibleRanges().map(range => ({ start: { line: range.startLineNumber, character: range.startColumn }, end: { line: range.endLineNumber, character: range.endColumn } })) }; this.setSharedSelection(textSelection); } } async registerTextDocument(document) { 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); } } registerTextObserver(path, document, yjsText) { const textObserver = this.documentDisposables.get(path); if (textObserver) { textObserver.dispose(); } const resyncThrottle = this.getOrCreateThrottle(path, document); const observer = (textEvent) => { 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) }); } updateDocument(document, edits) { document.pushStackElement(); document.pushEditOperations(null, edits, () => null); document.pushStackElement(); } createEditsFromTextEvent(changes, document) { const edits = []; 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; } updateTextDocument(event, document) { 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)(); }); } } getOrCreateThrottle(path, document) { 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; } updateDocumentContent(document, newContent) { this.yjsMutex(() => { if (this.options.editor) { const edits = [{ range: document.getFullModelRange(), text: newContent }]; this.updateDocument(document, edits); } }); } rerenderPresence() { const states = this.yjsAwareness.getStates(); 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 = { startLineNumber: start.lineNumber, startColumn: start.column, endLineNumber: end.lineNumber, endColumn: end.column }; const contentClassNames = [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 } }]); } } } } setDecorations(peer, decorations) { 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)); } } setSharedSelection(selection) { this.yjsAwareness.setLocalStateField('selection', selection); } updateSelectionPath(newPath) { const currentState = this.yjsAwareness.getLocalState(); if (currentState?.selection && types.ClientTextSelection.is(currentState.selection)) { const newSelection = { ...currentState.selection, path: newPath }; this.setSharedSelection(newSelection); } } createSelectionFromRelative(selection, model) { 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; } getHostPath(path) { // 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) { for (const peer of [data.host, ...data.guests]) { this.peers.set(peer.id, new DisposablePeer(this.yjsAwareness, peer)); } this.notifyUsersChanged(); } getProtocolPath(uri) { if (!uri) { return undefined; } return uri.path.startsWith('/') ? uri.path.substring(1) : uri.path; } getResourceUri(path) { return new monaco.Uri().with({ path }); } async readFile() { 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); } } } //# sourceMappingURL=collaboration-instance.js.map