open-collaboration-monaco
Version:
Connect a single Monaco Editor to an Open Collaboration Tools session
660 lines (587 loc) • 26.1 kB
text/typescript
// ******************************************************************************
// 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);
}
}
}