@theia/collaboration 1.53.0-next.55 → 1.53.0-next.64

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.
@@ -1,819 +1,819 @@
1
- // *****************************************************************************
2
- // Copyright (C) 2024 TypeFox and others.
3
- //
4
- // This program and the accompanying materials are made available under the
5
- // terms of the Eclipse Public License v. 2.0 which is available at
6
- // http://www.eclipse.org/legal/epl-2.0.
7
- //
8
- // This Source Code may also be made available under the following Secondary
9
- // Licenses when the conditions for such availability set forth in the Eclipse
10
- // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
- // with the GNU Classpath Exception which is available at
12
- // https://www.gnu.org/software/classpath/license.html.
13
- //
14
- // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
- // *****************************************************************************
16
-
17
- import * as types from 'open-collaboration-protocol';
18
- import * as Y from 'yjs';
19
- import * as awarenessProtocol from 'y-protocols/awareness';
20
-
21
- import { Disposable, DisposableCollection, Emitter, Event, MessageService, URI, nls } from '@theia/core';
22
- import { Container, inject, injectable, interfaces, postConstruct } from '@theia/core/shared/inversify';
23
- import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell';
24
- import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
25
- import { FileService } from '@theia/filesystem/lib/browser/file-service';
26
- import { MonacoTextModelService } from '@theia/monaco/lib/browser/monaco-text-model-service';
27
- import { CollaborationWorkspaceService } from './collaboration-workspace-service';
28
- import { Range as MonacoRange } from '@theia/monaco-editor-core';
29
- import { MonacoEditorModel } from '@theia/monaco/lib/browser/monaco-editor-model';
30
- import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor';
31
- import { Deferred } from '@theia/core/lib/common/promise-util';
32
- import { EditorDecoration, EditorWidget, Selection, TextEditorDocument, TrackedRangeStickiness } from '@theia/editor/lib/browser';
33
- import { DecorationStyle, OpenerService, SaveReason } from '@theia/core/lib/browser';
34
- import { CollaborationFileSystemProvider, CollaborationURI } from './collaboration-file-system-provider';
35
- import { Range } from '@theia/core/shared/vscode-languageserver-protocol';
36
- import { CollaborationColorService } from './collaboration-color-service';
37
- import { BinaryBuffer } from '@theia/core/lib/common/buffer';
38
- import { FileChange, FileChangeType, FileOperation } from '@theia/filesystem/lib/common/files';
39
- import { OpenCollaborationYjsProvider } from 'open-collaboration-yjs';
40
- import { createMutex } from 'lib0/mutex';
41
- import { CollaborationUtils } from './collaboration-utils';
42
- import debounce = require('@theia/core/shared/lodash.debounce');
43
-
44
- export const CollaborationInstanceFactory = Symbol('CollaborationInstanceFactory');
45
- export type CollaborationInstanceFactory = (connection: CollaborationInstanceOptions) => CollaborationInstance;
46
-
47
- export const CollaborationInstanceOptions = Symbol('CollaborationInstanceOptions');
48
- export interface CollaborationInstanceOptions {
49
- role: 'host' | 'guest';
50
- connection: types.ProtocolBroadcastConnection;
51
- }
52
-
53
- export function createCollaborationInstanceContainer(parent: interfaces.Container, options: CollaborationInstanceOptions): Container {
54
- const child = new Container();
55
- child.parent = parent;
56
- child.bind(CollaborationInstance).toSelf().inTransientScope();
57
- child.bind(CollaborationInstanceOptions).toConstantValue(options);
58
- return child;
59
- }
60
-
61
- export interface DisposablePeer extends Disposable {
62
- peer: types.Peer;
63
- }
64
-
65
- export const COLLABORATION_SELECTION = 'theia-collaboration-selection';
66
- export const COLLABORATION_SELECTION_MARKER = 'theia-collaboration-selection-marker';
67
- export const COLLABORATION_SELECTION_INVERTED = 'theia-collaboration-selection-inverted';
68
-
69
- @injectable()
70
- export class CollaborationInstance implements Disposable {
71
-
72
- @inject(MessageService)
73
- protected readonly messageService: MessageService;
74
-
75
- @inject(CollaborationWorkspaceService)
76
- protected readonly workspaceService: CollaborationWorkspaceService;
77
-
78
- @inject(FileService)
79
- protected readonly fileService: FileService;
80
-
81
- @inject(MonacoTextModelService)
82
- protected readonly monacoModelService: MonacoTextModelService;
83
-
84
- @inject(EditorManager)
85
- protected readonly editorManager: EditorManager;
86
-
87
- @inject(OpenerService)
88
- protected readonly openerService: OpenerService;
89
-
90
- @inject(ApplicationShell)
91
- protected readonly shell: ApplicationShell;
92
-
93
- @inject(CollaborationInstanceOptions)
94
- protected readonly options: CollaborationInstanceOptions;
95
-
96
- @inject(CollaborationColorService)
97
- protected readonly collaborationColorService: CollaborationColorService;
98
-
99
- @inject(CollaborationUtils)
100
- protected readonly utils: CollaborationUtils;
101
-
102
- protected identity = new Deferred<types.Peer>();
103
- protected peers = new Map<string, DisposablePeer>();
104
- protected yjs = new Y.Doc();
105
- protected yjsAwareness = new awarenessProtocol.Awareness(this.yjs);
106
- protected yjsProvider: OpenCollaborationYjsProvider;
107
- protected colorIndex = 0;
108
- protected editorDecorations = new Map<EditorWidget, string[]>();
109
- protected fileSystem?: CollaborationFileSystemProvider;
110
- protected permissions: types.Permissions = {
111
- readonly: false
112
- };
113
-
114
- protected onDidCloseEmitter = new Emitter<void>();
115
-
116
- get onDidClose(): Event<void> {
117
- return this.onDidCloseEmitter.event;
118
- }
119
-
120
- protected toDispose = new DisposableCollection();
121
- protected _readonly = false;
122
-
123
- get readonly(): boolean {
124
- return this._readonly;
125
- }
126
-
127
- set readonly(value: boolean) {
128
- if (value !== this.readonly) {
129
- if (this.options.role === 'guest' && this.fileSystem) {
130
- this.fileSystem.readonly = value;
131
- } else if (this.options.role === 'host') {
132
- this.options.connection.room.updatePermissions({
133
- ...(this.permissions ?? {}),
134
- readonly: value
135
- });
136
- }
137
- if (this.permissions) {
138
- this.permissions.readonly = value;
139
- }
140
- this._readonly = value;
141
- }
142
- }
143
-
144
- get isHost(): boolean {
145
- return this.options.role === 'host';
146
- }
147
-
148
- get host(): types.Peer {
149
- return Array.from(this.peers.values()).find(e => e.peer.host)!.peer;
150
- }
151
-
152
- @postConstruct()
153
- protected init(): void {
154
- const connection = this.options.connection;
155
- connection.onDisconnect(() => this.dispose());
156
- connection.onConnectionError(message => {
157
- this.messageService.error(message);
158
- this.dispose();
159
- });
160
- this.yjsProvider = new OpenCollaborationYjsProvider(connection, this.yjs, this.yjsAwareness);
161
- this.yjsProvider.connect();
162
- this.toDispose.push(Disposable.create(() => this.yjs.destroy()));
163
- this.toDispose.push(this.yjsProvider);
164
- this.toDispose.push(connection);
165
- this.toDispose.push(this.onDidCloseEmitter);
166
-
167
- this.registerProtocolEvents(connection);
168
- this.registerEditorEvents(connection);
169
- this.registerFileSystemEvents(connection);
170
-
171
- if (this.isHost) {
172
- this.registerFileSystemChanges();
173
- }
174
- }
175
-
176
- protected registerProtocolEvents(connection: types.ProtocolBroadcastConnection): void {
177
- connection.peer.onJoinRequest(async (_, user) => {
178
- const allow = nls.localizeByDefault('Allow');
179
- const deny = nls.localizeByDefault('Deny');
180
- const result = await this.messageService.info(
181
- nls.localize('theia/collaboration/userWantsToJoin', "User '{0}' wants to join the collaboration room", user.email ? `${user.name} (${user.email})` : user.name),
182
- allow,
183
- deny
184
- );
185
- if (result === allow) {
186
- const roots = await this.workspaceService.roots;
187
- return {
188
- workspace: {
189
- name: this.workspaceService.workspace?.name ?? nls.localize('theia/collaboration/collaboration', 'Collaboration'),
190
- folders: roots.map(e => e.name)
191
- }
192
- };
193
- } else {
194
- return undefined;
195
- }
196
- });
197
- connection.room.onJoin(async (_, peer) => {
198
- this.addPeer(peer);
199
- if (this.isHost) {
200
- const roots = await this.workspaceService.roots;
201
- const data: types.InitData = {
202
- protocol: types.VERSION,
203
- host: await this.identity.promise,
204
- guests: Array.from(this.peers.values()).map(e => e.peer),
205
- capabilities: {},
206
- permissions: this.permissions,
207
- workspace: {
208
- name: this.workspaceService.workspace?.name ?? nls.localize('theia/collaboration/collaboration', 'Collaboration'),
209
- folders: roots.map(e => e.name)
210
- }
211
- };
212
- connection.peer.init(peer.id, data);
213
- }
214
- });
215
- connection.room.onLeave((_, peer) => {
216
- this.peers.get(peer.id)?.dispose();
217
- });
218
- connection.room.onClose(() => {
219
- this.dispose();
220
- });
221
- connection.room.onPermissions((_, permissions) => {
222
- if (this.fileSystem) {
223
- this.fileSystem.readonly = permissions.readonly;
224
- }
225
- });
226
- connection.peer.onInfo((_, peer) => {
227
- this.yjsAwareness.setLocalStateField('peer', peer.id);
228
- this.identity.resolve(peer);
229
- });
230
- connection.peer.onInit(async (_, data) => {
231
- await this.initialize(data);
232
- });
233
- }
234
-
235
- protected registerEditorEvents(connection: types.ProtocolBroadcastConnection): void {
236
- for (const model of this.monacoModelService.models) {
237
- if (this.isSharedResource(new URI(model.uri))) {
238
- this.registerModelUpdate(model);
239
- }
240
- }
241
- this.toDispose.push(this.monacoModelService.onDidCreate(newModel => {
242
- if (this.isSharedResource(new URI(newModel.uri))) {
243
- this.registerModelUpdate(newModel);
244
- }
245
- }));
246
- this.toDispose.push(this.editorManager.onCreated(widget => {
247
- if (this.isSharedResource(widget.getResourceUri())) {
248
- this.registerPresenceUpdate(widget);
249
- }
250
- }));
251
- this.getOpenEditors().forEach(widget => {
252
- if (this.isSharedResource(widget.getResourceUri())) {
253
- this.registerPresenceUpdate(widget);
254
- }
255
- });
256
- this.shell.onDidChangeActiveWidget(e => {
257
- if (e.newValue instanceof EditorWidget) {
258
- this.updateEditorPresence(e.newValue);
259
- }
260
- });
261
-
262
- this.yjsAwareness.on('change', () => {
263
- this.rerenderPresence();
264
- });
265
-
266
- connection.editor.onOpen(async (_, path) => {
267
- const uri = this.utils.getResourceUri(path);
268
- if (uri) {
269
- await this.openUri(uri);
270
- } else {
271
- throw new Error('Could find file: ' + path);
272
- }
273
- return undefined;
274
- });
275
- }
276
-
277
- protected isSharedResource(resource?: URI): boolean {
278
- if (!resource) {
279
- return false;
280
- }
281
- return this.isHost ? resource.scheme === 'file' : resource.scheme === CollaborationURI.scheme;
282
- }
283
-
284
- protected registerFileSystemEvents(connection: types.ProtocolBroadcastConnection): void {
285
- connection.fs.onReadFile(async (_, path) => {
286
- const uri = this.utils.getResourceUri(path);
287
- if (uri) {
288
- const content = await this.fileService.readFile(uri);
289
- return {
290
- content: content.value.buffer
291
- };
292
- } else {
293
- throw new Error('Could find file: ' + path);
294
- }
295
- });
296
- connection.fs.onReaddir(async (_, path) => {
297
- const uri = this.utils.getResourceUri(path);
298
- if (uri) {
299
- const resolved = await this.fileService.resolve(uri);
300
- if (resolved.children) {
301
- const dir: Record<string, types.FileType> = {};
302
- for (const child of resolved.children) {
303
- dir[child.name] = child.isDirectory ? types.FileType.Directory : types.FileType.File;
304
- }
305
- return dir;
306
- } else {
307
- return {};
308
- }
309
- } else {
310
- throw new Error('Could find directory: ' + path);
311
- }
312
- });
313
- connection.fs.onStat(async (_, path) => {
314
- const uri = this.utils.getResourceUri(path);
315
- if (uri) {
316
- const content = await this.fileService.resolve(uri, {
317
- resolveMetadata: true
318
- });
319
- return {
320
- type: content.isDirectory ? types.FileType.Directory : types.FileType.File,
321
- ctime: content.ctime,
322
- mtime: content.mtime,
323
- size: content.size,
324
- permissions: content.isReadonly ? types.FilePermission.Readonly : undefined
325
- };
326
- } else {
327
- throw new Error('Could find file: ' + path);
328
- }
329
- });
330
- connection.fs.onWriteFile(async (_, path, data) => {
331
- const uri = this.utils.getResourceUri(path);
332
- if (uri) {
333
- const model = this.getModel(uri);
334
- if (model) {
335
- const content = new TextDecoder().decode(data.content);
336
- if (content !== model.getText()) {
337
- model.textEditorModel.setValue(content);
338
- }
339
- await model.save({ saveReason: SaveReason.Manual });
340
- } else {
341
- await this.fileService.createFile(uri, BinaryBuffer.wrap(data.content));
342
- }
343
- } else {
344
- throw new Error('Could find file: ' + path);
345
- }
346
- });
347
- connection.fs.onMkdir(async (_, path) => {
348
- const uri = this.utils.getResourceUri(path);
349
- if (uri) {
350
- await this.fileService.createFolder(uri);
351
- } else {
352
- throw new Error('Could find path: ' + path);
353
- }
354
- });
355
- connection.fs.onDelete(async (_, path) => {
356
- const uri = this.utils.getResourceUri(path);
357
- if (uri) {
358
- await this.fileService.delete(uri);
359
- } else {
360
- throw new Error('Could find entry: ' + path);
361
- }
362
- });
363
- connection.fs.onRename(async (_, from, to) => {
364
- const fromUri = this.utils.getResourceUri(from);
365
- const toUri = this.utils.getResourceUri(to);
366
- if (fromUri && toUri) {
367
- await this.fileService.move(fromUri, toUri);
368
- } else {
369
- throw new Error('Could find entries: ' + from + ' -> ' + to);
370
- }
371
- });
372
- connection.fs.onChange(async (_, event) => {
373
- // Only guests need to handle file system changes
374
- if (!this.isHost && this.fileSystem) {
375
- const changes: FileChange[] = [];
376
- for (const change of event.changes) {
377
- const uri = this.utils.getResourceUri(change.path);
378
- if (uri) {
379
- changes.push({
380
- type: change.type === types.FileChangeEventType.Create
381
- ? FileChangeType.ADDED
382
- : change.type === types.FileChangeEventType.Update
383
- ? FileChangeType.UPDATED
384
- : FileChangeType.DELETED,
385
- resource: uri
386
- });
387
- }
388
- }
389
- this.fileSystem.triggerEvent(changes);
390
- }
391
- });
392
- }
393
-
394
- protected rerenderPresence(...widgets: EditorWidget[]): void {
395
- const decorations = new Map<string, EditorDecoration[]>();
396
- const states = this.yjsAwareness.getStates() as Map<number, types.ClientAwareness>;
397
- for (const [clientID, state] of states.entries()) {
398
- if (clientID === this.yjs.clientID) {
399
- // Ignore own awareness state
400
- continue;
401
- }
402
- const peer = state.peer;
403
- if (!state.selection || !this.peers.has(peer)) {
404
- continue;
405
- }
406
- if (!types.ClientTextSelection.is(state.selection)) {
407
- continue;
408
- }
409
- const { path, textSelections } = state.selection;
410
- const selection = textSelections[0];
411
- if (!selection) {
412
- continue;
413
- }
414
- const uri = this.utils.getResourceUri(path);
415
- if (uri) {
416
- const model = this.getModel(uri);
417
- if (model) {
418
- let existing = decorations.get(path);
419
- if (!existing) {
420
- existing = [];
421
- decorations.set(path, existing);
422
- }
423
- const forward = selection.direction === types.SelectionDirection.LeftToRight;
424
- let startIndex = Y.createAbsolutePositionFromRelativePosition(selection.start, this.yjs);
425
- let endIndex = Y.createAbsolutePositionFromRelativePosition(selection.end, this.yjs);
426
- if (startIndex && endIndex) {
427
- if (startIndex.index > endIndex.index) {
428
- [startIndex, endIndex] = [endIndex, startIndex];
429
- }
430
- const start = model.positionAt(startIndex.index);
431
- const end = model.positionAt(endIndex.index);
432
- const inverted = (forward && end.line === 0) || (!forward && start.line === 0);
433
- const range = {
434
- start,
435
- end
436
- };
437
- const contentClassNames: string[] = [COLLABORATION_SELECTION_MARKER, `${COLLABORATION_SELECTION_MARKER}-${peer}`];
438
- if (inverted) {
439
- contentClassNames.push(COLLABORATION_SELECTION_INVERTED);
440
- }
441
- const item: EditorDecoration = {
442
- range,
443
- options: {
444
- className: `${COLLABORATION_SELECTION} ${COLLABORATION_SELECTION}-${peer}`,
445
- beforeContentClassName: !forward ? contentClassNames.join(' ') : undefined,
446
- afterContentClassName: forward ? contentClassNames.join(' ') : undefined,
447
- stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges
448
- }
449
- };
450
- existing.push(item);
451
- }
452
- }
453
- }
454
- }
455
- this.rerenderPresenceDecorations(decorations, ...widgets);
456
- }
457
-
458
- protected rerenderPresenceDecorations(decorations: Map<string, EditorDecoration[]>, ...widgets: EditorWidget[]): void {
459
- for (const editor of new Set(this.getOpenEditors().concat(widgets))) {
460
- const uri = editor.getResourceUri();
461
- const path = this.utils.getProtocolPath(uri);
462
- if (path) {
463
- const old = this.editorDecorations.get(editor) ?? [];
464
- this.editorDecorations.set(editor, editor.editor.deltaDecorations({
465
- newDecorations: decorations.get(path) ?? [],
466
- oldDecorations: old
467
- }));
468
- }
469
- }
470
- }
471
-
472
- protected registerFileSystemChanges(): void {
473
- // Event listener for disk based events
474
- this.fileService.onDidFilesChange(event => {
475
- const changes: types.FileChange[] = [];
476
- for (const change of event.changes) {
477
- const path = this.utils.getProtocolPath(change.resource);
478
- if (path) {
479
- let type: types.FileChangeEventType | undefined;
480
- if (change.type === FileChangeType.ADDED) {
481
- type = types.FileChangeEventType.Create;
482
- } else if (change.type === FileChangeType.DELETED) {
483
- type = types.FileChangeEventType.Delete;
484
- }
485
- // Updates to files on disk are not sent
486
- if (type !== undefined) {
487
- changes.push({
488
- path,
489
- type
490
- });
491
- }
492
- }
493
- }
494
- if (changes.length) {
495
- this.options.connection.fs.change({ changes });
496
- }
497
- });
498
- // Event listener for user based events
499
- this.fileService.onDidRunOperation(operation => {
500
- const path = this.utils.getProtocolPath(operation.resource);
501
- if (!path) {
502
- return;
503
- }
504
- let type = types.FileChangeEventType.Update;
505
- if (operation.isOperation(FileOperation.CREATE) || operation.isOperation(FileOperation.COPY)) {
506
- type = types.FileChangeEventType.Create;
507
- } else if (operation.isOperation(FileOperation.DELETE)) {
508
- type = types.FileChangeEventType.Delete;
509
- }
510
- this.options.connection.fs.change({
511
- changes: [{
512
- path,
513
- type
514
- }]
515
- });
516
- });
517
- }
518
-
519
- protected async registerPresenceUpdate(widget: EditorWidget): Promise<void> {
520
- const uri = widget.getResourceUri();
521
- const path = this.utils.getProtocolPath(uri);
522
- if (path) {
523
- if (!this.isHost) {
524
- this.options.connection.editor.open(this.host.id, path);
525
- }
526
- let currentSelection = widget.editor.selection;
527
- // // Update presence information when the selection changes
528
- const selectionChange = widget.editor.onSelectionChanged(selection => {
529
- if (!this.rangeEqual(currentSelection, selection)) {
530
- this.updateEditorPresence(widget);
531
- currentSelection = selection;
532
- }
533
- });
534
- const widgetDispose = widget.onDidDispose(() => {
535
- widgetDispose.dispose();
536
- selectionChange.dispose();
537
- // Remove presence information when the editor closes
538
- const state = this.yjsAwareness.getLocalState();
539
- if (state?.currentSelection?.path === path) {
540
- delete state.currentSelection;
541
- }
542
- this.yjsAwareness.setLocalState(state);
543
- });
544
- this.toDispose.push(selectionChange);
545
- this.toDispose.push(widgetDispose);
546
- this.rerenderPresence(widget);
547
- }
548
- }
549
-
550
- protected updateEditorPresence(widget: EditorWidget): void {
551
- const uri = widget.getResourceUri();
552
- const path = this.utils.getProtocolPath(uri);
553
- if (path) {
554
- const ytext = this.yjs.getText(path);
555
- const selection = widget.editor.selection;
556
- let start = widget.editor.document.offsetAt(selection.start);
557
- let end = widget.editor.document.offsetAt(selection.end);
558
- if (start > end) {
559
- [start, end] = [end, start];
560
- }
561
- const direction = selection.direction === 'ltr'
562
- ? types.SelectionDirection.LeftToRight
563
- : types.SelectionDirection.RightToLeft;
564
- const editorSelection: types.RelativeTextSelection = {
565
- start: Y.createRelativePositionFromTypeIndex(ytext, start),
566
- end: Y.createRelativePositionFromTypeIndex(ytext, end),
567
- direction
568
- };
569
- const textSelection: types.ClientTextSelection = {
570
- path,
571
- textSelections: [editorSelection]
572
- };
573
- this.setSharedSelection(textSelection);
574
- }
575
- }
576
-
577
- protected setSharedSelection(selection?: types.ClientSelection): void {
578
- this.yjsAwareness.setLocalStateField('selection', selection);
579
- }
580
-
581
- protected rangeEqual(a: Range, b: Range): boolean {
582
- return a.start.line === b.start.line
583
- && a.start.character === b.start.character
584
- && a.end.line === b.end.line
585
- && a.end.character === b.end.character;
586
- }
587
-
588
- async initialize(data: types.InitData): Promise<void> {
589
- this.permissions = data.permissions;
590
- this.readonly = data.permissions.readonly;
591
- for (const peer of [...data.guests, data.host]) {
592
- this.addPeer(peer);
593
- }
594
- this.fileSystem = new CollaborationFileSystemProvider(this.options.connection, data.host, this.yjs);
595
- this.fileSystem.readonly = this.readonly;
596
- this.toDispose.push(this.fileService.registerProvider(CollaborationURI.scheme, this.fileSystem));
597
- const workspaceDisposable = await this.workspaceService.setHostWorkspace(data.workspace, this.options.connection);
598
- this.toDispose.push(workspaceDisposable);
599
- }
600
-
601
- protected addPeer(peer: types.Peer): void {
602
- const collection = new DisposableCollection();
603
- collection.push(this.createPeerStyleSheet(peer));
604
- collection.push(Disposable.create(() => this.peers.delete(peer.id)));
605
- const disposablePeer = {
606
- peer,
607
- dispose: () => collection.dispose()
608
- };
609
- this.peers.set(peer.id, disposablePeer);
610
- }
611
-
612
- protected createPeerStyleSheet(peer: types.Peer): Disposable {
613
- const style = DecorationStyle.createStyleElement(`${peer.id}-collaboration-selection`);
614
- const colors = this.collaborationColorService.getColors();
615
- const sheet = style.sheet!;
616
- const color = colors[this.colorIndex++ % colors.length];
617
- const colorString = `rgb(${color.r}, ${color.g}, ${color.b})`;
618
- sheet.insertRule(`
619
- .${COLLABORATION_SELECTION}-${peer.id} {
620
- opacity: 0.2;
621
- background: ${colorString};
622
- }
623
- `);
624
- sheet.insertRule(`
625
- .${COLLABORATION_SELECTION_MARKER}-${peer.id} {
626
- background: ${colorString};
627
- border-color: ${colorString};
628
- }`
629
- );
630
- sheet.insertRule(`
631
- .${COLLABORATION_SELECTION_MARKER}-${peer.id}::after {
632
- content: "${peer.name}";
633
- background: ${colorString};
634
- color: ${this.collaborationColorService.requiresDarkFont(color)
635
- ? this.collaborationColorService.dark
636
- : this.collaborationColorService.light};
637
- z-index: ${(100 + this.colorIndex).toFixed()}
638
- }`
639
- );
640
- return Disposable.create(() => style.remove());
641
- }
642
-
643
- protected getOpenEditors(uri?: URI): EditorWidget[] {
644
- const widgets = this.shell.widgets;
645
- let editors = widgets.filter(e => e instanceof EditorWidget) as EditorWidget[];
646
- if (uri) {
647
- const uriString = uri.toString();
648
- editors = editors.filter(e => e.getResourceUri()?.toString() === uriString);
649
- }
650
- return editors;
651
- }
652
-
653
- protected createSelectionFromRelative(selection: types.RelativeTextSelection, model: MonacoEditorModel): Selection | undefined {
654
- const start = Y.createAbsolutePositionFromRelativePosition(selection.start, this.yjs);
655
- const end = Y.createAbsolutePositionFromRelativePosition(selection.end, this.yjs);
656
- if (start && end) {
657
- return {
658
- start: model.positionAt(start.index),
659
- end: model.positionAt(end.index),
660
- direction: selection.direction === types.SelectionDirection.LeftToRight ? 'ltr' : 'rtl'
661
- };
662
- }
663
- return undefined;
664
- }
665
-
666
- protected createRelativeSelection(selection: Selection, model: TextEditorDocument, ytext: Y.Text): types.RelativeTextSelection {
667
- const start = Y.createRelativePositionFromTypeIndex(ytext, model.offsetAt(selection.start));
668
- const end = Y.createRelativePositionFromTypeIndex(ytext, model.offsetAt(selection.end));
669
- return {
670
- start,
671
- end,
672
- direction: selection.direction === 'ltr'
673
- ? types.SelectionDirection.LeftToRight
674
- : types.SelectionDirection.RightToLeft
675
- };
676
- }
677
-
678
- protected readonly yjsMutex = createMutex();
679
-
680
- protected registerModelUpdate(model: MonacoEditorModel): void {
681
- let updating = false;
682
- const modelPath = this.utils.getProtocolPath(new URI(model.uri));
683
- if (!modelPath) {
684
- return;
685
- }
686
- const unknownModel = !this.yjs.share.has(modelPath);
687
- const ytext = this.yjs.getText(modelPath);
688
- const modelText = model.textEditorModel.getValue();
689
- if (this.isHost && unknownModel) {
690
- // If we are hosting the room, set the initial content
691
- // First off, reset the shared content to be empty
692
- // This has the benefit of effectively clearing the memory of the shared content across all peers
693
- // This is important because the shared content accumulates changes/memory usage over time
694
- this.resetYjsText(ytext, modelText);
695
- } else {
696
- this.options.connection.editor.open(this.host.id, modelPath);
697
- }
698
- // The Ytext instance is our source of truth for the model content
699
- // Sometimes (especially after a lot of sequential undo/redo operations) our model content can get out of sync
700
- // This resyncs the model content with the Ytext content after a delay
701
- const resyncDebounce = debounce(() => {
702
- this.yjsMutex(() => {
703
- const newContent = ytext.toString();
704
- if (model.textEditorModel.getValue() !== newContent) {
705
- updating = true;
706
- this.softReplaceModel(model, newContent);
707
- updating = false;
708
- }
709
- });
710
- }, 200);
711
- const disposable = new DisposableCollection();
712
- disposable.push(model.onDidChangeContent(e => {
713
- if (updating) {
714
- return;
715
- }
716
- this.yjsMutex(() => {
717
- this.yjs.transact(() => {
718
- for (const change of e.contentChanges) {
719
- ytext.delete(change.rangeOffset, change.rangeLength);
720
- ytext.insert(change.rangeOffset, change.text);
721
- }
722
- });
723
- resyncDebounce();
724
- });
725
- }));
726
-
727
- const observer = (textEvent: Y.YTextEvent) => {
728
- if (textEvent.transaction.local || model.getText() === ytext.toString()) {
729
- // Ignore local changes and changes that are already reflected in the model
730
- return;
731
- }
732
- this.yjsMutex(() => {
733
- updating = true;
734
- try {
735
- let index = 0;
736
- const operations: { range: MonacoRange, text: string }[] = [];
737
- textEvent.delta.forEach(delta => {
738
- if (delta.retain !== undefined) {
739
- index += delta.retain;
740
- } else if (delta.insert !== undefined) {
741
- const pos = model.textEditorModel.getPositionAt(index);
742
- const range = new MonacoRange(pos.lineNumber, pos.column, pos.lineNumber, pos.column);
743
- const insert = delta.insert as string;
744
- operations.push({ range, text: insert });
745
- index += insert.length;
746
- } else if (delta.delete !== undefined) {
747
- const pos = model.textEditorModel.getPositionAt(index);
748
- const endPos = model.textEditorModel.getPositionAt(index + delta.delete);
749
- const range = new MonacoRange(pos.lineNumber, pos.column, endPos.lineNumber, endPos.column);
750
- operations.push({ range, text: '' });
751
- }
752
- });
753
- this.pushChangesToModel(model, operations);
754
- } catch (err) {
755
- console.error(err);
756
- }
757
- resyncDebounce();
758
- updating = false;
759
- });
760
- };
761
-
762
- ytext.observe(observer);
763
- disposable.push(Disposable.create(() => ytext.unobserve(observer)));
764
- model.onDispose(() => disposable.dispose());
765
- }
766
-
767
- protected resetYjsText(yjsText: Y.Text, text: string): void {
768
- this.yjs.transact(() => {
769
- yjsText.delete(0, yjsText.length);
770
- yjsText.insert(0, text);
771
- });
772
- }
773
-
774
- protected getModel(uri: URI): MonacoEditorModel | undefined {
775
- const existing = this.monacoModelService.models.find(e => e.uri === uri.toString());
776
- if (existing) {
777
- return existing;
778
- } else {
779
- return undefined;
780
- }
781
- }
782
-
783
- protected pushChangesToModel(model: MonacoEditorModel, changes: { range: MonacoRange, text: string, forceMoveMarkers?: boolean }[]): void {
784
- const editor = MonacoEditor.findByDocument(this.editorManager, model)[0];
785
- const cursorState = editor?.getControl().getSelections() ?? [];
786
- model.textEditorModel.pushStackElement();
787
- try {
788
- model.textEditorModel.pushEditOperations(cursorState, changes, () => cursorState);
789
- model.textEditorModel.pushStackElement();
790
- } catch (err) {
791
- console.error(err);
792
- }
793
- }
794
-
795
- protected softReplaceModel(model: MonacoEditorModel, text: string): void {
796
- this.pushChangesToModel(model, [{
797
- range: model.textEditorModel.getFullModelRange(),
798
- text,
799
- forceMoveMarkers: false
800
- }]);
801
- }
802
-
803
- protected async openUri(uri: URI): Promise<void> {
804
- const ref = await this.monacoModelService.createModelReference(uri);
805
- if (ref.object) {
806
- this.toDispose.push(ref);
807
- } else {
808
- ref.dispose();
809
- }
810
- }
811
-
812
- dispose(): void {
813
- for (const peer of this.peers.values()) {
814
- peer.dispose();
815
- }
816
- this.onDidCloseEmitter.fire();
817
- this.toDispose.dispose();
818
- }
819
- }
1
+ // *****************************************************************************
2
+ // Copyright (C) 2024 TypeFox and others.
3
+ //
4
+ // This program and the accompanying materials are made available under the
5
+ // terms of the Eclipse Public License v. 2.0 which is available at
6
+ // http://www.eclipse.org/legal/epl-2.0.
7
+ //
8
+ // This Source Code may also be made available under the following Secondary
9
+ // Licenses when the conditions for such availability set forth in the Eclipse
10
+ // Public License v. 2.0 are satisfied: GNU General Public License, version 2
11
+ // with the GNU Classpath Exception which is available at
12
+ // https://www.gnu.org/software/classpath/license.html.
13
+ //
14
+ // SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
15
+ // *****************************************************************************
16
+
17
+ import * as types from 'open-collaboration-protocol';
18
+ import * as Y from 'yjs';
19
+ import * as awarenessProtocol from 'y-protocols/awareness';
20
+
21
+ import { Disposable, DisposableCollection, Emitter, Event, MessageService, URI, nls } from '@theia/core';
22
+ import { Container, inject, injectable, interfaces, postConstruct } from '@theia/core/shared/inversify';
23
+ import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell';
24
+ import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
25
+ import { FileService } from '@theia/filesystem/lib/browser/file-service';
26
+ import { MonacoTextModelService } from '@theia/monaco/lib/browser/monaco-text-model-service';
27
+ import { CollaborationWorkspaceService } from './collaboration-workspace-service';
28
+ import { Range as MonacoRange } from '@theia/monaco-editor-core';
29
+ import { MonacoEditorModel } from '@theia/monaco/lib/browser/monaco-editor-model';
30
+ import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor';
31
+ import { Deferred } from '@theia/core/lib/common/promise-util';
32
+ import { EditorDecoration, EditorWidget, Selection, TextEditorDocument, TrackedRangeStickiness } from '@theia/editor/lib/browser';
33
+ import { DecorationStyle, OpenerService, SaveReason } from '@theia/core/lib/browser';
34
+ import { CollaborationFileSystemProvider, CollaborationURI } from './collaboration-file-system-provider';
35
+ import { Range } from '@theia/core/shared/vscode-languageserver-protocol';
36
+ import { CollaborationColorService } from './collaboration-color-service';
37
+ import { BinaryBuffer } from '@theia/core/lib/common/buffer';
38
+ import { FileChange, FileChangeType, FileOperation } from '@theia/filesystem/lib/common/files';
39
+ import { OpenCollaborationYjsProvider } from 'open-collaboration-yjs';
40
+ import { createMutex } from 'lib0/mutex';
41
+ import { CollaborationUtils } from './collaboration-utils';
42
+ import debounce = require('@theia/core/shared/lodash.debounce');
43
+
44
+ export const CollaborationInstanceFactory = Symbol('CollaborationInstanceFactory');
45
+ export type CollaborationInstanceFactory = (connection: CollaborationInstanceOptions) => CollaborationInstance;
46
+
47
+ export const CollaborationInstanceOptions = Symbol('CollaborationInstanceOptions');
48
+ export interface CollaborationInstanceOptions {
49
+ role: 'host' | 'guest';
50
+ connection: types.ProtocolBroadcastConnection;
51
+ }
52
+
53
+ export function createCollaborationInstanceContainer(parent: interfaces.Container, options: CollaborationInstanceOptions): Container {
54
+ const child = new Container();
55
+ child.parent = parent;
56
+ child.bind(CollaborationInstance).toSelf().inTransientScope();
57
+ child.bind(CollaborationInstanceOptions).toConstantValue(options);
58
+ return child;
59
+ }
60
+
61
+ export interface DisposablePeer extends Disposable {
62
+ peer: types.Peer;
63
+ }
64
+
65
+ export const COLLABORATION_SELECTION = 'theia-collaboration-selection';
66
+ export const COLLABORATION_SELECTION_MARKER = 'theia-collaboration-selection-marker';
67
+ export const COLLABORATION_SELECTION_INVERTED = 'theia-collaboration-selection-inverted';
68
+
69
+ @injectable()
70
+ export class CollaborationInstance implements Disposable {
71
+
72
+ @inject(MessageService)
73
+ protected readonly messageService: MessageService;
74
+
75
+ @inject(CollaborationWorkspaceService)
76
+ protected readonly workspaceService: CollaborationWorkspaceService;
77
+
78
+ @inject(FileService)
79
+ protected readonly fileService: FileService;
80
+
81
+ @inject(MonacoTextModelService)
82
+ protected readonly monacoModelService: MonacoTextModelService;
83
+
84
+ @inject(EditorManager)
85
+ protected readonly editorManager: EditorManager;
86
+
87
+ @inject(OpenerService)
88
+ protected readonly openerService: OpenerService;
89
+
90
+ @inject(ApplicationShell)
91
+ protected readonly shell: ApplicationShell;
92
+
93
+ @inject(CollaborationInstanceOptions)
94
+ protected readonly options: CollaborationInstanceOptions;
95
+
96
+ @inject(CollaborationColorService)
97
+ protected readonly collaborationColorService: CollaborationColorService;
98
+
99
+ @inject(CollaborationUtils)
100
+ protected readonly utils: CollaborationUtils;
101
+
102
+ protected identity = new Deferred<types.Peer>();
103
+ protected peers = new Map<string, DisposablePeer>();
104
+ protected yjs = new Y.Doc();
105
+ protected yjsAwareness = new awarenessProtocol.Awareness(this.yjs);
106
+ protected yjsProvider: OpenCollaborationYjsProvider;
107
+ protected colorIndex = 0;
108
+ protected editorDecorations = new Map<EditorWidget, string[]>();
109
+ protected fileSystem?: CollaborationFileSystemProvider;
110
+ protected permissions: types.Permissions = {
111
+ readonly: false
112
+ };
113
+
114
+ protected onDidCloseEmitter = new Emitter<void>();
115
+
116
+ get onDidClose(): Event<void> {
117
+ return this.onDidCloseEmitter.event;
118
+ }
119
+
120
+ protected toDispose = new DisposableCollection();
121
+ protected _readonly = false;
122
+
123
+ get readonly(): boolean {
124
+ return this._readonly;
125
+ }
126
+
127
+ set readonly(value: boolean) {
128
+ if (value !== this.readonly) {
129
+ if (this.options.role === 'guest' && this.fileSystem) {
130
+ this.fileSystem.readonly = value;
131
+ } else if (this.options.role === 'host') {
132
+ this.options.connection.room.updatePermissions({
133
+ ...(this.permissions ?? {}),
134
+ readonly: value
135
+ });
136
+ }
137
+ if (this.permissions) {
138
+ this.permissions.readonly = value;
139
+ }
140
+ this._readonly = value;
141
+ }
142
+ }
143
+
144
+ get isHost(): boolean {
145
+ return this.options.role === 'host';
146
+ }
147
+
148
+ get host(): types.Peer {
149
+ return Array.from(this.peers.values()).find(e => e.peer.host)!.peer;
150
+ }
151
+
152
+ @postConstruct()
153
+ protected init(): void {
154
+ const connection = this.options.connection;
155
+ connection.onDisconnect(() => this.dispose());
156
+ connection.onConnectionError(message => {
157
+ this.messageService.error(message);
158
+ this.dispose();
159
+ });
160
+ this.yjsProvider = new OpenCollaborationYjsProvider(connection, this.yjs, this.yjsAwareness);
161
+ this.yjsProvider.connect();
162
+ this.toDispose.push(Disposable.create(() => this.yjs.destroy()));
163
+ this.toDispose.push(this.yjsProvider);
164
+ this.toDispose.push(connection);
165
+ this.toDispose.push(this.onDidCloseEmitter);
166
+
167
+ this.registerProtocolEvents(connection);
168
+ this.registerEditorEvents(connection);
169
+ this.registerFileSystemEvents(connection);
170
+
171
+ if (this.isHost) {
172
+ this.registerFileSystemChanges();
173
+ }
174
+ }
175
+
176
+ protected registerProtocolEvents(connection: types.ProtocolBroadcastConnection): void {
177
+ connection.peer.onJoinRequest(async (_, user) => {
178
+ const allow = nls.localizeByDefault('Allow');
179
+ const deny = nls.localizeByDefault('Deny');
180
+ const result = await this.messageService.info(
181
+ nls.localize('theia/collaboration/userWantsToJoin', "User '{0}' wants to join the collaboration room", user.email ? `${user.name} (${user.email})` : user.name),
182
+ allow,
183
+ deny
184
+ );
185
+ if (result === allow) {
186
+ const roots = await this.workspaceService.roots;
187
+ return {
188
+ workspace: {
189
+ name: this.workspaceService.workspace?.name ?? nls.localize('theia/collaboration/collaboration', 'Collaboration'),
190
+ folders: roots.map(e => e.name)
191
+ }
192
+ };
193
+ } else {
194
+ return undefined;
195
+ }
196
+ });
197
+ connection.room.onJoin(async (_, peer) => {
198
+ this.addPeer(peer);
199
+ if (this.isHost) {
200
+ const roots = await this.workspaceService.roots;
201
+ const data: types.InitData = {
202
+ protocol: types.VERSION,
203
+ host: await this.identity.promise,
204
+ guests: Array.from(this.peers.values()).map(e => e.peer),
205
+ capabilities: {},
206
+ permissions: this.permissions,
207
+ workspace: {
208
+ name: this.workspaceService.workspace?.name ?? nls.localize('theia/collaboration/collaboration', 'Collaboration'),
209
+ folders: roots.map(e => e.name)
210
+ }
211
+ };
212
+ connection.peer.init(peer.id, data);
213
+ }
214
+ });
215
+ connection.room.onLeave((_, peer) => {
216
+ this.peers.get(peer.id)?.dispose();
217
+ });
218
+ connection.room.onClose(() => {
219
+ this.dispose();
220
+ });
221
+ connection.room.onPermissions((_, permissions) => {
222
+ if (this.fileSystem) {
223
+ this.fileSystem.readonly = permissions.readonly;
224
+ }
225
+ });
226
+ connection.peer.onInfo((_, peer) => {
227
+ this.yjsAwareness.setLocalStateField('peer', peer.id);
228
+ this.identity.resolve(peer);
229
+ });
230
+ connection.peer.onInit(async (_, data) => {
231
+ await this.initialize(data);
232
+ });
233
+ }
234
+
235
+ protected registerEditorEvents(connection: types.ProtocolBroadcastConnection): void {
236
+ for (const model of this.monacoModelService.models) {
237
+ if (this.isSharedResource(new URI(model.uri))) {
238
+ this.registerModelUpdate(model);
239
+ }
240
+ }
241
+ this.toDispose.push(this.monacoModelService.onDidCreate(newModel => {
242
+ if (this.isSharedResource(new URI(newModel.uri))) {
243
+ this.registerModelUpdate(newModel);
244
+ }
245
+ }));
246
+ this.toDispose.push(this.editorManager.onCreated(widget => {
247
+ if (this.isSharedResource(widget.getResourceUri())) {
248
+ this.registerPresenceUpdate(widget);
249
+ }
250
+ }));
251
+ this.getOpenEditors().forEach(widget => {
252
+ if (this.isSharedResource(widget.getResourceUri())) {
253
+ this.registerPresenceUpdate(widget);
254
+ }
255
+ });
256
+ this.shell.onDidChangeActiveWidget(e => {
257
+ if (e.newValue instanceof EditorWidget) {
258
+ this.updateEditorPresence(e.newValue);
259
+ }
260
+ });
261
+
262
+ this.yjsAwareness.on('change', () => {
263
+ this.rerenderPresence();
264
+ });
265
+
266
+ connection.editor.onOpen(async (_, path) => {
267
+ const uri = this.utils.getResourceUri(path);
268
+ if (uri) {
269
+ await this.openUri(uri);
270
+ } else {
271
+ throw new Error('Could find file: ' + path);
272
+ }
273
+ return undefined;
274
+ });
275
+ }
276
+
277
+ protected isSharedResource(resource?: URI): boolean {
278
+ if (!resource) {
279
+ return false;
280
+ }
281
+ return this.isHost ? resource.scheme === 'file' : resource.scheme === CollaborationURI.scheme;
282
+ }
283
+
284
+ protected registerFileSystemEvents(connection: types.ProtocolBroadcastConnection): void {
285
+ connection.fs.onReadFile(async (_, path) => {
286
+ const uri = this.utils.getResourceUri(path);
287
+ if (uri) {
288
+ const content = await this.fileService.readFile(uri);
289
+ return {
290
+ content: content.value.buffer
291
+ };
292
+ } else {
293
+ throw new Error('Could find file: ' + path);
294
+ }
295
+ });
296
+ connection.fs.onReaddir(async (_, path) => {
297
+ const uri = this.utils.getResourceUri(path);
298
+ if (uri) {
299
+ const resolved = await this.fileService.resolve(uri);
300
+ if (resolved.children) {
301
+ const dir: Record<string, types.FileType> = {};
302
+ for (const child of resolved.children) {
303
+ dir[child.name] = child.isDirectory ? types.FileType.Directory : types.FileType.File;
304
+ }
305
+ return dir;
306
+ } else {
307
+ return {};
308
+ }
309
+ } else {
310
+ throw new Error('Could find directory: ' + path);
311
+ }
312
+ });
313
+ connection.fs.onStat(async (_, path) => {
314
+ const uri = this.utils.getResourceUri(path);
315
+ if (uri) {
316
+ const content = await this.fileService.resolve(uri, {
317
+ resolveMetadata: true
318
+ });
319
+ return {
320
+ type: content.isDirectory ? types.FileType.Directory : types.FileType.File,
321
+ ctime: content.ctime,
322
+ mtime: content.mtime,
323
+ size: content.size,
324
+ permissions: content.isReadonly ? types.FilePermission.Readonly : undefined
325
+ };
326
+ } else {
327
+ throw new Error('Could find file: ' + path);
328
+ }
329
+ });
330
+ connection.fs.onWriteFile(async (_, path, data) => {
331
+ const uri = this.utils.getResourceUri(path);
332
+ if (uri) {
333
+ const model = this.getModel(uri);
334
+ if (model) {
335
+ const content = new TextDecoder().decode(data.content);
336
+ if (content !== model.getText()) {
337
+ model.textEditorModel.setValue(content);
338
+ }
339
+ await model.save({ saveReason: SaveReason.Manual });
340
+ } else {
341
+ await this.fileService.createFile(uri, BinaryBuffer.wrap(data.content));
342
+ }
343
+ } else {
344
+ throw new Error('Could find file: ' + path);
345
+ }
346
+ });
347
+ connection.fs.onMkdir(async (_, path) => {
348
+ const uri = this.utils.getResourceUri(path);
349
+ if (uri) {
350
+ await this.fileService.createFolder(uri);
351
+ } else {
352
+ throw new Error('Could find path: ' + path);
353
+ }
354
+ });
355
+ connection.fs.onDelete(async (_, path) => {
356
+ const uri = this.utils.getResourceUri(path);
357
+ if (uri) {
358
+ await this.fileService.delete(uri);
359
+ } else {
360
+ throw new Error('Could find entry: ' + path);
361
+ }
362
+ });
363
+ connection.fs.onRename(async (_, from, to) => {
364
+ const fromUri = this.utils.getResourceUri(from);
365
+ const toUri = this.utils.getResourceUri(to);
366
+ if (fromUri && toUri) {
367
+ await this.fileService.move(fromUri, toUri);
368
+ } else {
369
+ throw new Error('Could find entries: ' + from + ' -> ' + to);
370
+ }
371
+ });
372
+ connection.fs.onChange(async (_, event) => {
373
+ // Only guests need to handle file system changes
374
+ if (!this.isHost && this.fileSystem) {
375
+ const changes: FileChange[] = [];
376
+ for (const change of event.changes) {
377
+ const uri = this.utils.getResourceUri(change.path);
378
+ if (uri) {
379
+ changes.push({
380
+ type: change.type === types.FileChangeEventType.Create
381
+ ? FileChangeType.ADDED
382
+ : change.type === types.FileChangeEventType.Update
383
+ ? FileChangeType.UPDATED
384
+ : FileChangeType.DELETED,
385
+ resource: uri
386
+ });
387
+ }
388
+ }
389
+ this.fileSystem.triggerEvent(changes);
390
+ }
391
+ });
392
+ }
393
+
394
+ protected rerenderPresence(...widgets: EditorWidget[]): void {
395
+ const decorations = new Map<string, EditorDecoration[]>();
396
+ const states = this.yjsAwareness.getStates() as Map<number, types.ClientAwareness>;
397
+ for (const [clientID, state] of states.entries()) {
398
+ if (clientID === this.yjs.clientID) {
399
+ // Ignore own awareness state
400
+ continue;
401
+ }
402
+ const peer = state.peer;
403
+ if (!state.selection || !this.peers.has(peer)) {
404
+ continue;
405
+ }
406
+ if (!types.ClientTextSelection.is(state.selection)) {
407
+ continue;
408
+ }
409
+ const { path, textSelections } = state.selection;
410
+ const selection = textSelections[0];
411
+ if (!selection) {
412
+ continue;
413
+ }
414
+ const uri = this.utils.getResourceUri(path);
415
+ if (uri) {
416
+ const model = this.getModel(uri);
417
+ if (model) {
418
+ let existing = decorations.get(path);
419
+ if (!existing) {
420
+ existing = [];
421
+ decorations.set(path, existing);
422
+ }
423
+ const forward = selection.direction === types.SelectionDirection.LeftToRight;
424
+ let startIndex = Y.createAbsolutePositionFromRelativePosition(selection.start, this.yjs);
425
+ let endIndex = Y.createAbsolutePositionFromRelativePosition(selection.end, this.yjs);
426
+ if (startIndex && endIndex) {
427
+ if (startIndex.index > endIndex.index) {
428
+ [startIndex, endIndex] = [endIndex, startIndex];
429
+ }
430
+ const start = model.positionAt(startIndex.index);
431
+ const end = model.positionAt(endIndex.index);
432
+ const inverted = (forward && end.line === 0) || (!forward && start.line === 0);
433
+ const range = {
434
+ start,
435
+ end
436
+ };
437
+ const contentClassNames: string[] = [COLLABORATION_SELECTION_MARKER, `${COLLABORATION_SELECTION_MARKER}-${peer}`];
438
+ if (inverted) {
439
+ contentClassNames.push(COLLABORATION_SELECTION_INVERTED);
440
+ }
441
+ const item: EditorDecoration = {
442
+ range,
443
+ options: {
444
+ className: `${COLLABORATION_SELECTION} ${COLLABORATION_SELECTION}-${peer}`,
445
+ beforeContentClassName: !forward ? contentClassNames.join(' ') : undefined,
446
+ afterContentClassName: forward ? contentClassNames.join(' ') : undefined,
447
+ stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges
448
+ }
449
+ };
450
+ existing.push(item);
451
+ }
452
+ }
453
+ }
454
+ }
455
+ this.rerenderPresenceDecorations(decorations, ...widgets);
456
+ }
457
+
458
+ protected rerenderPresenceDecorations(decorations: Map<string, EditorDecoration[]>, ...widgets: EditorWidget[]): void {
459
+ for (const editor of new Set(this.getOpenEditors().concat(widgets))) {
460
+ const uri = editor.getResourceUri();
461
+ const path = this.utils.getProtocolPath(uri);
462
+ if (path) {
463
+ const old = this.editorDecorations.get(editor) ?? [];
464
+ this.editorDecorations.set(editor, editor.editor.deltaDecorations({
465
+ newDecorations: decorations.get(path) ?? [],
466
+ oldDecorations: old
467
+ }));
468
+ }
469
+ }
470
+ }
471
+
472
+ protected registerFileSystemChanges(): void {
473
+ // Event listener for disk based events
474
+ this.fileService.onDidFilesChange(event => {
475
+ const changes: types.FileChange[] = [];
476
+ for (const change of event.changes) {
477
+ const path = this.utils.getProtocolPath(change.resource);
478
+ if (path) {
479
+ let type: types.FileChangeEventType | undefined;
480
+ if (change.type === FileChangeType.ADDED) {
481
+ type = types.FileChangeEventType.Create;
482
+ } else if (change.type === FileChangeType.DELETED) {
483
+ type = types.FileChangeEventType.Delete;
484
+ }
485
+ // Updates to files on disk are not sent
486
+ if (type !== undefined) {
487
+ changes.push({
488
+ path,
489
+ type
490
+ });
491
+ }
492
+ }
493
+ }
494
+ if (changes.length) {
495
+ this.options.connection.fs.change({ changes });
496
+ }
497
+ });
498
+ // Event listener for user based events
499
+ this.fileService.onDidRunOperation(operation => {
500
+ const path = this.utils.getProtocolPath(operation.resource);
501
+ if (!path) {
502
+ return;
503
+ }
504
+ let type = types.FileChangeEventType.Update;
505
+ if (operation.isOperation(FileOperation.CREATE) || operation.isOperation(FileOperation.COPY)) {
506
+ type = types.FileChangeEventType.Create;
507
+ } else if (operation.isOperation(FileOperation.DELETE)) {
508
+ type = types.FileChangeEventType.Delete;
509
+ }
510
+ this.options.connection.fs.change({
511
+ changes: [{
512
+ path,
513
+ type
514
+ }]
515
+ });
516
+ });
517
+ }
518
+
519
+ protected async registerPresenceUpdate(widget: EditorWidget): Promise<void> {
520
+ const uri = widget.getResourceUri();
521
+ const path = this.utils.getProtocolPath(uri);
522
+ if (path) {
523
+ if (!this.isHost) {
524
+ this.options.connection.editor.open(this.host.id, path);
525
+ }
526
+ let currentSelection = widget.editor.selection;
527
+ // // Update presence information when the selection changes
528
+ const selectionChange = widget.editor.onSelectionChanged(selection => {
529
+ if (!this.rangeEqual(currentSelection, selection)) {
530
+ this.updateEditorPresence(widget);
531
+ currentSelection = selection;
532
+ }
533
+ });
534
+ const widgetDispose = widget.onDidDispose(() => {
535
+ widgetDispose.dispose();
536
+ selectionChange.dispose();
537
+ // Remove presence information when the editor closes
538
+ const state = this.yjsAwareness.getLocalState();
539
+ if (state?.currentSelection?.path === path) {
540
+ delete state.currentSelection;
541
+ }
542
+ this.yjsAwareness.setLocalState(state);
543
+ });
544
+ this.toDispose.push(selectionChange);
545
+ this.toDispose.push(widgetDispose);
546
+ this.rerenderPresence(widget);
547
+ }
548
+ }
549
+
550
+ protected updateEditorPresence(widget: EditorWidget): void {
551
+ const uri = widget.getResourceUri();
552
+ const path = this.utils.getProtocolPath(uri);
553
+ if (path) {
554
+ const ytext = this.yjs.getText(path);
555
+ const selection = widget.editor.selection;
556
+ let start = widget.editor.document.offsetAt(selection.start);
557
+ let end = widget.editor.document.offsetAt(selection.end);
558
+ if (start > end) {
559
+ [start, end] = [end, start];
560
+ }
561
+ const direction = selection.direction === 'ltr'
562
+ ? types.SelectionDirection.LeftToRight
563
+ : types.SelectionDirection.RightToLeft;
564
+ const editorSelection: types.RelativeTextSelection = {
565
+ start: Y.createRelativePositionFromTypeIndex(ytext, start),
566
+ end: Y.createRelativePositionFromTypeIndex(ytext, end),
567
+ direction
568
+ };
569
+ const textSelection: types.ClientTextSelection = {
570
+ path,
571
+ textSelections: [editorSelection]
572
+ };
573
+ this.setSharedSelection(textSelection);
574
+ }
575
+ }
576
+
577
+ protected setSharedSelection(selection?: types.ClientSelection): void {
578
+ this.yjsAwareness.setLocalStateField('selection', selection);
579
+ }
580
+
581
+ protected rangeEqual(a: Range, b: Range): boolean {
582
+ return a.start.line === b.start.line
583
+ && a.start.character === b.start.character
584
+ && a.end.line === b.end.line
585
+ && a.end.character === b.end.character;
586
+ }
587
+
588
+ async initialize(data: types.InitData): Promise<void> {
589
+ this.permissions = data.permissions;
590
+ this.readonly = data.permissions.readonly;
591
+ for (const peer of [...data.guests, data.host]) {
592
+ this.addPeer(peer);
593
+ }
594
+ this.fileSystem = new CollaborationFileSystemProvider(this.options.connection, data.host, this.yjs);
595
+ this.fileSystem.readonly = this.readonly;
596
+ this.toDispose.push(this.fileService.registerProvider(CollaborationURI.scheme, this.fileSystem));
597
+ const workspaceDisposable = await this.workspaceService.setHostWorkspace(data.workspace, this.options.connection);
598
+ this.toDispose.push(workspaceDisposable);
599
+ }
600
+
601
+ protected addPeer(peer: types.Peer): void {
602
+ const collection = new DisposableCollection();
603
+ collection.push(this.createPeerStyleSheet(peer));
604
+ collection.push(Disposable.create(() => this.peers.delete(peer.id)));
605
+ const disposablePeer = {
606
+ peer,
607
+ dispose: () => collection.dispose()
608
+ };
609
+ this.peers.set(peer.id, disposablePeer);
610
+ }
611
+
612
+ protected createPeerStyleSheet(peer: types.Peer): Disposable {
613
+ const style = DecorationStyle.createStyleElement(`${peer.id}-collaboration-selection`);
614
+ const colors = this.collaborationColorService.getColors();
615
+ const sheet = style.sheet!;
616
+ const color = colors[this.colorIndex++ % colors.length];
617
+ const colorString = `rgb(${color.r}, ${color.g}, ${color.b})`;
618
+ sheet.insertRule(`
619
+ .${COLLABORATION_SELECTION}-${peer.id} {
620
+ opacity: 0.2;
621
+ background: ${colorString};
622
+ }
623
+ `);
624
+ sheet.insertRule(`
625
+ .${COLLABORATION_SELECTION_MARKER}-${peer.id} {
626
+ background: ${colorString};
627
+ border-color: ${colorString};
628
+ }`
629
+ );
630
+ sheet.insertRule(`
631
+ .${COLLABORATION_SELECTION_MARKER}-${peer.id}::after {
632
+ content: "${peer.name}";
633
+ background: ${colorString};
634
+ color: ${this.collaborationColorService.requiresDarkFont(color)
635
+ ? this.collaborationColorService.dark
636
+ : this.collaborationColorService.light};
637
+ z-index: ${(100 + this.colorIndex).toFixed()}
638
+ }`
639
+ );
640
+ return Disposable.create(() => style.remove());
641
+ }
642
+
643
+ protected getOpenEditors(uri?: URI): EditorWidget[] {
644
+ const widgets = this.shell.widgets;
645
+ let editors = widgets.filter(e => e instanceof EditorWidget) as EditorWidget[];
646
+ if (uri) {
647
+ const uriString = uri.toString();
648
+ editors = editors.filter(e => e.getResourceUri()?.toString() === uriString);
649
+ }
650
+ return editors;
651
+ }
652
+
653
+ protected createSelectionFromRelative(selection: types.RelativeTextSelection, model: MonacoEditorModel): Selection | undefined {
654
+ const start = Y.createAbsolutePositionFromRelativePosition(selection.start, this.yjs);
655
+ const end = Y.createAbsolutePositionFromRelativePosition(selection.end, this.yjs);
656
+ if (start && end) {
657
+ return {
658
+ start: model.positionAt(start.index),
659
+ end: model.positionAt(end.index),
660
+ direction: selection.direction === types.SelectionDirection.LeftToRight ? 'ltr' : 'rtl'
661
+ };
662
+ }
663
+ return undefined;
664
+ }
665
+
666
+ protected createRelativeSelection(selection: Selection, model: TextEditorDocument, ytext: Y.Text): types.RelativeTextSelection {
667
+ const start = Y.createRelativePositionFromTypeIndex(ytext, model.offsetAt(selection.start));
668
+ const end = Y.createRelativePositionFromTypeIndex(ytext, model.offsetAt(selection.end));
669
+ return {
670
+ start,
671
+ end,
672
+ direction: selection.direction === 'ltr'
673
+ ? types.SelectionDirection.LeftToRight
674
+ : types.SelectionDirection.RightToLeft
675
+ };
676
+ }
677
+
678
+ protected readonly yjsMutex = createMutex();
679
+
680
+ protected registerModelUpdate(model: MonacoEditorModel): void {
681
+ let updating = false;
682
+ const modelPath = this.utils.getProtocolPath(new URI(model.uri));
683
+ if (!modelPath) {
684
+ return;
685
+ }
686
+ const unknownModel = !this.yjs.share.has(modelPath);
687
+ const ytext = this.yjs.getText(modelPath);
688
+ const modelText = model.textEditorModel.getValue();
689
+ if (this.isHost && unknownModel) {
690
+ // If we are hosting the room, set the initial content
691
+ // First off, reset the shared content to be empty
692
+ // This has the benefit of effectively clearing the memory of the shared content across all peers
693
+ // This is important because the shared content accumulates changes/memory usage over time
694
+ this.resetYjsText(ytext, modelText);
695
+ } else {
696
+ this.options.connection.editor.open(this.host.id, modelPath);
697
+ }
698
+ // The Ytext instance is our source of truth for the model content
699
+ // Sometimes (especially after a lot of sequential undo/redo operations) our model content can get out of sync
700
+ // This resyncs the model content with the Ytext content after a delay
701
+ const resyncDebounce = debounce(() => {
702
+ this.yjsMutex(() => {
703
+ const newContent = ytext.toString();
704
+ if (model.textEditorModel.getValue() !== newContent) {
705
+ updating = true;
706
+ this.softReplaceModel(model, newContent);
707
+ updating = false;
708
+ }
709
+ });
710
+ }, 200);
711
+ const disposable = new DisposableCollection();
712
+ disposable.push(model.onDidChangeContent(e => {
713
+ if (updating) {
714
+ return;
715
+ }
716
+ this.yjsMutex(() => {
717
+ this.yjs.transact(() => {
718
+ for (const change of e.contentChanges) {
719
+ ytext.delete(change.rangeOffset, change.rangeLength);
720
+ ytext.insert(change.rangeOffset, change.text);
721
+ }
722
+ });
723
+ resyncDebounce();
724
+ });
725
+ }));
726
+
727
+ const observer = (textEvent: Y.YTextEvent) => {
728
+ if (textEvent.transaction.local || model.getText() === ytext.toString()) {
729
+ // Ignore local changes and changes that are already reflected in the model
730
+ return;
731
+ }
732
+ this.yjsMutex(() => {
733
+ updating = true;
734
+ try {
735
+ let index = 0;
736
+ const operations: { range: MonacoRange, text: string }[] = [];
737
+ textEvent.delta.forEach(delta => {
738
+ if (delta.retain !== undefined) {
739
+ index += delta.retain;
740
+ } else if (delta.insert !== undefined) {
741
+ const pos = model.textEditorModel.getPositionAt(index);
742
+ const range = new MonacoRange(pos.lineNumber, pos.column, pos.lineNumber, pos.column);
743
+ const insert = delta.insert as string;
744
+ operations.push({ range, text: insert });
745
+ index += insert.length;
746
+ } else if (delta.delete !== undefined) {
747
+ const pos = model.textEditorModel.getPositionAt(index);
748
+ const endPos = model.textEditorModel.getPositionAt(index + delta.delete);
749
+ const range = new MonacoRange(pos.lineNumber, pos.column, endPos.lineNumber, endPos.column);
750
+ operations.push({ range, text: '' });
751
+ }
752
+ });
753
+ this.pushChangesToModel(model, operations);
754
+ } catch (err) {
755
+ console.error(err);
756
+ }
757
+ resyncDebounce();
758
+ updating = false;
759
+ });
760
+ };
761
+
762
+ ytext.observe(observer);
763
+ disposable.push(Disposable.create(() => ytext.unobserve(observer)));
764
+ model.onDispose(() => disposable.dispose());
765
+ }
766
+
767
+ protected resetYjsText(yjsText: Y.Text, text: string): void {
768
+ this.yjs.transact(() => {
769
+ yjsText.delete(0, yjsText.length);
770
+ yjsText.insert(0, text);
771
+ });
772
+ }
773
+
774
+ protected getModel(uri: URI): MonacoEditorModel | undefined {
775
+ const existing = this.monacoModelService.models.find(e => e.uri === uri.toString());
776
+ if (existing) {
777
+ return existing;
778
+ } else {
779
+ return undefined;
780
+ }
781
+ }
782
+
783
+ protected pushChangesToModel(model: MonacoEditorModel, changes: { range: MonacoRange, text: string, forceMoveMarkers?: boolean }[]): void {
784
+ const editor = MonacoEditor.findByDocument(this.editorManager, model)[0];
785
+ const cursorState = editor?.getControl().getSelections() ?? [];
786
+ model.textEditorModel.pushStackElement();
787
+ try {
788
+ model.textEditorModel.pushEditOperations(cursorState, changes, () => cursorState);
789
+ model.textEditorModel.pushStackElement();
790
+ } catch (err) {
791
+ console.error(err);
792
+ }
793
+ }
794
+
795
+ protected softReplaceModel(model: MonacoEditorModel, text: string): void {
796
+ this.pushChangesToModel(model, [{
797
+ range: model.textEditorModel.getFullModelRange(),
798
+ text,
799
+ forceMoveMarkers: false
800
+ }]);
801
+ }
802
+
803
+ protected async openUri(uri: URI): Promise<void> {
804
+ const ref = await this.monacoModelService.createModelReference(uri);
805
+ if (ref.object) {
806
+ this.toDispose.push(ref);
807
+ } else {
808
+ ref.dispose();
809
+ }
810
+ }
811
+
812
+ dispose(): void {
813
+ for (const peer of this.peers.values()) {
814
+ peer.dispose();
815
+ }
816
+ this.onDidCloseEmitter.fire();
817
+ this.toDispose.dispose();
818
+ }
819
+ }