/** * DiffViewController — F6 diff-view toggle (spec §6.2/§6.4). Owns the baseline * lifecycle (initialize at first track / 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. */ import * as path from "node:path"; import * as vscode from "vscode"; import { BaselineStore, type Baseline, type BaselineReason } from "./baselineStore"; export const BASELINE_SCHEME = "cowriting-baseline"; export class DiffViewController implements vscode.Disposable { private readonly disposables: vscode.Disposable[] = []; /** Source of truth for the content provider; mirrors what the store persists. */ private readonly baselines = new Map(); private readonly onDidChangeEmitter = new vscode.EventEmitter(); private storageWarned = false; constructor( private readonly store: BaselineStore | null, private readonly rootDir: string, ) { const provider: vscode.TextDocumentContentProvider = { onDidChange: this.onDidChangeEmitter.event, provideTextDocumentContent: (uri) => { const docPath = this.docPathFromBaselineUri(uri); return this.baselines.get(docPath)?.text ?? ""; }, }; this.disposables.push( this.onDidChangeEmitter, vscode.workspace.registerTextDocumentContentProvider(BASELINE_SCHEME, provider), vscode.commands.registerCommand("cowriting.toggleDiffView", () => this.toggle(vscode.window.activeTextEditor), ), vscode.commands.registerCommand("cowriting.pinDiffBaseline", () => this.pinCommand(vscode.window.activeTextEditor), ), ); } // ---- tracking / uri helpers -------------------------------------------------------- private isTracked(document: vscode.TextDocument): boolean { return document.uri.scheme === "file" && document.uri.fsPath.startsWith(this.rootDir); } private docPathOf(uri: vscode.Uri): string { return vscode.workspace.asRelativePath(uri, false); } /** 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 }); } private docPathFromBaselineUri(uri: vscode.Uri): string { return uri.path.replace(/^\//, ""); } // ---- baseline lifecycle (§6.4) ----------------------------------------------------- /** First sight of a tracked 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) { try { const stored = this.store.load(docPath); if (stored) { this.baselines.set(docPath, 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.isTracked(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; this.capture(document, "pinned"); } /** Capture buffer text at this epoch, persist, and refresh any open diff's left side. */ 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) { try { this.store.save(docPath, baseline); } catch { this.warnStorageOnce(); } } // An open diff re-requests the left side when its baseline URI changes. this.onDidChangeEmitter.fire(this.baselineUri(docPath)); } private warnStorageOnce(): void { if (this.storageWarned) return; this.storageWarned = true; void vscode.window.showWarningMessage( "Cowriting: diff-view storage is unavailable — baselines are kept in memory only and won't survive a reload.", ); } // ---- 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. */ private async toggle(editor: vscode.TextEditor | undefined): Promise { if (!editor || !this.isTracked(editor.document)) { void vscode.window.showWarningMessage( "Cowriting: open a tracked workspace document 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); await vscode.window.showTextDocument(document, { preview: false }); return; } this.ensureBaseline(document); const baseline = this.baselines.get(docPath)!; const title = `${path.basename(docPath)} — my changes since ${this.epochLabel(baseline)}`; await vscode.commands.executeCommand( "vscode.diff", this.baselineUri(docPath), document.uri, title, { preview: false }, ); } /** The open baseline-diff tab for this document, if any. */ private findDiffTab(modified: vscode.Uri): vscode.Tab | undefined { for (const group of vscode.window.tabGroups.all) { for (const tab of group.tabs) { const input = tab.input; if ( input instanceof vscode.TabInputTextDiff && input.original.scheme === BASELINE_SCHEME && input.modified.toString() === modified.toString() ) { return tab; } } } return undefined; } /** Human-readable epoch for the diff tab title (§5 / §6.5). */ private epochLabel(baseline: Baseline): string { const time = new Date(baseline.capturedAt).toLocaleTimeString(); switch (baseline.reason) { case "opened": return `opened ${time}`; case "machine-landing": return `Claude landed ${time}`; case "pinned": return `pinned ${time}`; } } /** * 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; } dispose(): void { for (const d of this.disposables) d.dispose(); } }