diff --git a/package.json b/package.json index 2a72d27..31e4d4d 100644 --- a/package.json +++ b/package.json @@ -69,14 +69,9 @@ "title": "Propose Agent Edit (internal seam)", "category": "Cowriting" }, - { - "command": "cowriting.toggleDiffView", - "title": "Cowriting: Toggle Diff View", - "category": "Cowriting" - }, { "command": "cowriting.pinDiffBaseline", - "title": "Cowriting: Pin Diff Baseline to Now", + "title": "Cowriting: Pin Review Baseline to Now", "category": "Cowriting" }, { @@ -103,10 +98,6 @@ "command": "cowriting.rejectProposal", "when": "false" }, - { - "command": "cowriting.toggleDiffView", - "when": "false" - }, { "command": "cowriting.pinDiffBaseline", "when": "false" @@ -145,11 +136,6 @@ ] }, "keybindings": [ - { - "command": "cowriting.toggleDiffView", - "key": "ctrl+alt+d", - "when": "false" - }, { "command": "cowriting.showTrackChangesPreview", "key": "ctrl+alt+r", diff --git a/src/diffViewController.ts b/src/diffViewController.ts index df21bf6..ef649a2 100644 --- a/src/diffViewController.ts +++ b/src/diffViewController.ts @@ -1,12 +1,15 @@ /** - * DiffViewController — F6 diff-view toggle (spec §6.2/§6.4). Owns the baseline + * DiffViewController — F6 baseline data layer (spec §6.4/§6.7). Owns the baseline * lifecycle (initialize at first sight / advance at every machine landing / pin - * on demand), serves the baseline as a readonly `cowriting-baseline:` virtual - * document, and toggles a native vscode.diff (baseline left, the LIVE document - * right). A pure view: never mutates the document, sidecar, or attribution - * state (INV-19). + * on demand) and serves it to F7/F10 via `getBaseline` + the additive + * `onDidChangeBaseline` event. A pure data layer: never mutates the document, + * sidecar, or attribution state (INV-19). * - * F6 works on ANY text document, not just workspace files — it needs no + * The F6 two-pane `vscode.diff` *view* (toggle UI + the `cowriting-baseline:` + * virtual document) was removed in #34 once F10 made the rendered preview the + * single review surface; only the baseline store survives here (spec §6.7). + * + * The baseline works on ANY text document, not just workspace files — it needs no * `.threads/` sidecar, only a stable doc identity + a storage home. So it has * its OWN diffable predicate (any `file:` or `untitled:` doc), decoupled from * F2's workspace `isTracked`. Persistable docs (`file:`) keep their baseline in @@ -14,40 +17,24 @@ * untitled buffers have no durable identity, so their baseline is in-memory * only (lost on reload) — the same degrade the storage-unavailable path uses. */ -import * as path from "node:path"; import { createHash } from "node:crypto"; import * as vscode from "vscode"; import { BaselineStore, type Baseline, type BaselineReason } from "./baselineStore"; -export const BASELINE_SCHEME = "cowriting-baseline"; - export class DiffViewController implements vscode.Disposable { private readonly disposables: vscode.Disposable[] = []; - /** Source of truth for the content provider, keyed by `document.uri.toString()`. */ + /** Source of truth for the baseline, keyed by `document.uri.toString()`. */ private readonly baselines = new Map(); - private readonly onDidChangeEmitter = new vscode.EventEmitter(); /** F7 (additive): fires on every baseline capture (open / advance / pin) so - * the track-changes preview refreshes without polling. Mirrors the internal - * content-provider change signal but carries the real document URI. */ + * the track-changes preview refreshes without polling. Carries the real + * document URI. */ private readonly onDidChangeBaselineEmitter = new vscode.EventEmitter<{ uri: string }>(); readonly onDidChangeBaseline = this.onDidChangeBaselineEmitter.event; private storageWarned = false; constructor(private readonly store: BaselineStore | null) { - const provider: vscode.TextDocumentContentProvider = { - onDidChange: this.onDidChangeEmitter.event, - provideTextDocumentContent: (uri) => { - // The baseline URI carries the real document URI in its query (see baselineUri). - return this.baselines.get(uri.query)?.text ?? ""; - }, - }; this.disposables.push( - this.onDidChangeEmitter, this.onDidChangeBaselineEmitter, - vscode.workspace.registerTextDocumentContentProvider(BASELINE_SCHEME, provider), - vscode.commands.registerCommand("cowriting.toggleDiffView", () => - this.toggle(vscode.window.activeTextEditor), - ), vscode.commands.registerCommand("cowriting.pinDiffBaseline", () => this.pinCommand(vscode.window.activeTextEditor), ), @@ -60,7 +47,7 @@ export class DiffViewController implements vscode.Disposable { // ---- diffability / identity -------------------------------------------------------- - /** Any text document F6 can diff: a saved file OR an unsaved buffer. */ + /** Any text document F6 tracks a baseline for: a saved file OR an unsaved buffer. */ private isDiffable(document: vscode.TextDocument): boolean { return document.uri.scheme === "file" || document.uri.scheme === "untitled"; } @@ -76,11 +63,6 @@ export class DiffViewController implements vscode.Disposable { private storageKey(uriKey: string): string { return createHash("sha256").update(uriKey).digest("hex"); } - /** The readonly virtual-doc URI; its query carries the real document URI. */ - private baselineUri(document: vscode.TextDocument): vscode.Uri { - const name = path.basename(document.uri.path) || "untitled"; - return vscode.Uri.from({ scheme: BASELINE_SCHEME, path: "/" + name, query: this.uriKey(document) }); - } // ---- baseline lifecycle (§6.4) ----------------------------------------------------- @@ -109,13 +91,13 @@ export class DiffViewController implements vscode.Disposable { this.capture(document, "machine-landing"); } - /** Human pin: baseline := now; the open diff visibly empties (left = right). */ + /** Human pin: baseline := now; the preview's change-marks empty (left = right). */ pin(document: vscode.TextDocument): void { if (!this.isDiffable(document)) return; this.capture(document, "pinned"); } - /** Capture buffer text at this epoch, persist (if persistable), refresh any open diff. */ + /** Capture buffer text at this epoch, persist (if persistable), notify F7/F10. */ private capture(document: vscode.TextDocument, reason: BaselineReason): void { const key = this.uriKey(document); const baseline: Baseline = { uri: key, text: document.getText(), capturedAt: new Date().toISOString(), reason }; @@ -127,8 +109,6 @@ export class DiffViewController implements vscode.Disposable { this.warnStorageOnce(); } } - // An open diff re-requests the left side when its baseline URI changes. - this.onDidChangeEmitter.fire(this.baselineUri(document)); this.onDidChangeBaselineEmitter.fire({ uri: key }); } @@ -136,78 +116,13 @@ export class DiffViewController implements vscode.Disposable { if (this.storageWarned) return; this.storageWarned = true; void vscode.window.showWarningMessage( - "Cowriting: diff-view storage is unavailable — baselines are kept in memory only and won't survive a reload.", + "Cowriting: baseline storage is unavailable — baselines are kept in memory only and won't survive a reload.", ); } - // ---- toggle UX (§6.5 PUC-1) -------------------------------------------------------- - - /** - * If this doc's baseline diff is open → close it and reveal the normal editor; - * else open vscode.diff (baseline left, the live document right). Warns only - * when there is no active text editor (or a non-diffable scheme). - */ - private async toggle(editor: vscode.TextEditor | undefined): Promise { - if (!editor || !this.isDiffable(editor.document)) { - void vscode.window.showWarningMessage( - "Cowriting: focus a text editor to toggle its diff view.", - ); - return; - } - const document = editor.document; - const openTab = this.findDiffTab(document.uri); - if (openTab) { - await vscode.window.tabGroups.close(openTab); - await vscode.window.showTextDocument(document, { preview: false }); - return; - } - this.ensureBaseline(document); - const baseline = this.baselines.get(this.uriKey(document))!; - const name = path.basename(document.uri.path) || "untitled"; - const unsaved = this.isPersistable(document) ? "" : " (unsaved)"; - const title = `${name} — my changes since ${this.epochLabel(baseline)}${unsaved}`; - await vscode.commands.executeCommand( - "vscode.diff", - this.baselineUri(document), - document.uri, - title, - { preview: false }, - ); - } - - /** The open baseline-diff tab for this document, if any. */ - private findDiffTab(modified: vscode.Uri): vscode.Tab | undefined { - for (const group of vscode.window.tabGroups.all) { - for (const tab of group.tabs) { - const input = tab.input; - if ( - input instanceof vscode.TabInputTextDiff && - input.original.scheme === BASELINE_SCHEME && - input.modified.toString() === modified.toString() - ) { - return tab; - } - } - } - return undefined; - } - - /** Human-readable epoch for the diff tab title (§5 / §6.5). */ - private epochLabel(baseline: Baseline): string { - const time = new Date(baseline.capturedAt).toLocaleTimeString(); - switch (baseline.reason) { - case "opened": - return `opened ${time}`; - case "machine-landing": - return `Claude landed ${time}`; - case "pinned": - return `pinned ${time}`; - } - } - private pinCommand(editor: vscode.TextEditor | undefined): void { if (!editor || !this.isDiffable(editor.document)) { - void vscode.window.showWarningMessage("Cowriting: focus a text editor to pin its diff baseline."); + void vscode.window.showWarningMessage("Cowriting: focus a text editor to pin its review baseline."); return; } this.pin(editor.document); @@ -219,10 +134,6 @@ export class DiffViewController implements vscode.Disposable { const b = this.baselines.get(uriString); return b ? { text: b.text, reason: b.reason, capturedAt: b.capturedAt } : undefined; } - /** Is this doc's baseline diff currently open in any tab group? */ - isDiffOpen(uriString: string): boolean { - return this.findDiffTab(vscode.Uri.parse(uriString)) !== undefined; - } /** Absolute on-disk path of this doc's persisted baseline, or undefined (untitled/in-memory). */ baselineFilePath(uriString: string): string | undefined { if (!this.store || vscode.Uri.parse(uriString).scheme !== "file") return undefined; diff --git a/src/extension.ts b/src/extension.ts index c29b13a..4691f42 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -50,13 +50,14 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef }), ); - // --- F6: diff-view toggle (Features #17 + #19) — workspace-INDEPENDENT --- - // F6 works on ANY diffable doc (file or untitled), so it is constructed - // regardless of whether a folder is open, and its commands are always live - // (never stubbed). Baseline lives in VS Code's per-extension GLOBAL storage - // (always present), never the repo (INV-19). The machine-landing advance is - // wired in the with-root branch below (landings only occur on workspace files - // through the seam). The controller self-wires baseline capture on open. + // --- 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 + // F7/F10. It tracks a baseline for ANY diffable doc (file or untitled), so it + // is constructed regardless of whether a folder is open. Baseline lives in VS + // Code's per-extension GLOBAL storage (always present), never the repo + // (INV-19). The machine-landing advance is wired below. The controller + // self-wires baseline capture on open. const baselineStorageDir = context.globalStorageUri?.fsPath; const baselineStore = baselineStorageDir ? new BaselineStore(baselineStorageDir) : null; const diffViewController = new DiffViewController(baselineStore); diff --git a/test/e2e/suite-no-workspace/noWorkspace.test.ts b/test/e2e/suite-no-workspace/noWorkspace.test.ts index 0201df9..0dd93f1 100644 --- a/test/e2e/suite-no-workspace/noWorkspace.test.ts +++ b/test/e2e/suite-no-workspace/noWorkspace.test.ts @@ -40,8 +40,11 @@ suite("no-workspace authoring (F8 — real folder-less, #8 lineage)", () => { "cowriting.toggleAttribution", "cowriting.acceptProposal", "cowriting.rejectProposal", + // #34: the F6 two-pane diff VIEW was removed (F10 preview is the single + // review surface); its toggle command is gone (the baseline store stays). + "cowriting.toggleDiffView", ]) { - assert.ok(!all.includes(retired), `${retired} is retired (F10 preview-only)`); + assert.ok(!all.includes(retired), `${retired} is retired`); } }); @@ -73,25 +76,24 @@ suite("no-workspace authoring (F8 — real folder-less, #8 lineage)", () => { assert.strictEqual(api.sidecarRouter.sidecarPath(key), undefined, "untitled artifact is in-memory only (no disk)"); }); - // F6 (#19) is workspace-INDEPENDENT: its commands are real (not stubs) even - // with no folder open, and the toggle works on an untitled buffer. - test("F6 toggle works with no folder open (untitled buffer)", async () => { + // F6 (#19) baseline data layer is workspace-INDEPENDENT: it captures a baseline + // for an untitled buffer even with no folder open (the two-pane VIEW was + // removed in #34; only the data layer remains). pinDiffBaseline stays real. + test("F6 baseline data layer works with no folder open (untitled buffer)", async () => { const all = await vscode.commands.getCommands(true); - assert.ok(all.includes("cowriting.toggleDiffView"), "toggleDiffView registered"); assert.ok(all.includes("cowriting.pinDiffBaseline"), "pinDiffBaseline registered"); + const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; + const api = (await ext.activate()) as CowritingApi; const untitled = await vscode.workspace.openTextDocument({ content: "no-folder scratch\n", language: "markdown" }); await vscode.window.showTextDocument(untitled); await new Promise((r) => setTimeout(r, 300)); - await vscode.commands.executeCommand("cowriting.toggleDiffView"); + const key = untitled.uri.toString(); + const baseline = api.diffViewController.getBaseline(key); + assert.ok(baseline, "baseline captured for the untitled buffer with no folder"); + assert.strictEqual(baseline!.reason, "opened"); + // pin resets the baseline to now — works folder-less. + await vscode.commands.executeCommand("cowriting.pinDiffBaseline"); await new Promise((r) => setTimeout(r, 300)); - const opened = vscode.window.tabGroups.all.some((g) => - g.tabs.some( - (t) => - t.input instanceof vscode.TabInputTextDiff && - t.input.original.scheme === "cowriting-baseline" && - t.input.modified.toString() === untitled.uri.toString(), - ), - ); - assert.ok(opened, "a cowriting-baseline diff opened for the untitled buffer with no folder"); + assert.strictEqual(api.diffViewController.getBaseline(key)!.reason, "pinned", "pin works with no folder"); }); }); diff --git a/test/e2e/suite/diffView.test.ts b/test/e2e/suite/diffView.test.ts index d694633..7758ac9 100644 --- a/test/e2e/suite/diffView.test.ts +++ b/test/e2e/suite/diffView.test.ts @@ -23,10 +23,13 @@ async function getApi(): Promise { } const settle = () => new Promise((r) => setTimeout(r, 300)); -// Order-dependent (F2–F4 pattern): later tests consume earlier state. Owns -// docs/diffview.md exclusively. F6 works on ANY file (#19), so the last two -// tests use an out-of-workspace file and an untitled buffer. -suite("F6 diff-view toggle (host E2E — any file, programmatic seam ingress, no LLM)", () => { +// The F6 two-pane vscode.diff VIEW was removed in #34 (F10's rendered preview is +// the single review surface); only the baseline DATA layer survives, consumed by +// F7/F10. This suite covers that data layer. Order-dependent (F2–F4 pattern): +// later tests consume earlier state. Owns docs/diffview.md exclusively. The +// baseline works on ANY file (#19), so the last two tests use an out-of-workspace +// file and an untitled buffer. +suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress, no LLM)", () => { const TARGET = "A target sentence Claude will rewrite via the seam."; const REPLACEMENT = "A SENTENCE CLAUDE REWROTE via the seam."; @@ -40,21 +43,6 @@ suite("F6 diff-view toggle (host E2E — any file, programmatic seam ingress, no assert.strictEqual(baseline!.text, doc.getText(), "baseline = open-time buffer"); }); - test("toggle opens a diff tab (original scheme cowriting-baseline) over the live doc (PUC-1)", async () => { - const api = await getApi(); - assert.strictEqual(api.diffViewController.isDiffOpen(docUri()), false, "no diff open yet"); - await vscode.commands.executeCommand("cowriting.toggleDiffView"); - await settle(); - assert.strictEqual(api.diffViewController.isDiffOpen(docUri()), true, "diff tab open"); - const active = vscode.window.tabGroups.activeTabGroup.activeTab; - assert.ok(active && active.input instanceof vscode.TabInputTextDiff, "active tab is a diff"); - assert.strictEqual( - (active!.input as vscode.TabInputTextDiff).original.scheme, - "cowriting-baseline", - "left side served by the baseline provider", - ); - }); - test("typing leaves the baseline unchanged while the buffer diverges", async () => { const doc = await openDoc(); const api = await getApi(); @@ -105,17 +93,17 @@ suite("F6 diff-view toggle (host E2E — any file, programmatic seam ingress, no ); }); - test("pin resets the baseline to now: baseline == buffer, reason pinned (PUC-3)", async () => { + test("pin resets the baseline to now: baseline == buffer, reason pinned", async () => { const doc = await openDoc(); const api = await getApi(); await vscode.commands.executeCommand("cowriting.pinDiffBaseline"); await settle(); const baseline = api.diffViewController.getBaseline(docUri())!; assert.strictEqual(baseline.reason, "pinned"); - assert.strictEqual(baseline.text, doc.getText(), "pinned baseline == current buffer (diff empties)"); + assert.strictEqual(baseline.text, doc.getText(), "pinned baseline == current buffer (change-marks empty)"); }); - test("the baseline is persisted in GLOBAL storage, never the repo (PUC-4, INV-19)", async () => { + test("the baseline is persisted in GLOBAL storage, never the repo (INV-19)", async () => { await openDoc(); const api = await getApi(); const p = api.diffViewController.baselineFilePath(docUri()); @@ -131,25 +119,7 @@ suite("F6 diff-view toggle (host E2E — any file, programmatic seam ingress, no assert.ok(p!.includes(`${path.sep}baselines${path.sep}`), "baseline lives under /baselines/"); }); - test("toggle again closes the diff tab and reveals the normal editor (PUC-1)", async () => { - const api = await getApi(); - if (!api.diffViewController.isDiffOpen(docUri())) { - await openDoc(); - await vscode.commands.executeCommand("cowriting.toggleDiffView"); - await settle(); - } - assert.strictEqual(api.diffViewController.isDiffOpen(docUri()), true, "diff open before close"); - await vscode.commands.executeCommand("cowriting.toggleDiffView"); - await settle(); - assert.strictEqual(api.diffViewController.isDiffOpen(docUri()), false, "diff tab closed"); - assert.strictEqual( - vscode.window.activeTextEditor?.document.uri.toString(), - docUri(), - "the normal editor for the doc is active after closing the diff", - ); - }); - - test("toggle works on a file OUTSIDE the workspace folder, persisted in global storage (#19)", async () => { + test("a baseline is captured + persisted for a file OUTSIDE the workspace folder (#19)", async () => { const api = await getApi(); const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "cowriting-outside-")); const outsidePath = path.join(outsideDir, "outside.md"); @@ -162,19 +132,14 @@ suite("F6 diff-view toggle (host E2E — any file, programmatic seam ingress, no const baseline = api.diffViewController.getBaseline(outsideUri.toString()); assert.ok(baseline, "baseline captured for an out-of-folder file"); assert.strictEqual(baseline!.reason, "opened"); - await vscode.commands.executeCommand("cowriting.toggleDiffView"); - await settle(); - assert.strictEqual(api.diffViewController.isDiffOpen(outsideUri.toString()), true, "diff opens for the outside file"); const fp = api.diffViewController.baselineFilePath(outsideUri.toString()); assert.ok(fp && fs.existsSync(fp), "outside-file baseline persisted in global storage"); assert.ok(!fp!.startsWith(WS + path.sep), "not under the workspace folder"); - // close it so it doesn't bleed into the untitled test's active-tab checks - await vscode.commands.executeCommand("cowriting.toggleDiffView"); - await settle(); fs.rmSync(outsideDir, { recursive: true, force: true }); + void doc; }); - test("toggle works on an UNTITLED buffer, baseline in-memory only (#19)", async () => { + test("an UNTITLED buffer gets an in-memory baseline, never persisted to disk (#19)", async () => { const api = await getApi(); const untitled = await vscode.workspace.openTextDocument({ content: "scratch line\n", language: "markdown" }); await vscode.window.showTextDocument(untitled); @@ -184,8 +149,5 @@ suite("F6 diff-view toggle (host E2E — any file, programmatic seam ingress, no const baseline = api.diffViewController.getBaseline(key); assert.ok(baseline, "untitled buffer got an in-memory baseline"); assert.strictEqual(api.diffViewController.baselineFilePath(key), undefined, "untitled is NOT persisted to disk"); - await vscode.commands.executeCommand("cowriting.toggleDiffView"); - await settle(); - assert.strictEqual(api.diffViewController.isDiffOpen(key), true, "diff opens for the untitled buffer"); }); }); diff --git a/test/e2e/suite/f10Review.test.ts b/test/e2e/suite/f10Review.test.ts index 03fd037..7aec9ee 100644 --- a/test/e2e/suite/f10Review.test.ts +++ b/test/e2e/suite/f10Review.test.ts @@ -215,16 +215,19 @@ suite("F10 interactive review (host E2E — preview is the single review surface api.attributionController.getSpans("docs/f10clean.md").some((s) => s.authorKind === "agent"), "agent span recorded (attribution data layer intact)", ); - // the retired toggle is gone from the palette (no editor decorations — INV-32). + // the retired in-editor surfaces are gone from the palette (no editor decorations — INV-32). const all = await vscode.commands.getCommands(true); assert.ok(!all.includes("cowriting.toggleAttribution"), "cowriting.toggleAttribution is retired"); - // the F6 diff toggle's keybinding is hidden (when:false) — it is not a user surface. + // #34: the F6 two-pane diff VIEW was removed (F10 preview is the single review + // surface) — its toggle command + ctrl+alt+d keybinding are gone entirely. + assert.ok(!all.includes("cowriting.toggleDiffView"), "cowriting.toggleDiffView command is gone (#34)"); const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8")); const dKb = (pkg.contributes.keybindings as Array<{ command: string; key: string; when?: string }>).find( - (k) => k.command === "cowriting.toggleDiffView" && k.key === "ctrl+alt+d", + (k) => k.command === "cowriting.toggleDiffView", ); - assert.ok(dKb, "the ctrl+alt+d toggleDiffView keybinding is declared"); - assert.strictEqual(dKb!.when, "false", "…but hidden (when:false) — F6 is a data layer, not a user surface"); + assert.ok(!dKb, "the toggleDiffView keybinding is gone from package.json (#34)"); + // …but the F6 baseline data layer survives: pinDiffBaseline stays a real command. + assert.ok(all.includes("cowriting.pinDiffBaseline"), "pinDiffBaseline (baseline data layer) is kept"); void key; }); });