open-collaboration-service-process
Version:
A service process for integrating non Typescript projects with the Open Collaboration Tools project
242 lines (210 loc) • 10.4 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 * as types from 'open-collaboration-protocol';
import { DisposableCollection, Deferred } from 'open-collaboration-protocol';
import { LOCAL_ORIGIN, OpenCollaborationYjsProvider } from 'open-collaboration-yjs';
import * as Y from 'yjs';
import { Mutex } from 'async-mutex';
import * as awarenessProtocol from 'y-protocols/awareness';
import { BinaryResponse, ClientTextSelection, JoinSessionRequest, OCPBroadCast, OCPNotification, OCPRequest, OnInitNotification, TextDocumentInsert, toEncodedOCPMessage, UpdateDocumentContent, UpdateTextSelection } from './messages.js';
import { MessageConnection } from 'vscode-jsonrpc';
export class CollaborationInstance implements types.Disposable{
protected peers = new Map<string, types.Peer>();
protected hostInfo = new Deferred<types.Peer>();
protected peerInfo: types.Peer;
protected yjsProvider?: OpenCollaborationYjsProvider;
protected YjsDoc: Y.Doc;
protected yjsAwareness;
private yjsMutex = new Mutex();
protected connectionDisposables: DisposableCollection = new DisposableCollection();
protected identity = new Deferred<types.Peer>();
constructor(public currentConnection: types.ProtocolBroadcastConnection, protected communicationHandler: MessageConnection, protected host: boolean, workspace?: types.Workspace) {
if(host && !workspace) {
throw new Error('Host must provide workspace');
}
this.YjsDoc = new Y.Doc();
this.yjsAwareness = new awarenessProtocol.Awareness(this.YjsDoc);
this.yjsAwareness.on('change', ((_: any, origin: string) => {
if (origin !== LOCAL_ORIGIN) {
this.checkSelectionUpdated();
}
}));
this.connectionDisposables.push({
dispose: () => {
this.YjsDoc.destroy();
this.yjsAwareness.destroy();
}});
this.yjsProvider = new OpenCollaborationYjsProvider(currentConnection, this.YjsDoc, this.yjsAwareness, {
resyncTimer: 10_000
});
this.yjsProvider.connect();
this.connectionDisposables.push(currentConnection.onReconnect(() => {
this.yjsProvider?.connect();
}));
currentConnection.onDisconnect(() => {
this.dispose();
});
currentConnection.onRequest(async (origin, method, ...params) => {
const result = await this.communicationHandler.sendRequest(OCPRequest, toEncodedOCPMessage({
method,
params
}));
return BinaryResponse.is(result) ? types.Encoding.decode(Uint8Array.from(Buffer.from(result.data, 'base64'))) : result;
});
currentConnection.onNotification((origin, method, ...params) => {
this.communicationHandler.sendNotification(OCPNotification, toEncodedOCPMessage({method, params}));
});
currentConnection.onBroadcast((origin, method, ...params) => {
this.communicationHandler.sendNotification(OCPBroadCast, toEncodedOCPMessage({method, params}));
});
currentConnection.peer.onJoinRequest(async (_, user) => {
const accepted = await this.communicationHandler.sendRequest(JoinSessionRequest, user);
return accepted ? { workspace: workspace! } : undefined;
});
currentConnection.peer.onInfo((_, peer) => {
this.yjsAwareness.setLocalStateField('peer', peer.id);
this.identity.resolve(peer);
});
currentConnection.room.onJoin(async (_, peer) => {
if (host && workspace) {
// Only initialize the user if we are the host
const initData: types.InitData = {
protocol: types.VERSION,
host: await this.identity.promise,
guests: Array.from(this.peers.values()),
capabilities: {},
permissions: { readonly: false },
workspace: {
name: workspace.name ?? 'Collaboration',
folders: workspace.folders ?? []
}
};
currentConnection.peer.init(peer.id, initData);
}
});
currentConnection.peer.onInit((_, initData) => {
this.peers.set(initData.host.id, initData.host);
this.hostInfo.resolve(initData.host);
for (const guest of initData.guests) {
this.peers.set(guest.id, guest);
}
this.communicationHandler.sendNotification(OnInitNotification, initData);
});
}
async registerYjsObject(type: string, documentPath: string, text: string) {
if(type === 'text') {
const yjsText = this.YjsDoc.getText(documentPath);
if (this.host) {
this.YjsDoc.transact(() => {
yjsText.delete(0, yjsText.length);
yjsText.insert(0, text);
});
} else {
this.currentConnection.editor.open((await this.hostInfo.promise).id, documentPath);
}
const observer = (textEvent: Y.YTextEvent) => {
if (textEvent.transaction.local) {
// Ignore own events or if the document is already in sync
return;
}
const edits: TextDocumentInsert[] = [];
let index = 0;
textEvent.delta.forEach(delta => {
if (typeof delta.retain === 'number') {
index += delta.retain;
} else if (typeof delta.insert === 'string') {
edits.push({
startOffset: index,
text: delta.insert,
});
index += delta.insert.length;
} else if (typeof delta.delete === 'number') {
edits.push({
startOffset: index,
endOffset: index + delta.delete,
text: '',
});
}
});
this.communicationHandler.sendNotification(UpdateDocumentContent, documentPath, edits);
};
yjsText.observe(observer);
}
}
updateYjsObjectContent(documentPath: string, changes: TextDocumentInsert[]) {
if (changes.length === 0) {
return;
}
this.yjsMutex.runExclusive(async () => {
const yjsText = this.YjsDoc.getText(documentPath);
this.YjsDoc.transact(() => {
for(const change of changes) {
if(change.endOffset) {
yjsText.delete(change.startOffset, change.endOffset - change.startOffset);
}
yjsText.insert(change.startOffset, change.text);
}
});
});
}
private selectionState: Map<string, ClientTextSelection[]> = new Map();
checkSelectionUpdated() {
const states = this.yjsAwareness.getStates() as Map<number, types.ClientAwareness>;
const currentSelections: Map<string, ClientTextSelection[]> = new Map();
for (const [clientID, state] of states.entries()) {
if (types.ClientTextSelection.is(state.selection)) {
const selections = state.selection.textSelections.map(s => ({
peer: state.peer,
start: s.start.assoc,
end: s.end.assoc,
isReversed: s.direction === types.SelectionDirection.RightToLeft
}));
currentSelections.has(state.peer) ?
currentSelections.get(state.peer)!.push(...selections) :
currentSelections.set(clientID.toString(), selections);
}
}
const documentUpdates: string[] = [];
for (const [documentPath, selections] of currentSelections.entries()) {
if (JSON.stringify(this.selectionState.get(documentPath)) !== JSON.stringify(selections)) {
documentUpdates.push(documentPath);
}
}
this.selectionState = currentSelections;
for (const document of documentUpdates) {
this.communicationHandler.sendNotification(UpdateTextSelection, document, this.selectionState.get(document) ?? []);
}
}
updateYjsObjectSelection(documentPath: string, clientSelections: ClientTextSelection[]) {
if (documentPath) {
const ytext = this.YjsDoc.getText(documentPath);
const selections: types.RelativeTextSelection[] = [];
for (const clientSelection of clientSelections) {
selections.push({
direction: clientSelection.isReversed ?
types.SelectionDirection.RightToLeft :
types.SelectionDirection.LeftToRight,
start: Y.createRelativePositionFromTypeIndex(ytext, clientSelection.start),
end: Y.createRelativePositionFromTypeIndex(ytext, clientSelection.end)
});
}
const textSelection: types.ClientTextSelection = {
path: documentPath,
textSelections: selections
};
this.setSharedSelection(textSelection);
} else {
this.setSharedSelection(undefined);
}
}
private setSharedSelection(selection?: types.ClientSelection): void {
this.yjsAwareness.setLocalStateField('selection', selection);
}
dispose(): void {
this.yjsProvider?.dispose();
this.connectionDisposables.dispose();
}
}