diff --git a/examples/browser-only/package.json b/examples/browser-only/package.json
index 087fd5c6548a3..2913b38d8658e 100644
--- a/examples/browser-only/package.json
+++ b/examples/browser-only/package.json
@@ -18,6 +18,7 @@
"@theia/api-samples": "1.52.0",
"@theia/bulk-edit": "1.52.0",
"@theia/callhierarchy": "1.52.0",
+ "@theia/collaboration": "1.52.0",
"@theia/console": "1.52.0",
"@theia/core": "1.52.0",
"@theia/debug": "1.52.0",
diff --git a/examples/browser-only/tsconfig.json b/examples/browser-only/tsconfig.json
index d4bcfc14426b9..eef920de150af 100644
--- a/examples/browser-only/tsconfig.json
+++ b/examples/browser-only/tsconfig.json
@@ -14,6 +14,9 @@
{
"path": "../../packages/callhierarchy"
},
+ {
+ "path": "../../packages/collaboration"
+ },
{
"path": "../../packages/console"
},
diff --git a/examples/browser/package.json b/examples/browser/package.json
index 5a938e845cb07..0e2e714ea1756 100644
--- a/examples/browser/package.json
+++ b/examples/browser/package.json
@@ -24,6 +24,7 @@
"@theia/api-samples": "1.52.0",
"@theia/bulk-edit": "1.52.0",
"@theia/callhierarchy": "1.52.0",
+ "@theia/collaboration": "1.52.0",
"@theia/console": "1.52.0",
"@theia/core": "1.52.0",
"@theia/debug": "1.52.0",
diff --git a/examples/browser/tsconfig.json b/examples/browser/tsconfig.json
index c04673f8d70a7..60de2a565c967 100644
--- a/examples/browser/tsconfig.json
+++ b/examples/browser/tsconfig.json
@@ -14,6 +14,9 @@
{
"path": "../../packages/callhierarchy"
},
+ {
+ "path": "../../packages/collaboration"
+ },
{
"path": "../../packages/console"
},
diff --git a/examples/electron/package.json b/examples/electron/package.json
index 0cfe496522349..52eceabf186bd 100644
--- a/examples/electron/package.json
+++ b/examples/electron/package.json
@@ -30,6 +30,7 @@
"@theia/api-samples": "1.52.0",
"@theia/bulk-edit": "1.52.0",
"@theia/callhierarchy": "1.52.0",
+ "@theia/collaboration": "1.52.0",
"@theia/console": "1.52.0",
"@theia/core": "1.52.0",
"@theia/debug": "1.52.0",
diff --git a/examples/electron/tsconfig.json b/examples/electron/tsconfig.json
index 91edb2ac8dc55..8e8e306fc9c1a 100644
--- a/examples/electron/tsconfig.json
+++ b/examples/electron/tsconfig.json
@@ -17,6 +17,9 @@
{
"path": "../../packages/callhierarchy"
},
+ {
+ "path": "../../packages/collaboration"
+ },
{
"path": "../../packages/console"
},
diff --git a/packages/collaboration/.eslintrc.js b/packages/collaboration/.eslintrc.js
new file mode 100644
index 0000000000000..13089943582b6
--- /dev/null
+++ b/packages/collaboration/.eslintrc.js
@@ -0,0 +1,10 @@
+/** @type {import('eslint').Linter.Config} */
+module.exports = {
+ extends: [
+ '../../configs/build.eslintrc.json'
+ ],
+ parserOptions: {
+ tsconfigRootDir: __dirname,
+ project: 'tsconfig.json'
+ }
+};
diff --git a/packages/collaboration/README.md b/packages/collaboration/README.md
new file mode 100644
index 0000000000000..f97352acc298f
--- /dev/null
+++ b/packages/collaboration/README.md
@@ -0,0 +1,33 @@
+
+
+
+
+

