From 2597cba2294918d661ab81f4f347cbf67c716d19 Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 2 Jul 2026 06:40:15 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20CoeditingRegistry=20opt-in=20gate=20(IN?= =?UTF-8?q?V-10,=20spec=20=C2=A76.4)=20=E2=80=94=20enter/stop=20commands?= =?UTF-8?q?=20+=20context=20keys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 --- package.json | 20 ++++++++++ src/coeditingRegistry.ts | 57 +++++++++++++++++++++++++++++ src/extension.ts | 29 +++++++++++++++ test/coeditingRegistry.test.ts | 63 ++++++++++++++++++++++++++++++++ test/e2e/suite/coediting.test.ts | 20 ++++++++++ 5 files changed, 189 insertions(+) create mode 100644 src/coeditingRegistry.ts create mode 100644 test/coeditingRegistry.test.ts create mode 100644 test/e2e/suite/coediting.test.ts diff --git a/package.json b/package.json index 913b6fa..698ab25 100644 --- a/package.json +++ b/package.json @@ -118,6 +118,16 @@ "command": "cowriting.proposalRejectMenu", "title": "Reject Claude Proposal", "category": "Cowriting" + }, + { + "command": "cowriting.coeditDocument", + "title": "✦ Coedit this Document with Claude", + "category": "Cowriting" + }, + { + "command": "cowriting.stopCoediting", + "title": "Stop editing with Claude", + "category": "Cowriting" } ], "menus": { @@ -207,6 +217,16 @@ "command": "cowriting.createThread", "when": "editorHasSelection && (resourceScheme == file || resourceScheme == untitled)", "group": "1_cowriting@2" + }, + { + "command": "cowriting.coeditDocument", + "when": "editorLangId == markdown && !cowriting.isCoediting", + "group": "1_cowriting@1" + }, + { + "command": "cowriting.stopCoediting", + "when": "editorLangId == markdown && cowriting.isCoediting", + "group": "1_cowriting@9" } ], "comments/commentThread/context": [ diff --git a/src/coeditingRegistry.ts b/src/coeditingRegistry.ts new file mode 100644 index 0000000..9177478 --- /dev/null +++ b/src/coeditingRegistry.ts @@ -0,0 +1,57 @@ +/** + * CoeditingRegistry — the INV-10 activation gate (spec §6.4). Holds the set of + * documents the writer has explicitly entered into coediting; every surface + * (SCM/QuickDiff, comments, preview annotations, edit commands, decorations) + * checks membership before attaching. Persisted in workspaceState so a reload + * restores the set (the durable sidecar carries the artifacts; this carries + * only the mode). + */ +import * as vscode from "vscode"; + +const STATE_KEY = "cowriting.coeditingSet"; + +export class CoeditingRegistry implements vscode.Disposable { + private readonly set: Set; + private readonly emitter = new vscode.EventEmitter<{ uri: string; coediting: boolean }>(); + readonly onDidChange = this.emitter.event; + /** Set by the SCM surface (Task 3) so syncContext can expose the baseline mode. */ + baselineModeOf: ((uri: vscode.Uri) => "head" | "snapshot" | undefined) | undefined; + + constructor(private readonly state: vscode.Memento) { + this.set = new Set(state.get(STATE_KEY, [])); + } + + enter(uri: vscode.Uri): void { + const key = uri.toString(); + if (this.set.has(key)) return; + this.set.add(key); + void this.state.update(STATE_KEY, [...this.set]); + this.emitter.fire({ uri: key, coediting: true }); + } + + exit(uri: vscode.Uri): void { + const key = uri.toString(); + if (!this.set.delete(key)) return; + void this.state.update(STATE_KEY, [...this.set]); + this.emitter.fire({ uri: key, coediting: false }); + } + + isCoediting(uri: vscode.Uri): boolean { + return this.set.has(uri.toString()); + } + + list(): string[] { + return [...this.set]; + } + + syncContext(editor: vscode.TextEditor | undefined): void { + const coediting = !!editor && this.isCoediting(editor.document.uri); + void vscode.commands.executeCommand("setContext", "cowriting.isCoediting", coediting); + const mode = coediting && editor ? this.baselineModeOf?.(editor.document.uri) : undefined; + void vscode.commands.executeCommand("setContext", "cowriting.baselineMode", mode ?? ""); + } + + dispose(): void { + this.emitter.dispose(); + } +} diff --git a/src/extension.ts b/src/extension.ts index 3a81496..272ae45 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -14,6 +14,7 @@ import { TrackChangesPreviewController } from "./trackChangesPreview"; import { LiveProgressUi } from "./liveProgressUi"; import { EditorProposalController } from "./editorProposalController"; import { isAuthorable, routeEdit, selectionRejection } from "./workspacePath"; +import { CoeditingRegistry } from "./coeditingRegistry"; const CHANNEL_NAME = "Cowriting (Cline SDK)"; @@ -27,6 +28,7 @@ export interface CowritingApi { sidecarRouter: SidecarRouter; liveProgressUi: LiveProgressUi; editorProposalController: EditorProposalController; + coeditingRegistry: CoeditingRegistry; } export function activate(context: vscode.ExtensionContext): CowritingApi | undefined { @@ -59,6 +61,32 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef }), ); + // --- PUC-7 (native-surfaces migration): the INV-10 opt-in gate --- + // Every later native surface (SCM/QuickDiff, comments, preview annotations, + // edit commands, decorations) checks isCoediting before attaching. Persisted + // in workspaceState so a reload restores the set. + const coeditingRegistry = new CoeditingRegistry(context.workspaceState); + context.subscriptions.push(coeditingRegistry); + context.subscriptions.push( + vscode.commands.registerCommand("cowriting.coeditDocument", () => { + const ed = vscode.window.activeTextEditor; + if (!ed || ed.document.languageId !== "markdown" || !isAuthorable(ed.document.uri.scheme)) { + void vscode.window.showWarningMessage("Cowriting: open a Markdown document to coedit it."); + return; + } + coeditingRegistry.enter(ed.document.uri); + coeditingRegistry.syncContext(ed); + }), + vscode.commands.registerCommand("cowriting.stopCoediting", () => { + const ed = vscode.window.activeTextEditor; + if (!ed) return; + coeditingRegistry.exit(ed.document.uri); + coeditingRegistry.syncContext(ed); + }), + vscode.window.onDidChangeActiveTextEditor((ed) => coeditingRegistry.syncContext(ed)), + ); + coeditingRegistry.syncContext(vscode.window.activeTextEditor); + // --- F6: baseline data layer (Features #17 + #19) — workspace-INDEPENDENT --- // The two-pane vscode.diff VIEW was removed in #34 (F10's rendered preview is // the single review surface); only the baseline store survives, consumed by @@ -362,6 +390,7 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef sidecarRouter, liveProgressUi, editorProposalController, + coeditingRegistry, }; } diff --git a/test/coeditingRegistry.test.ts b/test/coeditingRegistry.test.ts new file mode 100644 index 0000000..05ff076 --- /dev/null +++ b/test/coeditingRegistry.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, it, vi } from "vitest"; + +// The pure set logic lives in a vscode-free helper the class wraps, so unit-test the class +// with a Memento stub (vitest runs vscode-free; the module imports vscode only for types + +// EventEmitter — mock it). +vi.mock("vscode", () => ({ + EventEmitter: class { + private handlers: Array<(e: unknown) => void> = []; + event = (h: (e: unknown) => void) => { this.handlers.push(h); return { dispose() {} }; }; + fire(e: unknown) { for (const h of this.handlers) h(e); } + dispose() {} + }, + Uri: { parse: (s: string) => ({ toString: () => s }) }, + commands: { executeCommand: vi.fn() }, + window: { activeTextEditor: undefined }, +})); +import { CoeditingRegistry } from "../src/coeditingRegistry"; + +function memento(): { store: Map } & { get: any; update: any } { + const store = new Map(); + return { + store, + get: (k: string, dflt?: unknown) => (store.has(k) ? store.get(k) : dflt), + update: (k: string, v: unknown) => { store.set(k, v); return Promise.resolve(); }, + } as any; +} + +describe("CoeditingRegistry", () => { + it("enter/exit flips membership and fires onDidChange", () => { + const reg = new CoeditingRegistry(memento() as any); + const events: Array<{ uri: string; coediting: boolean }> = []; + reg.onDidChange((e) => events.push(e as any)); + const uri = { toString: () => "file:///a.md" } as any; + expect(reg.isCoediting(uri)).toBe(false); + reg.enter(uri); + expect(reg.isCoediting(uri)).toBe(true); + reg.exit(uri); + expect(reg.isCoediting(uri)).toBe(false); + expect(events).toEqual([ + { uri: "file:///a.md", coediting: true }, + { uri: "file:///a.md", coediting: false }, + ]); + }); + + it("persists the set across construction (reload survival)", () => { + const m = memento(); + const reg1 = new CoeditingRegistry(m as any); + reg1.enter({ toString: () => "file:///a.md" } as any); + const reg2 = new CoeditingRegistry(m as any); + expect(reg2.isCoediting({ toString: () => "file:///a.md" } as any)).toBe(true); + expect(reg2.list()).toEqual(["file:///a.md"]); + }); + + it("enter is idempotent (no duplicate events)", () => { + const reg = new CoeditingRegistry(memento() as any); + const events: unknown[] = []; + reg.onDidChange((e) => events.push(e)); + const uri = { toString: () => "file:///a.md" } as any; + reg.enter(uri); + reg.enter(uri); + expect(events).toHaveLength(1); + }); +}); diff --git a/test/e2e/suite/coediting.test.ts b/test/e2e/suite/coediting.test.ts new file mode 100644 index 0000000..cd95bbc --- /dev/null +++ b/test/e2e/suite/coediting.test.ts @@ -0,0 +1,20 @@ +import * as assert from "node:assert"; +import * as vscode from "vscode"; + +async function api() { + const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; + return (await ext.activate()) as import("../../../src/extension").CowritingApi; +} + +suite("coediting registry (PUC-7)", () => { + test("enter → isCoediting true; exit → false; persists in list()", async () => { + const { coeditingRegistry } = await api(); + const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "# t\n\nbody\n" }); + await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); + assert.strictEqual(coeditingRegistry.isCoediting(doc.uri), true); + assert.ok(coeditingRegistry.list().includes(doc.uri.toString())); + await vscode.commands.executeCommand("cowriting.stopCoediting"); + assert.strictEqual(coeditingRegistry.isCoediting(doc.uri), false); + }); +});