From e43528001767dcf5b93a2bf49aea078ce12ed624 Mon Sep 17 00:00:00 2001 From: Wojciech Nawrocki Date: Wed, 13 May 2026 13:40:29 -0400 Subject: [PATCH 01/36] feat: NavbarExtra --- src/app/NavbarExtra.tsx | 38 ++++++++++++++++++++++++++++++++++++++ src/app/layout.tsx | 28 ++++++++++++++++------------ 2 files changed, 54 insertions(+), 12 deletions(-) create mode 100644 src/app/NavbarExtra.tsx 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/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}
+ +
) From 8b7d48c273cbc563af93f1d359ade2156aaa0464 Mon Sep 17 00:00:00 2001 From: Wojciech Nawrocki Date: Wed, 13 May 2026 13:41:20 -0400 Subject: [PATCH 02/36] feat: canAccessProject util --- src/app/[userName]/[projectName]/page.tsx | 4 ++-- src/lib/server/editorSessions.ts | 4 ++-- src/lib/server/util.ts | 7 +++++++ 3 files changed, 11 insertions(+), 4 deletions(-) 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/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..35b9585 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,8 @@ 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 +} From 870ff44c899501fe78892fb670b62dd60dc0437d Mon Sep 17 00:00:00 2001 From: Wojciech Nawrocki Date: Thu, 14 May 2026 11:38:36 -0400 Subject: [PATCH 03/36] refactor: sseStreamResponse --- src/app/api/setup-events/route.ts | 56 ++++++++++++------------------- src/lib/server/util.ts | 32 ++++++++++++++++++ 2 files changed, 54 insertions(+), 34 deletions(-) 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/lib/server/util.ts b/src/lib/server/util.ts index 35b9585..5044061 100644 --- a/src/lib/server/util.ts +++ b/src/lib/server/util.ts @@ -68,3 +68,35 @@ 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 = () => {} + const encoder = new TextEncoder() + const stream = new ReadableStream({ + start(controller) { + send = msg => { + controller.enqueue(encoder.encode(`data: ${JSON.stringify(msg)}\n\n`)) + } + close = () => { + controller.close() + } + }, + cancel() { + 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] +} From f5e99ef46efe10d4fed38e4679a9e6016d0284a3 Mon Sep 17 00:00:00 2001 From: Wojciech Nawrocki Date: Thu, 14 May 2026 12:04:48 -0400 Subject: [PATCH 04/36] fix: sse close --- src/lib/server/util.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/lib/server/util.ts b/src/lib/server/util.ts index 5044061..9d2ae02 100644 --- a/src/lib/server/util.ts +++ b/src/lib/server/util.ts @@ -74,17 +74,22 @@ export function canAccessProject(user: User, project: Project) { 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() }, }) From c4d3fbe4b0e822807aab96b6fcfca2c0d0c4e6d8 Mon Sep 17 00:00:00 2001 From: Wojciech Nawrocki Date: Thu, 14 May 2026 12:10:35 -0400 Subject: [PATCH 05/36] feat: initial awareness backend --- vscode-workbench/src/collabServer.ts | 53 +++++++++++ vscode-workbench/src/extension.ts | 64 ++----------- vscode-workbench/src/remoteDoc.ts | 72 -------------- vscode-workbench/src/textBinding.ts | 134 ++++++++++++++++----------- vscode-workbench/src/util.ts | 14 +++ 5 files changed, 155 insertions(+), 182 deletions(-) create mode 100644 vscode-workbench/src/collabServer.ts delete mode 100644 vscode-workbench/src/remoteDoc.ts diff --git a/vscode-workbench/src/collabServer.ts b/vscode-workbench/src/collabServer.ts new file mode 100644 index 0000000..e05be14 --- /dev/null +++ b/vscode-workbench/src/collabServer.ts @@ -0,0 +1,53 @@ +import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider' +import vs from 'vscode' +import WebSocket from 'ws' +import { BWRAP_COLLAB_SOCK_PATH, waitForPath } from './util' + +export class CollabServerConnection implements vs.Disposable { + constructor( + readonly collabSock: HocuspocusProviderWebsocket, + readonly awarenessProvider: HocuspocusProvider, + ) {} + + dispose() { + this.awarenessProvider.destroy() + this.collabSock.destroy() + } +} + +export async function connectToCollabServer(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, + }) + const awarenessProvider = new HocuspocusProvider({ + websocketProvider: collabSock, + name: '', + }) + awarenessProvider.attach() + awarenessProvider.setAwarenessField('user', { + name: 'TODO name', + }) + log.debug('Opened collab-server socket') + return new CollabServerConnection(collabSock, awarenessProvider) + } + + 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, + ) + .then(async s => { + if (s === action) { + await vs.commands.executeCommand('workbench.action.reloadWindow') + } + }) + + return undefined +} diff --git a/vscode-workbench/src/extension.ts b/vscode-workbench/src/extension.ts index 5917eae..c91de76 100644 --- a/vscode-workbench/src/extension.ts +++ b/vscode-workbench/src/extension.ts @@ -1,10 +1,8 @@ -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 { YTextBindingManager } from './textBinding' -import { BWRAP_COLLAB_SERVER_DIR, BWRAP_COLLAB_SOCK_PATH } from './util' +import { BWRAP_COLLAB_SERVER_DIR } from './util' /** Ensure we are in the expected Lean Workbench environment. * Return `false` if we are not, @@ -24,55 +22,6 @@ async function ensureWorkbenchEnv(log: vs.LogOutputChannel): Promise { return true } -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 -} - -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 - } - - 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, - ) - .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) } @@ -82,15 +31,14 @@ export async function activate(ctx: vs.ExtensionContext) { if (!(await ensureWorkbenchEnv(log))) return - const collabSock = await connectToCollabServer(ctx, log) - if (!collabSock) return - - const docs = new RemoteDocManager(collabSock, log) + const collabServer = await connectToCollabServer(log) + 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, syncableDirs(), log) ctx.subscriptions.push( bindings, vs.workspace.onDidChangeWorkspaceFolders(() => bindings.updateSyncableDirs(syncableDirs())), 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/textBinding.ts b/vscode-workbench/src/textBinding.ts index 34d039d..6f25c23 100644 --- a/vscode-workbench/src/textBinding.ts +++ b/vscode-workbench/src/textBinding.ts @@ -1,17 +1,18 @@ +import { HocuspocusProvider } from '@hocuspocus/provider' import path from 'node:path' import vs from 'vscode' import * as Y from 'yjs' -import { RemoteDocManager } from './remoteDoc' +import { CollabServerConnection } from './collabServer' import { 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 collabServer: CollabServerConnection, /** Directories to sync. Files not contained in any of these are not synced. */ private syncDirs: string[], private readonly log: vs.LogOutputChannel, @@ -20,6 +21,7 @@ export class YTextBindingManager implements vs.Disposable { vs.workspace.onDidOpenTextDocument(doc => this.onDidOpenTextDocument(doc)), vs.workspace.onDidCloseTextDocument(doc => this.onDidCloseTextDocument(doc)), vs.workspace.onDidChangeTextDocument(e => this.onDidChangeTextDocument(e)), + vs.window.onDidChangeTextEditorSelection(e => this.onDidChangeTextEditorSelection(e)), ) // Bind already-open buffers for (const doc of vs.workspace.textDocuments) this.onDidOpenTextDocument(doc) @@ -29,10 +31,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,73 +54,83 @@ 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.collabServer, 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) + } + + private onDidChangeTextEditorSelection(e: vs.TextEditorSelectionChangeEvent) { + if (e.textEditor.document.uri.scheme !== 'file') return + const filePath = e.textEditor.document.uri.fsPath + const binding = this.bindings.get(filePath) + if (!binding) return + binding.onDidChangeTextEditorSelection(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}. */ +/** Bidirectional binding between a {@link vs.TextDocument} + * and the {@link Y.Text} of a Hocuspocus document. */ 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 + private initialSyncDone = false /** Used to linearize async operations that might otherwise interleave. */ private pending: Promise = Promise.resolve() private disposables: { dispose(): unknown }[] = [] - /** May only be constructed with a `Y.Doc` whose provider has already `synced` at least once. */ + private hs: HocuspocusProvider + constructor( - readonly doc: vs.TextDocument, - readonly remoteDoc: Y.Doc, + private readonly doc: vs.TextDocument, + private readonly collabServer: CollabServerConnection, private readonly log: vs.LogOutputChannel, ) { - this.ytext = remoteDoc.getText(YTEXT_KEY) + // https://tiptap.dev/docs/hocuspocus/provider/examples#multiplexing + this.hs = new HocuspocusProvider({ + websocketProvider: this.collabServer.collabSock, + name: doc.uri.fsPath, + // We use a single, global awareness CRDT rather than per-document CRDTs. + awareness: null, + }) + this.hs.attach() - // Overwrite with remote contents on startup. - this.enqueue(() => this.replaceWithRemote()) + this.ytext = this.hs.document.getText(YTEXT_KEY) 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 + // First check prevents bounceback (https://beta.yjs.dev/docs/api/transactions/#the-origin-concept). + // Second ignores remote deltas before initial sync of remote doc. + if (transaction.origin === this || !this.initialSyncDone) return const delta = event.delta this.enqueue(() => this.applyDelta(delta)) } @@ -128,6 +140,16 @@ export class YTextBinding implements vs.Disposable { this.ytext.unobserve(observer) }, }) + + if (this.hs.synced) { + this.enqueue(() => this.initialSync()) + } else { + const onSynced = () => { + this.hs.off('synced', onSynced) + this.enqueue(() => this.initialSync()) + } + this.hs.on('synced', onSynced) + } } /** Place an operation on the work queue. @@ -140,13 +162,11 @@ export class YTextBinding implements vs.Disposable { } 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 + console.log('local change', JSON.stringify(e)) + if (this.applyingRemote || !this.initialSyncDone) return if (e.document !== this.doc) return if (e.contentChanges.length === 0) return - this.remoteDoc.transact(() => { + this.hs.document.transact(() => { // VSCode sorts `contentChanges` in reverse offset order // so they can be applied sequentially without offset adjustment. for (const ch of e.contentChanges) { @@ -156,6 +176,31 @@ export class YTextBinding implements vs.Disposable { }, this) } + onDidChangeTextEditorSelection(e: vs.TextEditorSelectionChangeEvent) { + console.log(JSON.stringify(e)) + } + + /** Ensure that buffer contents match the {@link Y.Doc} text + * by replacing the entire buffer if necessary. + * Avoids making an edit when contents already match. */ + private async initialSync(): Promise { + // Read `ytext` and set `initialSyncDone = true` synchronously + // so that any remote delta arriving during the subsequent `applyEdit` is queued (not dropped) + // and later applied on top of the new editor content. + const ytextStr = this.ytext.toString() + this.initialSyncDone = true + 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 + } + } + private async applyDelta(delta: Y.YTextEvent['delta']): Promise { const edit = new vs.WorkspaceEdit() let offset = 0 @@ -177,24 +222,9 @@ export class YTextBinding implements vs.Disposable { } } - /** 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 - } - } - dispose() { for (const d of this.disposables) d.dispose() + this.disposables = [] + this.hs.destroy() } } diff --git a/vscode-workbench/src/util.ts b/vscode-workbench/src/util.ts index e5d3f60..b398bfb 100644 --- a/vscode-workbench/src/util.ts +++ b/vscode-workbench/src/util.ts @@ -1,3 +1,5 @@ +import fs from 'node:fs/promises' + // FIXME: use same consts in workbench-app/collab-server for single source of truth. /** Working directory of collab-server in the VSCode and collab-server bwraps. */ @@ -9,3 +11,15 @@ export const BWRAP_COLLAB_SOCK_PATH = `${BWRAP_COLLAB_SERVER_DIR}/collab.sock` /** We keep a unique Y.Doc per file. * This is the Y.Doc key under which the text content lives. */ export const YTEXT_KEY = 'content' + +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 +} From fe54d77a77e97a0328c8271ac0694a7d553e80ba Mon Sep 17 00:00:00 2001 From: Wojciech Nawrocki Date: Thu, 14 May 2026 16:08:59 -0400 Subject: [PATCH 06/36] feat: workspace mdata --- src/lib/server/vscodeServer.ts | 15 +++++++++++++++ vscode-workbench/package.json | 3 ++- vscode-workbench/src/collabServer.ts | 12 ++++++++---- vscode-workbench/src/extension.ts | 23 +++++++++++++---------- vscode-workbench/src/util.ts | 15 +++++++++++++++ 5 files changed, 53 insertions(+), 15 deletions(-) diff --git a/src/lib/server/vscodeServer.ts b/src/lib/server/vscodeServer.ts index d5a2f0b..72b03d8 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. */ @@ -212,6 +213,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(), @@ -242,6 +244,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 +256,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/package.json b/vscode-workbench/package.json index 48e8382..86a9bef 100644 --- a/vscode-workbench/package.json +++ b/vscode-workbench/package.json @@ -38,6 +38,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 index e05be14..df5ea77 100644 --- a/vscode-workbench/src/collabServer.ts +++ b/vscode-workbench/src/collabServer.ts @@ -1,7 +1,7 @@ import { HocuspocusProvider, HocuspocusProviderWebsocket } from '@hocuspocus/provider' import vs from 'vscode' import WebSocket from 'ws' -import { BWRAP_COLLAB_SOCK_PATH, waitForPath } from './util' +import { BWRAP_COLLAB_SOCK_PATH, waitForPath, WorkspaceMetadata } from './util' export class CollabServerConnection implements vs.Disposable { constructor( @@ -15,22 +15,26 @@ export class CollabServerConnection implements vs.Disposable { } } -export async function connectToCollabServer(log: vs.LogOutputChannel): Promise { +export async function connectToCollabServer( + log: vs.LogOutputChannel, + mdata: WorkspaceMetadata, +): 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, }) + log.debug('Opened collab-server socket') const awarenessProvider = new HocuspocusProvider({ websocketProvider: collabSock, name: '', }) awarenessProvider.attach() awarenessProvider.setAwarenessField('user', { - name: 'TODO name', + name: mdata.viewer.name, + image: mdata.viewer.image, }) - log.debug('Opened collab-server socket') return new CollabServerConnection(collabSock, awarenessProvider) } diff --git a/vscode-workbench/src/extension.ts b/vscode-workbench/src/extension.ts index c91de76..8ac968e 100644 --- a/vscode-workbench/src/extension.ts +++ b/vscode-workbench/src/extension.ts @@ -2,24 +2,26 @@ import fs from 'node:fs/promises' import vs from 'vscode' import { connectToCollabServer } from './collabServer' import { YTextBindingManager } from './textBinding' -import { BWRAP_COLLAB_SERVER_DIR } 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 } function syncableDirs(): string[] { @@ -29,9 +31,10 @@ function syncableDirs(): string[] { 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 collabServer = await connectToCollabServer(log) + const collabServer = await connectToCollabServer(log, mdata) if (!collabServer) return ctx.subscriptions.push(collabServer) diff --git a/vscode-workbench/src/util.ts b/vscode-workbench/src/util.ts index b398bfb..561547d 100644 --- a/vscode-workbench/src/util.ts +++ b/vscode-workbench/src/util.ts @@ -1,7 +1,22 @@ import fs from 'node:fs/promises' +import { z } from 'zod' // 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' From 2bd1b429dd5680d942894f16c3374890e11aad50 Mon Sep 17 00:00:00 2001 From: Wojciech Nawrocki Date: Thu, 14 May 2026 16:32:47 -0400 Subject: [PATCH 07/36] feat: share selections --- vscode-workbench/src/textBinding.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/vscode-workbench/src/textBinding.ts b/vscode-workbench/src/textBinding.ts index 6f25c23..1d0752c 100644 --- a/vscode-workbench/src/textBinding.ts +++ b/vscode-workbench/src/textBinding.ts @@ -162,7 +162,6 @@ export class YTextBinding implements vs.Disposable { } onLocalChange(e: vs.TextDocumentChangeEvent): void { - console.log('local change', JSON.stringify(e)) if (this.applyingRemote || !this.initialSyncDone) return if (e.document !== this.doc) return if (e.contentChanges.length === 0) return @@ -177,7 +176,11 @@ export class YTextBinding implements vs.Disposable { } onDidChangeTextEditorSelection(e: vs.TextEditorSelectionChangeEvent) { - console.log(JSON.stringify(e)) + if (e.textEditor.document !== this.doc) return + this.collabServer.awarenessProvider.setAwarenessField('selection', { + filePath: this.doc.uri.fsPath, + selections: e.selections, + }) } /** Ensure that buffer contents match the {@link Y.Doc} text From d64755196ef1c33c9631a19a6671f09e9fc3b9d4 Mon Sep 17 00:00:00 2001 From: Wojciech Nawrocki Date: Thu, 14 May 2026 16:50:52 -0400 Subject: [PATCH 08/36] feat: awareness navbar --- package-lock.json | 3 + package.json | 3 + src/app/AvatarMenu.tsx | 10 +-- .../[projectName]/CollabAwareness.tsx | 33 +++++++++ src/app/[userName]/[projectName]/page.tsx | 11 ++- .../api/projects/[projectId]/viewers/route.ts | 73 +++++++++++++++++++ src/app/components/AvatarIcon.tsx | 14 ++++ src/lib/server/collabServer.ts | 2 + src/lib/server/editorSessions.ts | 6 ++ 9 files changed, 146 insertions(+), 9 deletions(-) create mode 100644 src/app/[userName]/[projectName]/CollabAwareness.tsx create mode 100644 src/app/api/projects/[projectId]/viewers/route.ts create mode 100644 src/app/components/AvatarIcon.tsx diff --git a/package-lock.json b/package-lock.json index 1c75ff5..f1de045 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", 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/[userName]/[projectName]/CollabAwareness.tsx b/src/app/[userName]/[projectName]/CollabAwareness.tsx new file mode 100644 index 0000000..3c46cee --- /dev/null +++ b/src/app/[userName]/[projectName]/CollabAwareness.tsx @@ -0,0 +1,33 @@ +'use client' + +import type { Viewer } from '@/app/api/projects/[projectId]/viewers/route' +import AvatarIcon from '@/app/components/AvatarIcon' +import Link from 'next/link' +import { useEffect, useState } from 'react' + +export default function CollabAwareness({ viewerName, projectId }: { viewerName: string; projectId: string }) { + const [viewers, setViewers] = useState([]) + + useEffect(() => { + const source = new EventSource(`/api/projects/${projectId}/viewers`) + source.onmessage = event => { + const data = JSON.parse(event.data) + if (Array.isArray(data.viewers)) { + setViewers(data.viewers.filter((v: Viewer) => v.name !== viewerName)) + } + } + return () => source.close() + }, [projectId, viewerName]) + + if (viewers.length === 0) return null + return ( + <> + {viewers.map(v => ( + + + + ))} +
+ + ) +} diff --git a/src/app/[userName]/[projectName]/page.tsx b/src/app/[userName]/[projectName]/page.tsx index 31cf050..0782d36 100644 --- a/src/app/[userName]/[projectName]/page.tsx +++ b/src/app/[userName]/[projectName]/page.tsx @@ -1,8 +1,10 @@ +import { SetNavbarExtra } from '@/app/NavbarExtra' 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' +import CollabAwareness from './CollabAwareness' const zParams = z.object({ userName: z.string().min(1), @@ -62,5 +64,12 @@ export default async function EditorSession({ params: params_ }: { params: Promi return } - return