+
+
ECLIPSE THEIA - COLLABORATION EXTENSION
+
+
+
+
+
+## Description
+
+The `@theia/collaboration` extension features to enable collaboration between multiple peers using Theia.
+This is built on top of the [Open Collaboration Tools](https://www.open-collab.tools/) ([GitHub](https://github.com/TypeFox/open-collaboration-tools)) project.
+
+Note that the project is still in a beta phase and can be subject to unexpected breaking changes. This package is therefore in a beta phase as well.
+
+## Additional Information
+
+- [API documentation for `@theia/collaboration`](https://eclipse-theia.github.io/theia/docs/next/modules/collaboration.html)
+- [Theia - GitHub](https://github.com/eclipse-theia/theia)
+- [Theia - Website](https://theia-ide.org/)
+
+## License
+
+- [Eclipse Public License 2.0](http://www.eclipse.org/legal/epl-2.0/)
+- [δΈ€ (Secondary) GNU General Public License, version 2 with the GNU Classpath Exception](https://projects.eclipse.org/license/secondary-gpl-2.0-cp)
+
+## Trademark
+"Theia" is a trademark of the Eclipse Foundation
+https://www.eclipse.org/theia
diff --git a/packages/collaboration/package.json b/packages/collaboration/package.json
new file mode 100644
index 0000000000000..130c2b6718399
--- /dev/null
+++ b/packages/collaboration/package.json
@@ -0,0 +1,57 @@
+{
+ "name": "@theia/collaboration",
+ "version": "1.52.0",
+ "description": "Theia - Collaboration Extension",
+ "dependencies": {
+ "@theia/core": "1.52.0",
+ "@theia/editor": "1.52.0",
+ "@theia/filesystem": "1.52.0",
+ "@theia/monaco": "1.52.0",
+ "@theia/monaco-editor-core": "1.83.101",
+ "@theia/workspace": "1.52.0",
+ "open-collaboration-protocol": "0.2.0",
+ "open-collaboration-yjs": "0.2.0",
+ "socket.io-client": "^4.5.3",
+ "yjs": "^13.6.7",
+ "lib0": "^0.2.52",
+ "y-protocols": "^1.0.6"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "theiaExtensions": [
+ {
+ "frontend": "lib/browser/collaboration-frontend-module"
+ }
+ ],
+ "keywords": [
+ "theia-extension"
+ ],
+ "license": "EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0",
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/eclipse-theia/theia.git"
+ },
+ "bugs": {
+ "url": "https://github.com/eclipse-theia/theia/issues"
+ },
+ "homepage": "https://github.com/eclipse-theia/theia",
+ "files": [
+ "lib",
+ "src"
+ ],
+ "scripts": {
+ "build": "theiaext build",
+ "clean": "theiaext clean",
+ "compile": "theiaext compile",
+ "lint": "theiaext lint",
+ "test": "theiaext test",
+ "watch": "theiaext watch"
+ },
+ "devDependencies": {
+ "@theia/ext-scripts": "1.52.0"
+ },
+ "nyc": {
+ "extends": "../../configs/nyc.json"
+ }
+}
diff --git a/packages/collaboration/src/browser/collaboration-color-service.ts b/packages/collaboration/src/browser/collaboration-color-service.ts
new file mode 100644
index 0000000000000..adfac8e819118
--- /dev/null
+++ b/packages/collaboration/src/browser/collaboration-color-service.ts
@@ -0,0 +1,77 @@
+// *****************************************************************************
+// Copyright (C) 2024 TypeFox and others.
+//
+// This program and the accompanying materials are made available under the
+// terms of the Eclipse Public License v. 2.0 which is available at
+// http://www.eclipse.org/legal/epl-2.0.
+//
+// This Source Code may also be made available under the following Secondary
+// Licenses when the conditions for such availability set forth in the Eclipse
+// Public License v. 2.0 are satisfied: GNU General Public License, version 2
+// with the GNU Classpath Exception which is available at
+// https://www.gnu.org/software/classpath/license.html.
+//
+// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
+// *****************************************************************************
+
+import { injectable } from '@theia/core/shared/inversify';
+
+export interface CollaborationColor {
+ r: number;
+ g: number;
+ b: number;
+}
+
+export namespace CollaborationColor {
+ export function fromString(code: string): CollaborationColor {
+ if (code.startsWith('#')) {
+ code = code.substring(1);
+ }
+ const r = parseInt(code.substring(0, 2), 16);
+ const g = parseInt(code.substring(2, 4), 16);
+ const b = parseInt(code.substring(4, 6), 16);
+ return { r, g, b };
+ }
+
+ export const Gold = fromString('#FFD700');
+ export const Tomato = fromString('#FF6347');
+ export const Aquamarine = fromString('#7FFFD4');
+ export const Beige = fromString('#F5F5DC');
+ export const Coral = fromString('#FF7F50');
+ export const DarkOrange = fromString('#FF8C00');
+ export const VioletRed = fromString('#C71585');
+ export const DodgerBlue = fromString('#1E90FF');
+ export const Chocolate = fromString('#D2691E');
+ export const LightGreen = fromString('#90EE90');
+ export const MediumOrchid = fromString('#BA55D3');
+ export const Orange = fromString('#FFA500');
+}
+
+@injectable()
+export class CollaborationColorService {
+
+ light = 'white';
+ dark = 'black';
+
+ getColors(): CollaborationColor[] {
+ return [
+ CollaborationColor.Gold,
+ CollaborationColor.Aquamarine,
+ CollaborationColor.Tomato,
+ CollaborationColor.MediumOrchid,
+ CollaborationColor.LightGreen,
+ CollaborationColor.Orange,
+ CollaborationColor.Beige,
+ CollaborationColor.Chocolate,
+ CollaborationColor.VioletRed,
+ CollaborationColor.Coral,
+ CollaborationColor.DodgerBlue,
+ CollaborationColor.DarkOrange
+ ];
+ }
+
+ requiresDarkFont(color: CollaborationColor): boolean {
+ // From https://stackoverflow.com/a/3943023
+ return ((color.r * 0.299) + (color.g * 0.587) + (color.b * 0.114)) > 186;
+ }
+}
diff --git a/packages/collaboration/src/browser/collaboration-file-system-provider.ts b/packages/collaboration/src/browser/collaboration-file-system-provider.ts
new file mode 100644
index 0000000000000..87c9f556b0d57
--- /dev/null
+++ b/packages/collaboration/src/browser/collaboration-file-system-provider.ts
@@ -0,0 +1,119 @@
+// *****************************************************************************
+// Copyright (C) 2024 TypeFox and others.
+//
+// This program and the accompanying materials are made available under the
+// terms of the Eclipse Public License v. 2.0 which is available at
+// http://www.eclipse.org/legal/epl-2.0.
+//
+// This Source Code may also be made available under the following Secondary
+// Licenses when the conditions for such availability set forth in the Eclipse
+// Public License v. 2.0 are satisfied: GNU General Public License, version 2
+// with the GNU Classpath Exception which is available at
+// https://www.gnu.org/software/classpath/license.html.
+//
+// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
+// *****************************************************************************
+
+import * as Y from 'yjs';
+import { Disposable, Emitter, Event, URI } from '@theia/core';
+import { injectable } from '@theia/core/shared/inversify';
+import {
+ FileChange, FileDeleteOptions,
+ FileOverwriteOptions, FileSystemProviderCapabilities, FileType, Stat, WatchOptions, FileSystemProviderWithFileReadWriteCapability, FileWriteOptions
+} from '@theia/filesystem/lib/common/files';
+import { ProtocolBroadcastConnection, Workspace, Peer } from 'open-collaboration-protocol';
+
+export namespace CollaborationURI {
+
+ export const scheme = 'collaboration';
+
+ export function create(workspace: Workspace, path?: string): URI {
+ return new URI(`${scheme}:///${workspace.name}${path ? '/' + path : ''}`);
+ }
+}
+
+@injectable()
+export class CollaborationFileSystemProvider implements FileSystemProviderWithFileReadWriteCapability {
+
+ capabilities = FileSystemProviderCapabilities.FileReadWrite;
+
+ protected _readonly: boolean;
+
+ get readonly(): boolean {
+ return this._readonly;
+ }
+
+ set readonly(value: boolean) {
+ if (this._readonly !== value) {
+ this._readonly = value;
+ if (value) {
+ this.capabilities |= FileSystemProviderCapabilities.Readonly;
+ } else {
+ this.capabilities &= ~FileSystemProviderCapabilities.Readonly;
+ }
+ this.onDidChangeCapabilitiesEmitter.fire();
+ }
+ }
+
+ constructor(readonly connection: ProtocolBroadcastConnection, readonly host: Peer, readonly yjs: Y.Doc) {
+ }
+
+ protected encoder = new TextEncoder();
+ protected decoder = new TextDecoder();
+ protected onDidChangeCapabilitiesEmitter = new Emitter();
+ protected onDidChangeFileEmitter = new Emitter();
+ protected onFileWatchErrorEmitter = new Emitter();
+
+ get onDidChangeCapabilities(): Event {
+ return this.onDidChangeCapabilitiesEmitter.event;
+ }
+ get onDidChangeFile(): Event {
+ return this.onDidChangeFileEmitter.event;
+ }
+ get onFileWatchError(): Event {
+ return this.onFileWatchErrorEmitter.event;
+ }
+ async readFile(resource: URI): Promise {
+ const path = this.getHostPath(resource);
+ if (this.yjs.share.has(path)) {
+ const stringValue = this.yjs.getText(path);
+ return this.encoder.encode(stringValue.toString());
+ } else {
+ const data = await this.connection.fs.readFile(this.host.id, path);
+ return data.content;
+ }
+ }
+ async writeFile(resource: URI, content: Uint8Array, opts: FileWriteOptions): Promise {
+ const path = this.getHostPath(resource);
+ await this.connection.fs.writeFile(this.host.id, path, { content });
+ }
+ watch(resource: URI, opts: WatchOptions): Disposable {
+ return Disposable.NULL;
+ }
+ stat(resource: URI): Promise {
+ return this.connection.fs.stat(this.host.id, this.getHostPath(resource));
+ }
+ mkdir(resource: URI): Promise {
+ return this.connection.fs.mkdir(this.host.id, this.getHostPath(resource));
+ }
+ async readdir(resource: URI): Promise<[string, FileType][]> {
+ const record = await this.connection.fs.readdir(this.host.id, this.getHostPath(resource));
+ return Object.entries(record);
+ }
+ delete(resource: URI, opts: FileDeleteOptions): Promise {
+ return this.connection.fs.delete(this.host.id, this.getHostPath(resource));
+ }
+ rename(from: URI, to: URI, opts: FileOverwriteOptions): Promise {
+ return this.connection.fs.rename(this.host.id, this.getHostPath(from), this.getHostPath(to));
+ }
+
+ protected getHostPath(uri: URI): string {
+ const path = uri.path.toString().substring(1).split('/');
+ return path.slice(1).join('/');
+ }
+
+ triggerEvent(changes: FileChange[]): void {
+ this.onDidChangeFileEmitter.fire(changes);
+ }
+
+}
diff --git a/packages/collaboration/src/browser/collaboration-frontend-contribution.ts b/packages/collaboration/src/browser/collaboration-frontend-contribution.ts
new file mode 100644
index 0000000000000..3d8784b84a9b3
--- /dev/null
+++ b/packages/collaboration/src/browser/collaboration-frontend-contribution.ts
@@ -0,0 +1,327 @@
+// *****************************************************************************
+// Copyright (C) 2024 TypeFox and others.
+//
+// This program and the accompanying materials are made available under the
+// terms of the Eclipse Public License v. 2.0 which is available at
+// http://www.eclipse.org/legal/epl-2.0.
+//
+// This Source Code may also be made available under the following Secondary
+// Licenses when the conditions for such availability set forth in the Eclipse
+// Public License v. 2.0 are satisfied: GNU General Public License, version 2
+// with the GNU Classpath Exception which is available at
+// https://www.gnu.org/software/classpath/license.html.
+//
+// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
+// *****************************************************************************
+
+import '../../src/browser/style/index.css';
+
+import {
+ CancellationToken, CancellationTokenSource, Command, CommandContribution, CommandRegistry, MessageService, nls, Progress, QuickInputService, QuickPickItem
+} from '@theia/core';
+import { inject, injectable, optional, postConstruct } from '@theia/core/shared/inversify';
+import { ConnectionProvider, SocketIoTransportProvider } from 'open-collaboration-protocol';
+import { WindowService } from '@theia/core/lib/browser/window/window-service';
+import { CollaborationInstance, CollaborationInstanceFactory } from './collaboration-instance';
+import { EnvVariablesServer } from '@theia/core/lib/common/env-variables';
+import { Deferred } from '@theia/core/lib/common/promise-util';
+import { CollaborationWorkspaceService } from './collaboration-workspace-service';
+import { StatusBar, StatusBarAlignment, StatusBarEntry } from '@theia/core/lib/browser/status-bar';
+import { codiconArray } from '@theia/core/lib/browser/widgets/widget';
+import { FrontendApplicationConfigProvider } from '@theia/core/lib/browser/frontend-application-config-provider';
+
+export const COLLABORATION_CATEGORY = 'Collaboration';
+
+export namespace CollaborationCommands {
+ export const CREATE_ROOM: Command = {
+ id: 'collaboration.create-room'
+ };
+ export const JOIN_ROOM: Command = {
+ id: 'collaboration.join-room'
+ };
+}
+
+export const COLLABORATION_STATUS_BAR_ID = 'statusBar.collaboration';
+
+export const COLLABORATION_AUTH_TOKEN = 'THEIA_COLLAB_AUTH_TOKEN';
+export const COLLABORATION_SERVER_URL = 'COLLABORATION_SERVER_URL';
+export const DEFAULT_COLLABORATION_SERVER_URL = 'https://api.open-collab.tools/';
+
+@injectable()
+export class CollaborationFrontendContribution implements CommandContribution {
+
+ protected readonly connectionProvider = new Deferred();
+
+ @inject(WindowService)
+ protected readonly windowService: WindowService;
+
+ @inject(QuickInputService) @optional()
+ protected readonly quickInputService?: QuickInputService;
+
+ @inject(EnvVariablesServer)
+ protected readonly envVariables: EnvVariablesServer;
+
+ @inject(CollaborationWorkspaceService)
+ protected readonly workspaceService: CollaborationWorkspaceService;
+
+ @inject(MessageService)
+ protected readonly messageService: MessageService;
+
+ @inject(CommandRegistry)
+ protected readonly commands: CommandRegistry;
+
+ @inject(StatusBar)
+ protected readonly statusBar: StatusBar;
+
+ @inject(CollaborationInstanceFactory)
+ protected readonly collaborationInstanceFactory: CollaborationInstanceFactory;
+
+ protected currentInstance?: CollaborationInstance;
+
+ @postConstruct()
+ protected init(): void {
+ this.setStatusBarEntryDefault();
+ this.getCollaborationServerUrl().then(serverUrl => {
+ const authHandler = new ConnectionProvider({
+ url: serverUrl,
+ client: FrontendApplicationConfigProvider.get().applicationName,
+ fetch: window.fetch.bind(window),
+ opener: url => this.windowService.openNewWindow(url, { external: true }),
+ transports: [SocketIoTransportProvider],
+ userToken: localStorage.getItem(COLLABORATION_AUTH_TOKEN) ?? undefined
+ });
+ this.connectionProvider.resolve(authHandler);
+ }, err => this.connectionProvider.reject(err));
+ }
+
+ protected async onStatusDefaultClick(): Promise {
+ const items: QuickPickItem[] = [];
+ if (this.workspaceService.opened) {
+ items.push({
+ label: nls.localize('theia/collaboration/createRoom', 'Create New Collaboration Session'),
+ iconClasses: codiconArray('add'),
+ execute: () => this.commands.executeCommand(CollaborationCommands.CREATE_ROOM.id)
+ });
+ }
+ items.push({
+ label: nls.localize('theia/collaboration/joinRoom', 'Join Collaboration Session'),
+ iconClasses: codiconArray('vm-connect'),
+ execute: () => this.commands.executeCommand(CollaborationCommands.JOIN_ROOM.id)
+ });
+ await this.quickInputService?.showQuickPick(items, {
+ placeholder: nls.localize('theia/collaboration/selectCollaboration', 'Select collaboration option')
+ });
+ }
+
+ protected async onStatusSharedClick(code: string): Promise {
+ const items: QuickPickItem[] = [{
+ label: nls.localize('theia/collaboration/invite', 'Invite Others'),
+ detail: nls.localize('theia/collaboration/inviteDetail', 'Copy the invitation code for sharing it with others to join the session.'),
+ iconClasses: codiconArray('clippy'),
+ execute: () => this.displayCopyNotification(code)
+ }];
+ if (this.currentInstance) {
+ // TODO: Implement readonly mode
+ // if (this.currentInstance.readonly) {
+ // items.push({
+ // label: nls.localize('theia/collaboration/enableEditing', 'Enable Workspace Editing'),
+ // detail: nls.localize('theia/collaboration/enableEditingDetail', 'Allow collaborators to modify content in your workspace.'),
+ // iconClasses: codiconArray('unlock'),
+ // execute: () => {
+ // if (this.currentInstance) {
+ // this.currentInstance.readonly = false;
+ // }
+ // }
+ // });
+ // } else {
+ // items.push({
+ // label: nls.localize('theia/collaboration/disableEditing', 'Disable Workspace Editing'),
+ // detail: nls.localize('theia/collaboration/disableEditingDetail', 'Restrict others from making changes to your workspace.'),
+ // iconClasses: codiconArray('lock'),
+ // execute: () => {
+ // if (this.currentInstance) {
+ // this.currentInstance.readonly = true;
+ // }
+ // }
+ // });
+ // }
+ }
+ items.push({
+ label: nls.localize('theia/collaboration/end', 'End Collaboration Session'),
+ detail: nls.localize('theia/collaboration/endDetail', 'Terminate the session, cease content sharing, and revoke access for others.'),
+ iconClasses: codiconArray('circle-slash'),
+ execute: () => this.currentInstance?.dispose()
+ });
+ await this.quickInputService?.showQuickPick(items, {
+ placeholder: nls.localize('theia/collaboration/whatToDo', 'What would you like to do with other collaborators?')
+ });
+ }
+
+ protected async onStatusConnectedClick(code: string): Promise {
+ const items: QuickPickItem[] = [{
+ label: nls.localize('theia/collaboration/invite', 'Invite Others'),
+ detail: nls.localize('theia/collaboration/inviteDetail', 'Copy the invitation code for sharing it with others to join the session.'),
+ iconClasses: codiconArray('clippy'),
+ execute: () => this.displayCopyNotification(code)
+ }];
+ items.push({
+ label: nls.localize('theia/collaboration/leave', 'Leave Collaboration Session'),
+ detail: nls.localize('theia/collaboration/leaveDetail', 'Disconnect from the current collaboration session and close the workspace.'),
+ iconClasses: codiconArray('circle-slash'),
+ execute: () => this.currentInstance?.dispose()
+ });
+ await this.quickInputService?.showQuickPick(items, {
+ placeholder: nls.localize('theia/collaboration/whatToDo', 'What would you like to do with other collaborators?')
+ });
+ }
+
+ protected async setStatusBarEntryDefault(): Promise {
+ await this.setStatusBarEntry({
+ text: '$(codicon-live-share) ' + nls.localize('theia/collaboration/collaborate', 'Collaborate'),
+ tooltip: nls.localize('theia/collaboration/startSession', 'Start or join collaboration session'),
+ onclick: () => this.onStatusDefaultClick()
+ });
+ }
+
+ protected async setStatusBarEntryShared(code: string): Promise {
+ await this.setStatusBarEntry({
+ text: '$(codicon-broadcast) ' + nls.localizeByDefault('Shared'),
+ tooltip: nls.localize('theia/collaboration/sharedSession', 'Shared a collaboration session'),
+ onclick: () => this.onStatusSharedClick(code)
+ });
+ }
+
+ protected async setStatusBarEntryConnected(code: string): Promise {
+ await this.setStatusBarEntry({
+ text: '$(codicon-broadcast) ' + nls.localize('theia/collaboration/connected', 'Connected'),
+ tooltip: nls.localize('theia/collaboration/connectedSession', 'Connected to a collaboration session'),
+ onclick: () => this.onStatusConnectedClick(code)
+ });
+ }
+
+ protected async setStatusBarEntry(entry: Omit): Promise {
+ await this.statusBar.setElement(COLLABORATION_STATUS_BAR_ID, {
+ ...entry,
+ alignment: StatusBarAlignment.LEFT,
+ priority: 5
+ });
+ }
+
+ protected async getCollaborationServerUrl(): Promise {
+ const serverUrlVariable = await this.envVariables.getValue(COLLABORATION_SERVER_URL);
+ return serverUrlVariable?.value || DEFAULT_COLLABORATION_SERVER_URL;
+ }
+
+ registerCommands(commands: CommandRegistry): void {
+ commands.registerCommand(CollaborationCommands.CREATE_ROOM, {
+ execute: async () => {
+ const cancelTokenSource = new CancellationTokenSource();
+ const progress = await this.messageService.showProgress({
+ text: nls.localize('theia/collaboration/creatingRoom', 'Creating Session'),
+ options: {
+ cancelable: true
+ }
+ }, () => cancelTokenSource.cancel());
+ try {
+ const authHandler = await this.connectionProvider.promise;
+ const roomClaim = await authHandler.createRoom({
+ reporter: info => progress.report({ message: info.message }),
+ abortSignal: this.toAbortSignal(cancelTokenSource.token)
+ });
+ if (roomClaim.loginToken) {
+ localStorage.setItem(COLLABORATION_AUTH_TOKEN, roomClaim.loginToken);
+ }
+ this.currentInstance?.dispose();
+ const connection = await authHandler.connect(roomClaim.roomToken);
+ this.currentInstance = this.collaborationInstanceFactory({
+ role: 'host',
+ connection
+ });
+ this.currentInstance.onDidClose(() => {
+ this.setStatusBarEntryDefault();
+ });
+ const roomCode = roomClaim.roomId;
+ this.setStatusBarEntryShared(roomCode);
+ this.displayCopyNotification(roomCode, true);
+ } catch (err) {
+ await this.messageService.error(nls.localize('theia/collaboration/failedCreate', 'Failed to create room: {0}', err.message));
+ } finally {
+ progress.cancel();
+ }
+ }
+ });
+ commands.registerCommand(CollaborationCommands.JOIN_ROOM, {
+ execute: async () => {
+ let joinRoomProgress: Progress | undefined;
+ const cancelTokenSource = new CancellationTokenSource();
+ try {
+ const authHandler = await this.connectionProvider.promise;
+ const id = await this.quickInputService?.input({
+ placeHolder: nls.localize('theia/collaboration/enterCode', 'Enter collaboration session code')
+ });
+ if (!id) {
+ return;
+ }
+ joinRoomProgress = await this.messageService.showProgress({
+ text: nls.localize('theia/collaboration/joiningRoom', 'Joining Session'),
+ options: {
+ cancelable: true
+ }
+ }, () => cancelTokenSource.cancel());
+ const roomClaim = await authHandler.joinRoom({
+ roomId: id,
+ reporter: info => joinRoomProgress?.report({ message: info.message }),
+ abortSignal: this.toAbortSignal(cancelTokenSource.token)
+ });
+ joinRoomProgress.cancel();
+ if (roomClaim.loginToken) {
+ localStorage.setItem(COLLABORATION_AUTH_TOKEN, roomClaim.loginToken);
+ }
+ this.currentInstance?.dispose();
+ const connection = await authHandler.connect(roomClaim.roomToken, roomClaim.host);
+ this.currentInstance = this.collaborationInstanceFactory({
+ role: 'guest',
+ connection
+ });
+ this.currentInstance.onDidClose(() => {
+ this.setStatusBarEntryDefault();
+ });
+ this.setStatusBarEntryConnected(roomClaim.roomId);
+ } catch (err) {
+ joinRoomProgress?.cancel();
+ await this.messageService.error(nls.localize('theia/collaboration/failedJoin', 'Failed to join room: {0}', err.message));
+ }
+ }
+ });
+ }
+
+ protected toAbortSignal(...tokens: CancellationToken[]): AbortSignal {
+ const controller = new AbortController();
+ tokens.forEach(token => token.onCancellationRequested(() => controller.abort()));
+ return controller.signal;
+ }
+
+ protected async displayCopyNotification(code: string, firstTime = false): Promise {
+ navigator.clipboard.writeText(code);
+ const notification = nls.localize('theia/collaboration/copiedInvitation', 'Invitation code copied to clipboard.');
+ if (firstTime) {
+ // const makeReadonly = nls.localize('theia/collaboration/makeReadonly', 'Make readonly');
+ const copyAgain = nls.localize('theia/collaboration/copyAgain', 'Copy Again');
+ const copyResult = await this.messageService.info(
+ notification,
+ // makeReadonly,
+ copyAgain
+ );
+ // if (copyResult === makeReadonly && this.currentInstance) {
+ // this.currentInstance.readonly = true;
+ // }
+ if (copyResult === copyAgain) {
+ navigator.clipboard.writeText(code);
+ }
+ } else {
+ await this.messageService.info(
+ notification
+ );
+ }
+ }
+}
diff --git a/packages/collaboration/src/browser/collaboration-frontend-module.ts b/packages/collaboration/src/browser/collaboration-frontend-module.ts
new file mode 100644
index 0000000000000..f0b9080e3113c
--- /dev/null
+++ b/packages/collaboration/src/browser/collaboration-frontend-module.ts
@@ -0,0 +1,37 @@
+// *****************************************************************************
+// Copyright (C) 2024 TypeFox and others.
+//
+// This program and the accompanying materials are made available under the
+// terms of the Eclipse Public License v. 2.0 which is available at
+// http://www.eclipse.org/legal/epl-2.0.
+//
+// This Source Code may also be made available under the following Secondary
+// Licenses when the conditions for such availability set forth in the Eclipse
+// Public License v. 2.0 are satisfied: GNU General Public License, version 2
+// with the GNU Classpath Exception which is available at
+// https://www.gnu.org/software/classpath/license.html.
+//
+// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
+// *****************************************************************************
+
+import { CommandContribution } from '@theia/core';
+import { ContainerModule } from '@theia/core/shared/inversify';
+import { WorkspaceService } from '@theia/workspace/lib/browser';
+import { CollaborationColorService } from './collaboration-color-service';
+import { CollaborationFrontendContribution } from './collaboration-frontend-contribution';
+import { CollaborationInstance, CollaborationInstanceFactory, CollaborationInstanceOptions, createCollaborationInstanceContainer } from './collaboration-instance';
+import { CollaborationUtils } from './collaboration-utils';
+import { CollaborationWorkspaceService } from './collaboration-workspace-service';
+
+export default new ContainerModule((bind, _, __, rebind) => {
+ bind(CollaborationWorkspaceService).toSelf().inSingletonScope();
+ rebind(WorkspaceService).toService(CollaborationWorkspaceService);
+ bind(CollaborationUtils).toSelf().inSingletonScope();
+ bind(CollaborationFrontendContribution).toSelf().inSingletonScope();
+ bind(CommandContribution).toService(CollaborationFrontendContribution);
+ bind(CollaborationInstanceFactory).toFactory(context => (options: CollaborationInstanceOptions) => {
+ const container = createCollaborationInstanceContainer(context.container, options);
+ return container.get(CollaborationInstance);
+ });
+ bind(CollaborationColorService).toSelf().inSingletonScope();
+});
diff --git a/packages/collaboration/src/browser/collaboration-instance.ts b/packages/collaboration/src/browser/collaboration-instance.ts
new file mode 100644
index 0000000000000..00c89e1908a77
--- /dev/null
+++ b/packages/collaboration/src/browser/collaboration-instance.ts
@@ -0,0 +1,819 @@
+// *****************************************************************************
+// Copyright (C) 2024 TypeFox and others.
+//
+// This program and the accompanying materials are made available under the
+// terms of the Eclipse Public License v. 2.0 which is available at
+// http://www.eclipse.org/legal/epl-2.0.
+//
+// This Source Code may also be made available under the following Secondary
+// Licenses when the conditions for such availability set forth in the Eclipse
+// Public License v. 2.0 are satisfied: GNU General Public License, version 2
+// with the GNU Classpath Exception which is available at
+// https://www.gnu.org/software/classpath/license.html.
+//
+// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
+// *****************************************************************************
+
+import * as types from 'open-collaboration-protocol';
+import * as Y from 'yjs';
+import * as awarenessProtocol from 'y-protocols/awareness';
+
+import { Disposable, DisposableCollection, Emitter, Event, MessageService, URI, nls } from '@theia/core';
+import { Container, inject, injectable, interfaces, postConstruct } from '@theia/core/shared/inversify';
+import { ApplicationShell } from '@theia/core/lib/browser/shell/application-shell';
+import { EditorManager } from '@theia/editor/lib/browser/editor-manager';
+import { FileService } from '@theia/filesystem/lib/browser/file-service';
+import { MonacoTextModelService } from '@theia/monaco/lib/browser/monaco-text-model-service';
+import { CollaborationWorkspaceService } from './collaboration-workspace-service';
+import { Range as MonacoRange } from '@theia/monaco-editor-core';
+import { MonacoEditorModel } from '@theia/monaco/lib/browser/monaco-editor-model';
+import { MonacoEditor } from '@theia/monaco/lib/browser/monaco-editor';
+import { Deferred } from '@theia/core/lib/common/promise-util';
+import { EditorDecoration, EditorWidget, Selection, TextEditorDocument, TrackedRangeStickiness } from '@theia/editor/lib/browser';
+import { DecorationStyle, OpenerService, SaveReason } from '@theia/core/lib/browser';
+import { CollaborationFileSystemProvider, CollaborationURI } from './collaboration-file-system-provider';
+import { Range } from '@theia/core/shared/vscode-languageserver-protocol';
+import { CollaborationColorService } from './collaboration-color-service';
+import { BinaryBuffer } from '@theia/core/lib/common/buffer';
+import { FileChange, FileChangeType, FileOperation } from '@theia/filesystem/lib/common/files';
+import { OpenCollaborationYjsProvider } from 'open-collaboration-yjs';
+import { createMutex } from 'lib0/mutex';
+import { CollaborationUtils } from './collaboration-utils';
+import debounce = require('@theia/core/shared/lodash.debounce');
+
+export const CollaborationInstanceFactory = Symbol('CollaborationInstanceFactory');
+export type CollaborationInstanceFactory = (connection: CollaborationInstanceOptions) => CollaborationInstance;
+
+export const CollaborationInstanceOptions = Symbol('CollaborationInstanceOptions');
+export interface CollaborationInstanceOptions {
+ role: 'host' | 'guest';
+ connection: types.ProtocolBroadcastConnection;
+}
+
+export function createCollaborationInstanceContainer(parent: interfaces.Container, options: CollaborationInstanceOptions): Container {
+ const child = new Container();
+ child.parent = parent;
+ child.bind(CollaborationInstance).toSelf().inTransientScope();
+ child.bind(CollaborationInstanceOptions).toConstantValue(options);
+ return child;
+}
+
+export interface DisposablePeer extends Disposable {
+ peer: types.Peer;
+}
+
+export const COLLABORATION_SELECTION = 'theia-collaboration-selection';
+export const COLLABORATION_SELECTION_MARKER = 'theia-collaboration-selection-marker';
+export const COLLABORATION_SELECTION_INVERTED = 'theia-collaboration-selection-inverted';
+
+@injectable()
+export class CollaborationInstance implements Disposable {
+
+ @inject(MessageService)
+ protected readonly messageService: MessageService;
+
+ @inject(CollaborationWorkspaceService)
+ protected readonly workspaceService: CollaborationWorkspaceService;
+
+ @inject(FileService)
+ protected readonly fileService: FileService;
+
+ @inject(MonacoTextModelService)
+ protected readonly monacoModelService: MonacoTextModelService;
+
+ @inject(EditorManager)
+ protected readonly editorManager: EditorManager;
+
+ @inject(OpenerService)
+ protected readonly openerService: OpenerService;
+
+ @inject(ApplicationShell)
+ protected readonly shell: ApplicationShell;
+
+ @inject(CollaborationInstanceOptions)
+ protected readonly options: CollaborationInstanceOptions;
+
+ @inject(CollaborationColorService)
+ protected readonly collaborationColorService: CollaborationColorService;
+
+ @inject(CollaborationUtils)
+ protected readonly utils: CollaborationUtils;
+
+ protected identity = new Deferred();
+ protected peers = new Map();
+ protected yjs = new Y.Doc();
+ protected yjsAwareness = new awarenessProtocol.Awareness(this.yjs);
+ protected yjsProvider: OpenCollaborationYjsProvider;
+ protected colorIndex = 0;
+ protected editorDecorations = new Map();
+ protected fileSystem?: CollaborationFileSystemProvider;
+ protected permissions: types.Permissions = {
+ readonly: false
+ };
+
+ protected onDidCloseEmitter = new Emitter();
+
+ get onDidClose(): Event {
+ return this.onDidCloseEmitter.event;
+ }
+
+ protected toDispose = new DisposableCollection();
+ protected _readonly = false;
+
+ get readonly(): boolean {
+ return this._readonly;
+ }
+
+ set readonly(value: boolean) {
+ if (value !== this.readonly) {
+ if (this.options.role === 'guest' && this.fileSystem) {
+ this.fileSystem.readonly = value;
+ } else if (this.options.role === 'host') {
+ this.options.connection.room.updatePermissions({
+ ...(this.permissions ?? {}),
+ readonly: value
+ });
+ }
+ if (this.permissions) {
+ this.permissions.readonly = value;
+ }
+ this._readonly = value;
+ }
+ }
+
+ get isHost(): boolean {
+ return this.options.role === 'host';
+ }
+
+ get host(): types.Peer {
+ return Array.from(this.peers.values()).find(e => e.peer.host)!.peer;
+ }
+
+ @postConstruct()
+ protected init(): void {
+ const connection = this.options.connection;
+ connection.onDisconnect(() => this.dispose());
+ connection.onConnectionError(message => {
+ this.messageService.error(message);
+ this.dispose();
+ });
+ this.yjsProvider = new OpenCollaborationYjsProvider(connection, this.yjs, this.yjsAwareness);
+ this.yjsProvider.connect();
+ this.toDispose.push(Disposable.create(() => this.yjs.destroy()));
+ this.toDispose.push(this.yjsProvider);
+ this.toDispose.push(connection);
+ this.toDispose.push(this.onDidCloseEmitter);
+
+ this.registerProtocolEvents(connection);
+ this.registerEditorEvents(connection);
+ this.registerFileSystemEvents(connection);
+
+ if (this.isHost) {
+ this.registerFileSystemChanges();
+ }
+ }
+
+ protected registerProtocolEvents(connection: types.ProtocolBroadcastConnection): void {
+ connection.peer.onJoinRequest(async (_, user) => {
+ const allow = nls.localizeByDefault('Allow');
+ const deny = nls.localizeByDefault('Deny');
+ const result = await this.messageService.info(
+ nls.localize('theia/collaboration/userWantsToJoin', "User '{0}' wants to join the collaboration room", user.email ? `${user.name} (${user.email})` : user.name),
+ allow,
+ deny
+ );
+ if (result === allow) {
+ const roots = await this.workspaceService.roots;
+ return {
+ workspace: {
+ name: this.workspaceService.workspace?.name ?? nls.localize('theia/collaboration/collaboration', 'Collaboration'),
+ folders: roots.map(e => e.name)
+ }
+ };
+ } else {
+ return undefined;
+ }
+ });
+ connection.room.onJoin(async (_, peer) => {
+ this.addPeer(peer);
+ if (this.isHost) {
+ const roots = await this.workspaceService.roots;
+ const data: types.InitData = {
+ protocol: types.VERSION,
+ host: await this.identity.promise,
+ guests: Array.from(this.peers.values()).map(e => e.peer),
+ capabilities: {},
+ permissions: this.permissions,
+ workspace: {
+ name: this.workspaceService.workspace?.name ?? nls.localize('theia/collaboration/collaboration', 'Collaboration'),
+ folders: roots.map(e => e.name)
+ }
+ };
+ connection.peer.init(peer.id, data);
+ }
+ });
+ connection.room.onLeave((_, peer) => {
+ this.peers.get(peer.id)?.dispose();
+ });
+ connection.room.onClose(() => {
+ this.dispose();
+ });
+ connection.room.onPermissions((_, permissions) => {
+ if (this.fileSystem) {
+ this.fileSystem.readonly = permissions.readonly;
+ }
+ });
+ connection.peer.onInfo((_, peer) => {
+ this.yjsAwareness.setLocalStateField('peer', peer.id);
+ this.identity.resolve(peer);
+ });
+ connection.peer.onInit(async (_, data) => {
+ await this.initialize(data);
+ });
+ }
+
+ protected registerEditorEvents(connection: types.ProtocolBroadcastConnection): void {
+ for (const model of this.monacoModelService.models) {
+ if (this.isSharedResource(new URI(model.uri))) {
+ this.registerModelUpdate(model);
+ }
+ }
+ this.toDispose.push(this.monacoModelService.onDidCreate(newModel => {
+ if (this.isSharedResource(new URI(newModel.uri))) {
+ this.registerModelUpdate(newModel);
+ }
+ }));
+ this.toDispose.push(this.editorManager.onCreated(widget => {
+ if (this.isSharedResource(widget.getResourceUri())) {
+ this.registerPresenceUpdate(widget);
+ }
+ }));
+ this.getOpenEditors().forEach(widget => {
+ if (this.isSharedResource(widget.getResourceUri())) {
+ this.registerPresenceUpdate(widget);
+ }
+ });
+ this.shell.onDidChangeActiveWidget(e => {
+ if (e.newValue instanceof EditorWidget) {
+ this.updateEditorPresence(e.newValue);
+ }
+ });
+
+ this.yjsAwareness.on('change', () => {
+ this.rerenderPresence();
+ });
+
+ connection.editor.onOpen(async (_, path) => {
+ const uri = this.utils.getResourceUri(path);
+ if (uri) {
+ await this.openUri(uri);
+ } else {
+ throw new Error('Could find file: ' + path);
+ }
+ return undefined;
+ });
+ }
+
+ protected isSharedResource(resource?: URI): boolean {
+ if (!resource) {
+ return false;
+ }
+ return this.isHost ? resource.scheme === 'file' : resource.scheme === CollaborationURI.scheme;
+ }
+
+ protected registerFileSystemEvents(connection: types.ProtocolBroadcastConnection): void {
+ connection.fs.onReadFile(async (_, path) => {
+ const uri = this.utils.getResourceUri(path);
+ if (uri) {
+ const content = await this.fileService.readFile(uri);
+ return {
+ content: content.value.buffer
+ };
+ } else {
+ throw new Error('Could find file: ' + path);
+ }
+ });
+ connection.fs.onReaddir(async (_, path) => {
+ const uri = this.utils.getResourceUri(path);
+ if (uri) {
+ const resolved = await this.fileService.resolve(uri);
+ if (resolved.children) {
+ const dir: Record = {};
+ for (const child of resolved.children) {
+ dir[child.name] = child.isDirectory ? types.FileType.Directory : types.FileType.File;
+ }
+ return dir;
+ } else {
+ return {};
+ }
+ } else {
+ throw new Error('Could find directory: ' + path);
+ }
+ });
+ connection.fs.onStat(async (_, path) => {
+ const uri = this.utils.getResourceUri(path);
+ if (uri) {
+ const content = await this.fileService.resolve(uri, {
+ resolveMetadata: true
+ });
+ return {
+ type: content.isDirectory ? types.FileType.Directory : types.FileType.File,
+ ctime: content.ctime,
+ mtime: content.mtime,
+ size: content.size,
+ permissions: content.isReadonly ? types.FilePermission.Readonly : undefined
+ };
+ } else {
+ throw new Error('Could find file: ' + path);
+ }
+ });
+ connection.fs.onWriteFile(async (_, path, data) => {
+ const uri = this.utils.getResourceUri(path);
+ if (uri) {
+ const model = this.getModel(uri);
+ if (model) {
+ const content = new TextDecoder().decode(data.content);
+ if (content !== model.getText()) {
+ model.textEditorModel.setValue(content);
+ }
+ await model.save({ saveReason: SaveReason.Manual });
+ } else {
+ await this.fileService.createFile(uri, BinaryBuffer.wrap(data.content));
+ }
+ } else {
+ throw new Error('Could find file: ' + path);
+ }
+ });
+ connection.fs.onMkdir(async (_, path) => {
+ const uri = this.utils.getResourceUri(path);
+ if (uri) {
+ await this.fileService.createFolder(uri);
+ } else {
+ throw new Error('Could find path: ' + path);
+ }
+ });
+ connection.fs.onDelete(async (_, path) => {
+ const uri = this.utils.getResourceUri(path);
+ if (uri) {
+ await this.fileService.delete(uri);
+ } else {
+ throw new Error('Could find entry: ' + path);
+ }
+ });
+ connection.fs.onRename(async (_, from, to) => {
+ const fromUri = this.utils.getResourceUri(from);
+ const toUri = this.utils.getResourceUri(to);
+ if (fromUri && toUri) {
+ await this.fileService.move(fromUri, toUri);
+ } else {
+ throw new Error('Could find entries: ' + from + ' -> ' + to);
+ }
+ });
+ connection.fs.onChange(async (_, event) => {
+ // Only guests need to handle file system changes
+ if (!this.isHost && this.fileSystem) {
+ const changes: FileChange[] = [];
+ for (const change of event.changes) {
+ const uri = this.utils.getResourceUri(change.path);
+ if (uri) {
+ changes.push({
+ type: change.type === types.FileChangeEventType.Create
+ ? FileChangeType.ADDED
+ : change.type === types.FileChangeEventType.Update
+ ? FileChangeType.UPDATED
+ : FileChangeType.DELETED,
+ resource: uri
+ });
+ }
+ }
+ this.fileSystem.triggerEvent(changes);
+ }
+ });
+ }
+
+ protected rerenderPresence(...widgets: EditorWidget[]): void {
+ const decorations = new Map();
+ const states = this.yjsAwareness.getStates() as Map;
+ for (const [clientID, state] of states.entries()) {
+ if (clientID === this.yjs.clientID) {
+ // Ignore own awareness state
+ continue;
+ }
+ const peer = state.peer;
+ if (!state.selection || !this.peers.has(peer)) {
+ continue;
+ }
+ if (!types.ClientTextSelection.is(state.selection)) {
+ continue;
+ }
+ const { path, textSelections } = state.selection;
+ const selection = textSelections[0];
+ if (!selection) {
+ continue;
+ }
+ const uri = this.utils.getResourceUri(path);
+ if (uri) {
+ const model = this.getModel(uri);
+ if (model) {
+ let existing = decorations.get(path);
+ if (!existing) {
+ existing = [];
+ decorations.set(path, existing);
+ }
+ const forward = selection.direction === types.SelectionDirection.LeftToRight;
+ let startIndex = Y.createAbsolutePositionFromRelativePosition(selection.start, this.yjs);
+ let endIndex = Y.createAbsolutePositionFromRelativePosition(selection.end, this.yjs);
+ if (startIndex && endIndex) {
+ if (startIndex.index > endIndex.index) {
+ [startIndex, endIndex] = [endIndex, startIndex];
+ }
+ const start = model.positionAt(startIndex.index);
+ const end = model.positionAt(endIndex.index);
+ const inverted = (forward && end.line === 0) || (!forward && start.line === 0);
+ const range = {
+ start,
+ end
+ };
+ const contentClassNames: string[] = [COLLABORATION_SELECTION_MARKER, `${COLLABORATION_SELECTION_MARKER}-${peer}`];
+ if (inverted) {
+ contentClassNames.push(COLLABORATION_SELECTION_INVERTED);
+ }
+ const item: EditorDecoration = {
+ range,
+ options: {
+ className: `${COLLABORATION_SELECTION} ${COLLABORATION_SELECTION}-${peer}`,
+ beforeContentClassName: !forward ? contentClassNames.join(' ') : undefined,
+ afterContentClassName: forward ? contentClassNames.join(' ') : undefined,
+ stickiness: TrackedRangeStickiness.NeverGrowsWhenTypingAtEdges
+ }
+ };
+ existing.push(item);
+ }
+ }
+ }
+ }
+ this.rerenderPresenceDecorations(decorations, ...widgets);
+ }
+
+ protected rerenderPresenceDecorations(decorations: Map, ...widgets: EditorWidget[]): void {
+ for (const editor of new Set(this.getOpenEditors().concat(widgets))) {
+ const uri = editor.getResourceUri();
+ const path = this.utils.getProtocolPath(uri);
+ if (path) {
+ const old = this.editorDecorations.get(editor) ?? [];
+ this.editorDecorations.set(editor, editor.editor.deltaDecorations({
+ newDecorations: decorations.get(path) ?? [],
+ oldDecorations: old
+ }));
+ }
+ }
+ }
+
+ protected registerFileSystemChanges(): void {
+ // Event listener for disk based events
+ this.fileService.onDidFilesChange(event => {
+ const changes: types.FileChange[] = [];
+ for (const change of event.changes) {
+ const path = this.utils.getProtocolPath(change.resource);
+ if (path) {
+ let type: types.FileChangeEventType | undefined;
+ if (change.type === FileChangeType.ADDED) {
+ type = types.FileChangeEventType.Create;
+ } else if (change.type === FileChangeType.DELETED) {
+ type = types.FileChangeEventType.Delete;
+ }
+ // Updates to files on disk are not sent
+ if (type !== undefined) {
+ changes.push({
+ path,
+ type
+ });
+ }
+ }
+ }
+ if (changes.length) {
+ this.options.connection.fs.change({ changes });
+ }
+ });
+ // Event listener for user based events
+ this.fileService.onDidRunOperation(operation => {
+ const path = this.utils.getProtocolPath(operation.resource);
+ if (!path) {
+ return;
+ }
+ let type = types.FileChangeEventType.Update;
+ if (operation.isOperation(FileOperation.CREATE) || operation.isOperation(FileOperation.COPY)) {
+ type = types.FileChangeEventType.Create;
+ } else if (operation.isOperation(FileOperation.DELETE)) {
+ type = types.FileChangeEventType.Delete;
+ }
+ this.options.connection.fs.change({
+ changes: [{
+ path,
+ type
+ }]
+ });
+ });
+ }
+
+ protected async registerPresenceUpdate(widget: EditorWidget): Promise {
+ const uri = widget.getResourceUri();
+ const path = this.utils.getProtocolPath(uri);
+ if (path) {
+ if (!this.isHost) {
+ this.options.connection.editor.open(this.host.id, path);
+ }
+ let currentSelection = widget.editor.selection;
+ // // Update presence information when the selection changes
+ const selectionChange = widget.editor.onSelectionChanged(selection => {
+ if (!this.rangeEqual(currentSelection, selection)) {
+ this.updateEditorPresence(widget);
+ currentSelection = selection;
+ }
+ });
+ const widgetDispose = widget.onDidDispose(() => {
+ widgetDispose.dispose();
+ selectionChange.dispose();
+ // Remove presence information when the editor closes
+ const state = this.yjsAwareness.getLocalState();
+ if (state?.currentSelection?.path === path) {
+ delete state.currentSelection;
+ }
+ this.yjsAwareness.setLocalState(state);
+ });
+ this.toDispose.push(selectionChange);
+ this.toDispose.push(widgetDispose);
+ this.rerenderPresence(widget);
+ }
+ }
+
+ protected updateEditorPresence(widget: EditorWidget): void {
+ const uri = widget.getResourceUri();
+ const path = this.utils.getProtocolPath(uri);
+ if (path) {
+ const ytext = this.yjs.getText(path);
+ const selection = widget.editor.selection;
+ let start = widget.editor.document.offsetAt(selection.start);
+ let end = widget.editor.document.offsetAt(selection.end);
+ if (start > end) {
+ [start, end] = [end, start];
+ }
+ const direction = selection.direction === 'ltr'
+ ? types.SelectionDirection.LeftToRight
+ : types.SelectionDirection.RightToLeft;
+ const editorSelection: types.RelativeTextSelection = {
+ start: Y.createRelativePositionFromTypeIndex(ytext, start),
+ end: Y.createRelativePositionFromTypeIndex(ytext, end),
+ direction
+ };
+ const textSelection: types.ClientTextSelection = {
+ path,
+ textSelections: [editorSelection]
+ };
+ this.setSharedSelection(textSelection);
+ }
+ }
+
+ protected setSharedSelection(selection?: types.ClientSelection): void {
+ this.yjsAwareness.setLocalStateField('selection', selection);
+ }
+
+ protected rangeEqual(a: Range, b: Range): boolean {
+ return a.start.line === b.start.line
+ && a.start.character === b.start.character
+ && a.end.line === b.end.line
+ && a.end.character === b.end.character;
+ }
+
+ async initialize(data: types.InitData): Promise {
+ this.permissions = data.permissions;
+ this.readonly = data.permissions.readonly;
+ for (const peer of [...data.guests, data.host]) {
+ this.addPeer(peer);
+ }
+ this.fileSystem = new CollaborationFileSystemProvider(this.options.connection, data.host, this.yjs);
+ this.fileSystem.readonly = this.readonly;
+ this.toDispose.push(this.fileService.registerProvider(CollaborationURI.scheme, this.fileSystem));
+ const workspaceDisposable = await this.workspaceService.setHostWorkspace(data.workspace, this.options.connection);
+ this.toDispose.push(workspaceDisposable);
+ }
+
+ protected addPeer(peer: types.Peer): void {
+ const collection = new DisposableCollection();
+ collection.push(this.createPeerStyleSheet(peer));
+ collection.push(Disposable.create(() => this.peers.delete(peer.id)));
+ const disposablePeer = {
+ peer,
+ dispose: () => collection.dispose()
+ };
+ this.peers.set(peer.id, disposablePeer);
+ }
+
+ protected createPeerStyleSheet(peer: types.Peer): Disposable {
+ const style = DecorationStyle.createStyleElement(`${peer.id}-collaboration-selection`);
+ const colors = this.collaborationColorService.getColors();
+ const sheet = style.sheet!;
+ const color = colors[this.colorIndex++ % colors.length];
+ const colorString = `rgb(${color.r}, ${color.g}, ${color.b})`;
+ sheet.insertRule(`
+ .${COLLABORATION_SELECTION}-${peer.id} {
+ opacity: 0.2;
+ background: ${colorString};
+ }
+ `);
+ sheet.insertRule(`
+ .${COLLABORATION_SELECTION_MARKER}-${peer.id} {
+ background: ${colorString};
+ border-color: ${colorString};
+ }`
+ );
+ sheet.insertRule(`
+ .${COLLABORATION_SELECTION_MARKER}-${peer.id}::after {
+ content: "${peer.name}";
+ background: ${colorString};
+ color: ${this.collaborationColorService.requiresDarkFont(color)
+ ? this.collaborationColorService.dark
+ : this.collaborationColorService.light};
+ z-index: ${(100 + this.colorIndex).toFixed()}
+ }`
+ );
+ return Disposable.create(() => style.remove());
+ }
+
+ protected getOpenEditors(uri?: URI): EditorWidget[] {
+ const widgets = this.shell.widgets;
+ let editors = widgets.filter(e => e instanceof EditorWidget) as EditorWidget[];
+ if (uri) {
+ const uriString = uri.toString();
+ editors = editors.filter(e => e.getResourceUri()?.toString() === uriString);
+ }
+ return editors;
+ }
+
+ protected createSelectionFromRelative(selection: types.RelativeTextSelection, model: MonacoEditorModel): Selection | undefined {
+ const start = Y.createAbsolutePositionFromRelativePosition(selection.start, this.yjs);
+ const end = Y.createAbsolutePositionFromRelativePosition(selection.end, this.yjs);
+ if (start && end) {
+ return {
+ start: model.positionAt(start.index),
+ end: model.positionAt(end.index),
+ direction: selection.direction === types.SelectionDirection.LeftToRight ? 'ltr' : 'rtl'
+ };
+ }
+ return undefined;
+ }
+
+ protected createRelativeSelection(selection: Selection, model: TextEditorDocument, ytext: Y.Text): types.RelativeTextSelection {
+ const start = Y.createRelativePositionFromTypeIndex(ytext, model.offsetAt(selection.start));
+ const end = Y.createRelativePositionFromTypeIndex(ytext, model.offsetAt(selection.end));
+ return {
+ start,
+ end,
+ direction: selection.direction === 'ltr'
+ ? types.SelectionDirection.LeftToRight
+ : types.SelectionDirection.RightToLeft
+ };
+ }
+
+ protected readonly yjsMutex = createMutex();
+
+ protected registerModelUpdate(model: MonacoEditorModel): void {
+ let updating = false;
+ const modelPath = this.utils.getProtocolPath(new URI(model.uri));
+ if (!modelPath) {
+ return;
+ }
+ const unknownModel = !this.yjs.share.has(modelPath);
+ const ytext = this.yjs.getText(modelPath);
+ const modelText = model.textEditorModel.getValue();
+ if (this.isHost && unknownModel) {
+ // If we are hosting the room, set the initial content
+ // First off, reset the shared content to be empty
+ // This has the benefit of effectively clearing the memory of the shared content across all peers
+ // This is important because the shared content accumulates changes/memory usage over time
+ this.resetYjsText(ytext, modelText);
+ } else {
+ this.options.connection.editor.open(this.host.id, modelPath);
+ }
+ // The Ytext instance is our source of truth for the model content
+ // Sometimes (especially after a lot of sequential undo/redo operations) our model content can get out of sync
+ // This resyncs the model content with the Ytext content after a delay
+ const resyncDebounce = debounce(() => {
+ this.yjsMutex(() => {
+ const newContent = ytext.toString();
+ if (model.textEditorModel.getValue() !== newContent) {
+ updating = true;
+ this.softReplaceModel(model, newContent);
+ updating = false;
+ }
+ });
+ }, 200);
+ const disposable = new DisposableCollection();
+ disposable.push(model.onDidChangeContent(e => {
+ if (updating) {
+ return;
+ }
+ this.yjsMutex(() => {
+ this.yjs.transact(() => {
+ for (const change of e.contentChanges) {
+ ytext.delete(change.rangeOffset, change.rangeLength);
+ ytext.insert(change.rangeOffset, change.text);
+ }
+ });
+ resyncDebounce();
+ });
+ }));
+
+ const observer = (textEvent: Y.YTextEvent) => {
+ if (textEvent.transaction.local || model.getText() === ytext.toString()) {
+ // Ignore local changes and changes that are already reflected in the model
+ return;
+ }
+ this.yjsMutex(() => {
+ updating = true;
+ try {
+ let index = 0;
+ const operations: { range: MonacoRange, text: string }[] = [];
+ textEvent.delta.forEach(delta => {
+ if (delta.retain !== undefined) {
+ index += delta.retain;
+ } else if (delta.insert !== undefined) {
+ const pos = model.textEditorModel.getPositionAt(index);
+ const range = new MonacoRange(pos.lineNumber, pos.column, pos.lineNumber, pos.column);
+ const insert = delta.insert as string;
+ operations.push({ range, text: insert });
+ index += insert.length;
+ } else if (delta.delete !== undefined) {
+ const pos = model.textEditorModel.getPositionAt(index);
+ const endPos = model.textEditorModel.getPositionAt(index + delta.delete);
+ const range = new MonacoRange(pos.lineNumber, pos.column, endPos.lineNumber, endPos.column);
+ operations.push({ range, text: '' });
+ }
+ });
+ this.pushChangesToModel(model, operations);
+ } catch (err) {
+ console.error(err);
+ }
+ resyncDebounce();
+ updating = false;
+ });
+ };
+
+ ytext.observe(observer);
+ disposable.push(Disposable.create(() => ytext.unobserve(observer)));
+ model.onDispose(() => disposable.dispose());
+ }
+
+ protected resetYjsText(yjsText: Y.Text, text: string): void {
+ this.yjs.transact(() => {
+ yjsText.delete(0, yjsText.length);
+ yjsText.insert(0, text);
+ });
+ }
+
+ protected getModel(uri: URI): MonacoEditorModel | undefined {
+ const existing = this.monacoModelService.models.find(e => e.uri === uri.toString());
+ if (existing) {
+ return existing;
+ } else {
+ return undefined;
+ }
+ }
+
+ protected pushChangesToModel(model: MonacoEditorModel, changes: { range: MonacoRange, text: string, forceMoveMarkers?: boolean }[]): void {
+ const editor = MonacoEditor.findByDocument(this.editorManager, model)[0];
+ const cursorState = editor?.getControl().getSelections() ?? [];
+ model.textEditorModel.pushStackElement();
+ try {
+ model.textEditorModel.pushEditOperations(cursorState, changes, () => cursorState);
+ model.textEditorModel.pushStackElement();
+ } catch (err) {
+ console.error(err);
+ }
+ }
+
+ protected softReplaceModel(model: MonacoEditorModel, text: string): void {
+ this.pushChangesToModel(model, [{
+ range: model.textEditorModel.getFullModelRange(),
+ text,
+ forceMoveMarkers: false
+ }]);
+ }
+
+ protected async openUri(uri: URI): Promise {
+ const ref = await this.monacoModelService.createModelReference(uri);
+ if (ref.object) {
+ this.toDispose.push(ref);
+ } else {
+ ref.dispose();
+ }
+ }
+
+ dispose(): void {
+ for (const peer of this.peers.values()) {
+ peer.dispose();
+ }
+ this.onDidCloseEmitter.fire();
+ this.toDispose.dispose();
+ }
+}
diff --git a/packages/collaboration/src/browser/collaboration-utils.ts b/packages/collaboration/src/browser/collaboration-utils.ts
new file mode 100644
index 0000000000000..c1398033a4026
--- /dev/null
+++ b/packages/collaboration/src/browser/collaboration-utils.ts
@@ -0,0 +1,59 @@
+// *****************************************************************************
+// Copyright (C) 2024 TypeFox and others.
+//
+// This program and the accompanying materials are made available under the
+// terms of the Eclipse Public License v. 2.0 which is available at
+// http://www.eclipse.org/legal/epl-2.0.
+//
+// This Source Code may also be made available under the following Secondary
+// Licenses when the conditions for such availability set forth in the Eclipse
+// Public License v. 2.0 are satisfied: GNU General Public License, version 2
+// with the GNU Classpath Exception which is available at
+// https://www.gnu.org/software/classpath/license.html.
+//
+// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
+// *****************************************************************************
+
+import { URI } from '@theia/core';
+import { inject, injectable } from '@theia/core/shared/inversify';
+import { CollaborationWorkspaceService } from './collaboration-workspace-service';
+
+@injectable()
+export class CollaborationUtils {
+
+ @inject(CollaborationWorkspaceService)
+ protected readonly workspaceService: CollaborationWorkspaceService;
+
+ getProtocolPath(uri?: URI): string | undefined {
+ if (!uri) {
+ return undefined;
+ }
+ const path = uri.path.toString();
+ const roots = this.workspaceService.tryGetRoots();
+ for (const root of roots) {
+ const rootUri = root.resource.path.toString() + '/';
+ if (path.startsWith(rootUri)) {
+ return root.name + '/' + path.substring(rootUri.length);
+ }
+ }
+ return undefined;
+ }
+
+ getResourceUri(path?: string): URI | undefined {
+ if (!path) {
+ return undefined;
+ }
+ const parts = path.split('/');
+ const root = parts[0];
+ const rest = parts.slice(1);
+ const stat = this.workspaceService.tryGetRoots().find(e => e.name === root);
+ if (stat) {
+ const uriPath = stat.resource.path.join(...rest);
+ const uri = stat.resource.withPath(uriPath);
+ return uri;
+ } else {
+ return undefined;
+ }
+ }
+
+}
diff --git a/packages/collaboration/src/browser/collaboration-workspace-service.ts b/packages/collaboration/src/browser/collaboration-workspace-service.ts
new file mode 100644
index 0000000000000..ac1cd59914e67
--- /dev/null
+++ b/packages/collaboration/src/browser/collaboration-workspace-service.ts
@@ -0,0 +1,69 @@
+// *****************************************************************************
+// Copyright (C) 2024 TypeFox and others.
+//
+// This program and the accompanying materials are made available under the
+// terms of the Eclipse Public License v. 2.0 which is available at
+// http://www.eclipse.org/legal/epl-2.0.
+//
+// This Source Code may also be made available under the following Secondary
+// Licenses when the conditions for such availability set forth in the Eclipse
+// Public License v. 2.0 are satisfied: GNU General Public License, version 2
+// with the GNU Classpath Exception which is available at
+// https://www.gnu.org/software/classpath/license.html.
+//
+// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
+// *****************************************************************************
+
+import { nls } from '@theia/core';
+import { injectable } from '@theia/core/shared/inversify';
+import { Disposable } from '@theia/core/shared/vscode-languageserver-protocol';
+import { FileStat } from '@theia/filesystem/lib/common/files';
+import { WorkspaceService } from '@theia/workspace/lib/browser';
+import { Workspace, ProtocolBroadcastConnection } from 'open-collaboration-protocol';
+import { CollaborationURI } from './collaboration-file-system-provider';
+
+@injectable()
+export class CollaborationWorkspaceService extends WorkspaceService {
+
+ protected collabWorkspace?: Workspace;
+ protected connection?: ProtocolBroadcastConnection;
+
+ async setHostWorkspace(workspace: Workspace, connection: ProtocolBroadcastConnection): Promise {
+ this.collabWorkspace = workspace;
+ this.connection = connection;
+ await this.setWorkspace({
+ isDirectory: false,
+ isFile: true,
+ isReadonly: false,
+ isSymbolicLink: false,
+ name: nls.localize('theia/collaboration/collaborationWorkspace', 'Collaboration Workspace'),
+ resource: CollaborationURI.create(this.collabWorkspace)
+ });
+ return Disposable.create(() => {
+ this.collabWorkspace = undefined;
+ this.connection = undefined;
+ this.setWorkspace(undefined);
+ });
+ }
+
+ protected override async computeRoots(): Promise {
+ if (this.collabWorkspace) {
+ return this.collabWorkspace.folders.map(e => this.entryToStat(e));
+ } else {
+ return super.computeRoots();
+ }
+ }
+
+ protected entryToStat(entry: string): FileStat {
+ const uri = CollaborationURI.create(this.collabWorkspace!, entry);
+ return {
+ resource: uri,
+ name: entry,
+ isDirectory: true,
+ isFile: false,
+ isReadonly: false,
+ isSymbolicLink: false
+ };
+ }
+
+}
diff --git a/packages/collaboration/src/browser/style/index.css b/packages/collaboration/src/browser/style/index.css
new file mode 100644
index 0000000000000..1d1eac50c03c8
--- /dev/null
+++ b/packages/collaboration/src/browser/style/index.css
@@ -0,0 +1,22 @@
+.theia-collaboration-selection-marker {
+ position: absolute;
+ content: " ";
+ border-right: solid 2px;
+ border-top: solid 2px;
+ border-bottom: solid 2px;
+ height: 100%;
+ box-sizing: border-box;
+}
+
+.theia-collaboration-selection-marker::after {
+ position: absolute;
+ transform: translateY(-100%);
+ padding: 0 4px;
+ border-radius: 4px 4px 4px 0px;
+}
+
+.theia-collaboration-selection-marker.theia-collaboration-selection-inverted::after {
+ transform: translateY(100%);
+ margin-top: -2px;
+ border-radius: 0px 4px 4px 4px;
+}
diff --git a/packages/collaboration/src/package.spec.ts b/packages/collaboration/src/package.spec.ts
new file mode 100644
index 0000000000000..4e6f3abdcdccd
--- /dev/null
+++ b/packages/collaboration/src/package.spec.ts
@@ -0,0 +1,28 @@
+// *****************************************************************************
+// Copyright (C) 2023 TypeFox and others.
+//
+// This program and the accompanying materials are made available under the
+// terms of the Eclipse Public License v. 2.0 which is available at
+// http://www.eclipse.org/legal/epl-2.0.
+//
+// This Source Code may also be made available under the following Secondary
+// Licenses when the conditions for such availability set forth in the Eclipse
+// Public License v. 2.0 are satisfied: GNU General Public License, version 2
+// with the GNU Classpath Exception which is available at
+// https://www.gnu.org/software/classpath/license.html.
+//
+// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
+// *****************************************************************************
+
+/* note: this bogus test file is required so that
+ we are able to run mocha unit tests on this
+ package, without having any actual unit tests in it.
+ This way a coverage report will be generated,
+ showing 0% coverage, instead of no report.
+ This file can be removed once we have real unit
+ tests in place. */
+
+describe('request package', () => {
+
+ it('should support code coverage statistics', () => true);
+});
diff --git a/packages/collaboration/tsconfig.json b/packages/collaboration/tsconfig.json
new file mode 100644
index 0000000000000..5920c2dd0ba35
--- /dev/null
+++ b/packages/collaboration/tsconfig.json
@@ -0,0 +1,28 @@
+{
+ "extends": "../../configs/base.tsconfig",
+ "compilerOptions": {
+ "composite": true,
+ "rootDir": "src",
+ "outDir": "lib"
+ },
+ "include": [
+ "src"
+ ],
+ "references": [
+ {
+ "path": "../core"
+ },
+ {
+ "path": "../editor"
+ },
+ {
+ "path": "../filesystem"
+ },
+ {
+ "path": "../monaco"
+ },
+ {
+ "path": "../workspace"
+ }
+ ]
+}
diff --git a/packages/editor/src/browser/editor-manager.ts b/packages/editor/src/browser/editor-manager.ts
index 1da41c8a31246..50cf0d7d2bc03 100644
--- a/packages/editor/src/browser/editor-manager.ts
+++ b/packages/editor/src/browser/editor-manager.ts
@@ -268,7 +268,10 @@ export class EditorManager extends NavigatableWidgetOpenHandler {
editor.revealPosition(selection);
} else if (Range.is(selection)) {
editor.cursor = selection.end;
- editor.selection = selection;
+ editor.selection = {
+ ...selection,
+ direction: 'ltr'
+ };
editor.revealRange(selection);
}
}
diff --git a/packages/editor/src/browser/editor.ts b/packages/editor/src/browser/editor.ts
index 0613b7b60936f..0ee8222ac59f9 100644
--- a/packages/editor/src/browser/editor.ts
+++ b/packages/editor/src/browser/editor.ts
@@ -216,8 +216,8 @@ export interface TextEditor extends Disposable, TextEditorSelection, Navigatable
cursor: Position;
readonly onCursorPositionChanged: Event;
- selection: Range;
- readonly onSelectionChanged: Event;
+ selection: Selection;
+ readonly onSelectionChanged: Event;
/**
* The text editor should be revealed,
@@ -297,6 +297,10 @@ export interface TextEditor extends Disposable, TextEditorSelection, Navigatable
shouldDisplayDirtyDiff(): boolean;
}
+export interface Selection extends Range {
+ direction: 'ltr' | 'rtl';
+}
+
export interface Dimension {
width: number;
height: number;
diff --git a/packages/monaco/src/browser/monaco-editor-model.ts b/packages/monaco/src/browser/monaco-editor-model.ts
index b1ebdbaffe75f..9e0651118a8ba 100644
--- a/packages/monaco/src/browser/monaco-editor-model.ts
+++ b/packages/monaco/src/browser/monaco-editor-model.ts
@@ -14,7 +14,7 @@
// SPDX-License-Identifier: EPL-2.0 OR GPL-2.0-only WITH Classpath-exception-2.0
// *****************************************************************************
-import { Position, Range, TextDocumentSaveReason, TextDocumentContentChangeEvent } from '@theia/core/shared/vscode-languageserver-protocol';
+import { Position, Range, TextDocumentSaveReason } from '@theia/core/shared/vscode-languageserver-protocol';
import { TextEditorDocument, EncodingMode, FindMatchesOptions, FindMatch, EditorPreferences } from '@theia/editor/lib/browser';
import { DisposableCollection, Disposable } from '@theia/core/lib/common/disposable';
import { Emitter, Event } from '@theia/core/lib/common/event';
@@ -48,7 +48,14 @@ export interface WillSaveMonacoModelEvent {
export interface MonacoModelContentChangedEvent {
readonly model: MonacoEditorModel;
- readonly contentChanges: TextDocumentContentChangeEvent[];
+ readonly contentChanges: MonacoTextDocumentContentChange[];
+}
+
+export interface MonacoTextDocumentContentChange {
+ readonly range: Range;
+ readonly rangeOffset: number;
+ readonly rangeLength: number;
+ readonly text: string;
}
export class MonacoEditorModel implements IResolvedTextEditorModel, TextEditorDocument {
@@ -479,8 +486,8 @@ export class MonacoEditorModel implements IResolvedTextEditorModel, TextEditorDo
}
protected ignoreContentChanges = false;
- protected readonly contentChanges: TextDocumentContentChangeEvent[] = [];
- protected pushContentChanges(contentChanges: TextDocumentContentChangeEvent[]): void {
+ protected readonly contentChanges: MonacoTextDocumentContentChange[] = [];
+ protected pushContentChanges(contentChanges: MonacoTextDocumentContentChange[]): void {
if (!this.ignoreContentChanges) {
this.contentChanges.push(...contentChanges);
}
@@ -503,11 +510,12 @@ export class MonacoEditorModel implements IResolvedTextEditorModel, TextEditorDo
const contentChanges = event.changes.map(change => this.asTextDocumentContentChangeEvent(change));
return { model: this, contentChanges };
}
- protected asTextDocumentContentChangeEvent(change: monaco.editor.IModelContentChange): TextDocumentContentChangeEvent {
+ protected asTextDocumentContentChangeEvent(change: monaco.editor.IModelContentChange): MonacoTextDocumentContentChange {
const range = this.m2p.asRange(change.range);
+ const rangeOffset = change.rangeOffset;
const rangeLength = change.rangeLength;
const text = change.text;
- return { range, rangeLength, text };
+ return { range, rangeOffset, rangeLength, text };
}
protected applyEdits(
diff --git a/packages/monaco/src/browser/monaco-editor.ts b/packages/monaco/src/browser/monaco-editor.ts
index 5df77abe242ca..069e5ae66b4cf 100644
--- a/packages/monaco/src/browser/monaco-editor.ts
+++ b/packages/monaco/src/browser/monaco-editor.ts
@@ -62,6 +62,7 @@ import { IAccessibilityService } from '@theia/monaco-editor-core/esm/vs/platform
import { ILanguageConfigurationService } from '@theia/monaco-editor-core/esm/vs/editor/common/languages/languageConfigurationRegistry';
import { ILanguageFeaturesService } from '@theia/monaco-editor-core/esm/vs/editor/common/services/languageFeatures';
import * as objects from '@theia/monaco-editor-core/esm/vs/base/common/objects';
+import { Selection } from '@theia/editor/lib/browser/editor';
export type ServicePair = [ServiceIdentifier, T];
@@ -94,7 +95,7 @@ export class MonacoEditor extends MonacoEditorServices implements TextEditor {
protected editor: monaco.editor.IStandaloneCodeEditor;
protected readonly onCursorPositionChangedEmitter = new Emitter();
- protected readonly onSelectionChangedEmitter = new Emitter();
+ protected readonly onSelectionChangedEmitter = new Emitter();
protected readonly onFocusChangedEmitter = new Emitter();
protected readonly onDocumentContentChangedEmitter = new Emitter();
protected readonly onMouseDownEmitter = new Emitter();
@@ -192,8 +193,11 @@ export class MonacoEditor extends MonacoEditorServices implements TextEditor {
this.toDispose.push(codeEditor.onDidChangeCursorPosition(() =>
this.onCursorPositionChangedEmitter.fire(this.cursor)
));
- this.toDispose.push(codeEditor.onDidChangeCursorSelection(() =>
- this.onSelectionChangedEmitter.fire(this.selection)
+ this.toDispose.push(codeEditor.onDidChangeCursorSelection(event =>
+ this.onSelectionChangedEmitter.fire({
+ ...this.m2p.asRange(event.selection),
+ direction: event.selection.getDirection() === monaco.SelectionDirection.LTR ? 'ltr' : 'rtl'
+ })
));
this.toDispose.push(codeEditor.onDidFocusEditorText(() =>
this.onFocusChangedEmitter.fire(this.isFocused())
@@ -261,16 +265,16 @@ export class MonacoEditor extends MonacoEditorServices implements TextEditor {
return this.onCursorPositionChangedEmitter.event;
}
- get selection(): Range {
- return this.m2p.asRange(this.editor.getSelection()!);
+ get selection(): Selection {
+ return this.m2p.asSelection(this.editor.getSelection()!);
}
- set selection(selection: Range) {
+ set selection(selection: Selection) {
const range = this.p2m.asRange(selection);
this.editor.setSelection(range);
}
- get onSelectionChanged(): Event {
+ get onSelectionChanged(): Event {
return this.onSelectionChangedEmitter.event;
}
diff --git a/packages/monaco/src/browser/monaco-quick-input-service.ts b/packages/monaco/src/browser/monaco-quick-input-service.ts
index 777649ceb87b6..a611b6fe33e21 100644
--- a/packages/monaco/src/browser/monaco-quick-input-service.ts
+++ b/packages/monaco/src/browser/monaco-quick-input-service.ts
@@ -375,21 +375,6 @@ export class MonacoQuickInputService implements QuickInputService {
wrapped.activeItems = [options.activeItem];
}
- wrapped.onDidAccept(() => {
- if (options?.onDidAccept) {
- options.onDidAccept();
- }
- wrapped.hide();
- resolve(wrapped.selectedItems[0]);
- });
-
- wrapped.onDidHide(() => {
- if (options.onDidHide) {
- options.onDidHide();
- };
- wrapped.dispose();
- setTimeout(() => resolve(undefined));
- });
wrapped.onDidChangeValue((filter: string) => {
if (options.onDidChangeValue) {
options.onDidChangeValue(wrapped, filter);
@@ -425,6 +410,20 @@ export class MonacoQuickInputService implements QuickInputService {
}
});
}
+ wrapped.onDidAccept(() => {
+ if (options?.onDidAccept) {
+ options.onDidAccept();
+ }
+ wrapped.hide();
+ resolve(wrapped.selectedItems[0]);
+ });
+ wrapped.onDidHide(() => {
+ if (options?.onDidHide) {
+ options?.onDidHide();
+ };
+ wrapped.dispose();
+ setTimeout(() => resolve(undefined));
+ });
wrapped.show();
}).then(item => {
if (item?.execute) {
diff --git a/packages/monaco/src/browser/monaco-to-protocol-converter.ts b/packages/monaco/src/browser/monaco-to-protocol-converter.ts
index 4b054e8fb8ab5..c88629e92b5a5 100644
--- a/packages/monaco/src/browser/monaco-to-protocol-converter.ts
+++ b/packages/monaco/src/browser/monaco-to-protocol-converter.ts
@@ -18,6 +18,7 @@ import { injectable } from '@theia/core/shared/inversify';
import { Position, Range } from '@theia/core/shared/vscode-languageserver-protocol';
import { RecursivePartial } from '@theia/core/lib/common/types';
import * as monaco from '@theia/monaco-editor-core';
+import { Selection } from '@theia/editor/lib/browser';
export interface MonacoRangeReplace {
insert: monaco.IRange;
@@ -68,4 +69,14 @@ export class MonacoToProtocolConverter {
}
}
+ asSelection(selection: monaco.Selection): Selection {
+ const start = this.asPosition(selection.selectionStartLineNumber, selection.selectionStartColumn);
+ const end = this.asPosition(selection.positionLineNumber, selection.positionColumn);
+ return {
+ start,
+ end,
+ direction: selection.getDirection() === monaco.SelectionDirection.LTR ? 'ltr' : 'rtl'
+ };
+ }
+
}
diff --git a/packages/preview/src/browser/preview-contribution.ts b/packages/preview/src/browser/preview-contribution.ts
index 18f105327ba91..048af03188dbf 100644
--- a/packages/preview/src/browser/preview-contribution.ts
+++ b/packages/preview/src/browser/preview-contribution.ts
@@ -163,7 +163,10 @@ export class PreviewContribution extends NavigatableWidgetOpenHandler {
const { editor } = await this.openSource(ref);
editor.revealPosition(location.range.start);
- editor.selection = location.range;
+ editor.selection = {
+ ...location.range,
+ direction: 'ltr'
+ };
ref.revealForSourceLine(location.range.start.line);
});
ref.disposed.connect(() => disposable.dispose());
diff --git a/packages/workspace/src/browser/workspace-commands.ts b/packages/workspace/src/browser/workspace-commands.ts
index a60ea862efc84..f000ffacf755f 100644
--- a/packages/workspace/src/browser/workspace-commands.ts
+++ b/packages/workspace/src/browser/workspace-commands.ts
@@ -544,8 +544,8 @@ export class WorkspaceRootUriAwareCommandHandler extends UriAwareCommandHandler<
// eslint-disable-next-line @typescript-eslint/no-explicit-any
protected override getUri(...args: any[]): URI | undefined {
const uri = super.getUri(...args);
- // Return the `uri` immediately if the resource exists in any of the workspace roots and is of `file` scheme.
- if (uri && uri.scheme === 'file' && this.workspaceService.getWorkspaceRootUri(uri)) {
+ // Return the `uri` immediately if the resource exists in any of the workspace roots.
+ if (uri && this.workspaceService.getWorkspaceRootUri(uri)) {
return uri;
}
// Return the first root if available.
diff --git a/packages/workspace/src/browser/workspace-service.ts b/packages/workspace/src/browser/workspace-service.ts
index 178bd9cf98141..44eeb15e58043 100644
--- a/packages/workspace/src/browser/workspace-service.ts
+++ b/packages/workspace/src/browser/workspace-service.ts
@@ -660,7 +660,7 @@ export class WorkspaceService implements FrontendApplicationContribution {
const rootUris: URI[] = [];
for (const root of this.tryGetRoots()) {
const rootUri = root.resource;
- if (rootUri && rootUri.isEqualOrParent(uri)) {
+ if (rootUri && rootUri.scheme === uri.scheme && rootUri.isEqualOrParent(uri)) {
rootUris.push(rootUri);
}
}
diff --git a/tsconfig.json b/tsconfig.json
index fec10e328dcec..53c3b18c75a75 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -60,6 +60,9 @@
{
"path": "packages/callhierarchy"
},
+ {
+ "path": "packages/collaboration"
+ },
{
"path": "packages/console"
},
diff --git a/yarn.lock b/yarn.lock
index f38c8d1ebaa33..3631e45e52b4c 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3362,7 +3362,7 @@ balanced-match@^1.0.0:
resolved "https://registry.yarnpkg.com/balanced-match/-/balanced-match-1.0.2.tgz#e83e3a7e3f300b34cb9d87f615fa0cbf357690ee"
integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
-base64-js@^1.3.1:
+base64-js@^1.3.1, base64-js@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a"
integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==
@@ -5666,6 +5666,11 @@ fd-slicer@~1.1.0:
dependencies:
pend "~1.2.0"
+fflate@^0.8.2:
+ version "0.8.2"
+ resolved "https://registry.yarnpkg.com/fflate/-/fflate-0.8.2.tgz#fc8631f5347812ad6028bbe4a2308b2792aa1dea"
+ integrity sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==
+
figures@3.2.0, figures@^3.0.0:
version "3.2.0"
resolved "https://registry.yarnpkg.com/figures/-/figures-3.2.0.tgz#625c18bd293c604dc4a8ddb2febf0c88341746af"
@@ -7117,6 +7122,11 @@ isobject@^3.0.1:
resolved "https://registry.yarnpkg.com/isobject/-/isobject-3.0.1.tgz#4e431e92b11a9731636aa1f9c8d1ccbcfdab78df"
integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg==
+isomorphic.js@^0.2.4:
+ version "0.2.5"
+ resolved "https://registry.yarnpkg.com/isomorphic.js/-/isomorphic.js-0.2.5.tgz#13eecf36f2dba53e85d355e11bf9d4208c6f7f88"
+ integrity sha512-PIeMbHqMt4DnUP3MA/Flc0HElYjMXArsw1qwJZcm9sqR8mq3l8NYizFMty0pWwE/tzIGH3EKK5+jes5mAr85yw==
+
istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0:
version "3.2.2"
resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz#2d166c4b0644d43a39f04bf6c2edd1e585f31756"
@@ -7585,6 +7595,20 @@ levn@^0.4.1:
prelude-ls "^1.2.1"
type-check "~0.4.0"
+lib0@^0.2.52, lib0@^0.2.94:
+ version "0.2.94"
+ resolved "https://registry.yarnpkg.com/lib0/-/lib0-0.2.94.tgz#fc28b4b65f816599f1e2f59d3401e231709535b3"
+ integrity sha512-hZ3p54jL4Wpu7IOg26uC7dnEWiMyNlUrb9KoG7+xYs45WkQwpVvKFndVq2+pqLYKe1u8Fp3+zAfZHVvTK34PvQ==
+ dependencies:
+ isomorphic.js "^0.2.4"
+
+lib0@^0.2.85, lib0@^0.2.86:
+ version "0.2.93"
+ resolved "https://registry.yarnpkg.com/lib0/-/lib0-0.2.93.tgz#95487c2a97657313cb1d91fbcf9f6d64b7fcd062"
+ integrity sha512-M5IKsiFJYulS+8Eal8f+zAqf5ckm1vffW0fFDxfgxJ+uiVopvDdd3PxJmz0GsVi3YNO7QCFSq0nAsiDmNhLj9Q==
+ dependencies:
+ isomorphic.js "^0.2.4"
+
libnpmaccess@7.0.2:
version "7.0.2"
resolved "https://registry.yarnpkg.com/libnpmaccess/-/libnpmaccess-7.0.2.tgz#7f056c8c933dd9c8ba771fa6493556b53c5aac52"
@@ -9016,6 +9040,26 @@ onetime@^5.1.0, onetime@^5.1.2:
dependencies:
mimic-fn "^2.1.0"
+open-collaboration-protocol@0.2.0, open-collaboration-protocol@^0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/open-collaboration-protocol/-/open-collaboration-protocol-0.2.0.tgz#f3f93f22bb5fbb46e3fd31e6bb87f52a9ce6526b"
+ integrity sha512-ZaLMTMyVoJJ0vPjoMXGhNZqiycbfyJPbNCkbI9uHTOYRsvZqreRAFhSd7p9RbxLJNS5xeQGNSfldrhhec94Bmg==
+ dependencies:
+ base64-js "^1.5.1"
+ fflate "^0.8.2"
+ msgpackr "^1.10.2"
+ semver "^7.6.2"
+ socket.io-client "^4.7.5"
+
+open-collaboration-yjs@0.2.0:
+ version "0.2.0"
+ resolved "https://registry.yarnpkg.com/open-collaboration-yjs/-/open-collaboration-yjs-0.2.0.tgz#7c7e30dba444b9f6947fe76ae02a7c3fdaec6172"
+ integrity sha512-HT2JU/HJObIaQMF/MHt5/5VdOnGn+bVTaTJnyYfyaa/vjqg4Z4Glas3Hc9Ua970ssP3cOIRUQoHQumM0giaxrw==
+ dependencies:
+ lib0 "^0.2.94"
+ open-collaboration-protocol "^0.2.0"
+ y-protocols "^1.0.6"
+
open@^7.4.2:
version "7.4.2"
resolved "https://registry.yarnpkg.com/open/-/open-7.4.2.tgz#b8147e26dcf3e426316c730089fd71edd29c2321"
@@ -10474,6 +10518,11 @@ semver@^7.0.0, semver@^7.1.1, semver@^7.2.1, semver@^7.3.2, semver@^7.3.4, semve
dependencies:
lru-cache "^6.0.0"
+semver@^7.6.2:
+ version "7.6.2"
+ resolved "https://registry.yarnpkg.com/semver/-/semver-7.6.2.tgz#1e3b34759f896e8f14d6134732ce798aeb0c6e13"
+ integrity sha512-FNAIBWCx9qcRhoHcgcJ0gvU7SN1lYU2ZXuSfl04bSC5OpvDHFyJCjdNHomPXxjQlCBU67YW64PzY7/VIEH7F2w==
+
send@0.18.0:
version "0.18.0"
resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be"
@@ -10735,6 +10784,16 @@ socket.io-client@^4.5.3:
engine.io-client "~6.5.2"
socket.io-parser "~4.2.4"
+socket.io-client@^4.7.5:
+ version "4.7.5"
+ resolved "https://registry.yarnpkg.com/socket.io-client/-/socket.io-client-4.7.5.tgz#919be76916989758bdc20eec63f7ee0ae45c05b7"
+ integrity sha512-sJ/tqHOCe7Z50JCBCXrsY3I2k03iOiUe+tj1OmKeD2lXPiGH/RUCdTZFoqVyN7l1MnpIzPrGtLcijffmeouNlQ==
+ dependencies:
+ "@socket.io/component-emitter" "~3.1.0"
+ debug "~4.3.2"
+ engine.io-client "~6.5.2"
+ socket.io-parser "~4.2.4"
+
socket.io-parser@~4.2.4:
version "4.2.4"
resolved "https://registry.yarnpkg.com/socket.io-parser/-/socket.io-parser-4.2.4.tgz#c806966cf7270601e47469ddeec30fbdfda44c83"
@@ -12433,6 +12492,13 @@ xterm@^5.3.0:
resolved "https://registry.yarnpkg.com/xterm/-/xterm-5.3.0.tgz#867daf9cc826f3d45b5377320aabd996cb0fce46"
integrity sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg==
+y-protocols@^1.0.6:
+ version "1.0.6"
+ resolved "https://registry.yarnpkg.com/y-protocols/-/y-protocols-1.0.6.tgz#66dad8a95752623443e8e28c0e923682d2c0d495"
+ integrity sha512-vHRF2L6iT3rwj1jub/K5tYcTT/mEYDUppgNPXwp8fmLpui9f7Yeq3OEtTLVF012j39QnV+KEQpNqoN7CWU7Y9Q==
+ dependencies:
+ lib0 "^0.2.85"
+
y18n@^4.0.0:
version "4.0.3"
resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.3.tgz#b5f259c82cd6e336921efd7bfd8bf560de9eeedf"
@@ -12554,6 +12620,13 @@ yazl@^2.2.2:
dependencies:
buffer-crc32 "~0.2.3"
+yjs@^13.6.7:
+ version "13.6.15"
+ resolved "https://registry.yarnpkg.com/yjs/-/yjs-13.6.15.tgz#5a2402632aabf83e5baf56342b4c82fe40859306"
+ integrity sha512-moFv4uNYhp8BFxIk3AkpoAnnjts7gwdpiG8RtyFiKbMtxKCS0zVZ5wPaaGpwC3V2N/K8TK8MwtSI3+WO9CHWjQ==
+ dependencies:
+ lib0 "^0.2.86"
+
yocto-queue@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"