@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.
- package/README.md +33 -33
- package/lib/browser/collaboration-instance.js +15 -15
- package/package.json +7 -7
- package/src/browser/collaboration-color-service.ts +77 -77
- package/src/browser/collaboration-file-system-provider.ts +119 -119
- package/src/browser/collaboration-frontend-contribution.ts +327 -327
- package/src/browser/collaboration-frontend-module.ts +37 -37
- package/src/browser/collaboration-instance.ts +819 -819
- package/src/browser/collaboration-utils.ts +59 -59
- package/src/browser/collaboration-workspace-service.ts +69 -69
- package/src/browser/style/index.css +22 -22
- package/src/package.spec.ts +28 -28
|
@@ -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
|
+
}
|