diff --git a/src/diffViewController.ts b/src/diffViewController.ts new file mode 100644 index 0000000..11285fb --- /dev/null +++ b/src/diffViewController.ts @@ -0,0 +1,154 @@ +/** + * 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); + } + + // Replaced by the real implementation in Task 4 (toggle UX, SLICE-3). + private toggle(_editor: vscode.TextEditor | undefined): void { + /* stub */ + } + + dispose(): void { + for (const d of this.disposables) d.dispose(); + } +}