@hereugo/open-collaboration-monaco 0.3.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +87 -0
- package/lib/collaboration-connection.d.ts +10 -0
- package/lib/collaboration-connection.d.ts.map +1 -0
- package/lib/collaboration-connection.js +64 -0
- package/lib/collaboration-connection.js.map +1 -0
- package/lib/collaboration-instance.d.ts +98 -0
- package/lib/collaboration-instance.d.ts.map +1 -0
- package/lib/collaboration-instance.js +606 -0
- package/lib/collaboration-instance.js.map +1 -0
- package/lib/collaboration-peer.d.ts +19 -0
- package/lib/collaboration-peer.d.ts.map +1 -0
- package/lib/collaboration-peer.js +113 -0
- package/lib/collaboration-peer.js.map +1 -0
- package/lib/example.d.ts +2 -0
- package/lib/example.d.ts.map +1 -0
- package/lib/example.js +84 -0
- package/lib/example.js.map +1 -0
- package/lib/index.d.ts +6 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +11 -0
- package/lib/index.js.map +1 -0
- package/lib/monaco-api.d.ts +49 -0
- package/lib/monaco-api.d.ts.map +1 -0
- package/lib/monaco-api.js +168 -0
- package/lib/monaco-api.js.map +1 -0
- package/lib/types.d.ts +4 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +7 -0
- package/lib/types.js.map +1 -0
- package/package.json +75 -0
- package/src/collaboration-connection.ts +69 -0
- package/src/collaboration-instance.ts +688 -0
- package/src/collaboration-peer.ts +133 -0
- package/src/example.ts +96 -0
- package/src/index.ts +11 -0
- package/src/monaco-api.ts +235 -0
- package/src/types.ts +9 -0
|
@@ -0,0 +1,688 @@
|
|
|
1
|
+
// ******************************************************************************
|
|
2
|
+
// Copyright 2024 TypeFox GmbH
|
|
3
|
+
// This program and the accompanying materials are made available under the
|
|
4
|
+
// terms of the MIT License, which is available in the project root.
|
|
5
|
+
// ******************************************************************************
|
|
6
|
+
|
|
7
|
+
import { Deferred, DisposableCollection, ProtocolBroadcastConnection, encodeUserPermission, getUserPermissionKey, resolveReadonly } from '@hereugo/open-collaboration-protocol';
|
|
8
|
+
import * as Y from 'yjs';
|
|
9
|
+
import * as monaco from 'monaco-editor';
|
|
10
|
+
import * as awarenessProtocol from 'y-protocols/awareness';
|
|
11
|
+
import * as types from '@hereugo/open-collaboration-protocol';
|
|
12
|
+
import { LOCAL_ORIGIN, OpenCollaborationYjsProvider, YTextChange, YTextChangeDelta } from '@hereugo/open-collaboration-yjs';
|
|
13
|
+
import { createMutex } from 'lib0/mutex';
|
|
14
|
+
import { debounce } from 'lodash';
|
|
15
|
+
import { MonacoCollabCallbacks } from './monaco-api.js';
|
|
16
|
+
import { DisposablePeer } from './collaboration-peer.js';
|
|
17
|
+
|
|
18
|
+
export type UsersChangeEvent = () => void;
|
|
19
|
+
export type FileNameChangeEvent = (fileName: string) => void;
|
|
20
|
+
|
|
21
|
+
export interface Disposable {
|
|
22
|
+
dispose(): void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CollaborationInstanceOptions {
|
|
26
|
+
connection: ProtocolBroadcastConnection;
|
|
27
|
+
host: boolean;
|
|
28
|
+
callbacks: MonacoCollabCallbacks;
|
|
29
|
+
editor?: monaco.editor.IStandaloneCodeEditor;
|
|
30
|
+
roomClaim: types.CreateRoomResponse | types.JoinRoomResponse;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class CollaborationInstance implements Disposable {
|
|
34
|
+
protected readonly yjs: Y.Doc = new Y.Doc();
|
|
35
|
+
protected readonly yjsAwareness: awarenessProtocol.Awareness;
|
|
36
|
+
protected readonly yjsProvider: OpenCollaborationYjsProvider;
|
|
37
|
+
protected readonly yjsMutex = createMutex();
|
|
38
|
+
|
|
39
|
+
protected readonly identity = new Deferred<types.Peer>();
|
|
40
|
+
protected readonly documentDisposables = new Map<string, DisposableCollection>();
|
|
41
|
+
protected readonly peers = new Map<string, DisposablePeer>();
|
|
42
|
+
protected readonly throttles = new Map<string, () => void>();
|
|
43
|
+
protected readonly decorations = new Map<DisposablePeer, monaco.editor.IEditorDecorationsCollection>();
|
|
44
|
+
protected readonly usersChangedCallbacks: UsersChangeEvent[] = [];
|
|
45
|
+
protected readonly fileNameChangeCallbacks: FileNameChangeEvent[] = [];
|
|
46
|
+
protected _permissions: types.Permissions = { readonly: false };
|
|
47
|
+
protected effectiveReadonly = false;
|
|
48
|
+
|
|
49
|
+
protected currentPath?: string;
|
|
50
|
+
protected stopPropagation = false;
|
|
51
|
+
protected _following?: string;
|
|
52
|
+
protected _fileName: string;
|
|
53
|
+
protected previousFileName?: string;
|
|
54
|
+
protected _workspaceName: string;
|
|
55
|
+
|
|
56
|
+
protected connection: ProtocolBroadcastConnection;
|
|
57
|
+
|
|
58
|
+
get following(): string | undefined {
|
|
59
|
+
return this._following;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
get connectedUsers(): DisposablePeer[] {
|
|
63
|
+
return Array.from(this.peers.values());
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
get ownUserData(): Promise<types.Peer> {
|
|
67
|
+
return this.identity.promise;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get isHost(): boolean {
|
|
71
|
+
return this.options.host;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
get host(): types.Peer | undefined {
|
|
75
|
+
return 'host' in this.options.roomClaim ? this.options.roomClaim.host : undefined;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
get roomId(): string {
|
|
79
|
+
return this.options.roomClaim.roomId;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
get fileName(): string {
|
|
83
|
+
return this._fileName;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
get workspaceName(): string {
|
|
87
|
+
return this._workspaceName;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
set workspaceName(_workspaceName: string) {
|
|
91
|
+
this._workspaceName = _workspaceName;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* access token for the room. allow to join or reconnect as host
|
|
96
|
+
*/
|
|
97
|
+
get roomToken(): string {
|
|
98
|
+
return this.options.roomClaim.roomToken;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
onUsersChanged(callback: UsersChangeEvent) {
|
|
102
|
+
this.usersChangedCallbacks.push(callback);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
onFileNameChange(callback: FileNameChangeEvent) {
|
|
106
|
+
this.fileNameChangeCallbacks.push(callback);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
constructor(protected options: CollaborationInstanceOptions) {
|
|
110
|
+
this.connection = options.connection;
|
|
111
|
+
this.yjsAwareness = new awarenessProtocol.Awareness(this.yjs);
|
|
112
|
+
this.yjsProvider = new OpenCollaborationYjsProvider(this.options.connection, this.yjs, this.yjsAwareness, {
|
|
113
|
+
resyncTimer: 10_000
|
|
114
|
+
});
|
|
115
|
+
this.yjsProvider.connect();
|
|
116
|
+
|
|
117
|
+
this._fileName = 'myFile.txt';
|
|
118
|
+
this._workspaceName = this.roomId;
|
|
119
|
+
|
|
120
|
+
this.setupConnectionHandlers();
|
|
121
|
+
this.setupFileSystemHandlers();
|
|
122
|
+
this.options.editor && this.registerEditorEvents();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
private setupConnectionHandlers(): void {
|
|
126
|
+
this.connection.peer.onJoinRequest(async (_, user) => {
|
|
127
|
+
const result = await this.options.callbacks.onUserRequestsAccess(user);
|
|
128
|
+
return result ? {
|
|
129
|
+
workspace: {
|
|
130
|
+
name: this.workspaceName,
|
|
131
|
+
folders: [this.workspaceName]
|
|
132
|
+
}
|
|
133
|
+
} : undefined;
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
this.connection.room.onJoin(async (_, peer) => {
|
|
137
|
+
this.peers.set(peer.id, new DisposablePeer(this.yjsAwareness, peer));
|
|
138
|
+
const initData: types.InitData = {
|
|
139
|
+
protocol: '0.0.1',
|
|
140
|
+
host: await this.identity.promise,
|
|
141
|
+
guests: Array.from(this.peers.values()).map(e => e.peer),
|
|
142
|
+
capabilities: {},
|
|
143
|
+
permissions: {
|
|
144
|
+
...this._permissions,
|
|
145
|
+
[getUserPermissionKey(peer.id)]: encodeUserPermission(true)
|
|
146
|
+
},
|
|
147
|
+
workspace: {
|
|
148
|
+
name: this.workspaceName,
|
|
149
|
+
folders: [this.workspaceName]
|
|
150
|
+
}
|
|
151
|
+
};
|
|
152
|
+
this.connection.peer.init(peer.id, initData);
|
|
153
|
+
this.notifyUsersChanged();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
this.connection.room.onLeave(async (_, peer) => {
|
|
157
|
+
const disposable = this.peers.get(peer.id);
|
|
158
|
+
if (disposable) {
|
|
159
|
+
this.peers.delete(peer.id);
|
|
160
|
+
this.notifyUsersChanged();
|
|
161
|
+
}
|
|
162
|
+
this.rerenderPresence();
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
this.connection.peer.onInfo((_, peer) => {
|
|
166
|
+
this.yjsAwareness.setLocalStateField('peer', peer.id);
|
|
167
|
+
this.identity.resolve(peer);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
this.connection.peer.onInit(async (_, initData) => {
|
|
171
|
+
await this.initialize(initData);
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
this.connection.room.onPermissions((_, permissions) => {
|
|
175
|
+
void this.applyPermissionsUpdate(permissions);
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private async applyPermissionsUpdate(permissions: types.Permissions): Promise<void> {
|
|
180
|
+
this._permissions = permissions;
|
|
181
|
+
const peer = await this.identity.promise;
|
|
182
|
+
const readonly = resolveReadonly(permissions, peer.id);
|
|
183
|
+
this.effectiveReadonly = readonly;
|
|
184
|
+
this.updateEditorReadonly(readonly);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
private updateEditorReadonly(readonly: boolean): void {
|
|
188
|
+
if (this.options.editor) {
|
|
189
|
+
this.options.editor.updateOptions({ readOnly: readonly });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private setupFileSystemHandlers(): void {
|
|
194
|
+
this.connection.fs.onReadFile(this.handleReadFile.bind(this));
|
|
195
|
+
this.connection.fs.onStat(this.handleStat.bind(this));
|
|
196
|
+
this.connection.fs.onReaddir(this.handleReaddir.bind(this));
|
|
197
|
+
this.connection.fs.onChange(this.handleFileChange.bind(this));
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private async handleReadFile(_: unknown, path: string): Promise<{ content: Uint8Array }> {
|
|
201
|
+
if (path === this._fileName && this.options.editor) {
|
|
202
|
+
const text = this.options.editor.getModel()?.getValue();
|
|
203
|
+
const encoder = new TextEncoder();
|
|
204
|
+
const content = encoder.encode(text);
|
|
205
|
+
return { content };
|
|
206
|
+
}
|
|
207
|
+
throw new Error('Could not read file');
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
private async handleStat(_: unknown, path: string): Promise<{ type: types.FileType; mtime: number; ctime: number; size: number }> {
|
|
211
|
+
return {
|
|
212
|
+
type: path === this.workspaceName ? types.FileType.Directory : types.FileType.File,
|
|
213
|
+
mtime: 0,
|
|
214
|
+
ctime: 0,
|
|
215
|
+
size: 0
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private async handleReaddir(_: unknown, path: string): Promise<Record<string, types.FileType>> {
|
|
220
|
+
const uri = this.getResourceUri(path);
|
|
221
|
+
if (uri) {
|
|
222
|
+
return {
|
|
223
|
+
[this._fileName]: types.FileType.File
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
throw new Error('Could not read directory');
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
private handleFileChange(_: unknown, change: types.FileChangeEvent): void {
|
|
230
|
+
const deleteChange = change.changes.find(c => c.type === types.FileChangeEventType.Delete);
|
|
231
|
+
const createChange = change.changes.find(c => c.type === types.FileChangeEventType.Create);
|
|
232
|
+
if (deleteChange && createChange) {
|
|
233
|
+
this._fileName = createChange.path;
|
|
234
|
+
const model = this.options.editor?.getModel();
|
|
235
|
+
if (model) {
|
|
236
|
+
this.registerTextDocument(model);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
private notifyUsersChanged(): void {
|
|
242
|
+
this.usersChangedCallbacks.forEach(callback => callback());
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private notifyFileNameChanged(fileName: string): void {
|
|
246
|
+
this.fileNameChangeCallbacks.forEach(callback => callback(fileName));
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
setEditor(editor: monaco.editor.IStandaloneCodeEditor): void {
|
|
250
|
+
this.options.editor = editor;
|
|
251
|
+
this.updateEditorReadonly(this.effectiveReadonly);
|
|
252
|
+
this.registerEditorEvents();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
async setFileName(fileName: string): Promise<void> {
|
|
256
|
+
const oldFileName = this._fileName;
|
|
257
|
+
this._fileName = fileName;
|
|
258
|
+
const model = this.options.editor?.getModel();
|
|
259
|
+
if (model) {
|
|
260
|
+
await this.registerTextDocument(model);
|
|
261
|
+
this.connection.fs.change({
|
|
262
|
+
changes: [
|
|
263
|
+
{
|
|
264
|
+
type: types.FileChangeEventType.Create,
|
|
265
|
+
path: fileName
|
|
266
|
+
},
|
|
267
|
+
{
|
|
268
|
+
type: types.FileChangeEventType.Delete,
|
|
269
|
+
path: oldFileName
|
|
270
|
+
}
|
|
271
|
+
]
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
dispose() {
|
|
277
|
+
this.peers.clear();
|
|
278
|
+
this.documentDisposables.forEach(e => e.dispose());
|
|
279
|
+
this.documentDisposables.clear();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
leaveRoom() {
|
|
283
|
+
this.options.connection.room.leave();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
getCurrentConnection(): ProtocolBroadcastConnection {
|
|
287
|
+
return this.options.connection;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
protected pushDocumentDisposable(path: string, disposable: Disposable) {
|
|
291
|
+
let disposables = this.documentDisposables.get(path);
|
|
292
|
+
if (!disposables) {
|
|
293
|
+
disposables = new DisposableCollection();
|
|
294
|
+
this.documentDisposables.set(path, disposables);
|
|
295
|
+
}
|
|
296
|
+
disposables.push(disposable);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
protected registerEditorEvents(): void {
|
|
300
|
+
if (!this.options.editor) {
|
|
301
|
+
return;
|
|
302
|
+
}
|
|
303
|
+
const text = this.options.editor.getModel();
|
|
304
|
+
if (text) {
|
|
305
|
+
this.registerTextDocument(text);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
this.options.editor.onDidChangeModelContent(event => {
|
|
309
|
+
if (text && !this.stopPropagation) {
|
|
310
|
+
this.updateTextDocument(event, text);
|
|
311
|
+
}
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
this.options.editor.onDidChangeCursorSelection(() => {
|
|
315
|
+
if (this.options.editor && !this.stopPropagation) {
|
|
316
|
+
this.updateTextSelection(this.options.editor);
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
const awarenessDebounce = debounce(() => {
|
|
321
|
+
this.rerenderPresence();
|
|
322
|
+
}, 2000);
|
|
323
|
+
|
|
324
|
+
this.yjsAwareness.on('change', async (_: unknown, origin: string) => {
|
|
325
|
+
if (origin !== LOCAL_ORIGIN) {
|
|
326
|
+
this.updateFollow();
|
|
327
|
+
this.rerenderPresence();
|
|
328
|
+
awarenessDebounce();
|
|
329
|
+
}
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
followUser(id?: string) {
|
|
334
|
+
this._following = id;
|
|
335
|
+
if (id) {
|
|
336
|
+
this.updateFollow();
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
protected updateFollow(): void {
|
|
341
|
+
if (this._following) {
|
|
342
|
+
let userState: types.ClientAwareness | undefined = undefined;
|
|
343
|
+
const states = this.yjsAwareness.getStates() as Map<number, types.ClientAwareness>;
|
|
344
|
+
for (const state of states.values()) {
|
|
345
|
+
const peer = this.peers.get(state.peer);
|
|
346
|
+
if (peer?.peer.id === this._following) {
|
|
347
|
+
userState = state;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (userState) {
|
|
351
|
+
if (types.ClientTextSelection.is(userState.selection)) {
|
|
352
|
+
this.followSelection(userState.selection);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
protected async followSelection(selection: types.ClientTextSelection): Promise<void> {
|
|
359
|
+
if (!this.options.editor) {
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const uri = this.getResourceUri(selection.path);
|
|
363
|
+
const text = this.yjs.getText(selection.path);
|
|
364
|
+
|
|
365
|
+
const prevPath = this.currentPath;
|
|
366
|
+
this.currentPath = selection.path;
|
|
367
|
+
if (prevPath !== selection.path) {
|
|
368
|
+
this.stopPropagation = true;
|
|
369
|
+
this.options.editor.setValue(text.toString());
|
|
370
|
+
this.stopPropagation = false;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const filename = this.getHostPath(selection.path);
|
|
374
|
+
if (this._fileName !== filename) {
|
|
375
|
+
this._fileName = filename;
|
|
376
|
+
this.previousFileName = filename;
|
|
377
|
+
this.notifyFileNameChanged(this._fileName);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
this.registerTextObserver(selection.path, this.options.editor.getModel()!, text);
|
|
381
|
+
if (uri && selection.visibleRanges && selection.visibleRanges.length > 0) {
|
|
382
|
+
const visibleRange = selection.visibleRanges[0];
|
|
383
|
+
const range = new monaco.Range(visibleRange.start.line, visibleRange.start.character, visibleRange.end.line, visibleRange.end.character);
|
|
384
|
+
this.options.editor && this.options.editor.revealRange(range);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
protected updateTextSelection(editor: monaco.editor.IStandaloneCodeEditor): void {
|
|
389
|
+
const document = editor.getModel();
|
|
390
|
+
const selections = editor.getSelections();
|
|
391
|
+
if (!document || !selections) {
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
const path = this.currentPath;
|
|
395
|
+
if (path) {
|
|
396
|
+
const ytext = this.yjs.getText(path);
|
|
397
|
+
const textSelections: types.RelativeTextSelection[] = [];
|
|
398
|
+
for (const selection of selections) {
|
|
399
|
+
const start = document.getOffsetAt(selection.getStartPosition());
|
|
400
|
+
const end = document.getOffsetAt(selection.getEndPosition());
|
|
401
|
+
const direction = selection.getDirection() === monaco.SelectionDirection.RTL
|
|
402
|
+
? types.SelectionDirection.RightToLeft
|
|
403
|
+
: types.SelectionDirection.LeftToRight;
|
|
404
|
+
const editorSelection: types.RelativeTextSelection = {
|
|
405
|
+
start: Y.createRelativePositionFromTypeIndex(ytext, start),
|
|
406
|
+
end: Y.createRelativePositionFromTypeIndex(ytext, end),
|
|
407
|
+
direction
|
|
408
|
+
};
|
|
409
|
+
textSelections.push(editorSelection);
|
|
410
|
+
}
|
|
411
|
+
const textSelection: types.ClientTextSelection = {
|
|
412
|
+
path,
|
|
413
|
+
textSelections,
|
|
414
|
+
visibleRanges: editor.getVisibleRanges().map(range => ({
|
|
415
|
+
start: {
|
|
416
|
+
line: range.startLineNumber,
|
|
417
|
+
character: range.startColumn
|
|
418
|
+
},
|
|
419
|
+
end: {
|
|
420
|
+
line: range.endLineNumber,
|
|
421
|
+
character: range.endColumn
|
|
422
|
+
}
|
|
423
|
+
}))
|
|
424
|
+
};
|
|
425
|
+
this.setSharedSelection(textSelection);
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
protected async registerTextDocument(document: monaco.editor.ITextModel): Promise<void> {
|
|
430
|
+
const uri = this.getResourceUri(`${this._workspaceName}/${this._fileName}`);
|
|
431
|
+
const path = this.getProtocolPath(uri);
|
|
432
|
+
if (!this.currentPath || this.currentPath !== path) {
|
|
433
|
+
this.currentPath = path;
|
|
434
|
+
}
|
|
435
|
+
if (path) {
|
|
436
|
+
const text = document.getValue();
|
|
437
|
+
const yjsText = this.yjs.getText(path);
|
|
438
|
+
let ytextContent = '';
|
|
439
|
+
if (this.isHost) {
|
|
440
|
+
this.yjs.transact(() => {
|
|
441
|
+
yjsText.delete(0, yjsText.length);
|
|
442
|
+
yjsText.insert(0, text);
|
|
443
|
+
});
|
|
444
|
+
ytextContent = yjsText.toString();
|
|
445
|
+
} else {
|
|
446
|
+
ytextContent = await this.readFile();
|
|
447
|
+
if (this._fileName !== this.previousFileName) {
|
|
448
|
+
this.previousFileName = this._fileName;
|
|
449
|
+
this.notifyFileNameChanged(this._fileName);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
if (text !== ytextContent) {
|
|
453
|
+
this.yjsMutex(() => {
|
|
454
|
+
document.setValue(ytextContent);
|
|
455
|
+
});
|
|
456
|
+
}
|
|
457
|
+
this.registerTextObserver(path, document, yjsText);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
protected registerTextObserver(path: string, document: monaco.editor.ITextModel, yjsText: Y.Text): void {
|
|
462
|
+
const textObserver = this.documentDisposables.get(path);
|
|
463
|
+
if (textObserver) {
|
|
464
|
+
textObserver.dispose();
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
const resyncThrottle = this.getOrCreateThrottle(path, document);
|
|
468
|
+
const observer = (textEvent: Y.YTextEvent) => {
|
|
469
|
+
this.yjsMutex(async () => {
|
|
470
|
+
if (this.options.editor) {
|
|
471
|
+
const changes = YTextChangeDelta.toChanges(textEvent.delta);
|
|
472
|
+
const edits = this.createEditsFromTextEvent(changes, document);
|
|
473
|
+
this.updateDocument(document, edits);
|
|
474
|
+
resyncThrottle();
|
|
475
|
+
}
|
|
476
|
+
});
|
|
477
|
+
};
|
|
478
|
+
yjsText.observe(observer);
|
|
479
|
+
this.pushDocumentDisposable(path, { dispose: () => yjsText.unobserve(observer) });
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
protected updateDocument(document: monaco.editor.ITextModel, edits: monaco.editor.IIdentifiedSingleEditOperation[]): void {
|
|
483
|
+
document.pushStackElement();
|
|
484
|
+
document.pushEditOperations(null, edits, () => null);
|
|
485
|
+
document.pushStackElement();
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
private createEditsFromTextEvent(changes: YTextChange[], document: monaco.editor.ITextModel): monaco.editor.IIdentifiedSingleEditOperation[] {
|
|
489
|
+
const edits: monaco.editor.IIdentifiedSingleEditOperation[] = [];
|
|
490
|
+
changes.forEach(change => {
|
|
491
|
+
const start = document.getPositionAt(change.start);
|
|
492
|
+
const end = document.getPositionAt(change.end);
|
|
493
|
+
edits.push({
|
|
494
|
+
range: new monaco.Range(start.lineNumber, start.column, end.lineNumber, end.column),
|
|
495
|
+
text: change.text
|
|
496
|
+
});
|
|
497
|
+
});
|
|
498
|
+
return edits;
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
protected updateTextDocument(event: monaco.editor.IModelContentChangedEvent, document: monaco.editor.ITextModel): void {
|
|
502
|
+
if (this.effectiveReadonly) {
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const path = this.currentPath;
|
|
506
|
+
if (path) {
|
|
507
|
+
this.yjsMutex(() => {
|
|
508
|
+
const ytext = this.yjs.getText(path);
|
|
509
|
+
this.yjs.transact(() => {
|
|
510
|
+
for (const change of event.changes) {
|
|
511
|
+
ytext.delete(change.rangeOffset, change.rangeLength);
|
|
512
|
+
ytext.insert(change.rangeOffset, change.text);
|
|
513
|
+
}
|
|
514
|
+
});
|
|
515
|
+
this.getOrCreateThrottle(path, document)();
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
protected getOrCreateThrottle(path: string, document: monaco.editor.ITextModel): () => void {
|
|
521
|
+
let value = this.throttles.get(path);
|
|
522
|
+
if (!value) {
|
|
523
|
+
value = debounce(() => {
|
|
524
|
+
this.yjsMutex(() => {
|
|
525
|
+
const yjsText = this.yjs.getText(path);
|
|
526
|
+
const newContent = yjsText.toString();
|
|
527
|
+
if (newContent !== document.getValue()) {
|
|
528
|
+
this.updateDocumentContent(document, newContent);
|
|
529
|
+
}
|
|
530
|
+
});
|
|
531
|
+
}, 100, {
|
|
532
|
+
leading: false,
|
|
533
|
+
trailing: true
|
|
534
|
+
});
|
|
535
|
+
this.throttles.set(path, value);
|
|
536
|
+
}
|
|
537
|
+
return value;
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
private updateDocumentContent(document: monaco.editor.ITextModel, newContent: string): void {
|
|
541
|
+
this.yjsMutex(() => {
|
|
542
|
+
if (this.options.editor) {
|
|
543
|
+
const edits: monaco.editor.IIdentifiedSingleEditOperation[] = [{
|
|
544
|
+
range: document.getFullModelRange(),
|
|
545
|
+
text: newContent
|
|
546
|
+
}];
|
|
547
|
+
this.updateDocument(document, edits);
|
|
548
|
+
}
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
protected rerenderPresence() {
|
|
553
|
+
const states = this.yjsAwareness.getStates() as Map<number, types.ClientAwareness>;
|
|
554
|
+
for (const [clientID, state] of states.entries()) {
|
|
555
|
+
if (clientID === this.yjs.clientID) {
|
|
556
|
+
// Ignore own awareness state
|
|
557
|
+
continue;
|
|
558
|
+
}
|
|
559
|
+
const peerId = state.peer;
|
|
560
|
+
const peer = this.peers.get(peerId);
|
|
561
|
+
if (!state.selection || !peer) {
|
|
562
|
+
continue;
|
|
563
|
+
}
|
|
564
|
+
if (!types.ClientTextSelection.is(state.selection)) {
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
const { path, textSelections } = state.selection;
|
|
568
|
+
const selection = textSelections[0];
|
|
569
|
+
if (!selection) {
|
|
570
|
+
continue;
|
|
571
|
+
}
|
|
572
|
+
const uri = this.getResourceUri(path);
|
|
573
|
+
if (uri && this.options.editor) {
|
|
574
|
+
const model = this.options.editor.getModel();
|
|
575
|
+
const forward = selection.direction === 1;
|
|
576
|
+
let startIndex = Y.createAbsolutePositionFromRelativePosition(selection.start, this.yjs);
|
|
577
|
+
let endIndex = Y.createAbsolutePositionFromRelativePosition(selection.end, this.yjs);
|
|
578
|
+
if (model && startIndex && endIndex) {
|
|
579
|
+
if (startIndex.index > endIndex.index) {
|
|
580
|
+
[startIndex, endIndex] = [endIndex, startIndex];
|
|
581
|
+
}
|
|
582
|
+
const start = model.getPositionAt(startIndex.index);
|
|
583
|
+
const end = model.getPositionAt(endIndex.index);
|
|
584
|
+
const inverted = (forward && end.lineNumber === 1) || (!forward && start.lineNumber === 1);
|
|
585
|
+
const range: monaco.IRange = {
|
|
586
|
+
startLineNumber: start.lineNumber,
|
|
587
|
+
startColumn: start.column,
|
|
588
|
+
endLineNumber: end.lineNumber,
|
|
589
|
+
endColumn: end.column
|
|
590
|
+
};
|
|
591
|
+
const contentClassNames: string[] = [peer.decoration.cursorClassName];
|
|
592
|
+
if (inverted) {
|
|
593
|
+
contentClassNames.push(peer.decoration.cursorInvertedClassName);
|
|
594
|
+
}
|
|
595
|
+
this.setDecorations(peer, [{
|
|
596
|
+
range,
|
|
597
|
+
options: {
|
|
598
|
+
className: peer.decoration.selectionClassName,
|
|
599
|
+
beforeContentClassName: !forward ? contentClassNames.join(' ') : undefined,
|
|
600
|
+
afterContentClassName: forward ? contentClassNames.join(' ') : undefined,
|
|
601
|
+
stickiness: monaco.editor.TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges
|
|
602
|
+
}
|
|
603
|
+
}]);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
protected setDecorations(peer: DisposablePeer, decorations: monaco.editor.IModelDeltaDecoration[]): void {
|
|
610
|
+
if (this.decorations.has(peer)) {
|
|
611
|
+
this.decorations.get(peer)?.set(decorations);
|
|
612
|
+
} else if (this.options.editor) {
|
|
613
|
+
this.decorations.set(peer, this.options.editor.createDecorationsCollection(decorations));
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
protected setSharedSelection(selection?: types.ClientSelection): void {
|
|
618
|
+
this.yjsAwareness.setLocalStateField('selection', selection);
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
protected updateSelectionPath(newPath: string): void {
|
|
622
|
+
const currentState = this.yjsAwareness.getLocalState() as types.ClientAwareness;
|
|
623
|
+
if (currentState?.selection && types.ClientTextSelection.is(currentState.selection)) {
|
|
624
|
+
const newSelection: types.ClientTextSelection = {
|
|
625
|
+
...currentState.selection,
|
|
626
|
+
path: newPath
|
|
627
|
+
};
|
|
628
|
+
this.setSharedSelection(newSelection);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
protected createSelectionFromRelative(selection: types.RelativeTextSelection, model: monaco.editor.ITextModel): monaco.Selection | undefined {
|
|
633
|
+
const start = Y.createAbsolutePositionFromRelativePosition(selection.start, this.yjs);
|
|
634
|
+
const end = Y.createAbsolutePositionFromRelativePosition(selection.end, this.yjs);
|
|
635
|
+
if (start && end) {
|
|
636
|
+
let anchor = model.getPositionAt(start.index);
|
|
637
|
+
let head = model.getPositionAt(end.index);
|
|
638
|
+
if (selection.direction === types.SelectionDirection.RightToLeft) {
|
|
639
|
+
[anchor, head] = [head, anchor];
|
|
640
|
+
}
|
|
641
|
+
return new monaco.Selection(anchor.lineNumber, anchor.column, head.lineNumber, head.column);
|
|
642
|
+
}
|
|
643
|
+
return undefined;
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
protected getHostPath(path: string): string {
|
|
647
|
+
// When creating a URI as a guest, we always prepend it with the name of the workspace
|
|
648
|
+
// This just removes the workspace name from the path to get the path expected by the protocol
|
|
649
|
+
const subpath = path.substring(1).split('/');
|
|
650
|
+
return subpath.slice(1).join('/');
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
async initialize(data: types.InitData): Promise<void> {
|
|
654
|
+
for (const peer of [data.host, ...data.guests]) {
|
|
655
|
+
this.peers.set(peer.id, new DisposablePeer(this.yjsAwareness, peer));
|
|
656
|
+
}
|
|
657
|
+
await this.applyPermissionsUpdate(data.permissions);
|
|
658
|
+
this.workspaceName = data.workspace.name;
|
|
659
|
+
this.notifyUsersChanged();
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
getProtocolPath(uri?: monaco.Uri): string | undefined {
|
|
663
|
+
if (!uri) {
|
|
664
|
+
return undefined;
|
|
665
|
+
}
|
|
666
|
+
return uri.path.startsWith('/') ? uri.path.substring(1) : uri.path;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
getResourceUri(path?: string): monaco.Uri | undefined {
|
|
670
|
+
return new monaco.Uri().with({ path });
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
async readFile(): Promise<string> {
|
|
674
|
+
if (!this.currentPath) {
|
|
675
|
+
return '';
|
|
676
|
+
}
|
|
677
|
+
const path = this.getHostPath(this.currentPath);
|
|
678
|
+
|
|
679
|
+
if (this.yjs.share.has(path)) {
|
|
680
|
+
const stringValue = this.yjs.getText(path);
|
|
681
|
+
return stringValue.toString();
|
|
682
|
+
} else {
|
|
683
|
+
const file = await this.connection.fs.readFile(this.host?.id, path);
|
|
684
|
+
const decoder = new TextDecoder();
|
|
685
|
+
return decoder.decode(file.content);
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
}
|