deploy: current vibn theia state
Made-with: Cursor
This commit is contained in:
10
packages/collaboration/.eslintrc.js
Normal file
10
packages/collaboration/.eslintrc.js
Normal file
@@ -0,0 +1,10 @@
|
||||
/** @type {import('eslint').Linter.Config} */
|
||||
module.exports = {
|
||||
extends: [
|
||||
'../../configs/build.eslintrc.json'
|
||||
],
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: 'tsconfig.json'
|
||||
}
|
||||
};
|
||||
34
packages/collaboration/README.md
Normal file
34
packages/collaboration/README.md
Normal 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>
|
||||
59
packages/collaboration/package.json
Normal file
59
packages/collaboration/package.json
Normal 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"
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 });
|
||||
});
|
||||
819
packages/collaboration/src/browser/collaboration-instance.ts
Normal file
819
packages/collaboration/src/browser/collaboration-instance.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
59
packages/collaboration/src/browser/collaboration-utils.ts
Normal file
59
packages/collaboration/src/browser/collaboration-utils.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
22
packages/collaboration/src/browser/style/index.css
Normal file
22
packages/collaboration/src/browser/style/index.css
Normal 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;
|
||||
}
|
||||
@@ -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'),
|
||||
};
|
||||
@@ -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 });
|
||||
});
|
||||
28
packages/collaboration/src/package.spec.ts
Normal file
28
packages/collaboration/src/package.spec.ts
Normal 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);
|
||||
});
|
||||
28
packages/collaboration/tsconfig.json
Normal file
28
packages/collaboration/tsconfig.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user