deploy: current vibn theia state
Some checks failed
Playwright Tests / Playwright Tests (ubuntu-22.04, Node.js 22.x) (push) Has been cancelled
3PP License Check / 3PP License Check (11, 22.x, ubuntu-22.04) (push) Has been cancelled
Publish packages to NPM / Perform Publishing (push) Has been cancelled

Made-with: Cursor
This commit is contained in:
2026-02-27 12:01:08 -08:00
commit 8bb5110148
3782 changed files with 640947 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
/** @type {import('eslint').Linter.Config} */
module.exports = {
extends: [
'../../configs/build.eslintrc.json'
],
parserOptions: {
tsconfigRootDir: __dirname,
project: 'tsconfig.json'
}
};

View File

@@ -0,0 +1,34 @@
<div align='center'>
<br />
<img src='https://raw.githubusercontent.com/eclipse-theia/theia/master/logo/theia.svg?sanitize=true' alt='theia-ext-logo' width='100px' />
<h2>ECLIPSE THEIA - COLLABORATION EXTENSION</h2>
<hr />
</div>
## 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/_theia_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>

View File

@@ -0,0 +1,59 @@
{
"name": "@theia/collaboration",
"version": "1.68.0",
"description": "Theia - Collaboration Extension",
"dependencies": {
"@theia/core": "1.68.0",
"@theia/editor": "1.68.0",
"@theia/filesystem": "1.68.0",
"@theia/monaco": "1.68.0",
"@theia/monaco-editor-core": "1.96.302",
"@theia/workspace": "1.68.0",
"lib0": "^0.2.52",
"open-collaboration-protocol": "0.3.0",
"open-collaboration-yjs": "0.3.0",
"socket.io-client": "^4.5.3",
"y-protocols": "^1.0.6",
"yjs": "^13.6.7"
},
"publishConfig": {
"access": "public"
},
"theiaExtensions": [
{
"frontend": "lib/browser/collaboration-frontend-module",
"backend": "lib/node/collaboration-backend-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.68.0"
},
"nyc": {
"extends": "../../configs/nyc.json"
},
"gitHead": "21358137e41342742707f660b8e222f940a27652"
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,117 @@
// *****************************************************************************
// 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 {
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 : ''}`);
}
}
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<void>();
protected onDidChangeFileEmitter = new Emitter<readonly FileChange[]>();
protected onFileWatchErrorEmitter = new Emitter<void>();
get onDidChangeCapabilities(): Event<void> {
return this.onDidChangeCapabilitiesEmitter.event;
}
get onDidChangeFile(): Event<readonly FileChange[]> {
return this.onDidChangeFileEmitter.event;
}
get onFileWatchError(): Event<void> {
return this.onFileWatchErrorEmitter.event;
}
async readFile(resource: URI): Promise<Uint8Array> {
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<void> {
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<Stat> {
return this.connection.fs.stat(this.host.id, this.getHostPath(resource));
}
mkdir(resource: URI): Promise<void> {
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<void> {
return this.connection.fs.delete(this.host.id, this.getHostPath(resource));
}
rename(from: URI, to: URI, opts: FileOverwriteOptions): Promise<void> {
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);
}
}

View File

@@ -0,0 +1,430 @@
// *****************************************************************************
// 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, PreferenceService, Progress, QuickInputService, QuickPickItem,
URI
} from '@theia/core';
import { inject, injectable, optional, postConstruct } from '@theia/core/shared/inversify';
import { AuthMetadata, AuthProvider, ConnectionProvider, FormAuthProvider, initializeProtocol, SocketIoTransportProvider, WebAuthProvider } 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 { 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';
initializeProtocol({
cryptoModule: window.crypto
});
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 SIGN_OUT: Command = {
id: 'collaboration.sign-out',
label: nls.localizeByDefault('Sign Out'),
category: COLLABORATION_CATEGORY,
};
}
export interface CollaborationAuthQuickPickItem extends QuickPickItem {
provider: AuthProvider;
}
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 {
@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(PreferenceService)
protected readonly preferenceService: PreferenceService;
@inject(StatusBar)
protected readonly statusBar: StatusBar;
@inject(CollaborationInstanceFactory)
protected readonly collaborationInstanceFactory: CollaborationInstanceFactory;
protected currentInstance?: CollaborationInstance;
@postConstruct()
protected init(): void {
this.setStatusBarEntryDefault();
}
protected async createConnectionProvider(): Promise<ConnectionProvider> {
const serverUrl = await this.getCollaborationServerUrl();
return new ConnectionProvider({
url: serverUrl,
client: FrontendApplicationConfigProvider.get().applicationName,
fetch: window.fetch.bind(window),
authenticationHandler: (token, meta) => this.handleAuth(serverUrl, token, meta),
transports: [SocketIoTransportProvider],
userToken: localStorage.getItem(COLLABORATION_AUTH_TOKEN) ?? undefined
});
}
protected async handleAuth(serverUrl: string, token: string, metaData: AuthMetadata): Promise<boolean> {
const hasAuthProviders = Boolean(metaData.providers.length);
if (!hasAuthProviders && metaData.loginPageUrl) {
if (metaData.loginPageUrl) {
this.windowService.openNewWindow(metaData.loginPageUrl, { external: true });
return true;
} else {
this.messageService.error(nls.localize('theia/collaboration/noAuth', 'No authentication method provided by the server.'));
return false;
}
}
if (!this.quickInputService) {
return false;
}
const quickPickItems: CollaborationAuthQuickPickItem[] = metaData.providers.map(provider => ({
label: provider.label.message,
detail: provider.details?.message,
provider
}));
const item = await this.quickInputService.pick(quickPickItems, {
title: nls.localize('theia/collaboration/selectAuth', 'Select Authentication Method'),
});
if (item) {
switch (item.provider.type) {
case 'form':
return this.handleFormAuth(serverUrl, token, item.provider);
case 'web':
return this.handleWebAuth(serverUrl, token, item.provider);
}
}
return false;
}
protected async handleFormAuth(serverUrl: string, token: string, provider: FormAuthProvider): Promise<boolean> {
const fields = provider.fields;
const values: Record<string, string> = {
token
};
for (const field of fields) {
let placeHolder: string;
if (field.placeHolder) {
placeHolder = field.placeHolder.message;
} else {
placeHolder = field.label.message;
}
placeHolder += field.required ? '' : ` (${nls.localize('theia/collaboration/optional', 'optional')})`;
const value = await this.quickInputService!.input({
prompt: field.label.message,
placeHolder,
});
// Test for thruthyness to also test for empty string
if (value) {
values[field.name] = value;
} else if (field.required) {
this.messageService.error(nls.localize('theia/collaboration/fieldRequired', 'The {0} field is required. Login aborted.', field.label.message));
return false;
}
}
const endpointUrl = new URI(serverUrl).withPath(provider.endpoint);
const response = await fetch(endpointUrl.toString(true), {
method: 'POST',
body: JSON.stringify(values),
headers: {
'Content-Type': 'application/json'
}
});
if (response.ok) {
this.messageService.info(nls.localize('theia/collaboration/loginSuccessful', 'Login successful.'));
} else {
this.messageService.error(nls.localize('theia/collaboration/loginFailed', 'Login failed.'));
}
return response.ok;
}
protected async handleWebAuth(serverUrl: string, token: string, provider: WebAuthProvider): Promise<boolean> {
const uri = new URI(serverUrl).withPath(provider.endpoint).withQuery('token=' + token);
this.windowService.openNewWindow(uri.toString(true), { external: true });
return true;
}
protected async onStatusDefaultClick(): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<StatusBarEntry, 'alignment'>): Promise<void> {
await this.statusBar.setElement(COLLABORATION_STATUS_BAR_ID, {
...entry,
alignment: StatusBarAlignment.LEFT,
priority: 5
});
}
protected async getCollaborationServerUrl(): Promise<string> {
const serverUrlVariable = await this.envVariables.getValue(COLLABORATION_SERVER_URL);
const serverUrlPreference = this.preferenceService.get<string>('collaboration.serverUrl');
return serverUrlVariable?.value || serverUrlPreference || 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.createConnectionProvider();
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.createConnectionProvider();
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));
}
}
});
commands.registerCommand(CollaborationCommands.SIGN_OUT, {
execute: async () => {
localStorage.removeItem(COLLABORATION_AUTH_TOKEN);
}
});
}
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<void> {
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
);
}
}
}

View File

@@ -0,0 +1,40 @@
// *****************************************************************************
// 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, PreferenceContribution } 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';
import { collaborationPreferencesSchema } from '../common/collaboration-preferences';
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();
bind(PreferenceContribution).toConstantValue({ schema: collaborationPreferencesSchema });
});

View File

@@ -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<types.Peer>();
protected peers = new Map<string, DisposablePeer>();
protected yjs = new Y.Doc();
protected yjsAwareness = new awarenessProtocol.Awareness(this.yjs);
protected yjsProvider: OpenCollaborationYjsProvider;
protected colorIndex = 0;
protected editorDecorations = new Map<EditorWidget, string[]>();
protected fileSystem?: CollaborationFileSystemProvider;
protected permissions: types.Permissions = {
readonly: false
};
protected onDidCloseEmitter = new Emitter<void>();
get onDidClose(): Event<void> {
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<string, types.FileType> = {};
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<string, EditorDecoration[]>();
const states = this.yjsAwareness.getStates() as Map<number, types.ClientAwareness>;
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<string, EditorDecoration[]>, ...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<void> {
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<void> {
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<void> {
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();
}
}

View File

@@ -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;
}
}
}

View File

@@ -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<Disposable> {
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<FileStat[]> {
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
};
}
}

View File

@@ -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;
}

View File

@@ -0,0 +1,29 @@
// *****************************************************************************
// Copyright (C) 2025 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, PreferenceSchema } from '@theia/core';
export const collaborationPreferencesSchema: PreferenceSchema = {
properties: {
'collaboration.serverUrl': {
type: 'string',
default: 'https://api.open-collab.tools/',
title: nls.localize('theia/collaboration/serverUrl', 'Server URL'),
description: nls.localize('theia/collaboration/serverUrlDescription', 'URL of the Open Collaboration Tools Server instance for live collaboration sessions'),
},
},
title: nls.localize('theia/collaboration/collaboration', 'Collaboration'),
};

View File

@@ -0,0 +1,23 @@
// *****************************************************************************
// Copyright (C) 2025 STMicroelectronics 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 { PreferenceContribution } from '@theia/core';
import { ContainerModule } from '@theia/core/shared/inversify';
import { collaborationPreferencesSchema } from '../common/collaboration-preferences';
export default new ContainerModule(bind => {
bind(PreferenceContribution).toConstantValue({ schema: collaborationPreferencesSchema });
});

View File

@@ -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);
});

View File

@@ -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"
}
]
}