diff --git a/package-lock.json b/package-lock.json index c032b38..547f348 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1304,6 +1304,28 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@microsoft/servicehub-framework": { + "version": "2.6.74", + "resolved": "https://registry.npmjs.org/@microsoft/servicehub-framework/-/servicehub-framework-2.6.74.tgz", + "integrity": "sha512-QJ//zzvxffupIkzupnVbMYY5YDOP+g5FlG6x0Pl7svRyq8pAouiibckJJcZlMtsMypKWwAnVBKb9/sonEOsUxw==", + "license": "LICENSE.txt", + "dependencies": { + "await-semaphore": "^0.1.3", + "msgpack-lite": "^0.1.26", + "nerdbank-streams": "2.5.60", + "strict-event-emitter-types": "^2.0.0", + "vscode-jsonrpc": "^4.0.0" + } + }, + "node_modules/@microsoft/servicehub-framework/node_modules/vscode-jsonrpc": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-4.0.0.tgz", + "integrity": "sha512-perEnXQdQOJMTDFNv+UF3h1Y0z4iSiaN9jIlb0OqIYgosPCZGYh/MCUlkFtV2668PL69lRDO32hmvL2yiidUYg==", + "license": "MIT", + "engines": { + "node": ">=8.0.0 || >=10.0.0" + } + }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", @@ -3657,6 +3679,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/await-semaphore": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/await-semaphore/-/await-semaphore-0.1.3.tgz", + "integrity": "sha512-d1W2aNSYcz/sxYO4pMGX9vq65qOTu0P800epMud+6cYYX0QcT7zyqcxec3VWzpgvdXo57UWmVbZpLMjX2m1I7Q==", + "license": "MIT" + }, "node_modules/axios": { "version": "1.13.2", "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.2.tgz", @@ -3984,6 +4012,18 @@ "node": ">=6" } }, + "node_modules/cancellationtoken": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/cancellationtoken/-/cancellationtoken-2.2.0.tgz", + "integrity": "sha512-uF4sHE5uh2VdEZtIRJKGoXAD9jm7bFY0tDRCzH4iLp262TOJ2lrtNHjMG2zc8H+GICOpELIpM7CGW5JeWnb3Hg==", + "license": "MIT" + }, + "node_modules/caught": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/caught/-/caught-0.1.3.tgz", + "integrity": "sha512-DTWI84qfoqHEV5jHRpsKNnEisVCeuBDscXXaXyRLXC+4RD6rFftUNuTElcQ7LeO7w622pfzWkA1f6xu5qEAidw==", + "license": "MIT" + }, "node_modules/chai": { "version": "5.3.3", "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", @@ -5626,6 +5666,12 @@ "node": ">= 0.6" } }, + "node_modules/event-lite": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/event-lite/-/event-lite-0.1.3.tgz", + "integrity": "sha512-8qz9nOz5VeD2z96elrEKD2U433+L3DWdUdDkOINLGOJvx1GsMBbMn0aCeu28y8/e85A6mCigBiFlYMnTBEGlSw==", + "license": "MIT" + }, "node_modules/execa": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/execa/-/execa-1.0.0.tgz", @@ -6715,7 +6761,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", - "dev": true, "funding": [ { "type": "github", @@ -6730,8 +6775,7 @@ "url": "https://feross.org/support" } ], - "license": "BSD-3-Clause", - "optional": true + "license": "BSD-3-Clause" }, "node_modules/ignore": { "version": "5.3.2", @@ -6797,6 +6841,12 @@ "license": "ISC", "optional": true }, + "node_modules/int64-buffer": { + "version": "0.1.10", + "resolved": "https://registry.npmjs.org/int64-buffer/-/int64-buffer-0.1.10.tgz", + "integrity": "sha512-v7cSY1J8ydZ0GyjUHqF+1bshJ6cnEVLo9EnjB8p+4HDRPZc9N5jjmvUV7NvEsqQOKyH0pmIBFWXVQbiS0+OBbA==", + "license": "MIT" + }, "node_modules/internal-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", @@ -8031,6 +8081,27 @@ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/msgpack-lite": { + "version": "0.1.26", + "resolved": "https://registry.npmjs.org/msgpack-lite/-/msgpack-lite-0.1.26.tgz", + "integrity": "sha512-SZ2IxeqZ1oRFGo0xFGbvBJWMp3yLIY9rlIJyxy8CGrwZn1f0ZK4r6jV/AM1r0FZMDUkWkglOk/eeKIL9g77Nxw==", + "license": "MIT", + "dependencies": { + "event-lite": "^0.1.1", + "ieee754": "^1.1.8", + "int64-buffer": "^0.1.9", + "isarray": "^1.0.0" + }, + "bin": { + "msgpack": "bin/msgpack" + } + }, + "node_modules/msgpack-lite/node_modules/isarray": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", + "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", + "license": "MIT" + }, "node_modules/msgpackr": { "version": "1.11.5", "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.5.tgz", @@ -8111,6 +8182,18 @@ "node": ">= 0.6" } }, + "node_modules/nerdbank-streams": { + "version": "2.5.60", + "resolved": "https://registry.npmjs.org/nerdbank-streams/-/nerdbank-streams-2.5.60.tgz", + "integrity": "sha512-saQaMyTtVDAEc+S+BPXKM6K1AF3FyrorFSDzaCkdmtDe2kZzu1aYPQZNLmnxJhxbTcghYrEmYFFoaDxBDVadCw==", + "license": "MIT", + "dependencies": { + "await-semaphore": "^0.1.3", + "cancellationtoken": "^2.0.1", + "caught": "^0.1.3", + "msgpack-lite": "^0.1.26" + } + }, "node_modules/nice-try": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", @@ -10294,6 +10377,12 @@ "node": ">= 0.4" } }, + "node_modules/strict-event-emitter-types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz", + "integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==", + "license": "ISC" + }, "node_modules/string_decoder": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", @@ -11493,6 +11582,15 @@ "vscode-messenger-common": "^0.6" } }, + "node_modules/vsls": { + "version": "1.0.4753", + "resolved": "https://registry.npmjs.org/vsls/-/vsls-1.0.4753.tgz", + "integrity": "sha512-hmrsMbhjuLoU8GgtVfqhbV4ZkGvDpLV2AFmzx+cCOGNra2qk0Q36dYkfwENqy/vJVQ/2/lhxcn+69FYnKQRhgg==", + "license": "SEE LICENSE IN LICENSE.txt", + "dependencies": { + "@microsoft/servicehub-framework": "^2.6.74" + } + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", @@ -12149,7 +12247,8 @@ "react-dom": "^19.2.4", "reflect-metadata": "~0.2.2", "vscode-messenger": "^0.6.0", - "vscode-messenger-webview": "^0.6.0" + "vscode-messenger-webview": "^0.6.0", + "vsls": "1.0.4753" }, "devDependencies": { "@types/node-fetch": "~2.6.12", diff --git a/packages/open-collaboration-vscode/package.json b/packages/open-collaboration-vscode/package.json index bb881ce..4f205c1 100644 --- a/packages/open-collaboration-vscode/package.json +++ b/packages/open-collaboration-vscode/package.json @@ -315,7 +315,8 @@ "react-dom": "^19.2.4", "reflect-metadata": "~0.2.2", "vscode-messenger": "^0.6.0", - "vscode-messenger-webview": "^0.6.0" + "vscode-messenger-webview": "^0.6.0", + "vsls": "1.0.4753" }, "devDependencies": { "@types/node-fetch": "~2.6.12", diff --git a/packages/open-collaboration-vscode/src/api/api.ts b/packages/open-collaboration-vscode/src/api/api.ts new file mode 100644 index 0000000..3e3f63f --- /dev/null +++ b/packages/open-collaboration-vscode/src/api/api.ts @@ -0,0 +1,353 @@ +// ****************************************************************************** +// Copyright 2026 TypeFox GmbH +// This program and the accompanying materials are made available under the +// terms of the MIT License, which is available in the project root. +// ****************************************************************************** + +import type * as vscodeType from 'vscode'; +import * as vscode from 'vscode'; +import { + Access, + type ContactServiceProvider, + type ContactsCollection, + LiveShare, + Role, + type Server, + type Activity, + type JoinOptions, + type Peer, + type PeersChangeEvent, + type PresenceProvider, + type PresenceProviderEvent, + type Services, + type Session, + type SessionChangeEvent, + type ShareOptions, + type SharedService, + type SharedServiceProxy, + type UserInfo, + View +} from 'vsls/vscode'; + +import { CollaborationInstance } from '../collaboration-instance.js'; +import { CollaborationRoomService } from '../collaboration-room-service.js'; +import { OctSharedService, OctSharedServiceProxy } from './shared-service.js'; + +/** + * Re-export Live Share types for compatibility. + */ +export type { Access, Activity, JoinOptions, Peer, PeersChangeEvent, PresenceProvider, PresenceProviderEvent, Role, Services, Session, SessionChangeEvent, ShareOptions, SharedService, SharedServiceProxy, UserInfo }; + +/** + * Root interface for accessing the Open Collaboration API. + */ +export interface OpenCollaborationExtension { + /** + * Retrieves the Open Collaboration API for a given version. + * @returns The API, or null if not available or activation failed. + */ + getApi(apiVersion: string): Promise; +} + +// Session state when no collaboration session is active +const SESSION_NO_ACTIVE: Session = { + peerNumber: 0, + user: null, + role: Role.None, + access: Access.None, + id: null, + presentationMode: false, +}; + +let peerCounter = 1; + +/** + * Single long-lived implementation of OpenCollaborationSession. + * + * This object is created once and returned by getApi(). It reflects the + * current collaboration state reactively — session and peers are empty/None + * when no session is active, and filled in when a session starts. + * + * This matches the Live Share contract where getApi() always returns the same + * stable object and consumers observe changes via events. + */ +class OpenCollaborationSessionImpl implements LiveShare { + + private readonly onDidChangeSessionEmitter = new vscode.EventEmitter(); + readonly onDidChangeSession = this.onDidChangeSessionEmitter.event; + + private readonly onDidChangePeersEmitter = new vscode.EventEmitter(); + readonly onDidChangePeers = this.onDidChangePeersEmitter.event; + + private currentInstance: CollaborationInstance | undefined; + private peersByUserId: Map = new Map(); + private readonly sharedServices = new Map(); + private readonly sharedServiceProxies = new Map(); + + constructor(private readonly roomService: CollaborationRoomService) { + + this.roomService.onDidJoinRoom(instance => this.attachInstance(instance)); + + // Wire up an already-active instance (e.g. extension re-activation) + if (CollaborationInstance.Current) { + this.attachInstance(CollaborationInstance.Current); + } + } + + private attachInstance(instance: CollaborationInstance): void { + this.currentInstance = instance; + this.peersByUserId.clear(); + this.updateServiceAvailability(); + this.onDidChangeSessionEmitter.fire({ session: this.buildSession(instance) }); + + const usersDisposable = instance.onDidUsersChange(() => this.syncPeers(instance)); + const disposeDisposable = instance.onDidDispose(() => { + usersDisposable.dispose(); + disposeDisposable.dispose(); + // Clear all peers and fire removal event + const removed = Array.from(this.peersByUserId.values()); + this.peersByUserId.clear(); + if (removed.length > 0) { + this.onDidChangePeersEmitter.fire({ added: [], removed }); + } + this.currentInstance = undefined; + this.updateServiceAvailability(); + this.onDidChangeSessionEmitter.fire({ session: SESSION_NO_ACTIVE }); + }); + } + + private updateServiceAvailability(): void { + const canShare = Boolean(this.currentInstance?.host); + const canUseProxy = Boolean(this.currentInstance && !this.currentInstance.host); + const hostPeerId = this.currentInstance?.hostId; + + for (const service of this.sharedServices.values()) { + service.setAvailable(canShare); + } + for (const proxy of this.sharedServiceProxies.values()) { + proxy.setHostPeerId(hostPeerId); + proxy.setAvailable(canUseProxy); + } + } + + private buildSession(instance: CollaborationInstance): Session { + return { + peerNumber: 0, + user: null, // OCT doesn't expose own user info yet; extendable later + role: instance.host ? Role.Host : Role.Guest, + access: instance.permissions.readonly ? Access.ReadOnly : Access.ReadWrite, + id: instance.roomId, + presentationMode: false, + }; + } + + private async syncPeers(instance: CollaborationInstance): Promise { + const connectedUsers = await instance.connectedUsers; + const ownUser = await instance.ownUserData; + + const added: Peer[] = []; + const removed: Peer[] = []; + const seenIds = new Set(); + + for (const user of connectedUsers) { + if (user.id === ownUser.id) { + continue; // Skip self + } + seenIds.add(user.id); + if (!this.peersByUserId.has(user.id)) { + const peer: Peer = { + peerNumber: peerCounter++, + user: { displayName: user.name, emailAddress: null, userName: null, id: user.id }, + role: instance.host ? Role.Guest : Role.Host, + access: instance.permissions.readonly ? Access.ReadOnly : Access.ReadWrite, + }; + this.peersByUserId.set(user.id, peer); + added.push(peer); + } + } + + for (const [userId, peer] of this.peersByUserId) { + if (!seenIds.has(userId)) { + this.peersByUserId.delete(userId); + removed.push(peer); + } + } + + if (added.length > 0 || removed.length > 0) { + this.onDidChangePeersEmitter.fire({ added, removed }); + } + } + + get session(): Session { + return this.currentInstance + ? this.buildSession(this.currentInstance) + : SESSION_NO_ACTIVE; + } + + get peers(): Peer[] { + return Array.from(this.peersByUserId.values()); + } + + async share(_options?: ShareOptions): Promise { + + await this.roomService.createRoom(); + // roomService fires onDidJoinRoom which triggers attachInstance. + // The room ID becomes available via session.id after that event. + const roomId = this.currentInstance?.roomId; + return roomId ? vscode.Uri.parse(`oct://${roomId}`) : null; + } + + async join(link: vscodeType.Uri | string, options?: JoinOptions): Promise { + const roomId = typeof link === 'string' ? link : link.toString(); + await this.roomService.joinRoom(roomId, options?.newWindow); + } + + async shareService(name: string): Promise { + if (!this.currentInstance?.host) { + return null; + } + const existing = this.sharedServices.get(name); + if (existing) { + return existing; + } + const service = new OctSharedService(this.currentInstance.connection, name, true); + this.sharedServices.set(name, service); + this.updateServiceAvailability(); + return service; + } + + async unshareService(name: string): Promise { + const service = this.sharedServices.get(name); + if (service) { + service.deactivate(); + this.sharedServices.delete(name); + } + } + + async getSharedService(name: string): Promise { + if (!this.currentInstance || this.currentInstance.host) { + return null; + } + + const existing = this.sharedServiceProxies.get(name); + if (existing) { + existing.setHostPeerId(this.currentInstance.hostId); + existing.setAvailable(true); + return existing; + } + + const proxy = new OctSharedServiceProxy( + this.currentInstance.connection, + name, + this.currentInstance.hostId, + true, + ); + this.sharedServiceProxies.set(name, proxy); + this.updateServiceAvailability(); + return proxy; + } + + convertLocalUriToShared(localUri: vscodeType.Uri): vscodeType.Uri { + // OCT uses oct:// scheme. For now, convert file:// URIs to oct:// if in a shared workspace. + // Placeholder: return the input URI as-is. + return localUri; + } + + convertSharedUriToLocal(sharedUri: vscodeType.Uri): vscodeType.Uri { + // Placeholder: return the input URI as-is. + return sharedUri; + } + + get presenceProviders(): PresenceProvider[] { + // Not yet implemented in OCT. + return []; + } + + private readonly onPresenceProviderRegisteredEmitter = new vscode.EventEmitter(); + readonly onPresenceProviderRegistered = this.onPresenceProviderRegisteredEmitter.event; + + get services(): Services { + // Return a minimal Services object. Extended as OCT supports more features. + return { + async getRemoteServiceBroker() { + // Not yet implemented in OCT. + return null; + }, + }; + } + + registerCommand( + _command: string, + _isEnabled?: () => boolean, + _thisArg?: any, + ): vscodeType.Disposable | null { + return null; + } + + registerTreeDataProvider( + _viewId: View, + _treeDataProvider: vscodeType.TreeDataProvider, + ): vscodeType.Disposable | null { + return null; + } + + registerContactServiceProvider( + _name: string, + _contactServiceProvider: ContactServiceProvider, + ): vscodeType.Disposable | null { + return null; + } + + async shareServer(_server: Server): Promise { + throw new Error('shareServer is not yet supported in Open Collaboration Tools.'); + } + + async getContacts(_emails: string[]): Promise { + return { + contacts: {}, + async dispose() { + // no-op + }, + }; + } + + async getPeerForTextDocumentChangeEvent(e: vscodeType.TextDocumentChangeEvent): Promise { + // Placeholder: cannot determine peer from OCT given a text document change. + // This would require OCT's change event tracking; for now return a dummy peer. + throw new Error('getPeerForTextDocumentChangeEvent is not yet supported in Open Collaboration Tools.'); + } + + async end(): Promise { + if (this.currentInstance) { + await this.currentInstance.leave(); + } + } +} + +/** + * Implementation of the root extension API. + */ +class OpenCollaborationExtensionImpl implements OpenCollaborationExtension { + + private readonly api: OpenCollaborationSessionImpl; + + constructor(roomService: CollaborationRoomService) { + this.api = new OpenCollaborationSessionImpl(roomService); + } + + async getApi(apiVersion: string): Promise { + if (apiVersion !== '1' && apiVersion !== '1.0') { + return null; + } + return this.api; + } +} + +/** + * Creates the root Open Collaboration extension API. + * + * @returns Implementation of OpenCollaborationExtension. + */ +export function createOpenCollaborationExtensionApi(roomService: CollaborationRoomService): OpenCollaborationExtension { + return new OpenCollaborationExtensionImpl(roomService); +} diff --git a/packages/open-collaboration-vscode/src/api/shared-service.ts b/packages/open-collaboration-vscode/src/api/shared-service.ts new file mode 100644 index 0000000..fb357ba --- /dev/null +++ b/packages/open-collaboration-vscode/src/api/shared-service.ts @@ -0,0 +1,183 @@ +// ****************************************************************************** +// Copyright 2026 TypeFox GmbH +// This program and the accompanying materials are made available under the +// terms of the MIT License, which is available in the project root. +// ****************************************************************************** + +import { ProtocolBroadcastConnection } from 'open-collaboration-protocol'; +import * as vscode from 'vscode'; +import type { NotifyHandler, RequestHandler, SharedService, SharedServiceProxy } from 'vsls/vscode'; + +const REQUEST_PREFIX = '$oct.vsls.service.request'; +const HOST_NOTIFY_PREFIX = '$oct.vsls.service.notify.host'; +const GUEST_NOTIFY_PREFIX = '$oct.vsls.service.notify.guest'; +const NOOP_CANCELLATION_TOKEN: vscode.CancellationToken = { + isCancellationRequested: false, + onCancellationRequested: () => ({ + dispose() { + // no-op + }, + }), +}; + +function encodePart(value: string): string { + return encodeURIComponent(value); +} + +function requestMethod(serviceName: string, name: string): string { + return `${REQUEST_PREFIX}:${encodePart(serviceName)}:${encodePart(name)}`; +} + +function hostNotifyMethod(serviceName: string, name: string): string { + return `${HOST_NOTIFY_PREFIX}:${encodePart(serviceName)}:${encodePart(name)}`; +} + +function guestNotifyMethod(serviceName: string, name: string): string { + return `${GUEST_NOTIFY_PREFIX}:${encodePart(serviceName)}:${encodePart(name)}`; +} + +export class OctSharedService implements SharedService { + + private readonly onDidChangeIsServiceAvailableEmitter = new vscode.EventEmitter(); + readonly onDidChangeIsServiceAvailable = this.onDidChangeIsServiceAvailableEmitter.event; + + private available: boolean; + private readonly requestHandlers = new Map(); + private readonly notifyHandlers = new Map(); + private readonly notifyMethodRegistered = new Set(); + + constructor( + private readonly connection: ProtocolBroadcastConnection, + private readonly serviceName: string, + initialAvailability: boolean, + ) { + this.available = initialAvailability; + } + + get isServiceAvailable(): boolean { + return this.available; + } + + setAvailable(available: boolean): void { + if (this.available === available) { + return; + } + this.available = available; + this.onDidChangeIsServiceAvailableEmitter.fire(available); + } + + deactivate(): void { + this.setAvailable(false); + this.requestHandlers.clear(); + this.notifyHandlers.clear(); + } + + onRequest(name: string, handler: RequestHandler): void { + const method = requestMethod(this.serviceName, name); + this.requestHandlers.set(name, handler); + this.connection.onRequest(method, async (_origin, ...parameters) => { + if (!this.available) { + throw new Error(`Shared service '${this.serviceName}' is not available.`); + } + const requestHandler = this.requestHandlers.get(name); + if (!requestHandler) { + throw new Error(`No request handler registered for '${name}'.`); + } + return requestHandler(parameters, NOOP_CANCELLATION_TOKEN); + }); + } + + onNotify(name: string, handler: NotifyHandler): void { + const method = hostNotifyMethod(this.serviceName, name); + const handlers = this.notifyHandlers.get(name) ?? []; + handlers.push(handler); + this.notifyHandlers.set(name, handlers); + + if (!this.notifyMethodRegistered.has(name)) { + this.notifyMethodRegistered.add(name); + this.connection.onNotification(method, (_origin, args) => { + const listeners = this.notifyHandlers.get(name) ?? []; + for (const listener of listeners) { + listener(args as object); + } + }); + } + } + + notify(name: string, args: object): void { + if (!this.available) { + return; + } + void this.connection.sendBroadcast(guestNotifyMethod(this.serviceName, name), args); + } +} + +export class OctSharedServiceProxy implements SharedServiceProxy { + + private readonly onDidChangeIsServiceAvailableEmitter = new vscode.EventEmitter(); + readonly onDidChangeIsServiceAvailable = this.onDidChangeIsServiceAvailableEmitter.event; + + private available: boolean; + private hostPeerId: string | undefined; + private readonly notifyHandlers = new Map(); + private readonly notifyMethodRegistered = new Set(); + + constructor( + private readonly connection: ProtocolBroadcastConnection, + private readonly serviceName: string, + hostPeerId: string | undefined, + initialAvailability: boolean, + ) { + this.hostPeerId = hostPeerId; + this.available = initialAvailability; + } + + get isServiceAvailable(): boolean { + return this.available; + } + + setHostPeerId(hostPeerId: string | undefined): void { + this.hostPeerId = hostPeerId; + this.setAvailable(this.available && Boolean(hostPeerId)); + } + + setAvailable(available: boolean): void { + const normalized = available && Boolean(this.hostPeerId); + if (this.available === normalized) { + return; + } + this.available = normalized; + this.onDidChangeIsServiceAvailableEmitter.fire(this.available); + } + + onNotify(name: string, handler: NotifyHandler): void { + const method = guestNotifyMethod(this.serviceName, name); + const handlers = this.notifyHandlers.get(name) ?? []; + handlers.push(handler); + this.notifyHandlers.set(name, handlers); + + if (!this.notifyMethodRegistered.has(name)) { + this.notifyMethodRegistered.add(name); + this.connection.onBroadcast(method, (_origin, args) => { + const listeners = this.notifyHandlers.get(name) ?? []; + for (const listener of listeners) { + listener(args as object); + } + }); + } + } + + async request(name: string, args: any[], _cancellation?: vscode.CancellationToken): Promise { + if (!this.available || !this.hostPeerId) { + throw new Error(`Shared service '${this.serviceName}' is not available.`); + } + return this.connection.sendRequest(requestMethod(this.serviceName, name), this.hostPeerId, ...args); + } + + notify(name: string, args: object): void { + if (!this.available || !this.hostPeerId) { + return; + } + void this.connection.sendNotification(hostNotifyMethod(this.serviceName, name), this.hostPeerId, args); + } +} diff --git a/packages/open-collaboration-vscode/src/collaboration-instance.ts b/packages/open-collaboration-vscode/src/collaboration-instance.ts index c82d45d..256fdee 100644 --- a/packages/open-collaboration-vscode/src/collaboration-instance.ts +++ b/packages/open-collaboration-vscode/src/collaboration-instance.ts @@ -239,6 +239,10 @@ export class CollaborationInstance implements vscode.Disposable { return this.options.host; } + get hostId(): string | undefined { + return this.options.hostId; + } + get roomId(): string { return this.options.roomId; } diff --git a/packages/open-collaboration-vscode/src/collaboration-room-service.ts b/packages/open-collaboration-vscode/src/collaboration-room-service.ts index 3e98836..757c681 100644 --- a/packages/open-collaboration-vscode/src/collaboration-room-service.ts +++ b/packages/open-collaboration-vscode/src/collaboration-room-service.ts @@ -100,7 +100,7 @@ export class CollaborationRoomService { }); } - async joinRoom(roomId?: string): Promise { + async joinRoom(roomId?: string, newWindow?: boolean): Promise { if (!roomId) { roomId = await vscode.window.showInputBox({ placeHolder: vscode.l10n.t('Enter the invitation code') }); if (!roomId) { @@ -155,8 +155,8 @@ export class CollaborationRoomService { // We were able to store the workspace folders in a file // We now attempt to load that workspace file await vscode.commands.executeCommand(CodeCommands.OpenFolder, uri, { - forceNewWindow: false, - forceReuseWindow: true, + forceNewWindow: newWindow ?? true, + forceReuseWindow: !newWindow, noRecentEntry: true }); return true; diff --git a/packages/open-collaboration-vscode/src/extension-web.ts b/packages/open-collaboration-vscode/src/extension-web.ts index cace07a..fbb9e76 100644 --- a/packages/open-collaboration-vscode/src/extension-web.ts +++ b/packages/open-collaboration-vscode/src/extension-web.ts @@ -12,6 +12,8 @@ import { closeSharedEditors, removeWorkspaceFolders } from './utils/workspace.js import { createContainer } from './inversify.js'; import { Commands } from './commands.js'; import { Fetch } from './collaboration-connection-provider.js'; +import { CollaborationRoomService } from './collaboration-room-service.js'; +import { createOpenCollaborationExtensionApi, OpenCollaborationExtension } from './api/api.js'; initializeProtocol({ cryptoModule: globalThis.crypto @@ -22,6 +24,9 @@ export async function activate(context: vscode.ExtensionContext) { container.bind(Fetch).toConstantValue(fetch); const commands = container.get(Commands); commands.initialize(); + const roomService = container.get(CollaborationRoomService); + const api = createOpenCollaborationExtensionApi(roomService); + return api satisfies OpenCollaborationExtension; } export async function deactivate(): Promise { diff --git a/packages/open-collaboration-vscode/src/extension.ts b/packages/open-collaboration-vscode/src/extension.ts index b5b1db5..2a03d8e 100644 --- a/packages/open-collaboration-vscode/src/extension.ts +++ b/packages/open-collaboration-vscode/src/extension.ts @@ -16,6 +16,7 @@ import { Commands } from './commands.js'; import { Fetch } from './collaboration-connection-provider.js'; import fetch from 'node-fetch'; import { ChatWebview } from './chat-webview/chat-webview.js'; +import { createOpenCollaborationExtensionApi, OpenCollaborationExtension } from './api/api.js'; initializeProtocol({ cryptoModule: crypto.webcrypto @@ -28,6 +29,7 @@ export async function activate(context: vscode.ExtensionContext) { commands.initialize(); container.get(ChatWebview).register(); const roomService = container.get(CollaborationRoomService); + const api = createOpenCollaborationExtensionApi(roomService); const connection = await roomService.tryConnect(); if (connection) { @@ -38,6 +40,8 @@ export async function activate(context: vscode.ExtensionContext) { await closeSharedEditors(); removeWorkspaceFolders(); } + + return api satisfies OpenCollaborationExtension; } export async function deactivate(): Promise {