/** * 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 * single review surface; only the baseline store survives here (spec §6.7). * * The baseline works on ANY text document, not just workspace files — it needs no * `.threads/` sidecar, only a stable doc identity + a storage home. So it has * its OWN diffable predicate (any `file:` or `untitled:` doc), decoupled from * F2's workspace `isTracked`. Persistable docs (`file:`) keep their 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(); /** 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, private readonly git: GitBaselineAdapter, registry: CoeditingRegistry, ) { this.disposables.push( this.onDidChangeBaselineEmitter, vscode.commands.registerCommand("cowriting.markReviewed", () => this.pinCommand(vscode.window.activeTextEditor), ), // 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()), ); // 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 -------------------------------------------------------- /** Any text document F6 tracks a baseline for: a saved file OR an unsaved buffer. */ private isDiffable(document: vscode.TextDocument): boolean { return document.uri.scheme === "file" || document.uri.scheme === "untitled"; } /** Only `file:` docs have a durable identity to persist a SNAPSHOT baseline against. */ private isPersistable(document: vscode.TextDocument): boolean { return document.uri.scheme === "file"; } /** In-memory map key — the full document URI. */ private uriKey(document: vscode.TextDocument): string { return document.uri.toString(); } /** Filesystem-safe storage key for a persistable doc: sha256 of its URI. */ private storageKey(uriKey: string): string { return createHash("sha256").update(uriKey).digest("hex"); } // ---- baseline lifecycle (§6.4, INV-7) ----------------------------------------------- /** * 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); 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)) { 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, "entered"); } /** The mode a document's baseline resolved to, or undefined before `establish`. */ modeOf(uriString: string): "head" | "snapshot" | undefined { return this.modes.get(uriString); } /** 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"); } /** Capture buffer text at this epoch, persist (if persistable), notify F7/F10. */ private capture(document: vscode.TextDocument, reason: BaselineReason): void { const key = this.uriKey(document); const baseline: Baseline = { uri: key, text: document.getText(), capturedAt: new Date().toISOString(), reason }; this.baselines.set(key, baseline); if (this.store && this.isPersistable(document)) { 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 }); } private warnStorageOnce(): void { if (this.storageWarned) return; this.storageWarned = true; void vscode.window.showWarningMessage( "Cowriting: baseline storage is unavailable — baselines are kept in memory only and won't survive a reload.", ); } private pinCommand(editor: vscode.TextEditor | undefined): void { if (!editor || !this.isDiffable(editor.document)) { void vscode.window.showWarningMessage("Cowriting: focus a text editor to mark its changes as reviewed."); 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; } /** 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 { for (const d of this.disposables) d.dispose(); } }