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 @@ +
+ +
+ +theia-ext-logo + +

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"