From 447a1170ce857078b921af44eac0f5ddb96efd3c Mon Sep 17 00:00:00 2001 From: BenStullsBets Date: Thu, 2 Jul 2026 07:09:55 -0700 Subject: [PATCH] =?UTF-8?q?feat:=20baseline=20router=20=E2=80=94=20git=20H?= =?UTF-8?q?EAD=20+=20snapshot=20per=20INV-7/D13/D14;=20retire=20machine-la?= =?UTF-8?q?nding=20advance=20(spec=20=C2=A76.4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 2 of the native-surfaces migration plan. GitBaselineAdapter resolves a document's HEAD blob via the built-in vscode.git extension (hardened with a .git/logs/HEAD watch that nudges repo.status(), since the git extension's own watcher doesn't reliably notice out-of-band commits on non-workspace-folder repos in the E2E host). DiffViewController is reworked into a baseline router: head mode (git-tracked, never persisted, re-read on commit) vs snapshot mode (captured on CoeditingRegistry entry, re-pinned by "Mark Changes as Reviewed" — renamed from cowriting.pinDiffBaseline, D14). The shipped machine-landing baseline advance (#48/INV-18) is retired: a landed Claude edit now stays a visible change-since-baseline until commit or review (INV-7/D21). Legacy on-disk reasons ("opened"/"machine-landing") migrate to "entered" via BaselineStore.normalizeReason on load. Co-Authored-By: Claude Fable 5 --- package.json | 6 +- src/attributionController.ts | 20 ++- src/baselineStore.ts | 19 +- src/diffViewController.ts | 169 +++++++++++++----- src/editorProposalController.ts | 2 +- src/extension.ts | 35 ++-- src/gitBaseline.ts | 131 ++++++++++++++ src/proposalController.ts | 9 +- src/trackChangesModel.ts | 13 +- src/trackChangesPreview.ts | 10 +- test/baselineStore.test.ts | 42 ++++- .../suite-no-workspace/noWorkspace.test.ts | 15 +- test/e2e/suite/authorship.test.ts | 14 +- test/e2e/suite/baselineRouter.test.ts | 48 +++++ test/e2e/suite/diffView.test.ts | 66 ++++--- test/e2e/suite/f10Review.test.ts | 20 ++- test/e2e/suite/f11Toolbar.test.ts | 12 +- test/e2e/suite/f12InlineDiff.test.ts | 3 + test/e2e/suite/helpers.ts | 32 ++++ test/e2e/suite/s48PinClean.test.ts | 3 + test/e2e/suite/trackChangesPreview.test.ts | 21 ++- test/e2e/suite/undoMarks.test.ts | 4 + test/trackChangesModel.test.ts | 4 +- 23 files changed, 558 insertions(+), 140 deletions(-) create mode 100644 src/gitBaseline.ts create mode 100644 test/e2e/suite/baselineRouter.test.ts create mode 100644 test/e2e/suite/helpers.ts diff --git a/package.json b/package.json index 698ab25..eb44367 100644 --- a/package.json +++ b/package.json @@ -85,8 +85,8 @@ "category": "Cowriting" }, { - "command": "cowriting.pinDiffBaseline", - "title": "Cowriting: Pin Review Baseline to Now", + "command": "cowriting.markReviewed", + "title": "Mark Changes as Reviewed", "category": "Cowriting" }, { @@ -149,7 +149,7 @@ "when": "false" }, { - "command": "cowriting.pinDiffBaseline", + "command": "cowriting.markReviewed", "when": "editorLangId == markdown" }, { diff --git a/src/attributionController.ts b/src/attributionController.ts index c3e7066..94f759f 100644 --- a/src/attributionController.ts +++ b/src/attributionController.ts @@ -56,8 +56,11 @@ export class AttributionController implements vscode.Disposable { /** * F6 (§6.2/§6.4): the single machine-landing signal. Fired after a real * (non-no-op) seam apply succeeds. INV-9 makes the seam the sole machine-edit - * ingress, so this is the sole signal — DiffViewController subscribes to - * advance the baseline; no call-site wiring, no future driver can forget it. + * ingress, so this is the sole signal. Native-surfaces migration (INV-7/ + * INV-18 retirement, spec §6.4): DiffViewController no longer subscribes + * here to advance the baseline — a landed edit stays a visible + * change-since-baseline until commit (head mode) or "Mark Changes as + * Reviewed" (snapshot mode). Other consumers may still subscribe. */ private readonly applyEmitter = new vscode.EventEmitter<{ document: vscode.TextDocument }>(); readonly onDidApplyAgentEdit: vscode.Event<{ document: vscode.TextDocument }> = this.applyEmitter.event; @@ -291,9 +294,10 @@ export class AttributionController implements vscode.Disposable { ); } if (ok && opts?.landBaseline !== false) { - // F6 (INV-18): a real machine landing — advance the baseline. F12 (INV-48) - // suppresses this for optimistic apply: the proposed text is in the buffer - // but the change stays PENDING (baseline at pre-proposal) until accept. + // F6 (INV-18, retired as a baseline advance — spec §6.4/INV-7): fire the + // machine-landing signal. F12 (INV-48) suppresses this for optimistic + // apply: the proposed text is in the buffer but the change stays PENDING + // until accept. this.applyEmitter.fire({ document }); } return ok; @@ -302,8 +306,10 @@ export class AttributionController implements vscode.Disposable { /** * F12/#64 (INV-51): fire the machine-landing signal WITHOUT applying text — used * by finalize-in-place, where the proposed text already landed in the buffer via - * optimistic apply (`landBaseline:false`) and accept only needs to advance the - * F6 baseline so the now-accepted change stops reading as pending. + * optimistic apply (`landBaseline:false`) and accept marks the landing so the + * now-accepted change is recorded (INV-7: the baseline itself no longer + * advances on landing — it advances only on commit / "Mark Changes as + * Reviewed"). */ signalLanded(document: vscode.TextDocument): void { if (this.isTracked(document)) this.applyEmitter.fire({ document }); diff --git a/src/baselineStore.ts b/src/baselineStore.ts index 4980aff..b2c1a79 100644 --- a/src/baselineStore.ts +++ b/src/baselineStore.ts @@ -7,11 +7,19 @@ * 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). + * + * INV-7 (native-surfaces migration, spec §6.4): the baseline is now either the + * git-HEAD blob (never persisted — see GitBaselineAdapter/DiffViewController) + * or a snapshot captured on coediting-entry / re-pinned by "Mark Changes as + * Reviewed". `"opened"`/`"machine-landing"` are legacy reasons from the + * shipped #17/#48 baseline; `normalizeReason` migrates them on load so old + * on-disk baselines keep round-tripping under the new vocabulary. */ import * as fs from "node:fs"; import * as path from "node:path"; -export type BaselineReason = "opened" | "machine-landing" | "pinned"; +export type BaselineReason = "entered" | "pinned" | "head"; +type LegacyBaselineReason = BaselineReason | "opened" | "machine-landing"; export interface Baseline { /** the document URI (the identity the key is derived from) — for round-trip/debug. */ @@ -22,6 +30,12 @@ export interface Baseline { reason: BaselineReason; } +/** Migrate legacy on-disk reasons to the current vocabulary (§6.4 INV-7). */ +export function normalizeReason(reason: LegacyBaselineReason): BaselineReason { + if (reason === "opened" || reason === "machine-landing") return "entered"; + return reason; +} + export class BaselineStore { /** @param storageDir absolute VS Code GLOBAL-storage dir (context.globalStorageUri.fsPath). */ constructor(private readonly storageDir: string) {} @@ -34,7 +48,8 @@ export class BaselineStore { 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; + const raw = JSON.parse(fs.readFileSync(p, "utf8")) as Baseline; + return { ...raw, reason: normalizeReason(raw.reason) }; } /** Newest epoch wins — overwrite in place, no history (state-not-history, the F3/F4 precedent). */ diff --git a/src/diffViewController.ts b/src/diffViewController.ts index ef649a2..2a7f2fd 100644 --- a/src/diffViewController.ts +++ b/src/diffViewController.ts @@ -1,9 +1,16 @@ /** - * 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) 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). + * DiffViewController — the baseline router (native-surfaces migration, spec + * §6.4, INV-7). Resolves each coedited document's review baseline to one of two + * modes: **head** (git-tracked — the baseline is the `HEAD` blob, re-read on + * every commit, never persisted) or **snapshot** (untracked/no-repo — captured + * on coediting-entry, re-pinned on demand by "Mark Changes as Reviewed"). + * Baselines are established ONLY for documents the CoeditingRegistry has + * entered (INV-10) — opening a file no longer captures one (the retired + * `ensureBaseline`/#17 behavior). The shipped machine-landing advance (#48, + * INV-18) is retired too: a landed Claude edit stays a visible change-since- + * baseline until the file is committed (head mode) or reviewed (snapshot + * mode) — spec INV-7's note, D21. A pure data layer: never mutates the + * document, sidecar, or attribution state (INV-19). * * 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 @@ -12,37 +19,57 @@ * 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 - * 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. + * F2's workspace `isTracked`. Persistable docs (`file:`) keep their SNAPSHOT + * baseline in VS Code's per-extension GLOBAL storage keyed by a hash of the + * document URI (INV-7: head-mode baselines are storage-free — never + * persisted); 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 { createHash } from "node:crypto"; import * as vscode from "vscode"; import { BaselineStore, type Baseline, type BaselineReason } from "./baselineStore"; +import type { GitBaselineAdapter } from "./gitBaseline"; +import type { CoeditingRegistry } from "./coeditingRegistry"; export class DiffViewController implements vscode.Disposable { private readonly disposables: vscode.Disposable[] = []; /** Source of truth for the baseline, keyed by `document.uri.toString()`. */ private readonly baselines = new Map(); - /** F7 (additive): fires on every baseline capture (open / advance / pin) so - * the track-changes preview refreshes without polling. Carries the real - * document URI. */ + /** Which mode each established document resolved to (INV-7). */ + private readonly modes = new Map(); + /** F7 (additive): fires on every baseline capture (establish / head-refresh / + * pin) so 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) { + constructor( + private readonly store: BaselineStore | null, + private readonly git: GitBaselineAdapter, + registry: CoeditingRegistry, + ) { this.disposables.push( this.onDidChangeBaselineEmitter, - vscode.commands.registerCommand("cowriting.pinDiffBaseline", () => + vscode.commands.registerCommand("cowriting.markReviewed", () => 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)), + // INV-10: a baseline is established only once its document ENTERS + // coediting; exiting keeps the stored baseline as-is (PUC-7). + registry.onDidChange(({ uri, coediting }) => { + if (!coediting) return; + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); + if (doc) void this.establish(doc); + }), + // A commit on a watched repo may advance a head-mode baseline (D13). + git.onDidChangeHead(() => void this.refreshHeadBaselines()), ); - for (const d of vscode.workspace.textDocuments) this.ensureBaseline(d); + // Restore already-coediting documents (e.g. after an extension host reload). + for (const key of registry.list()) { + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key); + if (doc) void this.establish(doc); + } } // ---- diffability / identity -------------------------------------------------------- @@ -51,7 +78,7 @@ export class DiffViewController implements vscode.Disposable { private isDiffable(document: vscode.TextDocument): boolean { return document.uri.scheme === "file" || document.uri.scheme === "untitled"; } - /** Only `file:` docs have a durable identity to persist a baseline against. */ + /** Only `file:` docs have a durable identity to persist a SNAPSHOT baseline against. */ private isPersistable(document: vscode.TextDocument): boolean { return document.uri.scheme === "file"; } @@ -64,36 +91,66 @@ export class DiffViewController implements vscode.Disposable { return createHash("sha256").update(uriKey).digest("hex"); } - // ---- baseline lifecycle (§6.4) ----------------------------------------------------- + // ---- baseline lifecycle (§6.4, INV-7) ----------------------------------------------- - /** First sight of a diffable doc: load the stored baseline, else capture `opened`. */ - ensureBaseline(document: vscode.TextDocument): void { + /** + * Resolve this document's baseline mode and (re)establish its baseline. + * git-tracked (a HEAD blob resolves) → **head** mode, baseline = HEAD, never + * persisted. Otherwise → **snapshot** mode: load a previously-stored + * snapshot, else capture the buffer now (`"entered"`). Called once when a + * document enters coediting (registry.enter) and at startup for documents + * already coediting. + */ + async establish(document: vscode.TextDocument): Promise { if (!this.isDiffable(document)) return; const key = this.uriKey(document); - if (this.baselines.has(key)) return; + const head = await this.git.headFor(document.uri); + if (head) { + this.modes.set(key, "head"); + this.setBaseline(key, head.text, "head"); + return; + } + this.modes.set(key, "snapshot"); if (this.store && this.isPersistable(document)) { - try { - const stored = this.store.load(this.storageKey(key)); - if (stored) { - this.baselines.set(key, stored); - return; - } - } catch { - this.warnStorageOnce(); + const stored = this.tryLoad(key); // existing load path, legacy reasons normalized + if (stored) { + this.baselines.set(key, stored); + this.fire(key); + return; } } - this.capture(document, "opened"); + this.capture(document, "entered"); } - /** Machine landing (INV-18): re-capture so landed text never shows as a change. */ - advance(document: vscode.TextDocument): void { - if (!this.isDiffable(document)) return; - this.capture(document, "machine-landing"); + /** The mode a document's baseline resolved to, or undefined before `establish`. */ + modeOf(uriString: string): "head" | "snapshot" | undefined { + return this.modes.get(uriString); } - /** Human pin: baseline := now; the preview's change-marks empty (left = right). */ + /** Re-read HEAD for every head-mode document; a commit advances the baseline (D13). */ + private async refreshHeadBaselines(): Promise { + for (const [key, mode] of this.modes) { + if (mode !== "head") continue; + const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === key); + if (!doc) continue; + const head = await this.git.headFor(doc.uri); + if (head && head.text !== this.baselines.get(key)?.text) { + this.setBaseline(key, head.text, "head"); // commit advanced the baseline (D13) + } + } + } + + /** Human pin ("Mark Changes as Reviewed", D14): snapshot mode only — a + * git-tracked (head-mode) baseline advances only by commit. */ pin(document: vscode.TextDocument): void { if (!this.isDiffable(document)) return; + const key = this.uriKey(document); + if (this.modes.get(key) !== "snapshot") { + void vscode.window.showWarningMessage( + "Cowriting: this document is git-tracked — commit to advance the baseline.", + ); + return; + } this.capture(document, "pinned"); } @@ -103,12 +160,38 @@ export class DiffViewController implements vscode.Disposable { 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(this.storageKey(key), baseline); - } catch { - this.warnStorageOnce(); - } + this.trySave(key, baseline); } + this.fire(key); + } + + /** Set a baseline from resolved text directly (head mode — no document buffer read). */ + private setBaseline(key: string, text: string, reason: BaselineReason): void { + const baseline: Baseline = { uri: key, text, capturedAt: new Date().toISOString(), reason }; + this.baselines.set(key, baseline); + // head-mode baselines are storage-free (INV-7); persist only snapshots. + if (reason !== "head" && this.store) this.trySave(key, baseline); + this.fire(key); + } + + private tryLoad(key: string): Baseline | null { + try { + return this.store!.load(this.storageKey(key)); + } catch { + this.warnStorageOnce(); + return null; + } + } + + private trySave(key: string, baseline: Baseline): void { + try { + this.store!.save(this.storageKey(key), baseline); + } catch { + this.warnStorageOnce(); + } + } + + private fire(key: string): void { this.onDidChangeBaselineEmitter.fire({ uri: key }); } @@ -122,7 +205,7 @@ export class DiffViewController implements vscode.Disposable { 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 review baseline."); + void vscode.window.showWarningMessage("Cowriting: focus a text editor to mark its changes as reviewed."); return; } this.pin(editor.document); diff --git a/src/editorProposalController.ts b/src/editorProposalController.ts index 691af60..370b2c5 100644 --- a/src/editorProposalController.ts +++ b/src/editorProposalController.ts @@ -57,7 +57,7 @@ export class EditorProposalController implements vscode.Disposable, vscode.CodeL vscode.languages.registerCodeLensProvider({ language: "markdown" }, this), this.proposals.onDidChangeProposals(({ uri }) => this.scheduleApply(uri)), vscode.window.onDidChangeActiveTextEditor((ed) => ed && this.renderEditor(ed)), - // Refresh committed decorations when the baseline advances (pin / machine-landing / open). + // Refresh committed decorations when the baseline changes (establish / pin / head-refresh). this.diffView.onDidChangeBaseline(({ uri }) => { const ed = vscode.window.visibleTextEditors.find((e) => e.document.uri.toString() === uri); if (ed) this.renderEditor(ed); diff --git a/src/extension.ts b/src/extension.ts index 272ae45..410a504 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -10,6 +10,7 @@ import { BaselineStore } from "./baselineStore"; import { GlobalSidecarStore } from "./globalSidecarStore"; import { SidecarRouter } from "./sidecarRouter"; import { DiffViewController } from "./diffViewController"; +import { GitBaselineAdapter } from "./gitBaseline"; import { TrackChangesPreviewController } from "./trackChangesPreview"; import { LiveProgressUi } from "./liveProgressUi"; import { EditorProposalController } from "./editorProposalController"; @@ -87,17 +88,21 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef ); 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 - // 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. + // --- Baseline router (native-surfaces migration, spec §6.4, INV-7) — workspace- + // INDEPENDENT --- The 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. It tracks a baseline for ANY diffable + // doc (file or untitled), so it is constructed regardless of whether a folder + // is open. A SNAPSHOT baseline lives in VS Code's per-extension GLOBAL + // storage (always present), never the repo (INV-19); a git-tracked doc's HEAD + // baseline is never persisted (INV-7). Baselines are established only for + // documents the CoeditingRegistry has entered — see the registry wiring + // inside DiffViewController's constructor. + const gitBaseline = new GitBaselineAdapter(); + context.subscriptions.push(gitBaseline); const baselineStorageDir = context.globalStorageUri?.fsPath; const baselineStore = baselineStorageDir ? new BaselineStore(baselineStorageDir) : null; - const diffViewController = new DiffViewController(baselineStore); + const diffViewController = new DiffViewController(baselineStore, gitBaseline, coeditingRegistry); context.subscriptions.push(diffViewController); // F8: the out-of-workspace/untitled authoring sidecar — same GLOBAL storage @@ -176,12 +181,12 @@ export function activate(context: vscode.ExtensionContext): CowritingApi | undef }), ); - // --- 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)), - ); + // NOTE (native-surfaces migration, INV-7/INV-18 retirement): the shipped + // machine-landing baseline advance (#48) — wiring + // attributionController.onDidApplyAgentEdit → diffViewController.advance — + // was removed here. A landed Claude edit now stays a visible change-since- + // baseline until the file is committed (head mode) or reviewed via + // "Mark Changes as Reviewed" (snapshot mode). // One SHARED sidecar watcher for both controllers; self-writes are suppressed // centrally in the repo store (only repo `.threads/` sidecars are watched — diff --git a/src/gitBaseline.ts b/src/gitBaseline.ts new file mode 100644 index 0000000..3997b3b --- /dev/null +++ b/src/gitBaseline.ts @@ -0,0 +1,131 @@ +/** + * GitBaselineAdapter — resolves a document's git-HEAD baseline via the built-in + * Git extension API (spec §6.4, INV-7). Spike-verified: Repository.show('HEAD', + * path) + state.onDidChange work as assumed; the change event is coarse, so + * re-reads key off state.HEAD.commit; a nested repo may need openRepository(). + * + * Hardening (Task 2 E2E, host-verified): for a repo that is NOT a workspace + * folder (the common case here — F8/#19 precedent, coediting works on any + * file), the git extension's own filesystem watcher does not reliably notice + * an out-of-band commit (e.g. a `git commit` run from a terminal, or in the + * host E2E) — `repo.state.onDidChange` can simply never fire. We additionally + * watch `.git/logs/HEAD` (the reflog VS Code itself also watches for + * in-workspace repos) with Node's own `fs.watch` and nudge `repo.status()`, + * which reliably re-syncs `state.HEAD` and lets the existing onDidChange path + * do the rest. Best-effort: any failure here just falls back to the passive + * listener. + */ +import * as fs from "node:fs"; +import * as path from "node:path"; +import * as vscode from "vscode"; + +interface GitRepository { + rootUri: vscode.Uri; + state: { HEAD?: { commit?: string }; onDidChange: vscode.Event }; + show(ref: string, path: string): Promise; + status(): Promise; +} +interface GitAPI { + repositories: GitRepository[]; + getRepository(uri: vscode.Uri): GitRepository | null; + openRepository(root: vscode.Uri): Promise; + onDidOpenRepository: vscode.Event; +} + +export interface HeadBaseline { text: string; commit: string } + +export class GitBaselineAdapter implements vscode.Disposable { + private api: GitAPI | null | undefined; // undefined = not resolved yet; null = unavailable + private readonly hooked = new WeakSet(); + private readonly lastCommit = new Map(); // repo root → HEAD commit + private readonly emitter = new vscode.EventEmitter(); + readonly onDidChangeHead = this.emitter.event; + private readonly disposables: vscode.Disposable[] = [this.emitter]; + + private async gitApi(): Promise { + if (this.api !== undefined) return this.api; + try { + const ext = vscode.extensions.getExtension("vscode.git"); + if (!ext) return (this.api = null); + const exports = ext.isActive ? ext.exports : await ext.activate(); + this.api = exports.getAPI(1) as GitAPI; + this.disposables.push(this.api.onDidOpenRepository((r) => this.hook(r))); + for (const r of this.api.repositories) this.hook(r); + } catch { + this.api = null; + } + return this.api; + } + + /** HEAD blob text + commit for a tracked file; null when untracked / no repo / no HEAD. */ + async headFor(uri: vscode.Uri): Promise { + if (uri.scheme !== "file") return null; + const git = await this.gitApi(); + if (!git) return null; + let repo = git.getRepository(uri); + if (!repo) repo = await this.discoverNested(git, uri); + if (!repo) return null; + this.hook(repo); + try { + const text = await repo.show("HEAD", uri.fsPath); + return { text, commit: repo.state.HEAD?.commit ?? "" }; + } catch { + return null; // untracked, ignored, or no commit yet → snapshot mode + } + } + + /** Spike finding: a nested repo may not be auto-opened — walk up for .git, then openRepository. */ + private async discoverNested(git: GitAPI, uri: vscode.Uri): Promise { + let dir = path.dirname(uri.fsPath); + for (let i = 0; i < 32; i++) { + if (fs.existsSync(path.join(dir, ".git"))) { + await git.openRepository(vscode.Uri.file(dir)); + return git.getRepository(uri); + } + const parent = path.dirname(dir); + if (parent === dir) break; + dir = parent; + } + return null; + } + + /** Watch a repo; fire onDidChangeHead only when HEAD's commit actually moves. */ + private hook(repo: GitRepository): void { + if (this.hooked.has(repo)) return; + this.hooked.add(repo); + this.lastCommit.set(repo.rootUri.toString(), repo.state.HEAD?.commit ?? ""); + this.disposables.push( + repo.state.onDidChange(() => { + const key = repo.rootUri.toString(); + const commit = repo.state.HEAD?.commit ?? ""; + if (this.lastCommit.get(key) !== commit) { + this.lastCommit.set(key, commit); + this.emitter.fire(); + } + }), + ); + this.watchReflog(repo); + } + + /** Best-effort nudge: watch `.git/logs/HEAD` directly and force `repo.status()` + * on any write, so an out-of-band commit is picked up even when the git + * extension's own watcher doesn't fire (see the class doc). */ + private watchReflog(repo: GitRepository): void { + try { + const reflog = path.join(repo.rootUri.fsPath, ".git", "logs", "HEAD"); + if (!fs.existsSync(reflog)) return; + let pending: ReturnType | undefined; + const watcher = fs.watch(reflog, () => { + if (pending) clearTimeout(pending); + pending = setTimeout(() => void repo.status().catch(() => {}), 150); + }); + this.disposables.push({ dispose: () => watcher.close() }); + } catch { + // best-effort only — the passive repo.state.onDidChange listener still applies. + } + } + + dispose(): void { + for (const d of this.disposables) d.dispose(); + } +} diff --git a/src/proposalController.ts b/src/proposalController.ts index af90a30..792b082 100644 --- a/src/proposalController.ts +++ b/src/proposalController.ts @@ -380,8 +380,13 @@ export class ProposalController implements vscode.Disposable { /** * F12/#64 (INV-51): ACCEPT an optimistically-applied proposal — the text is - * already in the buffer, so this only advances the F6 baseline (machine-landing, - * via `attribution.signalLanded`) and clears the proposal. No re-application. + * already in the buffer, so this only marks the attribution landing (via + * `attribution.signalLanded`) and clears the proposal. No re-application. + * Native-surfaces migration (INV-7/INV-18 retirement, spec §6.4): this no + * longer advances the baseline — a landed proposal stays a visible change- + * since-baseline until commit (head mode) or "Mark Changes as Reviewed" + * (snapshot mode); nothing here subscribes to `signalLanded` for baseline + * purposes anymore (the `onDidApplyAgentEdit → advance` wire was removed). */ async finalizeInPlace(docPath: string, proposalId: string): Promise { const hit = this.byId(docPath, proposalId); diff --git a/src/trackChangesModel.ts b/src/trackChangesModel.ts index c349298..23400b3 100644 --- a/src/trackChangesModel.ts +++ b/src/trackChangesModel.ts @@ -548,11 +548,14 @@ export interface RenderOptions { /** Per-block markdown→HTML renderer (test seam). Defaults to the bundled markdown-it. */ render?: (src: string) => string; /** - * #48: the baseline was just PINNED (`reason === "pinned"`). With zero changes - * since that pin, the on-render is fully clean — no authorship coloring — so a - * pin reads as "this is my clean starting point". Distinct from a baseline - * advanced by a machine-landing (accept), which keeps its authorship coloring - * (F10 INV-33). Only consulted by `renderReview`. + * #48: the baseline was just PINNED (`reason === "pinned"` — "Mark Changes as + * Reviewed" in snapshot mode). With zero changes since that pin, the + * on-render is fully clean — no authorship coloring — so a pin reads as + * "this is my clean starting point". Distinct from an ordinary accepted + * proposal, which — since the native-surfaces migration retired the + * machine-landing baseline advance (INV-18/INV-7) — stays a visible change- + * since-baseline and keeps its authorship coloring (F10 INV-33). Only + * consulted by `renderReview`. */ pinned?: boolean; } diff --git a/src/trackChangesPreview.ts b/src/trackChangesPreview.ts index d7b0ffc..a6c0c8e 100644 --- a/src/trackChangesPreview.ts +++ b/src/trackChangesPreview.ts @@ -418,15 +418,15 @@ export class TrackChangesPreviewController implements vscode.Disposable { } private epochLabel(baseline: { reason: string; capturedAt: string } | undefined): string { - if (!baseline) return "opened (no baseline yet)"; + if (!baseline) return "not coediting (no baseline yet)"; const time = new Date(baseline.capturedAt).toLocaleTimeString(); switch (baseline.reason) { - case "machine-landing": - return `Claude landed ${time}`; + case "head": + return `HEAD ${time}`; case "pinned": - return `pinned ${time}`; + return `reviewed ${time}`; default: - return `opened ${time}`; + return `entered ${time}`; } } diff --git a/test/baselineStore.test.ts b/test/baselineStore.test.ts index 22af488..4f39d31 100644 --- a/test/baselineStore.test.ts +++ b/test/baselineStore.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect, beforeEach, afterEach } from "vitest"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; -import { BaselineStore, type Baseline } from "../src/baselineStore"; +import { BaselineStore, normalizeReason, type Baseline } from "../src/baselineStore"; let dir: string; @@ -16,9 +16,23 @@ afterEach(() => { 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" }; + return { uri, text: "hello\nworld\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "entered" }; } +describe("normalizeReason (INV-7 legacy migration)", () => { + it("migrates legacy 'opened' → 'entered'", () => { + expect(normalizeReason("opened")).toBe("entered"); + }); + it("migrates legacy 'machine-landing' → 'entered'", () => { + expect(normalizeReason("machine-landing")).toBe("entered"); + }); + it("passes current reasons through unchanged", () => { + expect(normalizeReason("entered")).toBe("entered"); + expect(normalizeReason("pinned")).toBe("pinned"); + expect(normalizeReason("head")).toBe("head"); + }); +}); + describe("BaselineStore", () => { it("returns null for a key with no baseline", () => { expect(new BaselineStore(dir).load(KEY)).toBeNull(); @@ -36,9 +50,31 @@ describe("BaselineStore", () => { expect(store.load(KEY)).toEqual(b); }); + it("migrates a legacy on-disk reason ('opened') to 'entered' on load", () => { + const store = new BaselineStore(dir); + fs.mkdirSync(path.dirname(store.baselinePath(KEY)), { recursive: true }); + fs.writeFileSync( + store.baselinePath(KEY), + JSON.stringify({ uri: "file:///a.md", text: "legacy\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "opened" }), + "utf8", + ); + expect(store.load(KEY)?.reason).toBe("entered"); + }); + + it("migrates a legacy on-disk reason ('machine-landing') to 'entered' on load", () => { + const store = new BaselineStore(dir); + fs.mkdirSync(path.dirname(store.baselinePath(KEY)), { recursive: true }); + fs.writeFileSync( + store.baselinePath(KEY), + JSON.stringify({ uri: "file:///a.md", text: "legacy\n", capturedAt: "2026-06-11T00:00:00.000Z", reason: "machine-landing" }), + "utf8", + ); + expect(store.load(KEY)?.reason).toBe("entered"); + }); + it("overwrites in place: the newest epoch wins, no history kept", () => { const store = new BaselineStore(dir); - 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: "v1", capturedAt: "2026-06-11T00:00:00.000Z", reason: "entered" }); 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" }); }); diff --git a/test/e2e/suite-no-workspace/noWorkspace.test.ts b/test/e2e/suite-no-workspace/noWorkspace.test.ts index 7c645e9..3f29e83 100644 --- a/test/e2e/suite-no-workspace/noWorkspace.test.ts +++ b/test/e2e/suite-no-workspace/noWorkspace.test.ts @@ -79,22 +79,25 @@ suite("no-workspace authoring (F8 — real folder-less, #8 lineage)", () => { // 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. + // removed in #34; only the data layer remains). markReviewed stays real. + // Native-surfaces migration (Task 2, INV-7): the baseline is established on + // coediting ENTRY, not merely on open. 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.pinDiffBaseline"), "pinDiffBaseline registered"); + assert.ok(all.includes("cowriting.markReviewed"), "markReviewed 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 vscode.commands.executeCommand("cowriting.coeditDocument"); await new Promise((r) => setTimeout(r, 300)); 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"); + assert.strictEqual(baseline!.reason, "entered"); + // markReviewed resets the baseline to now — works folder-less. + await vscode.commands.executeCommand("cowriting.markReviewed"); await new Promise((r) => setTimeout(r, 300)); - assert.strictEqual(api.diffViewController.getBaseline(key)!.reason, "pinned", "pin works with no folder"); + assert.strictEqual(api.diffViewController.getBaseline(key)!.reason, "pinned", "markReviewed works with no folder"); }); }); diff --git a/test/e2e/suite/authorship.test.ts b/test/e2e/suite/authorship.test.ts index efaa760..64d2ec9 100644 --- a/test/e2e/suite/authorship.test.ts +++ b/test/e2e/suite/authorship.test.ts @@ -16,10 +16,12 @@ async function getApi(): Promise { // F10 host E2E (no LLM): the rewrite of the obsolete F9 authorship-mode test. // F9's "authorship" mode / renderAuthorship is gone — the on-state renderReview -// now author-colors Claude's PENDING proposal blocks as cw-ins-claude. After the -// proposal is accepted the baseline advances (INV-18/F12) and the text becomes -// "unchanged" → renders plain. The class to check is cw-ins-claude (proposal block), -// NOT cw-by-claude (the old F9 authorship render). Owns its own markdown doc. +// now author-colors Claude's PENDING proposal blocks as cw-ins-claude. This +// suite checks that render BEFORE accepting (the class to check is cw-ins-claude, +// the proposal block, NOT cw-by-claude — the old F9 authorship render) and that +// the attribution data layer survives the accept; it does not exercise the F6 +// baseline (see diffView.test.ts / trackChangesPreview.test.ts / baselineRouter.test.ts +// for the post-accept baseline behavior, INV-7/D21). Owns its own markdown doc. suite("F10 review preview — pending Claude proposal renders cw-ins-claude in the on-state (host E2E, no LLM)", () => { const DOC_REL = "docs/f10claude.md"; const TARGET = "The sentence Claude will compose over."; @@ -65,7 +67,9 @@ suite("F10 review preview — pending Claude proposal renders cw-ins-claude in t "pending Claude proposal block carries cw-ins-claude in the on-state render", ); - // accept via the seam — baseline advances (machine-landing, INV-18/F12) + // accept via the seam — the proposal lands; the baseline no longer auto-advances + // (INV-18/#48 retired, spec INV-7), but that isn't asserted here (no coediting + // entry / baseline established for this doc — only the attribution data layer). assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam"); await settle(); diff --git a/test/e2e/suite/baselineRouter.test.ts b/test/e2e/suite/baselineRouter.test.ts new file mode 100644 index 0000000..cf80c35 --- /dev/null +++ b/test/e2e/suite/baselineRouter.test.ts @@ -0,0 +1,48 @@ +import * as assert from "node:assert"; +import { execFileSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import * as vscode from "vscode"; +import { activateApi, settle, settleUntil } from "./helpers"; + +suite("baseline router (PUC-1, INV-7/D13/D14)", () => { + test("snapshot mode: enter captures; markReviewed re-pins clean", async () => { + const api = await activateApi(); + const doc = await vscode.workspace.openTextDocument({ language: "markdown", content: "one\n" }); + const ed = await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); + await settle(); + assert.strictEqual(api.diffViewController.modeOf(doc.uri.toString()), "snapshot"); + assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.text, "one\n"); + await ed.edit((b) => b.insert(new vscode.Position(1, 0), "two\n")); + await vscode.commands.executeCommand("cowriting.markReviewed"); + const b = api.diffViewController.getBaseline(doc.uri.toString()); + assert.strictEqual(b?.text, "one\ntwo\n"); + assert.strictEqual(b?.reason, "pinned"); + }); + + test("head mode: baseline = HEAD; commit advances it", async function () { + this.timeout(30000); + const dir = fs.mkdtempSync(path.join(os.tmpdir(), "cw-git-")); + const git = (...a: string[]) => execFileSync("git", ["-C", dir, ...a], { encoding: "utf8" }); + git("init", "-q"); + fs.writeFileSync(path.join(dir, "doc.md"), "committed\n"); + git("add", "doc.md"); + git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-qm", "c1"); + const api = await activateApi(); + const doc = await vscode.workspace.openTextDocument(vscode.Uri.file(path.join(dir, "doc.md"))); + const ed = await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); + await settleUntil(() => api.diffViewController.modeOf(doc.uri.toString()) === "head", 10000); + assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.text, "committed\n"); + await ed.edit((b) => b.insert(new vscode.Position(1, 0), "uncommitted\n")); + await doc.save(); + git("-c", "user.name=t", "-c", "user.email=t@t", "commit", "-aqm", "c2"); + await settleUntil( + () => api.diffViewController.getBaseline(doc.uri.toString())?.text === "committed\nuncommitted\n", + 15000, + ); + assert.strictEqual(api.diffViewController.getBaseline(doc.uri.toString())?.reason, "head"); + }); +}); diff --git a/test/e2e/suite/diffView.test.ts b/test/e2e/suite/diffView.test.ts index 7758ac9..4ae7eeb 100644 --- a/test/e2e/suite/diffView.test.ts +++ b/test/e2e/suite/diffView.test.ts @@ -15,6 +15,16 @@ async function openDoc(): Promise { await vscode.window.showTextDocument(doc); return doc; } +/** Open the doc AND enter coediting (INV-10) — baselines are established only + * on entry (Task 2). Idempotent across tests: re-entering an already-coedited + * doc is a no-op, so the once-captured baseline survives (order-dependent + * suite, same as before). */ +async function openAndCoedit(): Promise { + const doc = await openDoc(); + await vscode.commands.executeCommand("cowriting.coeditDocument"); + await settle(); + return doc; +} async function getApi(): Promise { const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; const api = (await ext.activate()) as CowritingApi; @@ -29,22 +39,29 @@ const settle = () => new Promise((r) => setTimeout(r, 300)); // 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. +// +// Native-surfaces migration (Task 2, spec §6.4/INV-7): a baseline is now +// established only once a document ENTERS coediting (not merely opened), and +// the fixture workspace is NOT a git repo, so every doc here resolves to +// SNAPSHOT mode. The shipped machine-landing baseline advance (#48/INV-18) is +// retired — an accepted proposal now stays a visible change-since-baseline +// until "Mark Changes as Reviewed" (D21). 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."; - test("opening a tracked doc captures an `opened` baseline equal to the buffer (INV-18)", async () => { - const doc = await openDoc(); - await getApi(); + test("entering coediting on a snapshot-mode doc captures an `entered` baseline equal to the buffer (INV-7)", async () => { + const doc = await openAndCoedit(); const api = await getApi(); + assert.strictEqual(api.diffViewController.modeOf(docUri()), "snapshot", "no git repo → snapshot mode"); 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"); + assert.ok(baseline, "baseline captured on coediting entry"); + assert.strictEqual(baseline!.reason, "entered"); + assert.strictEqual(baseline!.text, doc.getText(), "baseline = entry-time buffer"); }); test("typing leaves the baseline unchanged while the buffer diverges", async () => { - const doc = await openDoc(); + const doc = await openAndCoedit(); const api = await getApi(); const before = api.diffViewController.getBaseline(docUri())!.text; const edit = new vscode.WorkspaceEdit(); @@ -55,9 +72,10 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress, assert.notStrictEqual(doc.getText(), before, "buffer diverged"); }); - test("accepting a proposal advances the baseline past the landed text (PUC-2, INV-18)", async () => { - const doc = await openDoc(); + test("accepting a proposal does NOT advance the baseline — the landed text stays a change (INV-7/D21, #48 retired)", async () => { + const doc = await openAndCoedit(); const api = await getApi(); + const before = api.diffViewController.getBaseline(docUri())!; const start = doc.getText().indexOf(TARGET); assert.ok(start >= 0, "fixture contains the target"); const id = await vscode.commands.executeCommand("cowriting.proposeAgentEdit", { @@ -73,14 +91,14 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress, assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam"); await settle(); 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"); - assert.strictEqual(baseline.text, doc.getText(), "baseline == buffer right after the landing"); + assert.strictEqual(baseline.reason, before.reason, "baseline reason untouched by the landing"); + assert.strictEqual(baseline.text, before.text, "baseline text untouched by the landing"); + assert.ok(!baseline.text.includes(REPLACEMENT), "landed text is NOT folded into the baseline"); + assert.notStrictEqual(baseline.text, doc.getText(), "baseline != buffer — the landing reads as a change"); }); - test("an operator edit after the landing makes baseline ≠ buffer (the operator delta)", async () => { - const doc = await openDoc(); + test("an operator edit after the landing keeps baseline ≠ buffer (the operator delta)", async () => { + const doc = await openAndCoedit(); const api = await getApi(); const edit = new vscode.WorkspaceEdit(); edit.insert(doc.uri, new vscode.Position(0, 0), "POST-LANDING OPERATOR LINE\n"); @@ -89,14 +107,14 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress, assert.notStrictEqual( api.diffViewController.getBaseline(docUri())!.text, doc.getText(), - "operator changes show against the advanced baseline", + "operator changes show against the untouched baseline", ); }); - test("pin resets the baseline to now: baseline == buffer, reason pinned", async () => { - const doc = await openDoc(); + test("markReviewed resets the baseline to now: baseline == buffer, reason pinned", async () => { + const doc = await openAndCoedit(); const api = await getApi(); - await vscode.commands.executeCommand("cowriting.pinDiffBaseline"); + await vscode.commands.executeCommand("cowriting.markReviewed"); await settle(); const baseline = api.diffViewController.getBaseline(docUri())!; assert.strictEqual(baseline.reason, "pinned"); @@ -104,14 +122,14 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress, }); test("the baseline is persisted in GLOBAL storage, never the repo (INV-19)", async () => { - await openDoc(); + await openAndCoedit(); const api = await getApi(); 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.uri, docUri(), "baseline records the document URI"); - assert.strictEqual(onDisk.reason, "pinned", "last epoch (pin) persisted"); + assert.strictEqual(onDisk.reason, "pinned", "last epoch (markReviewed) persisted"); 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"); @@ -127,11 +145,12 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress, const outsideUri = vscode.Uri.file(outsidePath); const doc = await vscode.workspace.openTextDocument(outsideUri); await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); await settle(); - // Captured on open even though it is NOT under the workspace folder. + // Captured on coediting entry 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"); + assert.strictEqual(baseline!.reason, "entered"); 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"); @@ -143,6 +162,7 @@ suite("F6 baseline data layer (host E2E — any file, programmatic seam ingress, const api = await getApi(); const untitled = await vscode.workspace.openTextDocument({ content: "scratch line\n", language: "markdown" }); await vscode.window.showTextDocument(untitled); + await vscode.commands.executeCommand("cowriting.coeditDocument"); await settle(); const key = untitled.uri.toString(); assert.strictEqual(untitled.uri.scheme, "untitled", "it really is an untitled buffer"); diff --git a/test/e2e/suite/f10Review.test.ts b/test/e2e/suite/f10Review.test.ts index bbe9928..7d733c3 100644 --- a/test/e2e/suite/f10Review.test.ts +++ b/test/e2e/suite/f10Review.test.ts @@ -15,7 +15,9 @@ async function getApi(): Promise { return api; } -/** Create + open a fresh markdown doc under WS, returning the doc + its uri key. */ +/** Create + open a fresh markdown doc under WS, returning the doc + its uri key. + * Also enters coediting (INV-10/Task 2): the baseline is established only on + * entry, not merely on open. */ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> { const abs = path.join(WS, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); @@ -23,6 +25,7 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo const uri = vscode.Uri.file(abs); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); await settle(); return { doc, key: uri.toString() }; } @@ -114,7 +117,7 @@ suite("F10 interactive review (host E2E — preview is the single review surface assert.ok(doc.getText().includes("A FIRST claude REPLACEMENT sentence."), "F12 optimistic-apply: replacement in buffer"); }); - test("accept → the proposal lands, clears from the preview, and the baseline advances (PUC-4)", async () => { + test("accept → the proposal lands, clears from the preview, and stays a visible change-since-baseline (PUC-4, INV-7/D21)", async () => { const { doc, key } = await reopen(DOC_REL); const api = await getApi(); const id = api.proposalController.listProposals(doc).find((v) => v.replaced === T1)!.id; @@ -126,10 +129,12 @@ suite("F10 interactive review (host E2E — preview is the single review surface assert.ok(!api.proposalController.listProposals(doc).some((v) => v.id === id), "proposal cleared from listProposals"); const html = api.trackChangesPreviewController.renderHtmlFor(key); assert.ok(!html.includes(`data-proposal-id="${id}"`), "the accepted proposal block is gone from the preview"); - // The baseline advanced on the landing (INV-18): the landed text is not marked. + // Native-surfaces migration (INV-7/D21): the shipped machine-landing baseline + // advance (#48/INV-18) is retired — the landed text stays a visible change + // until "Mark Changes as Reviewed". const model = api.trackChangesPreviewController.getLastModel(key) ?? []; const marked = model.some((o) => o.kind !== "unchanged" && o.block.raw.includes("FIRST claude REPLACEMENT")); - assert.ok(!marked, "the just-landed Claude text renders unmarked (baseline advanced)"); + assert.ok(marked, "the just-landed Claude text still renders as a change (baseline does not auto-advance)"); }); test("reject → the proposal vanishes and the document is reverted (PUC-5)", async () => { @@ -228,8 +233,8 @@ suite("F10 interactive review (host E2E — preview is the single review surface (k) => k.command === "cowriting.toggleDiffView", ); 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"); + // …but the F6 baseline data layer survives: markReviewed stays a real command. + assert.ok(all.includes("cowriting.markReviewed"), "markReviewed (baseline data layer) is kept"); void key; }); @@ -297,6 +302,9 @@ async function reopen(rel: string): Promise<{ doc: vscode.TextDocument; key: str const uri = vscode.Uri.file(path.join(WS, rel)); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); + // Idempotent (INV-10): the doc entered coediting in freshDoc(); re-entering + // is a no-op that leaves the once-captured baseline untouched. + await vscode.commands.executeCommand("cowriting.coeditDocument"); await settle(); return { doc, key: uri.toString() }; } diff --git a/test/e2e/suite/f11Toolbar.test.ts b/test/e2e/suite/f11Toolbar.test.ts index 31e69d8..ed5f01b 100644 --- a/test/e2e/suite/f11Toolbar.test.ts +++ b/test/e2e/suite/f11Toolbar.test.ts @@ -14,7 +14,9 @@ async function getApi(): Promise { return api; } -/** Create + open a fresh markdown doc under WS, returning the doc + its uri key. */ +/** Create + open a fresh markdown doc under WS, returning the doc + its uri key. + * Also enters coediting (INV-10/Task 2): the baseline is established only on + * entry, not merely on open. */ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> { const abs = path.join(WS, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); @@ -22,6 +24,7 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo const uri = vscode.Uri.file(abs); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); await settle(); return { doc, key: uri.toString() }; } @@ -57,12 +60,13 @@ suite("F11 preview toolbar (host E2E — message → seam wiring, no LLM)", () = }); // SLICE-1 reachability: the orphaned pin command gets a real palette `when`. - test("pinDiffBaseline is reachable from the command palette (when: editorLangId == markdown)", async () => { + // Renamed cowriting.pinDiffBaseline → cowriting.markReviewed (Task 2, D14). + test("markReviewed is reachable from the command palette (when: editorLangId == markdown)", async () => { const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, "../../../../package.json"), "utf8")); const entry = (pkg.contributes.menus.commandPalette as Array<{ command: string; when?: string }>).find( - (m) => m.command === "cowriting.pinDiffBaseline", + (m) => m.command === "cowriting.markReviewed", ); - assert.ok(entry, "pinDiffBaseline has a commandPalette entry"); + assert.ok(entry, "markReviewed has a commandPalette entry"); assert.notStrictEqual(entry!.when, "false", "it is no longer hidden (when:false)"); assert.match(entry!.when ?? "", /editorLangId == markdown/, "guarded on markdown"); }); diff --git a/test/e2e/suite/f12InlineDiff.test.ts b/test/e2e/suite/f12InlineDiff.test.ts index 3fe8599..07ae483 100644 --- a/test/e2e/suite/f12InlineDiff.test.ts +++ b/test/e2e/suite/f12InlineDiff.test.ts @@ -16,6 +16,8 @@ async function getApi(): Promise { return api; } +/** Also enters coediting (INV-10/Task 2): the baseline is established only on + * entry, not merely on open. */ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> { const abs = path.join(WS, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); @@ -23,6 +25,7 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo const uri = vscode.Uri.file(abs); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); await settle(); return { doc, key: uri.toString() }; } diff --git a/test/e2e/suite/helpers.ts b/test/e2e/suite/helpers.ts new file mode 100644 index 0000000..a976ee8 --- /dev/null +++ b/test/e2e/suite/helpers.ts @@ -0,0 +1,32 @@ +/** + * Shared host-E2E helpers (native-surfaces migration, Task 2). NEW suites use + * these; existing suites keep their own local copies (out of scope to + * refactor them onto this shared file — see the Task 2 brief). + */ +import * as assert from "node:assert"; +import * as vscode from "vscode"; +import type { CowritingApi } from "../../../src/extension"; + +/** Activate the extension and return its exported API (asserts it is real). */ +export async function activateApi(): Promise { + const ext = vscode.extensions.getExtension("benstull.vscode-cowriting-plugin")!; + const api = (await ext.activate()) as CowritingApi; + assert.ok(api?.diffViewController, "extension exports diffViewController"); + return api; +} + +/** A short fixed settle, for state that updates synchronously-ish after a command. */ +export function settle(): Promise { + return new Promise((r) => setTimeout(r, 150)); +} + +/** Poll `predicate` every 100ms until it is true, or fail after `timeoutMs`. */ +export async function settleUntil(predicate: () => boolean, timeoutMs = 5000): Promise { + const start = Date.now(); + while (!predicate()) { + if (Date.now() - start > timeoutMs) { + assert.fail(`settleUntil: predicate did not become true within ${timeoutMs}ms`); + } + await new Promise((r) => setTimeout(r, 100)); + } +} diff --git a/test/e2e/suite/s48PinClean.test.ts b/test/e2e/suite/s48PinClean.test.ts index a626f53..c524316 100644 --- a/test/e2e/suite/s48PinClean.test.ts +++ b/test/e2e/suite/s48PinClean.test.ts @@ -14,6 +14,8 @@ async function getApi(): Promise { return api; } +/** Also enters coediting (INV-10/Task 2): the baseline is established only on + * entry, not merely on open. */ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> { const abs = path.join(WS, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); @@ -21,6 +23,7 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo const uri = vscode.Uri.file(abs); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); await settle(); return { doc, key: uri.toString() }; } diff --git a/test/e2e/suite/trackChangesPreview.test.ts b/test/e2e/suite/trackChangesPreview.test.ts index 3d6cf95..26a1797 100644 --- a/test/e2e/suite/trackChangesPreview.test.ts +++ b/test/e2e/suite/trackChangesPreview.test.ts @@ -7,10 +7,14 @@ const WS = process.env.E2E_WORKSPACE!; const DOC_REL = "docs/preview.md"; const docUri = () => vscode.Uri.file(path.join(WS, DOC_REL)).toString(); +/** Open the doc AND enter coediting (INV-10/Task 2) — the baseline is + * established only on entry; re-entering an already-coedited doc is a no-op + * so the once-captured baseline survives across this order-dependent suite. */ async function openDoc(rel = DOC_REL): Promise { const uri = vscode.Uri.file(path.join(WS, rel)); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); return doc; } async function getApi(): Promise { @@ -58,7 +62,7 @@ suite("F7 track-changes preview (host E2E — markdown only, programmatic seam, ); }); - test("accepting a proposal advances the baseline; the landed block goes unchanged (PUC-3, INV-18)", async () => { + test("accepting a proposal does NOT advance the baseline; the landed block stays marked (PUC-3, INV-7/D21, #48 retired)", async () => { const doc = await openDoc(); const api = await getApi(); const start = doc.getText().indexOf(TARGET); @@ -75,23 +79,24 @@ suite("F7 track-changes preview (host E2E — markdown only, programmatic seam, assert.ok(id, "propose returns an id"); assert.ok(await api.proposalController.acceptById(DOC_REL, id!), "accept applies via the seam"); await settle(); - // The baseline advanced to include REPLACEMENT, so the buffer == baseline for - // that block → it is NOT marked. Confirm the replacement is not flagged as a change. + // Native-surfaces migration (INV-7/D21): the shipped machine-landing baseline + // advance (#48/INV-18) is retired — the landed block stays a visible change + // until "Mark Changes as Reviewed". const model = api.trackChangesPreviewController.getLastModel(docUri()) ?? []; const replacementMarked = model.some( (o) => o.kind !== "unchanged" && o.block.raw.includes("CLAUDE REWROTE"), ); - assert.ok(!replacementMarked, "the just-landed text renders unmarked (baseline advanced)"); + assert.ok(replacementMarked, "the just-landed text still renders marked (baseline does not auto-advance)"); }); - test("pin resets the baseline to now → preview shows no marks (PUC-4)", async () => { + test("markReviewed resets the baseline to now → preview shows no marks (PUC-4)", async () => { const doc = await openDoc(); const api = await getApi(); - await vscode.commands.executeCommand("cowriting.pinDiffBaseline"); + await vscode.commands.executeCommand("cowriting.markReviewed"); await settle(); assert.ok( kinds(api).every((k) => k === "unchanged"), - "after pin, every block is unchanged (baseline == buffer)", + "after markReviewed, every block is unchanged (baseline == buffer)", ); void doc; }); @@ -112,7 +117,7 @@ suite("F7 track-changes preview (host E2E — markdown only, programmatic seam, // Show the preview and pin the baseline so the fixture's `a --> b` flowchart // is the "before"; then add an edge `a --> c` and confirm the intra-diagram diff. await vscode.commands.executeCommand("cowriting.showTrackChangesPreview"); - await vscode.commands.executeCommand("cowriting.pinDiffBaseline"); + await vscode.commands.executeCommand("cowriting.markReviewed"); await settle(); const anchor = doc.getText().indexOf("a --> b"); diff --git a/test/e2e/suite/undoMarks.test.ts b/test/e2e/suite/undoMarks.test.ts index 1404e4d..1acc7ee 100644 --- a/test/e2e/suite/undoMarks.test.ts +++ b/test/e2e/suite/undoMarks.test.ts @@ -15,6 +15,9 @@ async function getApi(): Promise { return api; } +/** Also enters coediting (INV-10/Task 2): the baseline is established only on + * entry, not merely on open — needed for the preview's added/changed marks to + * reflect a real diff rather than a vacuous baseline==current fallback. */ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDocument; key: string }> { const abs = path.join(WS, rel); fs.mkdirSync(path.dirname(abs), { recursive: true }); @@ -22,6 +25,7 @@ async function freshDoc(rel: string, body: string): Promise<{ doc: vscode.TextDo const uri = vscode.Uri.file(abs); const doc = await vscode.workspace.openTextDocument(uri); await vscode.window.showTextDocument(doc); + await vscode.commands.executeCommand("cowriting.coeditDocument"); await settle(); return { doc, key: uri.toString() }; } diff --git a/test/trackChangesModel.test.ts b/test/trackChangesModel.test.ts index 8ae493f..c00f92d 100644 --- a/test/trackChangesModel.test.ts +++ b/test/trackChangesModel.test.ts @@ -440,9 +440,9 @@ describe("renderReview", () => { expect(html).toContain('data-proposal-id="p1"'); // proposal still shows (it is an action) expect(html).toContain("Person"); }); - test("renderReview: zero diff (e.g. machine-landing accept) renders unchanged blocks plain (Task 2 behavior)", () => { + test("renderReview: zero diff (baseline == current) renders unchanged blocks plain (Task 2 behavior)", () => { // Task 2: unchanged blocks always render plain — color means "changed since baseline". - // After accepting a Claude edit the text is the new baseline; no diff → no coloring. + // When baseline == current (e.g. right after a fresh snapshot) there's no diff → no coloring. const doc = "Human wrote this.\n\nClaude wrote that."; const spans: AuthorSpan[] = [{ start: 19, end: doc.length, author: "claude" }]; const html = renderReview(doc, doc, spans, []); // no pinned flag