diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 53327b2..88e6705 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -26,4 +26,7 @@ jobs: - run: make container - # TODO: tests + # Virtual display for VS Code tests + - run: sudo apt-get update && sudo apt-get install --yes xvfb + + - run: xvfb-run --auto-servernum npm --workspace vscode-workbench run test diff --git a/collab-server/src/server.ts b/collab-server/src/server.ts index 2b6b6a2..efcfbe3 100644 --- a/collab-server/src/server.ts +++ b/collab-server/src/server.ts @@ -90,7 +90,18 @@ server.httpServer.listen(socketPath, () => { await Promise.race([once(process, 'SIGINT'), once(process, 'SIGQUIT'), once(process, 'SIGTERM')]) console.log('Hocuspocus shutting down..') + +// Persist open documents to disk. +await Promise.all( + [...server.hocuspocus.documents.values()].map(async doc => { + try { + await fs.writeFile(checkedToDiskPath(doc.name), doc.getText(YTEXT_KEY).toString()) + console.log(`Saved '${doc.name}' to disk`) + } catch (e) { + console.error(`Failed to save '${doc.name}' to disk:`, e) + } + }), +) + await server.destroy() db.close() - -// TODO: ensure writes are flushed to disk. diff --git a/package-lock.json b/package-lock.json index 1c75ff5..5daf395 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "vscode-workbench" ], "dependencies": { + "@hocuspocus/provider": "^4.0.0", "@prisma/adapter-better-sqlite3": "^7.7.0", "@prisma/client": "^7.7.0", "better-auth": "^1.6.2", @@ -21,6 +22,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "swr": "^2.4.1", + "ws": "^8.20.0", "zod": "^4.3.6" }, "devDependencies": { @@ -28,6 +30,7 @@ "@types/node": "^24", "@types/react": "^19", "@types/react-dom": "^19", + "@types/ws": "^8.18.1", "babel-plugin-react-compiler": "1.0.0", "concurrently": "^9.2.1", "eslint": "^9", @@ -2568,13 +2571,6 @@ "@types/react": "^19.2.0" } }, - "node_modules/@types/vscode": { - "version": "1.118.0", - "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.118.0.tgz", - "integrity": "sha512-Ah6eTlqDcwIMELEVwQMO++rJAFBRz/oLluLD/vWdYrH1KuI9kfpaM+7pg0OvvascgcJy+ghLCERAYouM4QbzGw==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/ws": { "version": "8.18.1", "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", @@ -10644,11 +10640,12 @@ "version": "0.0.1", "dependencies": { "@hocuspocus/provider": "^4.0", - "ws": "^8.20.0" + "ws": "^8.20", + "zod": "^4.4" }, "devDependencies": { "@types/mocha": "^10.0", - "@types/vscode": "^1.109", + "@types/vscode": "~1.109", "@types/ws": "^8.18.1", "@vscode/test-cli": "^0.0.12", "@vscode/test-electron": "^2.5", @@ -10657,6 +10654,13 @@ "engines": { "vscode": "^1.109.0" } + }, + "vscode-workbench/node_modules/@types/vscode": { + "version": "1.109.0", + "resolved": "https://registry.npmjs.org/@types/vscode/-/vscode-1.109.0.tgz", + "integrity": "sha512-0Pf95rnwEIwDbmXGC08r0B4TQhAbsHQ5UyTIgVgoieDe4cOnf92usuR5dEczb6bTKEp7ziZH4TV1TRGPPCExtw==", + "dev": true, + "license": "MIT" } } } diff --git a/package.json b/package.json index 17ec476..d6b272a 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "lint": "prettier --check . && eslint" }, "dependencies": { + "@hocuspocus/provider": "^4.0.0", "@prisma/adapter-better-sqlite3": "^7.7.0", "@prisma/client": "^7.7.0", "better-auth": "^1.6.2", @@ -28,6 +29,7 @@ "react": "19.2.4", "react-dom": "19.2.4", "swr": "^2.4.1", + "ws": "^8.20.0", "zod": "^4.3.6" }, "devDependencies": { @@ -35,6 +37,7 @@ "@types/node": "^24", "@types/react": "^19", "@types/react-dom": "^19", + "@types/ws": "^8.18.1", "babel-plugin-react-compiler": "1.0.0", "concurrently": "^9.2.1", "eslint": "^9", diff --git a/src/app/AvatarMenu.tsx b/src/app/AvatarMenu.tsx index 2c8c253..80e03b3 100644 --- a/src/app/AvatarMenu.tsx +++ b/src/app/AvatarMenu.tsx @@ -1,9 +1,9 @@ 'use client' +import AvatarIcon from '@/app/components/AvatarIcon' import authClient from '@/lib/auth-client' import { ConfigCtx } from '@/lib/contexts' import { setIsAdmin } from '@/lib/server/actions' -import Image from 'next/image' import Link from 'next/link' import { useRouter } from 'next/navigation' import { useContext } from 'react' @@ -19,13 +19,7 @@ export default function AvatarMenu() { <> {user.isAdmin && admin}
- +
{user.name}
{user.isAdmin && Admin interface} diff --git a/src/app/NavbarExtra.tsx b/src/app/NavbarExtra.tsx new file mode 100644 index 0000000..7f366d7 --- /dev/null +++ b/src/app/NavbarExtra.tsx @@ -0,0 +1,38 @@ +'use client' + +import React, { createContext, type ReactNode, type RefObject, use, useLayoutEffect, useRef, useState } from 'react' + +type Setter = (_: ReactNode) => void +const NavbarExtraSetCtx = createContext | null>(null) + +export function NavbarExtraProvider({ children }: Readonly<{ children: ReactNode }>) { + const ref = useRef(() => { + console.warn('useNavbarExtra called before mounted') + }) + return {children} +} + +/** Renders extra contents of the navbar as set by specific pages. + * This component and the page that uses {@link SetNavbarExtra} + * must share a {@link NavbarExtraProvider} parent */ +export function NavbarExtra() { + const [extra, setExtra] = useState(null) + const ref = use(NavbarExtraSetCtx)! + useLayoutEffect(() => { + ref.current = setExtra + }, [ref, setExtra]) + return extra +} + +/** Sets extra contents of the navbar to its children. */ +export function SetNavbarExtra({ children }: Readonly<{ children: ReactNode }>) { + const ref = use(NavbarExtraSetCtx)! + React.useEffect(() => { + ref.current(children) + return () => { + // eslint-disable-next-line react-hooks/exhaustive-deps + ref.current(null) + } + }, [ref, children]) + return null +} diff --git a/src/app/[userName]/[projectName]/page.tsx b/src/app/[userName]/[projectName]/page.tsx index 7406b88..31cf050 100644 --- a/src/app/[userName]/[projectName]/page.tsx +++ b/src/app/[userName]/[projectName]/page.tsx @@ -1,6 +1,7 @@ import { requireAuth } from '@/lib/server/actions' import { getDb } from '@/lib/server/db' import { getEditorSessionManager } from '@/lib/server/editorSessions' +import { canAccessProject } from '@/lib/server/util' import z from 'zod' const zParams = z.object({ @@ -48,8 +49,7 @@ export default async function EditorSession({ params: params_ }: { params: Promi const project = await db.project.findUnique({ where: { userId_name: { userId: owner.id, name: params.projectName } }, }) - const isOwner = viewer.name === params.userName - if (!project || (!isOwner && !project.isPublic)) { + if (!project || !canAccessProject(viewer, project)) { return } diff --git a/src/app/api/setup-events/route.ts b/src/app/api/setup-events/route.ts index 6b8c6ea..abc4d70 100644 --- a/src/app/api/setup-events/route.ts +++ b/src/app/api/setup-events/route.ts @@ -1,41 +1,29 @@ import { getSeedState } from '@/lib/server/seed' +import { sseStreamResponse } from '@/lib/server/util' export async function GET() { - const encoder = new TextEncoder() - + // eslint-disable-next-line prefer-const let interval: ReturnType | undefined - const stream = new ReadableStream({ - start(controller) { - let cursor = 0 - - interval = setInterval(() => { - const st = getSeedState() - while (cursor < st.events.length) { - const event = st.events[cursor++] - controller.enqueue(encoder.encode(`data: ${JSON.stringify(event)}\n\n`)) - if (event.type === 'done' || event.type === 'error') { - clearInterval(interval) - controller.close() - return - } - } - if (!st.inProgress) { - clearInterval(interval) - controller.close() - } - }, 500) - }, - cancel() { - clearInterval(interval) - }, + const [response, send, close] = sseStreamResponse(() => { + clearInterval(interval) }) + let cursor = 0 + interval = setInterval(() => { + const st = getSeedState() + while (cursor < st.events.length) { + const event = st.events[cursor++] + send(event) + if (event.type === 'done' || event.type === 'error') { + clearInterval(interval) + close() + return + } + } + if (!st.inProgress) { + clearInterval(interval) + close() + } + }, 500) - return new Response(stream, { - headers: { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - Connection: 'keep-alive', - 'X-Accel-Buffering': 'no', - }, - }) + return response } diff --git a/src/app/components/AvatarIcon.tsx b/src/app/components/AvatarIcon.tsx new file mode 100644 index 0000000..b432a3c --- /dev/null +++ b/src/app/components/AvatarIcon.tsx @@ -0,0 +1,14 @@ +import type { User } from '@/lib/server/auth' +import Image from 'next/image' + +export default function AvatarIcon({ user }: { user: Pick }) { + return ( + + ) +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 8581614..7e2e6e6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -9,6 +9,7 @@ import { connection } from 'next/server' import { Suspense, type ReactNode } from 'react' import AvatarMenu from './AvatarMenu' import Breadcrumbs from './Breadcrumbs' +import { NavbarExtra, NavbarExtraProvider } from './NavbarExtra' const openSans = Open_Sans({ subsets: ['latin'], @@ -56,18 +57,21 @@ async function RootLayoutBody({ {/* https://nextjs.org/docs/app/getting-started/server-and-client-components#interleaving-server-and-client-components */} - - -
{children}
- + + + +
{children}
+ +
) diff --git a/src/lib/server/collabServer.ts b/src/lib/server/collabServer.ts index e3e4992..c1cfd1b 100644 --- a/src/lib/server/collabServer.ts +++ b/src/lib/server/collabServer.ts @@ -18,6 +18,8 @@ export class CollabServerHandle { readonly uuid = crypto.randomUUID() /** Directory in which `collab-server` places its files. */ readonly workDir: string = `/tmp/collab-server-${this.uuid}/` + /** Path to the `collab-server` UDS file. */ + readonly socketPath: string = path.join(this.workDir, COLLAB_SOCKET_FILENAME) constructor( /** Project that this server manages. */ diff --git a/src/lib/server/editorSessions.ts b/src/lib/server/editorSessions.ts index bb77132..4bcabd7 100644 --- a/src/lib/server/editorSessions.ts +++ b/src/lib/server/editorSessions.ts @@ -1,9 +1,9 @@ -import { User } from '@/lib/server/auth' +import type { User } from '@/lib/server/auth' import { CollabServerHandle } from '@/lib/server/collabServer' import { getWorkspacesDir } from '@/lib/server/config' import { getDb } from '@/lib/server/db' import { VscodeServerHandle } from '@/lib/server/vscodeServer' -import { Project } from '@/prisma/generated/client' +import type { Project } from '@/prisma/generated/client' import path from 'node:path' import { EventEmitter } from 'node:stream' import 'server-only' diff --git a/src/lib/server/util.ts b/src/lib/server/util.ts index ed12445..9d2ae02 100644 --- a/src/lib/server/util.ts +++ b/src/lib/server/util.ts @@ -1,5 +1,7 @@ +import type { Project } from '@/prisma/generated/client' import fs from 'node:fs/promises' import 'server-only' +import type { User } from './auth' export interface ProcessInfo { pid: number @@ -61,3 +63,45 @@ export const BWRAP_ARGS = export function bwrapProjectDir(projectName: string) { return `/workspace/${projectName}/` } + +export function canAccessProject(user: User, project: Project) { + const isOwner = user.id === project.userId + return isOwner || project.isPublic +} + +/** Returns `[response, send, close]`. + * See https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events */ +export function sseStreamResponse(onCancel?: () => void): [Response, (msg: object) => void, () => void] { + let send: (msg: object) => void = () => {} + let close: () => void = () => {} + let closed = false + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + send = msg => { + if (closed) return + controller.enqueue(encoder.encode(`data: ${JSON.stringify(msg)}\n\n`)) + } + close = () => { + if (closed) return + closed = true + controller.close() + } + }, + cancel() { + closed = true + if (onCancel) onCancel() + }, + }) + + const response = new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }) + + return [response, send, close] +} diff --git a/src/lib/server/vscodeServer.ts b/src/lib/server/vscodeServer.ts index d5a2f0b..45eef11 100644 --- a/src/lib/server/vscodeServer.ts +++ b/src/lib/server/vscodeServer.ts @@ -15,6 +15,7 @@ import { ChildProcess, exec, spawn } from 'node:child_process' import fs from 'node:fs/promises' import { request } from 'node:http' import path from 'node:path' +import type Stream from 'node:stream' import { promisify } from 'node:util' /** Create a VSCode machine settings file if one doesn't exist. */ @@ -44,6 +45,8 @@ async function ensureMachineSettings(serverDataDir: string): Promise { // those edits will be lost. // FIXME: inform users about this risk, and attempt detection in collab-server. 'files.saveConflictResolution': 'overwriteFileOnDisk', + // Auto-save on every keystroke interferes with collaborative editing state. + 'files.autoSave': 'off', }, null, 2, @@ -212,6 +215,7 @@ export class VscodeServerHandle { '--bind', this.projectDir, sandboxProjectDir, '--bind', this.collabWorkDir, '/workspace/.collab-server', '--bind', this.socketDir, '/workspace/.openvscode-server', + '--ro-bind-data', '3', '/workspace/.lean-workbench.json', ...overlayArgs, '--setenv', 'HOME', '/workspace', '--setenv', 'ELAN_HOME', getElanDir(), @@ -228,6 +232,8 @@ export class VscodeServerHandle { path.join(getOpenVscodeServerDir(), 'bin', 'openvscode-server'), '--socket-path', `/workspace/.openvscode-server/${VSCODE_SOCKET_FILENAME}`, '--without-connection-token', + // Reduce how long the extension host process waits for a web client to reconnect (default 3h). + '--reconnection-grace-time', '60', `--server-base-path=${this.vscodeIframePath}`, '--server-data-dir', '/workspace/.vscode-remote', // TODO: make a per-project user-data-dir to support concurrent editing sessions. @@ -242,6 +248,8 @@ export class VscodeServerHandle { }) this.proc = proc + const workspaceMdataPipe = proc.stdio[3] as Stream.Writable + await Promise.race([ // Reject if errors occur before setup is finished. new Promise((_, reject) => { @@ -252,9 +260,20 @@ export class VscodeServerHandle { proc.once('error', err => { reject(new Error(`${this.description} failed to start: ${String(err)}`)) }) + workspaceMdataPipe.once('error', err => { + reject(new Error(`${this.description} failed to write workspace metadata: ${String(err)}`)) + }) }), // Wait for the server to start listening and for Nginx to be ready. (async () => { + workspaceMdataPipe.end( + JSON.stringify({ + viewer: { + name: this.viewer.name, + image: this.viewer.image, + }, + }), + ) await this.writeNginxUserRoute() this.disposables.defer(async () => { await fs.rm(this.nginxUserRoutePath, { force: true }) diff --git a/vscode-workbench/.vscode-test.mjs b/vscode-workbench/.vscode-test.mjs index 6a36b3f..099761c 100644 --- a/vscode-workbench/.vscode-test.mjs +++ b/vscode-workbench/.vscode-test.mjs @@ -2,4 +2,7 @@ import { defineConfig } from '@vscode/test-cli' export default defineConfig({ files: 'out/test/**/*.test.js', + // Stable VS Code gates proposed APIs behind this flag: + // `enabledApiProposals` in package.json is ignored without it. + launchArgs: ['--enable-proposed-api', 'leanprover.workbench'], }) diff --git a/vscode-workbench/.vscodeignore b/vscode-workbench/.vscodeignore index 9f748ab..9413a44 100644 --- a/vscode-workbench/.vscodeignore +++ b/vscode-workbench/.vscodeignore @@ -5,4 +5,5 @@ !dist/extension.js !package.json -!LICENSE \ No newline at end of file +!LICENSE +!media \ No newline at end of file diff --git a/vscode-workbench/esbuild.mjs b/vscode-workbench/esbuild.mjs index c8c48e1..d575969 100644 --- a/vscode-workbench/esbuild.mjs +++ b/vscode-workbench/esbuild.mjs @@ -1,18 +1,23 @@ import esbuild from 'esbuild' +import { glob } from 'node:fs/promises' const isProd = process.argv.includes('--production') const watch = process.argv.includes('--watch') +const tests = process.argv.includes('--tests') + +const testFiles = async () => Array.fromAsync(glob('test/**/*.test.ts')) const ctx = await esbuild.context({ - entryPoints: ['src/extension.ts'], bundle: true, format: 'cjs', - outfile: 'dist/extension.js', external: ['vscode'], - minify: isProd, sourcemap: !isProd, platform: 'node', target: 'node22', + define: { 'import.meta.url': '__filename' }, + ...(tests + ? { entryPoints: await testFiles(), outdir: 'out/test' } + : { entryPoints: ['src/extension.ts'], outfile: 'dist/extension.js', minify: isProd }), }) if (watch) { diff --git a/vscode-workbench/media/logo.svg b/vscode-workbench/media/logo.svg new file mode 100644 index 0000000..40c20dc --- /dev/null +++ b/vscode-workbench/media/logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/vscode-workbench/package.json b/vscode-workbench/package.json index 48e8382..0a4fda2 100644 --- a/vscode-workbench/package.json +++ b/vscode-workbench/package.json @@ -6,7 +6,7 @@ "private": true, "publisher": "leanprover", "engines": { - "vscode": "^1.109.0" + "vscode": "1.109.5" }, "categories": [ "Other" @@ -18,19 +18,43 @@ "activationEvents": [ "onStartupFinished" ], + "enabledApiProposals": [ + "textDocumentChangeReason", + "extensionsAny" + ], + "contributes": { + "viewsContainers": { + "activitybar": [ + { + "id": "leanprover-workbench-view-container", + "title": "Lean Workbench", + "icon": "media/logo.svg" + } + ] + }, + "views": { + "leanprover-workbench-view-container": [ + { + "id": "leanprover-workbench-view", + "name": "", + "icon": "" + } + ] + } + }, "main": "./dist/extension.js", "scripts": { "watch": "concurrently --names esbuild,tsc 'npm run watch:esbuild' 'npm run watch:tsc'", "watch:esbuild": "node esbuild.mjs --watch", - "watch:tsc": "tsc --noEmit --watch --preserveWatchOutput", - "tsc": "tsc --noEmit", - "build": "tsc --noEmit && node esbuild.mjs --production", + "watch:tsc": "tsc --watch --preserveWatchOutput", + "tsc": "tsc", + "build": "tsc && node esbuild.mjs --production", "vscode:prepublish": "npm run build", - "test": "tsc && vscode-test" + "test": "tsc && node esbuild.mjs --tests && vscode-test" }, "devDependencies": { "@types/mocha": "^10.0", - "@types/vscode": "^1.109", + "@types/vscode": "~1.109", "@types/ws": "^8.18.1", "@vscode/test-cli": "^0.0.12", "@vscode/test-electron": "^2.5", @@ -38,6 +62,7 @@ }, "dependencies": { "@hocuspocus/provider": "^4.0", - "ws": "^8.20.0" + "ws": "^8.20", + "zod": "^4.4" } } diff --git a/vscode-workbench/src/collabServer.ts b/vscode-workbench/src/collabServer.ts new file mode 100644 index 0000000..77abb9e --- /dev/null +++ b/vscode-workbench/src/collabServer.ts @@ -0,0 +1,101 @@ +import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider' +import vs from 'vscode' +import WebSocket from 'ws' +import type { Awareness } from 'y-protocols/awareness' +import { + AWARENESS_CURSOR_COLORS, + AWARENESS_DOC_NAME, + AWARENESS_USER_KEY, + AwarenessUser, + BWRAP_COLLAB_SOCK_PATH, + waitForPath, + WorkspaceMetadata, +} from './util' + +export class CollabServerConnection implements vs.Disposable { + constructor( + readonly collabSock: HocuspocusProviderWebsocket, + private readonly awarenessProvider: HocuspocusProvider, + ) {} + + dispose() { + this.awarenessProvider.destroy() + this.collabSock.destroy() + } + + get awareness(): Awareness { + return this.awarenessProvider.awareness! + } +} + +export async function connectToCollabServer( + log: vs.LogOutputChannel, + mdata: WorkspaceMetadata, +): Promise { + const showErrorModal = () => { + const action = 'Reload window' + void vs.window + .showErrorMessage( + 'Collaboration server is not available - Lean Workbench will not function correctly.', + { modal: true }, + action, + ) + .then(async s => { + if (s === action) { + await vs.commands.executeCommand('workbench.action.reloadWindow') + } + }) + } + + log.debug('Waiting for collab-server socket..') + if (!(await waitForPath(BWRAP_COLLAB_SOCK_PATH, 5_000))) { + showErrorModal() + return undefined + } + + const collabSock = new HocuspocusProviderWebsocket({ + url: `ws+unix:${BWRAP_COLLAB_SOCK_PATH}:/`, + // Must use the `ws` package for https://github.com/websockets/ws/blob/master/doc/ws.md#ipc-connections. + WebSocketPolyfill: WebSocket, + }) + log.debug('Opened collab-server socket') + + const awarenessProvider = new HocuspocusProvider({ + websocketProvider: collabSock, + name: AWARENESS_DOC_NAME, + }) + awarenessProvider.attach() + // Wait so we can see other clients' colors before picking ours. + if (!awarenessProvider.isSynced) { + const success = await new Promise(resolve => { + awarenessProvider.on('synced', () => resolve(true)) + setTimeout(() => resolve(false), 5_000) + }) + if (!success) { + showErrorModal() + return undefined + } + } + + // Pick the color least used by other clients. Ties are broken by list order. + const awareness = awarenessProvider.awareness! + const counts = new Map(AWARENESS_CURSOR_COLORS.map(c => [c, 0])) + for (const [clientId, state] of awareness.getStates()) { + if (clientId === awareness.clientID) continue + const user = state[AWARENESS_USER_KEY] as AwarenessUser | undefined + if (!user) continue + counts.set(user.color, (counts.get(user.color) ?? 0) + 1) + } + const color = AWARENESS_CURSOR_COLORS.reduce( + (acc, c) => (counts.get(acc)! <= counts.get(c)! ? acc : c), + AWARENESS_CURSOR_COLORS[0], + ) + + awarenessProvider.setAwarenessField(AWARENESS_USER_KEY, { + name: mdata.viewer.name, + image: mdata.viewer.image, + color, + } satisfies AwarenessUser) + + return new CollabServerConnection(collabSock, awarenessProvider) +} diff --git a/vscode-workbench/src/extension.ts b/vscode-workbench/src/extension.ts index 5917eae..37588f6 100644 --- a/vscode-workbench/src/extension.ts +++ b/vscode-workbench/src/extension.ts @@ -1,99 +1,75 @@ -import { HocuspocusProviderWebsocket } from '@hocuspocus/provider' import fs from 'node:fs/promises' import vs from 'vscode' -import WebSocket from 'ws' -import { RemoteDocManager } from './remoteDoc' +import { connectToCollabServer } from './collabServer' +import { WorkbenchPanelProvider } from './panel' +import { RemoteSelectionDecorator } from './remoteSelections' import { YTextBindingManager } from './textBinding' -import { BWRAP_COLLAB_SERVER_DIR, BWRAP_COLLAB_SOCK_PATH } from './util' +import { BWRAP_METADATA_PATH, WorkspaceMetadata, zWorkspaceMetadata } from './util' -/** Ensure we are in the expected Lean Workbench environment. - * Return `false` if we are not, - * prompting the user to fix this whenever possible. */ -async function ensureWorkbenchEnv(log: vs.LogOutputChannel): Promise { +/** Ensure we are in the expected Lean Workbench environment + * and return the current workspace configuration. + * Otherwise display an error and return `undefined`. */ +async function readWorkspaceMdata(log: vs.LogOutputChannel): Promise { log.debug(`Workspace file: ${JSON.stringify(vs.workspace.workspaceFile)}`) log.debug(`Workspace folders: ${JSON.stringify(vs.workspace.workspaceFolders)}`) + let mdata: WorkspaceMetadata try { - await fs.access(BWRAP_COLLAB_SERVER_DIR) + const raw = await fs.readFile(BWRAP_METADATA_PATH, 'utf8') + mdata = zWorkspaceMetadata.parse(JSON.parse(raw)) } catch (err) { log.error(String(err)) void vs.window.showErrorMessage('Could not detect the Lean Workbench - shutting down.') - return false + return undefined } - return true + return mdata } -async function waitForPath(p: string, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - try { - await fs.access(p) - return true - } catch {} - await new Promise(r => setTimeout(r, 200)) - } - return false +function syncableDirs(): string[] { + return (vs.workspace.workspaceFolders ?? []).filter(f => f.uri.scheme === 'file').map(f => f.uri.fsPath) } -async function connectToCollabServer( - ctx: vs.ExtensionContext, - log: vs.LogOutputChannel, -): Promise { - const mk = () => { - const collabSock = new HocuspocusProviderWebsocket({ - url: `ws+unix:${BWRAP_COLLAB_SOCK_PATH}:/`, - // Must use the `ws` package for https://github.com/websockets/ws/blob/master/doc/ws.md#ipc-connections. - WebSocketPolyfill: WebSocket, - }) - ctx.subscriptions.push({ - dispose() { - collabSock.destroy() - }, - }) - log.debug('Opened collab-server socket') - return collabSock - } +/** Extensions that intercept text input and conflict with collaborative editing. */ +const CONFLICTING_EXTENSIONS = ['vscodevim.vim', 'asvetliakov.vscode-neovim'] - log.debug('Waiting for collab-server socket..') - if (await waitForPath(BWRAP_COLLAB_SOCK_PATH, 5_000)) return mk() - const action = 'Reload window' - void vs.window - .showErrorMessage( - 'Collaboration server is not available - Lean Workbench will not function correctly.', - { modal: true }, - action, +function checkInstalledExtensions(): void { + const conflicting = CONFLICTING_EXTENSIONS.filter(id => + vs.extensions.getExtension(id, true /* include browser extensions */), + ) + if (conflicting.length > 0) + void vs.window.showErrorMessage( + `The following extension(s) are not currently supported in the Lean Workbench - please disable them: ${conflicting.join(', ')}`, ) - .then(async s => { - if (s === action) { - await vs.commands.executeCommand('workbench.action.reloadWindow') - } - }) - - return undefined -} - -function syncableDirs(): string[] { - return (vs.workspace.workspaceFolders ?? []).filter(f => f.uri.scheme === 'file').map(f => f.uri.fsPath) } export async function activate(ctx: vs.ExtensionContext) { const log = vs.window.createOutputChannel('Lean 4 - Workbench', { log: true }) - if (!(await ensureWorkbenchEnv(log))) return + const mdata = await readWorkspaceMdata(log) + if (!mdata) return - const collabSock = await connectToCollabServer(ctx, log) - if (!collabSock) return + checkInstalledExtensions() + ctx.subscriptions.push(vs.extensions.onDidChange(checkInstalledExtensions)) - const docs = new RemoteDocManager(collabSock, log) + const collabServer = await connectToCollabServer(log, mdata) + if (!collabServer) return + ctx.subscriptions.push(collabServer) // We apply collaborative syncing to open folders (usually just the project folder) only. // User-specific folders such as /workspace/.vscode-remote are not synced // (though they would be if someone opens /workspace - TODO better UX). - const bindings = new YTextBindingManager(docs, syncableDirs(), log) + const bindings = new YTextBindingManager(collabServer.collabSock, syncableDirs(), log) ctx.subscriptions.push( bindings, vs.workspace.onDidChangeWorkspaceFolders(() => bindings.updateSyncableDirs(syncableDirs())), + // Remote presence indicators + new RemoteSelectionDecorator(collabServer.awareness), ) + + // Panel with workbench-specific information + const panel = new WorkbenchPanelProvider(collabServer.awareness, mdata, log) + ctx.subscriptions.push(panel, vs.window.registerTreeDataProvider('leanprover-workbench-view', panel)) + log.debug('Extension activated') } diff --git a/vscode-workbench/src/panel.ts b/vscode-workbench/src/panel.ts new file mode 100644 index 0000000..2656066 --- /dev/null +++ b/vscode-workbench/src/panel.ts @@ -0,0 +1,133 @@ +import vs from 'vscode' +import { Awareness } from 'y-protocols/awareness' +import { + AWARENESS_SELECTION_KEY, + AWARENESS_USER_KEY, + AwarenessSelection, + AwarenessUser, + equalAwarenessUsers, + equalMaps, + Logger, + logWithPrefix, + WorkspaceMetadata, +} from './util' + +type PanelItem = { kind: 'onlineUsersRoot' } | { kind: 'onlineUser'; user: AwarenessUser } + +const COMMAND_GOTO_AWARENESS_USER = 'leanprover.workbench.internal.goToAwarenessUser' + +// Copied from vscode-lean4 +async function revealEditorSelection(fsPath: string, selection?: vs.Selection) { + let editor = vs.window.visibleTextEditors.find(v => v.document.uri.fsPath === fsPath) + if (editor === undefined) { + editor = await vs.window.showTextDocument(vs.Uri.file(fsPath), { + viewColumn: vs.window.activeTextEditor?.viewColumn ?? vs.ViewColumn.One, + preserveFocus: false, + }) + } + if (selection !== undefined) { + editor.revealRange(selection, vs.TextEditorRevealType.InCenterIfOutsideViewport) + editor.selection = selection + // ensure the text document has the keyboard focus. + await vs.window.showTextDocument(editor.document, { viewColumn: editor.viewColumn, preserveFocus: false }) + } +} + +// https://code.visualstudio.com/api/extension-guides/tree-view +export class WorkbenchPanelProvider implements vs.TreeDataProvider, vs.Disposable { + /** username ↦ client data */ + private onlineUsers = new Map() + + private readonly onDidChangeTreeDataEmitter = new vs.EventEmitter() + readonly onDidChangeTreeData = this.onDidChangeTreeDataEmitter.event + + private readonly disposables: vs.Disposable[] = [] + + private readonly log: Logger + + constructor( + private readonly awareness: Awareness, + private readonly mdata: WorkspaceMetadata, + log_: Logger, + ) { + this.log = logWithPrefix(log_, '[WorkbenchPanelProvider]') + + const onAwarenessChange = () => this.onAwarenessChange() + awareness.on('change', onAwarenessChange) + this.disposables.push( + { dispose: () => awareness.off('change', onAwarenessChange) }, + vs.commands.registerCommand(COMMAND_GOTO_AWARENESS_USER, async (userName: string) => { + let filePath: string | undefined = undefined + let sel: vs.Selection | undefined = undefined + // One user can have multiple active sessions. + // We arbitrarily choose the first remote session with a non-empty selection. + for (const [clientId, state] of awareness.getStates()) { + if (this.awareness.clientID === clientId) continue + const user = state[AWARENESS_USER_KEY] as AwarenessUser | undefined + if (!user || user.name !== userName) continue + const sels = state[AWARENESS_SELECTION_KEY] as AwarenessSelection | undefined + if (!sels) continue + filePath = sels.filePath + if (0 < sels.selections.length) { + const active = sels.selections[0].active + const pos = new vs.Position(active.line, active.character) + sel = new vs.Selection(pos, pos) + break + } + } + if (filePath) await revealEditorSelection(filePath, sel) + }), + ) + this.onAwarenessChange() + } + + onAwarenessChange() { + const newUsers = new Map() + for (const [, state] of this.awareness.getStates()) { + const user = state[AWARENESS_USER_KEY] as AwarenessUser | undefined + // The same user can appear multiple times, e.g. when opening a project tab twice. + // FIXME: show with multiplicity? + if (user) newUsers.set(user.name, user) + } + if (equalMaps(this.onlineUsers, newUsers, equalAwarenessUsers)) return + this.log.debug(`users changed: ${JSON.stringify([...newUsers])}`) + this.onlineUsers = newUsers + this.onDidChangeTreeDataEmitter.fire() + } + + getTreeItem(item: PanelItem): vs.TreeItem { + switch (item.kind) { + case 'onlineUsersRoot': + return new vs.TreeItem('Online users', vs.TreeItemCollapsibleState.Expanded) + case 'onlineUser': { + let displayName = item.user.name + if (item.user.name === this.mdata.viewer.name) displayName += ' (You)' + const ti = new vs.TreeItem(displayName, vs.TreeItemCollapsibleState.None) + let iconUri: vs.Uri | undefined = undefined + try { + if (item.user.image) iconUri = vs.Uri.parse(item.user.image, true) + } catch {} + ti.iconPath = iconUri ? iconUri : new vs.ThemeIcon('account') + ti.command = { command: COMMAND_GOTO_AWARENESS_USER, title: 'Jump to cursor', arguments: [item.user.name] } + return ti + } + } + } + + getChildren(item?: PanelItem): PanelItem[] { + if (!item) return [{ kind: 'onlineUsersRoot' }] + if (item.kind !== 'onlineUsersRoot') return [] + const items = this.onlineUsers + .values() + .map(user => ({ kind: 'onlineUser', user }) satisfies PanelItem) + .toArray() + items.sort((a, b) => a.user.name.localeCompare(b.user.name)) + return items + } + + dispose() { + for (const d of this.disposables) d.dispose() + this.disposables.length = 0 + this.onDidChangeTreeDataEmitter.dispose() + } +} diff --git a/vscode-workbench/src/remoteDoc.ts b/vscode-workbench/src/remoteDoc.ts deleted file mode 100644 index f26babc..0000000 --- a/vscode-workbench/src/remoteDoc.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { HocuspocusProvider, type HocuspocusProviderWebsocket, type onSyncedParameters } from '@hocuspocus/provider' -import vs from 'vscode' -import * as Y from 'yjs' - -/** Manages per-doc connections to `collab-server`. - * We refer to collaborative text buffers as 'docs', matching Yjs's `Y.Doc`. - * Docs are addressed by file paths but exist in `collab-server`'s memory. - * Docs can be open despite not having an underlying file - * (e.g. when one person removes a file that others still have open). - * They are only written to the filesystem when users save. */ -export class RemoteDocManager implements vs.Disposable { - /** absolute path ↦ remote doc connection */ - private hpPromises = new Map>() - - constructor( - private readonly collabSock: HocuspocusProviderWebsocket, - private readonly log: vs.LogOutputChannel, - ) {} - - /** Return the remote connection if one has already been started for the given file path. - * Waits for initial sync but does not initiate a new connection. */ - async getStartedDoc(filePath: string): Promise { - return this.hpPromises.get(filePath) - } - - /** Return the remote connection for the given file path. - * Waits for initial sync of buffer contents before returning. */ - // TODO: when do we close a remote doc? `onDidCloseTextDocument`? - async ensureDoc(filePath: string): Promise { - let p = this.hpPromises.get(filePath) - if (!p) { - p = new Promise((resolve, reject) => { - // https://tiptap.dev/docs/hocuspocus/provider/examples#multiplexing - const hp = new HocuspocusProvider({ - websocketProvider: this.collabSock, - name: filePath, - }) - const timer = setTimeout(() => { - hp.destroy() - if (this.hpPromises.get(filePath) === p) this.hpPromises.delete(filePath) - reject(new Error(`[HocuspocusProvider] '${filePath}' timed out before initial sync`)) - }, 3_000) - const onInitialSync = (data?: onSyncedParameters) => { - clearTimeout(timer) - hp.off('synced', onInitialSync) - this.log.trace(`[HocuspocusProvider] '${filePath}' synced ${data ? String(data.state) : ''}`) - resolve(hp) - } - if (hp.synced) onInitialSync() - else hp.on('synced', onInitialSync) - hp.attach() - }) - this.hpPromises.set(filePath, p) - } - return (await p).document - } - - /** Signal `collab-server` to save the contents of the given doc. */ - async saveDoc(filePath: string) { - const p = this.hpPromises.get(filePath) - if (!p) return - const hp = await p - hp.sendStateless(JSON.stringify({ action: 'save' })) - } - - dispose() { - for (const p of this.hpPromises.values()) { - void p.then(hp => hp.destroy()).catch(() => {}) - } - this.hpPromises.clear() - } -} diff --git a/vscode-workbench/src/remoteSelections.ts b/vscode-workbench/src/remoteSelections.ts new file mode 100644 index 0000000..231e1ef --- /dev/null +++ b/vscode-workbench/src/remoteSelections.ts @@ -0,0 +1,149 @@ +import vs from 'vscode' +import { Awareness } from 'y-protocols/awareness' +import { AWARENESS_SELECTION_KEY, AWARENESS_USER_KEY, AwarenessSelection, AwarenessUser } from './util' + +interface AwarenessState { + [AWARENESS_USER_KEY]: AwarenessUser + [AWARENESS_SELECTION_KEY]: AwarenessSelection +} + +class ClientDecorations implements vs.Disposable { + readonly cursorBefore: vs.TextEditorDecorationType + readonly cursorAfter: vs.TextEditorDecorationType + + constructor(color: string) { + const selectionStyle: vs.DecorationRenderOptions = { + backgroundColor: `${color}40`, + rangeBehavior: vs.DecorationRangeBehavior.ClosedClosed, + overviewRulerColor: color, + overviewRulerLane: vs.OverviewRulerLane.Center, + } + const cursorStyle: vs.ThemableDecorationAttachmentRenderOptions = { + contentText: '', + border: `1px solid ${color}`, + margin: '0px -1px', + } + this.cursorBefore = vs.window.createTextEditorDecorationType({ + ...selectionStyle, + before: cursorStyle, + }) + this.cursorAfter = vs.window.createTextEditorDecorationType({ + ...selectionStyle, + after: cursorStyle, + }) + } + + dispose() { + this.cursorBefore.dispose() + this.cursorAfter.dispose() + } +} + +/** Listens for awareness changes from other clients + * (see `textBinding.ts` for the sending side) + * and shows remote selections as decorations in the appropriate editor view. */ +export class RemoteSelectionDecorator implements vs.Disposable { + private readonly localClientId: number + private readonly decorationTypes = new Map() + private states = new Map() + private readonly disposables: vs.Disposable[] = [] + + constructor(private readonly awareness: Awareness) { + this.localClientId = awareness.clientID + const onAwarenessChange = () => this.onAwarenessChange() + awareness.on('change', onAwarenessChange) + this.disposables.push( + { dispose: () => awareness.off('change', onAwarenessChange) }, + // Redraw indicators on editor visibility changes, too. + vs.window.onDidChangeVisibleTextEditors(() => this.render()), + vs.window.onDidChangeTextEditorSelection(e => { + if (e.textEditor.document.uri.scheme !== 'file') return + this.awareness.setLocalStateField(AWARENESS_SELECTION_KEY, { + filePath: e.textEditor.document.uri.fsPath, + // FIXME: use LSP types + selections: e.selections.map(s => ({ + anchor: { line: s.anchor.line, character: s.anchor.character }, + active: { line: s.active.line, character: s.active.character }, + })), + } satisfies AwarenessSelection) + }), + vs.workspace.onDidCloseTextDocument(doc => { + if (doc.uri.scheme !== 'file') return + // If cursor was in the now-closed doc, clear it. + const sel = this.awareness.getLocalState()?.[AWARENESS_SELECTION_KEY] as AwarenessSelection | undefined + if (sel?.filePath === doc.uri.fsPath) { + this.awareness.setLocalStateField(AWARENESS_SELECTION_KEY, null) + } + }), + ) + this.onAwarenessChange() + } + + private decorationsFor(clientId: number, color: string): ClientDecorations { + let decos = this.decorationTypes.get(clientId) + if (!decos) { + decos = new ClientDecorations(color) + this.decorationTypes.set(clientId, decos) + } + return decos + } + + private onAwarenessChange(): void { + const states = this.awareness.getStates() + const next = new Map() + for (const [clientId, state] of states.entries()) { + if (clientId === this.localClientId) continue + const selection = state[AWARENESS_SELECTION_KEY] as AwarenessSelection | undefined + const user = state[AWARENESS_USER_KEY] as AwarenessUser | undefined + if (!selection || !user) continue + next.set(clientId, { selection, user }) + } + // Drop decoration types for clients that are gone. + for (const [clientId, decos] of this.decorationTypes) { + if (next.has(clientId)) continue + decos.dispose() + this.decorationTypes.delete(clientId) + } + this.states = next + this.render() + } + + private render(): void { + for (const editor of vs.window.visibleTextEditors) { + if (editor.document.uri.scheme !== 'file') continue + const filePath = editor.document.uri.fsPath + for (const [clientId, { selection, user }] of this.states) { + const decos = this.decorationsFor(clientId, user.color) + const beforeRanges: vs.DecorationOptions[] = [] + const afterRanges: vs.DecorationOptions[] = [] + if (selection?.filePath === filePath) { + for (const s of selection.selections) { + const range = new vs.Range(s.anchor.line, s.anchor.character, s.active.line, s.active.character) + const opts = { range, hoverMessage: user?.name } + // Is `active` at the start or the end of the selection? + if ( + s.active.line < s.anchor.line || + (s.active.line === s.anchor.line && s.active.character < s.anchor.character) + ) { + beforeRanges.push(opts) + } else { + afterRanges.push(opts) + } + } + } + editor.setDecorations(decos.cursorBefore, beforeRanges) + editor.setDecorations(decos.cursorAfter, afterRanges) + } + } + } + + dispose(): void { + for (const d of this.disposables) d.dispose() + this.disposables.length = 0 + for (const decos of this.decorationTypes.values()) { + decos.dispose() + } + this.decorationTypes.clear() + this.states.clear() + } +} diff --git a/vscode-workbench/src/textBinding.ts b/vscode-workbench/src/textBinding.ts index 34d039d..1b3d116 100644 --- a/vscode-workbench/src/textBinding.ts +++ b/vscode-workbench/src/textBinding.ts @@ -1,17 +1,17 @@ +import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider' import path from 'node:path' import vs from 'vscode' import * as Y from 'yjs' -import { RemoteDocManager } from './remoteDoc' -import { YTEXT_KEY } from './util' +import { Logger, logWithPrefix, YTEXT_KEY } from './util' /** Maintains a {@link YTextBinding} binding for every open {@link vs.TextDocument} * whose path lies within one of the syncable directories. */ export class YTextBindingManager implements vs.Disposable { - private bindings = new Map>() + private bindings = new Map() private disposables: vs.Disposable[] = [] constructor( - private readonly docs: RemoteDocManager, + private readonly collabSock: HocuspocusProviderWebsocket, /** Directories to sync. Files not contained in any of these are not synced. */ private syncDirs: string[], private readonly log: vs.LogOutputChannel, @@ -29,10 +29,10 @@ export class YTextBindingManager implements vs.Disposable { updateSyncableDirs(syncDirs: string[]) { this.syncDirs = syncDirs // Tear down bindings no longer in any syncable dir - for (const [filePath, entry] of this.bindings) { + for (const [filePath, binding] of this.bindings) { if (this.shouldSyncPath(filePath)) continue this.bindings.delete(filePath) - void entry.then(hp => hp?.dispose()) + binding.dispose() } // Rebind already-open buffers in case they are now syncable // (no-op if already bound) @@ -52,149 +52,404 @@ export class YTextBindingManager implements vs.Disposable { if (!this.shouldSyncPath(filePath)) return // TODO: can one path have multiple `TextDocument`s? if (this.bindings.has(filePath)) return - const remoteDoc = this.docs.ensureDoc(filePath) - const promise = remoteDoc - .then(rd => new YTextBinding(doc, rd, this.log)) - .catch(err => { - this.log.error(`[onDidOpenTextDocument] failed to initialize Yjs binding for '${filePath}': ${String(err)}`) - if (this.bindings.get(filePath) === promise) this.bindings.delete(filePath) - return undefined - }) - this.bindings.set(filePath, promise) + this.bindings.set(filePath, new YTextBinding(doc, this.collabSock, this.log)) } private onDidCloseTextDocument(doc: vs.TextDocument) { if (doc.uri.scheme !== 'file') return const filePath = doc.uri.fsPath - const entry = this.bindings.get(filePath) - if (!entry) return + const binding = this.bindings.get(filePath) + if (!binding) return this.bindings.delete(filePath) - void entry.then(hp => hp?.dispose()) + binding.dispose() } private onDidChangeTextDocument(e: vs.TextDocumentChangeEvent) { if (e.document.uri.scheme !== 'file') return const filePath = e.document.uri.fsPath - const entry = this.bindings.get(filePath) - if (!entry) { + const binding = this.bindings.get(filePath) + if (!binding) { if (this.shouldSyncPath(filePath)) { this.log.warn(`[onDidChangeTextDocument] dropped edit on '${filePath}', missing YTextBinding`) } return } - void entry.then(hp => hp?.onLocalChange(e)) + binding.onLocalChange(e) } dispose() { for (const d of this.disposables) d.dispose() this.disposables = [] - for (const b of this.bindings.values()) void b.then(hp => hp?.dispose()) + for (const b of this.bindings.values()) b.dispose() this.bindings.clear() } } -/** Bidirectional binding between a {@link vs.TextDocument} and a {@link Y.Doc}. */ +/** Common interface between {@link vs.TextEditorEdit} and {@link vs.WorkspaceEdit}. */ +interface EditBuilder { + insert(location: vs.Position, newText: string): void + delete(location: vs.Range): void + replace(range: vs.Range, newText: string): void +} + +/** Bidirectional binding between a {@link vs.TextDocument} + * and the {@link Y.Text} of a {@link HocuspocusProvider}. + * + * WARNING: Unlike Monaco, VSCode has no API for synchronous edits. + * This binding is correspondingly much more subtle than Y-Monaco. + * When a remote change comes in, + * we repeatedly attempt to apply it as an asynchronous edit; + * an attempt may fail if a local change is made in the meantime. + * To compute the correct edit w.r.t. the current document contents, + * we diff the `remoteYtext` that contains remote changes + * against our `localYtext` that matches the VSCode-managed `doc`. + * + * The synchronization flow of a single change is: + * ```mermaid + * flowchart TB + * subgraph Alice["Alice's extensionHost"] + * ADoc["vscode.TextDocument"] + * AYjsL["local Y.Doc"] + * AYjsR["remote Y.Doc"] + * ADoc -->|"YTextBinding.onLocalChange"| AYjsL + * AYjsL -->|"YTextBinding.onLocalUpdate"| AYjsR + * end + + * subgraph Bob["Bob's extensionHost"] + * BDoc["vscode.TextDocument"] + * BYjsL["local Y.Doc"] + * BYjsR["remote Y.Doc"] + * BYjsR -->|"YTextBinding.mergeRemoteDiff"| BDoc & BYjsL + * end + + * subgraph Server["collab-server"] + * CYjs["collab Y.Doc"] + * end + + * AYjsR -->|"sync"| CYjs + * CYjs -->|"sync"| BYjsR + * ``` + */ export class YTextBinding implements vs.Disposable { - private ytext: Y.Text - /** Used to prevent `applyEdit` bounceback when applying remote changes; - * when set, local `onDidChangeTextDocument` events are ignored. */ - // FIXME: try hard to hack through vscode and tag edit events. Would be much simpler. - private applyingRemote = false - /** Used to linearize async operations that might otherwise interleave. */ - private pending: Promise = Promise.resolve() + private readonly hs: HocuspocusProvider + + /** Public for tests only. */ + get remoteYtext(): Y.Text { + return this.hs.document.getText(YTEXT_KEY) + } - private disposables: { dispose(): unknown }[] = [] + /** The CRDT that we base remote diffs on (see comment above). + * - Defined from initial sync onwards. + * - Inbetween {@link mutex}-guarded transactions, + * its contents must match those of {@link doc}. + * - Includes a subset of the updates seen by {@link hs}. */ + private localYdoc: Y.Doc | undefined + + /** True once we have received an initial remote doc from the collab-server. + * Public for tests only. */ + get initialSyncDone(): boolean { + return !!this.localYdoc + } + + private get localYtext(): Y.Text { + return this.localYdoc!.getText(YTEXT_KEY) + } + + private ensureSyncTimeout: NodeJS.Timeout | undefined + + /** Used to linearize and order async operations + * that temporarily create inconsistent states while suspended. */ + private mutex: Promise = Promise.resolve() + + private readonly log: Logger - /** May only be constructed with a `Y.Doc` whose provider has already `synced` at least once. */ constructor( readonly doc: vs.TextDocument, - readonly remoteDoc: Y.Doc, - private readonly log: vs.LogOutputChannel, + collabSock: HocuspocusProviderWebsocket, + log_: Logger, + /** The timeout period for {@link scheduleEnsureSync} in milliseconds, + * disabling {@link scheduleEnsureSync} if zero. + * Expected to be non-zero except in tests. */ + private readonly ensureSyncTimeoutMs: number = 3_000, + /** Name of this document in {@link collabSock}. + * Expected to be the file path except in tests. */ + docName: string = doc.uri.fsPath, ) { - this.ytext = remoteDoc.getText(YTEXT_KEY) + // https://tiptap.dev/docs/hocuspocus/provider/examples#multiplexing + this.hs = new HocuspocusProvider({ + websocketProvider: collabSock, + name: docName, + // We use a single, global awareness CRDT rather than per-document CRDTs. + awareness: null, + }) + this.hs.attach() + + this.log = logWithPrefix(log_, `[YTextBinding(${docName}|${this.hs.document.clientID.toString(16)})]`) - // Overwrite with remote contents on startup. - this.enqueue(() => this.replaceWithRemote()) + this.remoteYtext.observe(this.onRemoteUpdate) - const observer = (event: Y.YTextEvent, transaction: Y.Transaction) => { - // Prevent bounceback (https://beta.yjs.dev/docs/api/transactions/#the-origin-concept). - if (transaction.origin === this) return - const delta = event.delta - this.enqueue(() => this.applyDelta(delta)) + const onLaterSync = () => { + this.log.warn(`unexpected reconnection from collab-server`) + } + if (this.hs.synced) { + this.enqueueTransaction(() => this.initFromRemote()) + this.hs.on('synced', onLaterSync) + } else { + const onInitialSync = () => { + this.hs.off('synced', onInitialSync) + this.enqueueTransaction(() => this.initFromRemote()) + this.hs.on('synced', onLaterSync) + } + this.hs.on('synced', onInitialSync) } - this.ytext.observe(observer) - this.disposables.push({ - dispose: () => { - this.ytext.unobserve(observer) - }, - }) } /** Place an operation on the work queue. * Work items are atomic w.r.t. all other work items * (but not w.r.t. other `async` operations). */ - private enqueue(work: () => Promise): void { - this.pending = this.pending.then(work).catch(e => { - this.log.error(`[YTextBinding(${this.doc.uri.fsPath})] ${String(e)}`) + private enqueueTransaction(work: () => Promise): void { + this.mutex = this.mutex.then(work).catch(e => { + this.log.error(e) }) } + /** Attempt to make a local edit. Return `true` iff successful. */ + private async makeLocalEdit(fn: (_: EditBuilder) => void): Promise { + const MAX_EDITOR_RETRIES = 10 + const MAX_WORKSPACE_RETRIES = 10 + + /** First try {@link vs.TextEditor.edit}. + * Preferrable because it has a version guard: + * `fn` runs with a given {@link vs.TextDocument.version} + * and the resulting edit is rejected if the doc moves in the meantime, + * guaranteeing correct offsets. + * Needs the document to be open in a visible editor. */ + for (let i = 0; i < MAX_EDITOR_RETRIES; i++) { + let hasEditor = false + for (const e of vs.window.visibleTextEditors) { + if (e.document === this.doc) { + hasEditor = true + const success = await e.edit(fn) + if (success) { + this.log.trace('[makeLocalEdit] used TextEditor.edit') + return true + } + } + } + if (!hasEditor) break + } + + /** Then try {@link vs.WorkspaceEdit}. + * No version guard so can't handle concurrent local edits, + * but generally only runs if the document is not being actively edited + * (except for very rare races with programmatic edits). */ + for (let i = 0; i < MAX_WORKSPACE_RETRIES; i++) { + const edit = new vs.WorkspaceEdit() + fn({ + insert: (l, t) => edit.insert(this.doc.uri, l, t), + delete: r => edit.delete(this.doc.uri, r), + replace: (r, t) => edit.replace(this.doc.uri, r, t), + }) + const success = await vs.workspace.applyEdit(edit) + if (success) { + this.log.trace('[makeLocalEdit] used workspace.applyEdit') + return true + } + } + return false + } + + /** (Re)initialize {@link localYdoc} and {@link doc} with the remote text, + * replacing the entire {@link doc} buffer if necessary. + * Avoids making an edit when contents already match. */ + private async initFromRemote(): Promise { + if (this.localYdoc) { + this.localYdoc.off('update', this.onLocalUpdate) + this.localYdoc.destroy() + } + this.localYdoc = new Y.Doc() + Y.applyUpdate(this.localYdoc, Y.encodeStateAsUpdate(this.hs.document)) + this.localYdoc.on('update', this.onLocalUpdate) + + const remoteStr = this.remoteYtext.toString() + const localStr = this.doc.getText() + let success = false + if (remoteStr === localStr) { + success = true + } else { + success = await this.makeLocalEdit(b => { + const fullRange = new vs.Range(new vs.Position(0, 0), this.doc.positionAt(this.doc.getText().length)) + b.replace(fullRange, remoteStr) + }) + } + if (success) { + this.log.trace('[initFromRemote] synced') + } else { + this.log.error(`[initFromRemote] failed to overwrite document`) + } + } + + private onRemoteUpdate = (_: Y.YTextEvent, transaction: Y.Transaction) => { + // First check prevents bounceback (https://beta.yjs.dev/docs/api/transactions/#the-origin-concept). + // Second ignores remote deltas before remote doc has been received. + if (transaction.origin === this || !this.initialSyncDone) return + this.scheduleMergeRemoteDiff() + } + + /** Event handler for `this.localYDoc.on('update')`. */ + private onLocalUpdate = (update: Uint8Array, origin: unknown) => { + // On the local doc, `origin === this` means *do broadcast*. + if (origin !== this || !this.initialSyncDone) return + + /** Apply this broadcastable local change to the remote doc. + * Crucial to make edits on local Y.doc in {@link onLocalChange} and propagate the update to remote + * rather than the other way around: + * in general, remote has seen more updates than local, + * so an update generated on the remote CRDT may have overly recent clocks, + * thus being stashed in the local doc's `pendingStructs` + * rather than being immediately applied. + * This would lead it to produce deltas duplicating local changes in {@link mergeRemoteDiff}. */ + Y.applyUpdate(this.hs.document, update, this) + } + onLocalChange(e: vs.TextDocumentChangeEvent): void { - // BUG 1: local edits that arrive while `applyingRemote` is set, - // if that is possible, are lost. - // would also linearizing `onLocalChange` help? - if (this.applyingRemote) return - if (e.document !== this.doc) return - if (e.contentChanges.length === 0) return - this.remoteDoc.transact(() => { + if (e.document !== this.doc) throw new Error('internal error: received event for wrong document') + if (!this.initialSyncDone || e.contentChanges.length === 0) return + if (!e.detailedReason) throw new Error('internal error: textDocumentChangeReason API proposal is disabled') + // File re-read from disk + if (e.detailedReason.source === 'reloadFromDisk') { + // Prefer CRDT state to on-disk contents + this.scheduleEnsureSync() + return + } + /** Prevent loopback by checking for the two methods of editing used in {@link makeLocalEdit}. */ + if ( + e.detailedReason.source === 'unknown' && + e.detailedReason.metadata.source === 'unknown' && + (!e.detailedReason.metadata.name /* workspace.applyEdit */ || + e.detailedReason.metadata.name === 'pushEditOperation' /* workspace.applyEdit */ || + e.detailedReason.metadata.name === 'MainThreadTextEditor') /* TextEditor.edit */ + ) { + // TODO these checks are *incomplete*: + // we must not broadcast our applications of remote changes + // (since that would cause an infinite broadcast loop), + // but we should broadcast programmatic edits from other extensions + // (notably Vim and VSCode Neovim). + // However, there is no VSCode API to set the `detailedReason`, + // or any other field of the resulting `TextDocumentChangeEvent`, + // and checking document versions or edit content + // turned out prone to a number of race conditions. + // The only viable fix seems to be patching `openvscode-server` + // to support setting `detailedReason.source` on our own edits. + // For now, {@link scheduleEnsureSync} reverts unbroadcasted local changes. + this.scheduleEnsureSync() + return + } + + this.log.trace( + `[onLocalChange] broadcasting ${JSON.stringify(e.contentChanges.map(c => [c.rangeOffset, c.rangeLength, c.text]))} (detailed reason ${JSON.stringify(e.detailedReason)})`, + ) + + /** Apply the broadcastable local change to {@link localYdoc} with `this` origin. + * (Remote changes are applied to {@link localYdoc} in {@link mergeRemoteDiff}.) */ + this.localYdoc!.transact(() => { // VSCode sorts `contentChanges` in reverse offset order // so they can be applied sequentially without offset adjustment. for (const ch of e.contentChanges) { - if (ch.rangeLength) this.ytext.delete(ch.rangeOffset, ch.rangeLength) - if (ch.text) this.ytext.insert(ch.rangeOffset, ch.text) + if (ch.rangeLength) this.localYtext.delete(ch.rangeOffset, ch.rangeLength) + if (ch.text) this.localYtext.insert(ch.rangeOffset, ch.text) } }, this) + this.scheduleEnsureSync() + } + + /** Whether a run of {@link mergeRemoteDiff} is already scheduled. + * Used to avoid running many redundant merges with empty deltas. */ + private mergeRemoteDiffScheduled: boolean = false + + private scheduleMergeRemoteDiff(): void { + if (this.mergeRemoteDiffScheduled) return + this.enqueueTransaction(() => this.mergeRemoteDiff()) + this.mergeRemoteDiffScheduled = true } - private async applyDelta(delta: Y.YTextEvent['delta']): Promise { - const edit = new vs.WorkspaceEdit() - let offset = 0 - for (const op of delta) { - if (op.retain != null) { - offset += op.retain - } else if (op.delete != null) { - edit.delete(this.doc.uri, new vs.Range(this.doc.positionAt(offset), this.doc.positionAt(offset + op.delete))) - offset += op.delete - } else if (typeof op.insert === 'string') { - edit.insert(this.doc.uri, this.doc.positionAt(offset), op.insert) + private async mergeRemoteDiff(): Promise { + this.mergeRemoteDiffScheduled = false + let update: Uint8Array | undefined + const mkEdits = (b: EditBuilder) => { + // Observe the delta by updating a fresh Y.Doc + // (there is no way to compute a delta from an update directly). + const fork = new Y.Doc() + Y.applyUpdate(fork, Y.encodeStateAsUpdate(this.localYdoc!)) + // Yjs fires observers synchronously, thus populating `delta`, during `applyUpdate`. + let delta: Y.YTextEvent['delta'] = [] + fork.getText(YTEXT_KEY).observe(e => { + delta = e.delta + }) + update = Y.encodeStateAsUpdate(this.hs.document) + Y.applyUpdate(fork, update) + fork.destroy() + if (delta.length === 0) return + + this.log.trace(`[mergeRemoteDiff] applying delta ${JSON.stringify(delta)}`) + let offset = 0 + for (const op of delta) { + if (typeof op.retain === 'number') { + offset += op.retain + } else if (typeof op.delete === 'number') { + b.delete(new vs.Range(this.doc.positionAt(offset), this.doc.positionAt(offset + op.delete))) + offset += op.delete + } else if (typeof op.insert === 'string') { + // Ignoring `op.insert : object | Y.AbstractType` + b.insert(this.doc.positionAt(offset), op.insert) + } } } - this.applyingRemote = true - try { - await vs.workspace.applyEdit(edit) - } finally { - this.applyingRemote = false + + const success = await this.makeLocalEdit(mkEdits) + if (success) { + if (!update) throw new Error('[mergeRemoteDiff] internal error: edit succeeded but update missing') + // Edit is now included in `doc`, update `localYdoc` to match. + Y.applyUpdate(this.localYdoc!, update) + } else { + this.log.warn(`[mergeRemoteDiff] edit failed, dropping update`) } + this.scheduleEnsureSync() } - /** Ensure that buffer contents match the Y.Doc text - * by replacing the entire buffer if necessary. - * Avoids making an edit when contents already match. */ - async replaceWithRemote() { - const ytextStr = this.ytext.toString() - if (ytextStr === this.doc.getText()) return - const edit = new vs.WorkspaceEdit() - const fullRange = new vs.Range(new vs.Position(0, 0), this.doc.positionAt(this.doc.getText().length)) - edit.replace(this.doc.uri, fullRange, ytextStr) - this.applyingRemote = true - try { - await vs.workspace.applyEdit(edit) - } finally { - this.applyingRemote = false - } + /** Ensure that {@link localYdoc} has the same contents as {@link doc}, + * and that {@link localYdoc} and {@link hs} have the same CRDT state. + * Overwrite {@link localYdoc} and {@link doc} if this is not the case. + * Debounced - runs {@link ensureSyncTimeoutMs} after the most recent invocation. */ + private scheduleEnsureSync() { + if (!this.initialSyncDone || this.ensureSyncTimeoutMs === 0) return + if (this.ensureSyncTimeout) clearTimeout(this.ensureSyncTimeout) + this.ensureSyncTimeout = setTimeout(() => { + this.enqueueTransaction(async () => { + const localYtextStr = this.localYtext.toString() + const docStr = this.doc.getText() + if (docStr !== localYtextStr) { + this.log.debug('[ensureSync] doc<->localYdoc desync, overwriting') + await this.initFromRemote() + return + } + const sl = Y.encodeStateVector(this.localYdoc!) + const sr = Y.encodeStateVector(this.hs.document) + if (sl.length !== sr.length || !sl.every((b, i) => b === sr[i])) { + this.log.debug('[ensureSync] remoteYdoc<->localYdoc desync, overwriting') + await this.initFromRemote() + } + }) + }, this.ensureSyncTimeoutMs) } dispose() { - for (const d of this.disposables) d.dispose() + clearTimeout(this.ensureSyncTimeout) + + this.localYdoc?.off('update', this.onLocalUpdate) + this.localYdoc?.destroy() + + this.remoteYtext.unobserve(this.onRemoteUpdate) + this.hs.destroy() } } diff --git a/vscode-workbench/src/util.ts b/vscode-workbench/src/util.ts index e5d3f60..417a169 100644 --- a/vscode-workbench/src/util.ts +++ b/vscode-workbench/src/util.ts @@ -1,11 +1,110 @@ +import fs from 'node:fs/promises' +import vs from 'vscode' +import { z } from 'zod' + +/** Subset of {@link vs.LogOutputChannel} used for our purposes. */ +export type Logger = Pick + +/** Wrap `log` so that every message is prepended with `prefix`. */ +export function logWithPrefix(log: Logger, prefix: string): Logger { + class PrefixedLogger { + get logLevel() { + return log.logLevel + } + trace(msg: string, ...args: unknown[]) { + log.trace(`${prefix} ${msg}`, ...args) + } + debug(msg: string, ...args: unknown[]) { + log.debug(`${prefix} ${msg}`, ...args) + } + info(msg: string, ...args: unknown[]) { + log.info(`${prefix} ${msg}`, ...args) + } + warn(msg: string, ...args: unknown[]) { + log.warn(`${prefix} ${msg}`, ...args) + } + error(msg: string | Error, ...args: unknown[]) { + log.error(`${prefix} ${String(msg)}`, ...args) + } + } + return new PrefixedLogger() +} + +export function equalMaps(a: Map, b: Map, eqU?: (a: U, b: U) => boolean): boolean { + if (a.size !== b.size) return false + for (const [id, u] of a) { + if (!b.has(id)) return false + const v = b.get(id) as U + if (eqU ? !eqU(v, u) : v !== u) return false + } + return true +} + // FIXME: use same consts in workbench-app/collab-server for single source of truth. +/** Path to workspace metadata file in VSCode bwraps. */ +export const BWRAP_METADATA_PATH = '/workspace/.lean-workbench.json' + +export const zWorkspaceMetadata = z.object({ + /** Name of the user viewing/editing the project. */ + viewer: z.object({ + name: z.string(), + image: z.nullish(z.string()), + }), +}) + +/** Metadata of a Lean Workbench project workspace. */ +export type WorkspaceMetadata = z.infer + /** Working directory of collab-server in the VSCode and collab-server bwraps. */ export const BWRAP_COLLAB_SERVER_DIR = '/workspace/.collab-server' /** Collab-server socket path in the VSCode and collab-server bwraps. */ export const BWRAP_COLLAB_SOCK_PATH = `${BWRAP_COLLAB_SERVER_DIR}/collab.sock` -/** We keep a unique Y.Doc per file. +/** We keep a Y.Doc per collaboratively-editable file. * This is the Y.Doc key under which the text content lives. */ export const YTEXT_KEY = 'content' + +export interface Position { + line: number + character: number +} + +export interface Selection { + anchor: Position + active: Position +} + +export interface AwarenessUser { + name: string + color: string + image?: string | null +} + +export function equalAwarenessUsers(u: AwarenessUser, v: AwarenessUser): boolean { + return v.name === u.name && v.color === u.color && v.image === u.image +} + +export const AWARENESS_DOC_NAME = '' +export const AWARENESS_USER_KEY = 'user' +export const AWARENESS_SELECTION_KEY = 'selection' +/** Colors for remote collaborator cursors. */ +export const AWARENESS_CURSOR_COLORS = ['#5790FC', '#F89C20', '#E42536', '#964A8B', '#9C9CA1', '#7A21DD'] + +export interface AwarenessSelection { + filePath: string + selections: Selection[] +} + +export async function waitForPath(p: string, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + try { + await fs.access(p) + return true + } catch {} + await new Promise(r => setTimeout(r, 200)) + } + return false +} diff --git a/vscode-workbench/src/vscode.proposed.extensionsAny.d.ts b/vscode-workbench/src/vscode.proposed.extensionsAny.d.ts new file mode 100644 index 0000000..caaffee --- /dev/null +++ b/vscode-workbench/src/vscode.proposed.extensionsAny.d.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + // https://github.com/microsoft/vscode/issues/145307 @alexdima + + export interface Extension { + /** + * `true` when the extension is associated to another extension host. + * + * *Note* that an extension from another extension host cannot export + * API, e.g {@link Extension.exports its exports} are always `undefined`. + */ + readonly isFromDifferentExtensionHost: boolean + } + + export namespace extensions { + /** + * Get an extension by its full identifier in the form of: `publisher.name`. + * + * @param extensionId An extension identifier. + * @param includeDifferentExtensionHosts Include extensions from different extension host + * @return An extension or `undefined`. + */ + export function getExtension( + extensionId: string, + includeDifferentExtensionHosts: boolean, + ): Extension | undefined + export function getExtension( + extensionId: string, + includeDifferentExtensionHosts: true, + ): Extension | undefined + + /** + * All extensions across all extension hosts. + * + * @see {@link Extension.isFromDifferentExtensionHost} + */ + export const allAcrossExtensionHosts: readonly Extension[] + } +} diff --git a/vscode-workbench/src/vscode.proposed.textDocumentChangeReason.d.ts b/vscode-workbench/src/vscode.proposed.textDocumentChangeReason.d.ts new file mode 100644 index 0000000..8c25c8d --- /dev/null +++ b/vscode-workbench/src/vscode.proposed.textDocumentChangeReason.d.ts @@ -0,0 +1,29 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +declare module 'vscode' { + /** + * Detailed information about why a text document changed. + */ + export interface TextDocumentDetailedChangeReason { + /** + * The source of the change (e.g., 'inline-completion', 'chat-edit', 'extension') + */ + readonly source: string + + /** + * Additional context-specific metadata + */ + readonly metadata: { readonly [key: string]: unknown } + } + + export interface TextDocumentChangeEvent { + /** + * The precise reason for the document change. + * Only available to extensions that have enabled the `textDocumentChangeReason` proposed API. + */ + readonly detailedReason: TextDocumentDetailedChangeReason | undefined + } +} diff --git a/vscode-workbench/test/collab.test.ts b/vscode-workbench/test/collab.test.ts new file mode 100644 index 0000000..e4dacc7 --- /dev/null +++ b/vscode-workbench/test/collab.test.ts @@ -0,0 +1,221 @@ +import { HocuspocusProviderWebsocket } from '@hocuspocus/provider' +import { Server } from '@hocuspocus/server' +import * as assert from 'assert' +import * as vs from 'vscode' +import { YTextBinding } from '../src/textBinding' +import { Logger } from '../src/util' + +const delay = (ms: number) => new Promise(r => setTimeout(r, ms)) + +const logLevel: vs.LogLevel = vs.LogLevel.Debug as unknown as vs.LogLevel // cast prevents TS from complaining +const consoleLog: Logger = { + logLevel, + trace: (m, ...a) => { + if (logLevel !== vs.LogLevel.Off && logLevel <= vs.LogLevel.Trace) console.log('[trace]', m, ...a) + }, + debug: (m, ...a) => { + if (logLevel !== vs.LogLevel.Off && logLevel <= vs.LogLevel.Debug) console.log('[debug]', m, ...a) + }, + info: (m, ...a) => { + if (logLevel !== vs.LogLevel.Off && logLevel <= vs.LogLevel.Info) console.log('[info]', m, ...a) + }, + warn: (m, ...a) => { + if (logLevel !== vs.LogLevel.Off && logLevel <= vs.LogLevel.Warning) console.warn('[warn]', m, ...a) + }, + error: (m, ...a) => { + if (logLevel !== vs.LogLevel.Off && logLevel <= vs.LogLevel.Error) console.error('[error]', String(m), ...a) + }, +} + +/** Small seeded PRNG (mulberry32) for reproducibility. */ +function mulberry32(seed: number): () => number { + let a = seed >>> 0 + return () => { + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +const ALPHABET = 'abcdef \n' +function randomText(rng: () => number, len: number): string { + let s = '' + for (let i = 0; i < len; i++) s += ALPHABET[Math.floor(rng() * ALPHABET.length)] + return s +} + +/** Activate `doc` in `column`, then apply one random insert or delete + * by executing the `default:type` / `deleteLeft` commands on its editor. */ +async function randomEditOn(doc: vs.TextDocument, column: vs.ViewColumn, rng: () => number): Promise { + const editor = await vs.window.showTextDocument(doc, { viewColumn: column, preserveFocus: false, preview: false }) + const len = doc.getText().length + const insert = len === 0 || rng() < 0.6 + if (insert) { + const at = Math.floor(rng() * (len + 1)) + editor.selection = new vs.Selection(doc.positionAt(at), doc.positionAt(at)) + const text = randomText(rng, 1 + Math.floor(rng() * 6)) + try { + await vs.commands.executeCommand('default:type', { text }) + } catch (e) { + console.error('error on type', e) + } + } else { + const at = Math.floor(rng() * len) + const dl = 1 + Math.floor(rng() * Math.min(5, len - at)) + editor.selection = new vs.Selection(doc.positionAt(at), doc.positionAt(at + dl)) + try { + await vs.commands.executeCommand('deleteLeft') + } catch (e) { + console.error('error on deleteLeft', e) + } + } +} + +/** Poll until the given {@link YTextBinding}s receive their remote docs. */ +async function waitForInitialSync(bindings: YTextBinding[], timeoutMs: number): Promise { + const deadline = performance.now() + timeoutMs + while (!bindings.every(b => b.initialSyncDone)) { + if (performance.now() >= deadline) { + assert.fail('timed out waiting for initial sync') + } + await delay(50) + } +} + +/** Poll until the given {@link YTextBinding}s have remained unchanged for 1s. */ +async function waitForQuiescence(bindings: YTextBinding[], timeoutMs: number): Promise { + const deadline = performance.now() + timeoutMs + let prev = '' + let stableIters = 0 + while (performance.now() < deadline) { + const snap = bindings.map(b => b.remoteYtext.toString() + '|' + b.doc.getText()).join('|') + if (snap === prev) { + if (stableIters++ >= 5) return + } else { + prev = snap + stableIters = 0 + } + await delay(100) + } + assert.fail('timed out waiting for quiescent state') +} + +function diffMessage(label: string, a: string, b: string): string { + let i = 0 + while (i < a.length && i < b.length && a[i] === b[i]) i++ + const ctx = (s: string) => JSON.stringify(s.slice(Math.max(0, i - 25), i + 25)) + return `${label}: diverge at index ${i} (lenA=${a.length}, lenB=${b.length})\n A …${ctx(a)}…\n B …${ctx(b)}…` +} + +const DOC_NAME = '/test/shared.txt' +const COLUMNS: vs.ViewColumn[] = [vs.ViewColumn.One, vs.ViewColumn.Two] + +suite('Collaborative editing', () => { + interface TestHandles { + docs: vs.TextDocument[] + bindings: YTextBinding[] + dispose: () => Promise + } + + const mkHandles = async (ensureSyncTimeoutMs: number = 0): Promise => { + // In-memory Hocuspocus server on an ephemeral port; no persistence, no signal handlers. + const server = new Server({ stopOnSignals: false, quiet: true }) + await new Promise(resolve => server.httpServer.listen(0, '127.0.0.1', resolve)) + const url = `ws://127.0.0.1:${server.address.port}` + + const mkClient = () => new HocuspocusProviderWebsocket({ url }) + const clients = [mkClient(), mkClient()] + const docs = await Promise.all([ + vs.workspace.openTextDocument({ content: '', language: 'plaintext' }), + vs.workspace.openTextDocument({ content: '', language: 'plaintext' }), + ]) + const bindings = docs.map((doc, i) => new YTextBinding(doc, clients[i], consoleLog, ensureSyncTimeoutMs, DOC_NAME)) + + // Route document changes to the matching binding + // (`YTextBindingManager.onDidChangeTextDocument` does this in production). + const changeSub = vs.workspace.onDidChangeTextDocument(e => { + const i = docs.indexOf(e.document) + if (i >= 0) bindings[i].onLocalChange(e) + }) + + return { + docs, + bindings, + dispose: async () => { + changeSub.dispose() + for (const b of bindings) b.dispose() + // Not disposing old docs to avoid 'did you mean to close unsaved buffer' warnings. + for (const c of clients) c.destroy() + await server.destroy() + }, + } + } + + test('YTextBindings reach initial sync', async function () { + const handles = await mkHandles() + await waitForInitialSync(handles.bindings, 1_000) + await handles.dispose() + }) + + test('Single edit propagates correctly', async function () { + const handles = await mkHandles() + await waitForInitialSync(handles.bindings, 1_000) + + // Type on doc0 and confirm the change reaches doc1. + const ed0 = await vs.window.showTextDocument(handles.docs[0], { viewColumn: COLUMNS[0], preview: false }) + ed0.selection = new vs.Selection(0, 0, 0, 0) + const text = 'PROBE\n' + await vs.commands.executeCommand('default:type', { text }) + await waitForQuiescence(handles.bindings, 1_000) + assert.strictEqual(handles.docs[1].getText(), text) + await handles.dispose() + }) + + /** Drive a batch of edits, alternating between the two documents. + * Not waiting between edits opens a local-vs-remote change race window. */ + const makeConcurrentEdits = async (handles: TestHandles) => { + const rng = mulberry32(0xc0ffee) + const NUM_EDITS = 100 + for (let i = 0; i < NUM_EDITS; i++) { + await randomEditOn(handles.docs[i % 2], COLUMNS[i % 2], rng) + } + } + + const assertEqualStates = (handles: TestHandles) => { + const [d0, d1] = handles.docs.map(d => d.getText()) + const [y0, y1] = handles.bindings.map(b => b.remoteYtext.toString()) + + // Sanity check - YJs CRDT replicas converge. + assert.strictEqual(y0, y1, diffMessage('Y.Text replicas (client0 vs client1)', y0, y1)) + // Whether docs correctly track CRDT replicas. + assert.strictEqual(d0, y0, diffMessage('doc0 vs its Y.Text', d0, y0)) + assert.strictEqual(d1, y1, diffMessage('doc1 vs its Y.Text', d1, y1)) + } + + test('Concurrent edits settle on equal states', async function () { + this.timeout(20_000) + const ensureSyncTimeoutMs = 1_000 + const handles = await mkHandles(ensureSyncTimeoutMs) + await waitForInitialSync(handles.bindings, 1_000) + await makeConcurrentEdits(handles) + // Wait for ensureSync + await delay(ensureSyncTimeoutMs + 1_000) + assertEqualStates(handles) + await handles.dispose() + }) + + test('Concurrent edits settle on equal states (no ensureSync)', async function () { + this.timeout(20_000) + const handles = await mkHandles(0) + await waitForInitialSync(handles.bindings, 1_000) + // Ensure both documents are visible: + // only `TextEditor.edit`s are expected to converge without ensureSync. + await vs.window.showTextDocument(handles.docs[0], { viewColumn: COLUMNS[0], preview: false }) + await vs.window.showTextDocument(handles.docs[1], { viewColumn: COLUMNS[1], preview: false }) + await makeConcurrentEdits(handles) + await waitForQuiescence(handles.bindings, 3_000) + await assertEqualStates(handles) + await handles.dispose() + }) +}) diff --git a/vscode-workbench/test/extension.test.ts b/vscode-workbench/test/extension.test.ts deleted file mode 100644 index 7197e15..0000000 --- a/vscode-workbench/test/extension.test.ts +++ /dev/null @@ -1,11 +0,0 @@ -import * as assert from 'assert' -import * as vscode from 'vscode' - -suite('Extension Test Suite', () => { - vscode.window.showInformationMessage('Start all tests.') - - test('Sample test', () => { - assert.strictEqual(-1, [1, 2, 3].indexOf(5)) - assert.strictEqual(-1, [1, 2, 3].indexOf(0)) - }) -}) diff --git a/vscode-workbench/tsconfig.json b/vscode-workbench/tsconfig.json index 23d772a..96b9bed 100644 --- a/vscode-workbench/tsconfig.json +++ b/vscode-workbench/tsconfig.json @@ -1,8 +1,4 @@ { "extends": "../tsconfig.json", - "compilerOptions": { - "noEmit": false, - "outDir": "out" - }, "include": ["src/**/*.ts", "test/**/*.ts"] }