From e617f504e73b66321a2fde57019727fb3a344c38 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 07:47:05 -0700 Subject: [PATCH 1/3] =?UTF-8?q?feat(f6):=20diff-view=20toggle=20on=20any?= =?UTF-8?q?=20file=20=E2=80=94=20global=20storage,=20untitled=20in-memory?= =?UTF-8?q?=20(#19)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit F6 no longer requires a workspace file. It gets its own diffable predicate (any file: or untitled:) decoupled from F2 isTracked, keys baselines by a sha256 of the document URI, and stores them in VS Code GLOBAL storage (context.globalStorageUri) — always present, never the repo (INV-19). Untitled buffers have no durable identity → in-memory baseline only (lost on reload). DiffViewController is now constructed before the workspace-root check and its commands are always live (never stubbed) — the machine-landing advance wiring stays in the with-root branch since the seam only fires on workspace files. BaselineStore generalized to key-by-hash (Baseline.docPath → uri). Co-Authored-By: Claude Opus 4.8 --- src/baselineStore.ts | 29 ++++--- src/diffViewController.ts | 164 +++++++++++++++++++------------------ src/extension.ts | 28 ++++--- test/baselineStore.test.ts | 42 ++++++---- 4 files changed, 144 insertions(+), 119 deletions(-) diff --git a/src/baselineStore.ts b/src/baselineStore.ts index 44b0d33..4980aff 100644 --- a/src/baselineStore.ts +++ b/src/baselineStore.ts @@ -1,10 +1,12 @@ /** * BaselineStore — load/save one diff-view baseline JSON per document (F6 §6.3). * The CoauthorStore shape (src/store.ts): vscode-free (Node fs only, unit- - * testable), one file per docPath. INV-19: the storage dir is VS Code's - * per-workspace extension storage, NEVER the repo — so the baseline can never - * be committed, merged, or read by another rung, and the sidecar / cross-rung - * contract (INV-14..17) are untouched by construction. + * testable), one file per storage key. INV-19: the storage dir is VS Code's + * per-extension GLOBAL storage, NEVER the repo — so the baseline can never be + * committed, merged, or read by another rung. F6 works on *any* file, so the + * key is a hash of the document URI (the controller derives it), not a + * workspace-relative path; an untitled buffer has no durable identity and is + * never persisted here (in-memory only — see DiffViewController). */ import * as fs from "node:fs"; import * as path from "node:path"; @@ -12,7 +14,8 @@ import * as path from "node:path"; export type BaselineReason = "opened" | "machine-landing" | "pinned"; export interface Baseline { - docPath: string; + /** the document URI (the identity the key is derived from) — for round-trip/debug. */ + uri: string; /** full document text captured at the epoch (from the buffer, not disk — §6.3). */ text: string; capturedAt: string; @@ -20,23 +23,23 @@ export interface Baseline { } export class BaselineStore { - /** @param storageDir absolute VS Code workspace-storage dir (context.storageUri.fsPath). */ + /** @param storageDir absolute VS Code GLOBAL-storage dir (context.globalStorageUri.fsPath). */ constructor(private readonly storageDir: string) {} - /** `/baselines/.json` (§6.3). */ - baselinePath(docPath: string): string { - return path.join(this.storageDir, "baselines", `${docPath}.json`); + /** `/baselines/.json` — `key` is a filesystem-safe hash (§6.3). */ + baselinePath(key: string): string { + return path.join(this.storageDir, "baselines", `${key}.json`); } - load(docPath: string): Baseline | null { - const p = this.baselinePath(docPath); + load(key: string): Baseline | null { + const p = this.baselinePath(key); if (!fs.existsSync(p)) return null; return JSON.parse(fs.readFileSync(p, "utf8")) as Baseline; } /** Newest epoch wins — overwrite in place, no history (state-not-history, the F3/F4 precedent). */ - save(docPath: string, baseline: Baseline): void { - const p = this.baselinePath(docPath); + save(key: string, baseline: Baseline): void { + const p = this.baselinePath(key); fs.mkdirSync(path.dirname(p), { recursive: true }); fs.writeFileSync(p, JSON.stringify(baseline, null, 2) + "\n", "utf8"); } diff --git a/src/diffViewController.ts b/src/diffViewController.ts index 1d61fb7..cb5c904 100644 --- a/src/diffViewController.ts +++ b/src/diffViewController.ts @@ -1,14 +1,21 @@ /** * DiffViewController — F6 diff-view toggle (spec §6.2/§6.4). Owns the baseline - * lifecycle (initialize at first track / advance at every machine landing / pin + * 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). Baselines persist via the vscode-free BaselineStore; if - * storage is unavailable the controller degrades to in-memory baselines + one - * warning (reload survival is lost; the toggle still works) — §6.5 PUC-5. + * state (INV-19). + * + * F6 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 + * VS Code's per-extension GLOBAL storage keyed by a hash of the document URI; + * 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"; @@ -16,20 +23,17 @@ export const BASELINE_SCHEME = "cowriting-baseline"; export class DiffViewController implements vscode.Disposable { private readonly disposables: vscode.Disposable[] = []; - /** Source of truth for the content provider; mirrors what the store persists. */ + /** Source of truth for the content provider, keyed by `document.uri.toString()`. */ private readonly baselines = new Map(); private readonly onDidChangeEmitter = new vscode.EventEmitter(); private storageWarned = false; - constructor( - private readonly store: BaselineStore | null, - private readonly rootDir: string, - ) { + constructor(private readonly store: BaselineStore | null) { const provider: vscode.TextDocumentContentProvider = { onDidChange: this.onDidChangeEmitter.event, provideTextDocumentContent: (uri) => { - const docPath = this.docPathFromBaselineUri(uri); - return this.baselines.get(docPath)?.text ?? ""; + // The baseline URI carries the real document URI in its query (see baselineUri). + return this.baselines.get(uri.query)?.text ?? ""; }, }; this.disposables.push( @@ -41,37 +45,49 @@ export class DiffViewController implements vscode.Disposable { vscode.commands.registerCommand("cowriting.pinDiffBaseline", () => this.pinCommand(vscode.window.activeTextEditor), ), + // F6 captures a baseline for any diffable doc the moment it is first seen, + // independent of the workspace gate (so "opened" is the open-time text). + vscode.workspace.onDidOpenTextDocument((d) => this.ensureBaseline(d)), ); + for (const d of vscode.workspace.textDocuments) this.ensureBaseline(d); } - // ---- tracking / uri helpers -------------------------------------------------------- + // ---- diffability / identity -------------------------------------------------------- - private isTracked(document: vscode.TextDocument): boolean { - return document.uri.scheme === "file" && document.uri.fsPath.startsWith(this.rootDir); + /** Any text document F6 can diff: a saved file OR an unsaved buffer. */ + private isDiffable(document: vscode.TextDocument): boolean { + return document.uri.scheme === "file" || document.uri.scheme === "untitled"; } - private docPathOf(uri: vscode.Uri): string { - return vscode.workspace.asRelativePath(uri, false); + /** Only `file:` docs have a durable identity to persist a baseline against. */ + private isPersistable(document: vscode.TextDocument): boolean { + return document.uri.scheme === "file"; } - /** The readonly virtual-doc URI whose content the provider serves for this doc. */ - private baselineUri(docPath: string): vscode.Uri { - return vscode.Uri.from({ scheme: BASELINE_SCHEME, path: "/" + docPath }); + /** In-memory map key — the full document URI. */ + private uriKey(document: vscode.TextDocument): string { + return document.uri.toString(); } - private docPathFromBaselineUri(uri: vscode.Uri): string { - return uri.path.replace(/^\//, ""); + /** Filesystem-safe storage key for a persistable doc: sha256 of its URI. */ + 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) ----------------------------------------------------- - /** First sight of a tracked doc: load the stored baseline, else capture `opened`. */ + /** First sight of a diffable doc: load the stored baseline, else capture `opened`. */ ensureBaseline(document: vscode.TextDocument): void { - if (!this.isTracked(document)) return; - const docPath = this.docPathOf(document.uri); - if (this.baselines.has(docPath)) return; - if (this.store) { + if (!this.isDiffable(document)) return; + const key = this.uriKey(document); + if (this.baselines.has(key)) return; + if (this.store && this.isPersistable(document)) { try { - const stored = this.store.load(docPath); + const stored = this.store.load(this.storageKey(key)); if (stored) { - this.baselines.set(docPath, stored); + this.baselines.set(key, stored); return; } } catch { @@ -83,35 +99,30 @@ export class DiffViewController implements vscode.Disposable { /** Machine landing (INV-18): re-capture so landed text never shows as a change. */ advance(document: vscode.TextDocument): void { - if (!this.isTracked(document)) return; + if (!this.isDiffable(document)) return; this.capture(document, "machine-landing"); } /** Human pin: baseline := now; the open diff visibly empties (left = right). */ pin(document: vscode.TextDocument): void { - if (!this.isTracked(document)) return; + if (!this.isDiffable(document)) return; this.capture(document, "pinned"); } - /** Capture buffer text at this epoch, persist, and refresh any open diff's left side. */ + /** Capture buffer text at this epoch, persist (if persistable), refresh any open diff. */ private capture(document: vscode.TextDocument, reason: BaselineReason): void { - const docPath = this.docPathOf(document.uri); - const baseline: Baseline = { - docPath, - text: document.getText(), - capturedAt: new Date().toISOString(), - reason, - }; - this.baselines.set(docPath, baseline); - if (this.store) { + const key = this.uriKey(document); + const baseline: Baseline = { uri: key, text: document.getText(), capturedAt: new Date().toISOString(), reason }; + this.baselines.set(key, baseline); + if (this.store && this.isPersistable(document)) { try { - this.store.save(docPath, baseline); + this.store.save(this.storageKey(key), baseline); } catch { this.warnStorageOnce(); } } // An open diff re-requests the left side when its baseline URI changes. - this.onDidChangeEmitter.fire(this.baselineUri(docPath)); + this.onDidChangeEmitter.fire(this.baselineUri(document)); } private warnStorageOnce(): void { @@ -122,44 +133,21 @@ export class DiffViewController implements vscode.Disposable { ); } - // ---- commands (toggle implemented in SLICE-3, Task 4) ------------------------------ - - private pinCommand(editor: vscode.TextEditor | undefined): void { - if (!editor || !this.isTracked(editor.document)) { - void vscode.window.showWarningMessage("Cowriting: open a tracked workspace document to pin its diff baseline."); - return; - } - this.pin(editor.document); - } - - // ---- test-facing surface (§6.4) ---------------------------------------------------- - - getBaseline(docPath: string): { text: string; reason: BaselineReason; capturedAt: string } | undefined { - const b = this.baselines.get(docPath); - return b ? { text: b.text, reason: b.reason, capturedAt: b.capturedAt } : undefined; - } - /** Absolute on-disk path of this doc's persisted baseline, or undefined if in-memory. */ - baselineFilePath(docPath: string): string | undefined { - return this.store?.baselinePath(docPath); - } - // ---- toggle UX (§6.5 PUC-1) -------------------------------------------------------- /** - * If this doc's baseline diff is the active/open tab → close it and reveal the - * normal editor; if the active editor is a tracked doc with no diff open → - * open vscode.diff (baseline left, the live document right). Untracked → warn, - * no diff. + * 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.isTracked(editor.document)) { + if (!editor || !this.isDiffable(editor.document)) { void vscode.window.showWarningMessage( - "Cowriting: open a tracked workspace document to toggle its diff view.", + "Cowriting: focus a text editor to toggle its diff view.", ); return; } const document = editor.document; - const docPath = this.docPathOf(document.uri); const openTab = this.findDiffTab(document.uri); if (openTab) { await vscode.window.tabGroups.close(openTab); @@ -167,11 +155,13 @@ export class DiffViewController implements vscode.Disposable { return; } this.ensureBaseline(document); - const baseline = this.baselines.get(docPath)!; - const title = `${path.basename(docPath)} — my changes since ${this.epochLabel(baseline)}`; + 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(docPath), + this.baselineUri(document), document.uri, title, { preview: false }, @@ -208,12 +198,28 @@ export class DiffViewController implements vscode.Disposable { } } - /** - * Test-facing (§6.4): is this doc's baseline diff currently open in any tab - * group? The diff's `modified` side is the document's own file: URI. - */ - isDiffOpen(docPath: string): boolean { - return this.findDiffTab(vscode.Uri.file(path.join(this.rootDir, docPath))) !== undefined; + 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."); + return; + } + this.pin(editor.document); + } + + // ---- test-facing surface (§6.4) ---------------------------------------------------- + + getBaseline(uriString: string): { text: string; reason: BaselineReason; capturedAt: string } | undefined { + 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; + return this.store.baselinePath(this.storageKey(uriString)); } dispose(): void { diff --git a/src/extension.ts b/src/extension.ts index 3cb27a2..bf9965f 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -44,6 +44,18 @@ 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. + const baselineStorageDir = context.globalStorageUri?.fsPath; + const baselineStore = baselineStorageDir ? new BaselineStore(baselineStorageDir) : null; + const diffViewController = new DiffViewController(baselineStore); + context.subscriptions.push(diffViewController); + // --- F2: region-anchored threads (Feature #4) --- const root = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; if (!root) { @@ -66,11 +78,12 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef "cowriting.acceptProposal", "cowriting.rejectProposal", "cowriting.proposeAgentEdit", - "cowriting.toggleDiffView", - "cowriting.pinDiffBaseline", ]) { 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); @@ -88,14 +101,10 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef const proposalController = new ProposalController(store, attributionController, root, versionGuard); context.subscriptions.push(proposalController); - // --- F6: diff-view toggle (Feature #17) --- - // Baseline lives in VS Code workspace storage, never the repo (INV-19). - // storageUri can be undefined in odd host states → in-memory fallback (§6.5). - const storageDir = context.storageUri?.fsPath; - const baselineStore = storageDir ? new BaselineStore(storageDir) : null; - const diffViewController = new DiffViewController(baselineStore, root); - context.subscriptions.push(diffViewController); + // --- 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). context.subscriptions.push( attributionController.onDidApplyAgentEdit((e) => diffViewController.advance(e.document)), ); @@ -245,7 +254,6 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef threadController.renderAll(doc); attributionController.loadAll(doc); proposalController.renderAll(doc); - diffViewController.ensureBaseline(doc); } }; vscode.workspace.textDocuments.forEach(renderIfOpen); diff --git a/test/baselineStore.test.ts b/test/baselineStore.test.ts index ed7b516..22af488 100644 --- a/test/baselineStore.test.ts +++ b/test/baselineStore.test.ts @@ -13,41 +13,49 @@ afterEach(() => { fs.rmSync(dir, { recursive: true, force: true }); }); -function sample(docPath = "notes/chapter-1.md"): Baseline { - return { docPath, text: "hello\nworld\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "opened" }; +const KEY = "a1b2c3"; // a stand-in for the controller's sha256(uri) key + +function sample(uri = "file:///ws/notes/chapter-1.md"): Baseline { + return { uri, text: "hello\nworld\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "opened" }; } describe("BaselineStore", () => { - it("returns null for a document with no baseline", () => { - expect(new BaselineStore(dir).load("notes/chapter-1.md")).toBeNull(); + it("returns null for a key with no baseline", () => { + expect(new BaselineStore(dir).load(KEY)).toBeNull(); }); - it("computes the baseline path as baselines/.json", () => { + it("computes the baseline path as baselines/.json", () => { const store = new BaselineStore(dir); - expect(store.baselinePath("notes/chapter-1.md")).toBe( - path.join(dir, "baselines", "notes", "chapter-1.md.json"), - ); + expect(store.baselinePath(KEY)).toBe(path.join(dir, "baselines", "a1b2c3.json")); }); - it("save then load round-trips the baseline (including nested docPaths)", () => { + it("save then load round-trips the baseline (including the uri identity)", () => { const store = new BaselineStore(dir); const b = sample(); - store.save(b.docPath, b); - expect(store.load(b.docPath)).toEqual(b); + store.save(KEY, b); + expect(store.load(KEY)).toEqual(b); }); it("overwrites in place: the newest epoch wins, no history kept", () => { const store = new BaselineStore(dir); - store.save("d.md", { docPath: "d.md", text: "v1", capturedAt: "2026-06-11T00:00:00.000Z", reason: "opened" }); - store.save("d.md", { docPath: "d.md", text: "v2", capturedAt: "2026-06-11T00:01:00.000Z", reason: "pinned" }); - expect(store.load("d.md")).toEqual({ docPath: "d.md", text: "v2", capturedAt: "2026-06-11T00:01:00.000Z", reason: "pinned" }); + store.save(KEY, { uri: "untitled:Untitled-1", text: "v1", capturedAt: "2026-06-11T00:00:00.000Z", reason: "opened" }); + store.save(KEY, { uri: "untitled:Untitled-1", text: "v2", capturedAt: "2026-06-11T00:01:00.000Z", reason: "pinned" }); + expect(store.load(KEY)).toEqual({ uri: "untitled:Untitled-1", text: "v2", capturedAt: "2026-06-11T00:01:00.000Z", reason: "pinned" }); }); it("writes pretty JSON with a trailing newline", () => { const store = new BaselineStore(dir); - const b = sample("d.md"); - store.save("d.md", b); - const raw = fs.readFileSync(store.baselinePath("d.md"), "utf8"); + const b = sample(); + store.save(KEY, b); + const raw = fs.readFileSync(store.baselinePath(KEY), "utf8"); expect(raw).toBe(JSON.stringify(b, null, 2) + "\n"); }); + + it("distinct keys are independent files (no collision)", () => { + const store = new BaselineStore(dir); + store.save("k1", sample("file:///a.md")); + store.save("k2", sample("file:///b.md")); + expect(store.load("k1")!.uri).toBe("file:///a.md"); + expect(store.load("k2")!.uri).toBe("file:///b.md"); + }); }); -- 2.39.5 From 15d1df0f4cae3a0d6f65eb3a4693533eded8e589 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 07:49:05 -0700 Subject: [PATCH 2/3] test(f6): E2E for any-file + untitled diff-view (#19) Migrate test surface to URI-string keys; add out-of-workspace file (persisted in global storage) + untitled-buffer (in-memory, not persisted) cases; assert F6 toggle works with no folder open. No LLM. Co-Authored-By: Claude Opus 4.8 --- .../suite-no-workspace/noWorkspace.test.ts | 24 ++++- test/e2e/suite/diffView.test.ts | 101 +++++++++++------- 2 files changed, 85 insertions(+), 40 deletions(-) diff --git a/test/e2e/suite-no-workspace/noWorkspace.test.ts b/test/e2e/suite-no-workspace/noWorkspace.test.ts index e2aecb7..b6c1cb5 100644 --- a/test/e2e/suite-no-workspace/noWorkspace.test.ts +++ b/test/e2e/suite-no-workspace/noWorkspace.test.ts @@ -28,8 +28,6 @@ suite("no-workspace activation (#8)", () => { "cowriting.acceptProposal", "cowriting.rejectProposal", "cowriting.proposeAgentEdit", - "cowriting.toggleDiffView", - "cowriting.pinDiffBaseline", ]) { assert.ok(all.includes(command), `${command} is registered`); } @@ -39,4 +37,26 @@ suite("no-workspace activation (#8)", () => { await vscode.commands.executeCommand("cowriting.editSelection"); await vscode.commands.executeCommand("cowriting.createThread"); }); + + // 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 () => { + const all = await vscode.commands.getCommands(true); + assert.ok(all.includes("cowriting.toggleDiffView"), "toggleDiffView registered"); + assert.ok(all.includes("cowriting.pinDiffBaseline"), "pinDiffBaseline registered"); + 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"); + 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"); + }); }); diff --git a/test/e2e/suite/diffView.test.ts b/test/e2e/suite/diffView.test.ts index 2cc27e6..d694633 100644 --- a/test/e2e/suite/diffView.test.ts +++ b/test/e2e/suite/diffView.test.ts @@ -1,11 +1,13 @@ import * as assert from "assert"; import * as fs from "fs"; +import * as os from "os"; import * as path from "path"; import * as vscode from "vscode"; import type { CowritingApi } from "../../../src/extension"; const WS = process.env.E2E_WORKSPACE!; const DOC_REL = "docs/diffview.md"; +const docUri = () => vscode.Uri.file(path.join(WS, DOC_REL)).toString(); async function openDoc(): Promise { const uri = vscode.Uri.file(path.join(WS, DOC_REL)); @@ -22,16 +24,17 @@ 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. -suite("F6 diff-view toggle (host E2E — programmatic seam ingress, no LLM)", () => { +// 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)", () => { const TARGET = "A target sentence Claude will rewrite via the seam."; const REPLACEMENT = "A SENTENCE CLAUDE REWROTE via the seam."; test("opening a tracked doc captures an `opened` baseline equal to the buffer (INV-18)", async () => { const doc = await openDoc(); + await getApi(); const api = await getApi(); - // renderIfOpen on open already called ensureBaseline; assert it captured. - const baseline = api.diffViewController.getBaseline(DOC_REL); + const baseline = api.diffViewController.getBaseline(docUri()); assert.ok(baseline, "baseline captured on first sight"); assert.strictEqual(baseline!.reason, "opened"); assert.strictEqual(baseline!.text, doc.getText(), "baseline = open-time buffer"); @@ -39,11 +42,10 @@ suite("F6 diff-view toggle (host E2E — programmatic seam ingress, no LLM)", () 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(DOC_REL), false, "no diff open yet"); + assert.strictEqual(api.diffViewController.isDiffOpen(docUri()), false, "no diff open yet"); await vscode.commands.executeCommand("cowriting.toggleDiffView"); await settle(); - assert.strictEqual(api.diffViewController.isDiffOpen(DOC_REL), true, "diff tab open"); - // The active tab is a TextDiff whose original is the baseline scheme. + 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( @@ -56,12 +58,12 @@ suite("F6 diff-view toggle (host E2E — programmatic seam ingress, no LLM)", () test("typing leaves the baseline unchanged while the buffer diverges", async () => { const doc = await openDoc(); const api = await getApi(); - const before = api.diffViewController.getBaseline(DOC_REL)!.text; + const before = api.diffViewController.getBaseline(docUri())!.text; const edit = new vscode.WorkspaceEdit(); edit.insert(doc.uri, new vscode.Position(0, 0), "OPERATOR ADDED LINE\n"); assert.ok(await vscode.workspace.applyEdit(edit), "operator edit applied"); await settle(); - assert.strictEqual(api.diffViewController.getBaseline(DOC_REL)!.text, before, "baseline unchanged by typing"); + assert.strictEqual(api.diffViewController.getBaseline(docUri())!.text, before, "baseline unchanged by typing"); assert.notStrictEqual(doc.getText(), before, "buffer diverged"); }); @@ -82,7 +84,7 @@ suite("F6 diff-view toggle (host E2E — programmatic seam ingress, no LLM)", () assert.ok(id, "propose returns an id"); assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam"); await settle(); - const baseline = api.diffViewController.getBaseline(DOC_REL)!; + const baseline = api.diffViewController.getBaseline(docUri())!; assert.strictEqual(baseline.reason, "machine-landing", "baseline advanced on the landing"); assert.ok(baseline.text.includes(REPLACEMENT), "landed text is in the baseline (won't show as a change)"); assert.ok(!baseline.text.includes(TARGET), "old target gone from the baseline too"); @@ -97,7 +99,7 @@ suite("F6 diff-view toggle (host E2E — programmatic seam ingress, no LLM)", () assert.ok(await vscode.workspace.applyEdit(edit)); await settle(); assert.notStrictEqual( - api.diffViewController.getBaseline(DOC_REL)!.text, + api.diffViewController.getBaseline(docUri())!.text, doc.getText(), "operator changes show against the advanced baseline", ); @@ -108,59 +110,82 @@ suite("F6 diff-view toggle (host E2E — programmatic seam ingress, no LLM)", () const api = await getApi(); await vscode.commands.executeCommand("cowriting.pinDiffBaseline"); await settle(); - const baseline = api.diffViewController.getBaseline(DOC_REL)!; + const baseline = api.diffViewController.getBaseline(docUri())!; assert.strictEqual(baseline.reason, "pinned"); assert.strictEqual(baseline.text, doc.getText(), "pinned baseline == current buffer (diff empties)"); }); - test("the baseline is persisted on disk under the storage dir with the expected content (PUC-4)", async () => { + test("the baseline is persisted in GLOBAL storage, never the repo (PUC-4, INV-19)", async () => { + await openDoc(); const api = await getApi(); - const p = api.diffViewController.baselineFilePath(DOC_REL); - assert.ok(p, "storage-backed baseline path is available"); + const p = api.diffViewController.baselineFilePath(docUri()); + assert.ok(p, "storage-backed baseline path is available for a file: doc"); assert.ok(fs.existsSync(p!), `baseline file exists at ${p}`); const onDisk = JSON.parse(fs.readFileSync(p!, "utf8")); - assert.strictEqual(onDisk.docPath, DOC_REL); + assert.strictEqual(onDisk.uri, docUri(), "baseline records the document URI"); assert.strictEqual(onDisk.reason, "pinned", "last epoch (pin) persisted"); - assert.strictEqual(onDisk.text, api.diffViewController.getBaseline(DOC_REL)!.text, "on-disk == in-memory"); - // INV-19: nothing leaked into the repo's .threads sidecar tree. + assert.strictEqual(onDisk.text, api.diffViewController.getBaseline(docUri())!.text, "on-disk == in-memory"); + // INV-19: baseline lives under the extension's storage dir, not the repo. assert.ok(!p!.includes(`${path.sep}.threads${path.sep}`), "baseline is NOT in the sidecar tree"); + assert.ok(!p!.startsWith(WS + path.sep), "baseline is NOT under the workspace folder"); + 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(DOC_REL)) { + if (!api.diffViewController.isDiffOpen(docUri())) { + await openDoc(); await vscode.commands.executeCommand("cowriting.toggleDiffView"); await settle(); } - assert.strictEqual(api.diffViewController.isDiffOpen(DOC_REL), true, "diff open before close"); + assert.strictEqual(api.diffViewController.isDiffOpen(docUri()), true, "diff open before close"); await vscode.commands.executeCommand("cowriting.toggleDiffView"); await settle(); - assert.strictEqual(api.diffViewController.isDiffOpen(DOC_REL), false, "diff tab closed"); - // "Normal editor is back": the file is the active text editor, and no - // baseline-diff tab for it remains in any group. + assert.strictEqual(api.diffViewController.isDiffOpen(docUri()), false, "diff tab closed"); assert.strictEqual( vscode.window.activeTextEditor?.document.uri.toString(), - vscode.Uri.file(path.join(WS, DOC_REL)).toString(), + docUri(), "the normal editor for the doc is active after closing the diff", ); }); - test("toggling on an untracked doc warns and opens no diff (PUC-5)", async () => { - await getApi(); - const untracked = await vscode.workspace.openTextDocument({ content: "scratch", language: "markdown" }); - await vscode.window.showTextDocument(untracked); + test("toggle works on a file OUTSIDE the workspace folder, persisted in global storage (#19)", async () => { + const api = await getApi(); + const outsideDir = fs.mkdtempSync(path.join(os.tmpdir(), "cowriting-outside-")); + const outsidePath = path.join(outsideDir, "outside.md"); + fs.writeFileSync(outsidePath, "# Outside the workspace\n\nThe operator edits this too.\n", "utf8"); + const outsideUri = vscode.Uri.file(outsidePath); + const doc = await vscode.workspace.openTextDocument(outsideUri); + await vscode.window.showTextDocument(doc); await settle(); + // Captured on open even though it is NOT under the workspace folder. + 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(); - // No baseline-diff tab for an untitled doc anywhere. - const anyDiff = 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() === untracked.uri.toString(), - ), - ); - assert.strictEqual(anyDiff, false, "no diff opened for the untracked doc"); + 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 }); + }); + + test("toggle works on an UNTITLED buffer, baseline in-memory only (#19)", async () => { + const api = await getApi(); + const untitled = await vscode.workspace.openTextDocument({ content: "scratch line\n", language: "markdown" }); + await vscode.window.showTextDocument(untitled); + await settle(); + const key = untitled.uri.toString(); + assert.strictEqual(untitled.uri.scheme, "untitled", "it really is an untitled buffer"); + 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"); }); }); -- 2.39.5 From 5897fb7b2677a04c6721c99c24a92f2e6f7fdaa4 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 07:52:31 -0700 Subject: [PATCH 3/3] docs(f6): any-file behavior + Ctrl+Alt+D wording (#19) Co-Authored-By: Claude Opus 4.8 --- README.md | 30 ++++++++++++++++++------------ docs/MANUAL-SMOKE-F6.md | 18 +++++++++++------- 2 files changed, 29 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 362701b..c7fd4e6 100644 --- a/README.md +++ b/README.md @@ -132,26 +132,32 @@ record per the contract; git push/pull is the transport, no re-homing ever. Design: `vscode-cowriting-plugin-content/specs/coauthoring-cross-rung-format.md`. -## F6 — Diff-view toggle (Feature #17) +## F6 — Diff-view toggle (Feature #17, #19) -**Cmd/Ctrl-Alt-D** (or **Cowriting: Toggle Diff View**) flips a tracked -document into a native `vscode.diff` against a **coauthoring baseline** — the -readonly baseline on the left, your **live, editable** document on the right -(so you keep writing inside the diff; toggling again closes it). The diff -answers "what did *I* change?" in one keystroke instead of git archaeology. +**`Ctrl+Alt+D`** (the same chord on macOS — not `Cmd`; or **Cowriting: Toggle +Diff View**) flips the focused document into a native `vscode.diff` against a +**coauthoring baseline** — the readonly baseline on the left, your **live, +editable** document on the right (so you keep writing inside the diff; toggling +again closes it). The diff answers "what did *I* change?" in one keystroke +instead of git archaeology. +- **Works on any file (#19):** any document you can edit — a file inside or + outside the workspace folder, or an **untitled** scratch buffer. Only a + non-text-editor focus warns. (Untitled buffers diff in-memory; saving makes + the baseline persist.) - **Machine-factored baseline (INV-18):** the baseline initializes when a doc - is first tracked and **advances automatically at every machine landing** - (every successful `applyAgentEdit` seam apply — INV-9). So text Claude landed - never shows as a change; everything the diff shows is operator-authored by + is first seen and **advances automatically at every machine landing** (every + successful `applyAgentEdit` seam apply — INV-9). So text Claude landed never + shows as a change; everything the diff shows is operator-authored by construction — no attribution filtering. - **Pin on demand:** **Cowriting: Pin Diff Baseline to Now** resets the baseline to the current buffer for a deliberate "review my next pass" epoch; the diff tab title names the epoch (`opened` / `Claude landed` / `pinned`). - **Pure view, repo-free (INV-19):** the baseline snapshot lives in VS Code - workspace storage, **never** the repo — `.threads/`, the cross-rung contract - (INV-14..17), and `SCHEMA_VERSION` are untouched. Storage-unavailable - degrades to in-memory baselines + one warning. + **global** extension storage, keyed by a hash of the document URI, **never** + the repo — `.threads/`, the cross-rung contract (INV-14..17), and + `SCHEMA_VERSION` are untouched. Storage-unavailable degrades to in-memory + baselines + one warning. - **No LLM in CI:** host E2E (`test/e2e/suite/diffView.test.ts`) drives the same programmatic seam ingress (propose + accept) the F4 suite uses. diff --git a/docs/MANUAL-SMOKE-F6.md b/docs/MANUAL-SMOKE-F6.md index f3279fe..e619a14 100644 --- a/docs/MANUAL-SMOKE-F6.md +++ b/docs/MANUAL-SMOKE-F6.md @@ -6,10 +6,11 @@ rest of F6 needs no credentials and no network. 1. `npm run build`, launch the extension (F5 in VS Code opens the committed `sandbox/` playground), open `playground.md`. -2. Edit a sentence by hand, then **Cmd/Ctrl-Alt-D** (or run **Cowriting: Toggle - Diff View**). ✅ A diff opens: the readonly baseline on the left, your live - document on the right; the tab title reads `playground.md — my changes since - opened