From 7892e2fe870d663704b26ad992763c8370f869ce Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 13:13:26 -0700 Subject: [PATCH] feat(f8): wire SidecarRouter; authoring commands live folder-less (#19 precedent) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Spec §6.4: construct GlobalSidecarStore + SidecarRouter (CoauthorStore when root); remove the no-root early-return + command stubs so F2/F3/F4 are real folder-less; renderIfOpen gated by isAuthorable; editor-context menus widened to untitled; export sidecarRouter on CowritingApi. activate now always returns the API. Co-Authored-By: Claude Opus 4.8 --- package.json | 4 +-- src/extension.ts | 85 +++++++++++++++++++++--------------------------- 2 files changed, 39 insertions(+), 50 deletions(-) diff --git a/package.json b/package.json index 629ea5b..417a205 100644 --- a/package.json +++ b/package.json @@ -112,12 +112,12 @@ "editor/context": [ { "command": "cowriting.editSelection", - "when": "editorHasSelection && resourceScheme == file", + "when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled)", "group": "1_cowriting@1" }, { "command": "cowriting.createThread", - "when": "editorHasSelection && resourceScheme == file", + "when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled)", "group": "1_cowriting@2" } ], diff --git a/src/extension.ts b/src/extension.ts index 2bd71bd..2a2e23e 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -7,9 +7,11 @@ import { ProposalController } from "./proposalController"; import { buildFingerprint } from "./anchorer"; import { VersionGuard } from "./versionGuard"; import { BaselineStore } from "./baselineStore"; +import { GlobalSidecarStore } from "./globalSidecarStore"; +import { SidecarRouter } from "./sidecarRouter"; import { DiffViewController } from "./diffViewController"; import { TrackChangesPreviewController } from "./trackChangesPreview"; -import { isUnderRoot, selectionRejection } from "./workspacePath"; +import { isAuthorable, selectionRejection } from "./workspacePath"; const CHANNEL_NAME = "Cowriting (Cline SDK)"; @@ -20,6 +22,7 @@ export interface CowritingApi { versionGuard: VersionGuard; diffViewController: DiffViewController; trackChangesPreviewController: TrackChangesPreviewController; + sidecarRouter: SidecarRouter; } export function activate(context: vscode.ExtensionContext): CowritingApi | undefined { @@ -59,6 +62,14 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef const diffViewController = new DiffViewController(baselineStore); context.subscriptions.push(diffViewController); + // F8: the out-of-workspace/untitled authoring sidecar — same GLOBAL storage + // home as the F6 baseline, keyed by sha256(uri) (INV-19/24). Constructed + // workspace-independently; the router falls back to it for any non-in-folder + // doc. When globalStorageUri is unavailable, file writes throw (authoring + // degrades to errors-on-write, the F6-equivalent edge) but untitled in-memory + // still works — PUC-5. + const globalSidecarStore = new GlobalSidecarStore(baselineStorageDir ?? ""); + // --- F7: rendered track-changes preview (Feature #21) — workspace-INDEPENDENT --- // Like F6, F7 works on any markdown doc (incl. untitled / out-of-folder), so it // is constructed regardless of an open folder and its command is always live. @@ -69,64 +80,42 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef ); context.subscriptions.push(trackChangesPreviewController); - // --- F2: region-anchored threads (Feature #4) --- + // --- F8: authoring on ANY document (in-folder, out-of-folder, untitled) --- + // The router routes per-document: in-workspace file: → the committable repo + // `.threads/` sidecar (CoauthorStore, INV-2); out-of-folder file: + untitled: + // → the global-storage sidecar (GlobalSidecarStore). Constructed even with NO + // folder open (everything then routes global) — the F6 #19 precedent, now + // extended to authoring (F2 threads, F3 attribution, F4 propose/accept). const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; - if (!root) { - // No folder open → nothing to anchor against, but every contributed - // command must still exist: leave them unregistered and the palette - // errors with "command not found" (#8). Register warning stubs instead; - // opening a folder reloads the window, re-running activate with a root. - const stub = () => - void vscode.window.showWarningMessage( - "Cowriting: open a folder first — coauthoring anchors threads and attribution to workspace files.", - ); - for (const command of [ - "cowriting.createThread", - "cowriting.reply", - "cowriting.resolveThread", - "cowriting.reopenThread", - "cowriting.editSelection", - "cowriting.toggleAttribution", - "cowriting.applyAgentEdit", - "cowriting.acceptProposal", - "cowriting.rejectProposal", - "cowriting.proposeAgentEdit", - ]) { - context.subscriptions.push(vscode.commands.registerCommand(command, stub)); - } - // F6 (toggleDiffView / pinDiffBaseline) is NOT stubbed here — it works - // without a folder (on untitled buffers and out-of-folder files); its real - // commands were registered above by DiffViewController. - return undefined; - } - const store = new CoauthorStore(root); + const coauthorStore = root ? new CoauthorStore(root) : null; + const sidecarRouter = new SidecarRouter(coauthorStore, globalSidecarStore, root); // F5 (INV-16): one shared guard — newer-major sidecars are read-only, one // warning per doc across the three co-owning controllers. - const versionGuard = new VersionGuard(store); - const threadController = new ThreadController(store, root, versionGuard); + const versionGuard = new VersionGuard(sidecarRouter); + const threadController = new ThreadController(sidecarRouter, root, versionGuard); context.subscriptions.push(threadController); // --- F3: live attribution (Feature #6) --- - const attributionController = new AttributionController(store, root, versionGuard); + const attributionController = new AttributionController(sidecarRouter, root, versionGuard); context.subscriptions.push(attributionController); // --- F4: propose/accept (Feature #12) --- - const proposalController = new ProposalController(store, attributionController, root, versionGuard); + const proposalController = new ProposalController(sidecarRouter, attributionController, root, versionGuard); context.subscriptions.push(proposalController); - // --- F6 machine-landing wiring (with-root only) --- - // The seam's single machine-landing signal advances the baseline (INV-18). - // The seam fires only on workspace files, so this wiring belongs here; the - // DiffViewController itself was constructed above (workspace-independent). + // --- F6 machine-landing wiring — now for ANY authorable doc --- + // The seam's single machine-landing signal advances the F6 baseline (INV-18); + // the seam can now fire on out-of-folder files too, so wire it unconditionally. context.subscriptions.push( attributionController.onDidApplyAgentEdit((e) => diffViewController.advance(e.document)), ); - // One SHARED sidecar watcher for both controllers; self-writes are - // suppressed centrally in the store (the sidecar is co-owned). + // One SHARED sidecar watcher for both controllers; self-writes are suppressed + // centrally in the repo store (only repo `.threads/` sidecars are watched — + // global artifacts live outside the workspace). Harmless when no folder is open. const watcher = vscode.workspace.createFileSystemWatcher("**/.threads/**/*.json"); const onSidecar = (uri: vscode.Uri) => { - if (store.consumeSelfWrite(uri.fsPath)) return; + if (sidecarRouter.consumeSelfWrite(uri.fsPath)) return; threadController.handleExternalSidecarChange(uri); attributionController.handleExternalSidecarChange(uri); proposalController.handleExternalSidecarChange(uri); @@ -188,15 +177,14 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef context.subscriptions.push( vscode.commands.registerCommand("cowriting.editSelection", async () => { const editor = vscode.window.activeTextEditor; - // Each failure names its real reason (no editor / no selection / unsaved / - // outside the workspace folder) — not always "select some text" (#bug: - // an out-of-workspace file was rejected with the no-selection message). + // F8: authoring works on any file: or untitled: doc (the router decides + // where its artifact is stored). Each failure still names its real reason + // (no editor / no selection / a non-{file,untitled} read-only view) — not + // always "select some text" (#24's per-condition messaging). const reason = selectionRejection({ hasEditor: !!editor, selectionEmpty: editor?.selection.isEmpty ?? true, scheme: editor?.document.uri.scheme ?? "", - fsPath: editor?.document.uri.fsPath ?? "", - root, }); if (reason) { void vscode.window.showWarningMessage(reason); @@ -269,7 +257,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef // Render threads + attributions for already-open editors, and on future opens. const renderIfOpen = (doc: vscode.TextDocument) => { - if (doc.uri.scheme === "file" && isUnderRoot(doc.uri.fsPath, root)) { + if (isAuthorable(doc.uri.scheme)) { threadController.renderAll(doc); attributionController.loadAll(doc); proposalController.renderAll(doc); @@ -285,6 +273,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef versionGuard, diffViewController, trackChangesPreviewController, + sidecarRouter, }; }