/** * 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). * * The F6 two-pane `vscode.diff` *view* (toggle UI + the `cowriting-baseline:` * virtual document) was removed in #34 once F10 made the rendered preview the * single review surface; only the baseline store survives here (spec §6.7). * * The baseline works on ANY text document, not just workspace files — it needs no * `.threads/` sidecar, only a stable doc identity + a storage home. So it has * its OWN diffable predicate (any `file:` or `untitled:` doc), decoupled from * F2's workspace `isTracked`. Persistable docs (`file:`) keep their baseline in * 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 { createHash } from "node:crypto"; import * as vscode from "vscode"; import { BaselineStore, type Baseline, type BaselineReason } from "./baselineStore"; 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. */ private readonly onDidChangeBaselineEmitter = new vscode.EventEmitter<{ uri: string }>(); readonly onDidChangeBaseline = this.onDidChangeBaselineEmitter.event; private storageWarned = false; constructor(private readonly store: BaselineStore | null) { this.disposables.push( this.onDidChangeBaselineEmitter, 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); } // ---- 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 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) ----------------------------------------------------- /** First sight of a diffable doc: load the stored baseline, else capture `opened`. */ ensureBaseline(document: vscode.TextDocument): void { 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(this.storageKey(key)); if (stored) { this.baselines.set(key, stored); return; } } catch { this.warnStorageOnce(); } } this.capture(document, "opened"); } /** 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"); } /** Human pin: baseline := now; the preview's change-marks empty (left = right). */ pin(document: vscode.TextDocument): void { if (!this.isDiffable(document)) return; this.capture(document, "pinned"); } /** Capture buffer text at this epoch, persist (if persistable), 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)) { try { this.store.save(this.storageKey(key), baseline); } catch { this.warnStorageOnce(); } } 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 pin its review 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; } /** 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(); } }