/** * TrackChangesPreviewController — F7 vscode layer (spec §6.2/§6.4). Owns one * sealed webview panel per markdown document, beside the source editor. On open / * debounced edit / F6 baseline-epoch change it reads the baseline (from the * reused DiffViewController) + the live buffer, runs the pure render engine, and * posts the HTML. Pure read-only: never mutates the document, sidecar, or * baseline (INV-20). The webview is sealed: local assets only, strict CSP, * per-load nonce, no network (INV-21). */ import * as path from "node:path"; import { randomBytes } from "node:crypto"; import * as vscode from "vscode"; import type { DiffViewController } from "./diffViewController"; import type { AttributionController } from "./attributionController"; import { renderTrackChanges, renderAuthorship, diffBlocks, type BlockOp } from "./trackChangesModel"; const VIEW_TYPE = "cowriting.trackChangesPreview"; const DEBOUNCE_MS = 150; export class TrackChangesPreviewController implements vscode.Disposable { private readonly disposables: vscode.Disposable[] = []; private readonly panels = new Map(); private readonly lastModel = new Map(); private readonly debounces = new Map(); /** F9: per-panel view mode — track-changes (default) or authorship. */ private readonly mode = new Map(); constructor( private readonly diffView: DiffViewController, private readonly extensionUri: vscode.Uri, private readonly attribution: AttributionController, ) { this.disposables.push( vscode.commands.registerCommand("cowriting.showTrackChangesPreview", () => this.show(vscode.window.activeTextEditor?.document), ), vscode.workspace.onDidChangeTextDocument((e) => this.onEdit(e.document)), this.diffView.onDidChangeBaseline(({ uri }) => this.refreshByUri(uri)), ); } private isMarkdown(document: vscode.TextDocument): boolean { return document.languageId === "markdown"; } /** Open or reveal the preview for a markdown document (PUC-1). */ show(document: vscode.TextDocument | undefined): void { if (!document || !this.isMarkdown(document)) { void vscode.window.showWarningMessage( "Cowriting: open a Markdown document to use the track-changes preview (F6 covers other files).", ); return; } const key = document.uri.toString(); const existing = this.panels.get(key); if (existing) { existing.reveal(vscode.ViewColumn.Beside); this.refresh(document); return; } const name = path.basename(document.uri.path) || "untitled"; const panel = vscode.window.createWebviewPanel( VIEW_TYPE, `Track changes: ${name}`, { viewColumn: vscode.ViewColumn.Beside, preserveFocus: true }, { enableScripts: true, retainContextWhenHidden: false, localResourceRoots: [vscode.Uri.joinPath(this.extensionUri, "out", "media")], }, ); panel.webview.html = this.shellHtml(panel.webview); panel.onDidDispose( () => { this.panels.delete(key); this.lastModel.delete(key); this.mode.delete(key); }, null, this.disposables, ); // F9: the webview's header toggle posts the chosen mode back. panel.webview.onDidReceiveMessage( (m: { type?: string; mode?: "changes" | "authorship" }) => { if (m?.type === "setMode" && (m.mode === "changes" || m.mode === "authorship")) { this.mode.set(key, m.mode); this.refresh(document); } }, null, this.disposables, ); this.panels.set(key, panel); this.refresh(document); } private onEdit(document: vscode.TextDocument): void { const key = document.uri.toString(); if (!this.panels.has(key)) return; const pending = this.debounces.get(key); if (pending) clearTimeout(pending); this.debounces.set( key, setTimeout(() => { this.debounces.delete(key); this.refresh(document); }, DEBOUNCE_MS), ); } private refreshByUri(uri: string): void { const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); if (doc) this.refresh(doc); } /** Recompute the model + post HTML to the panel for its current mode (no-op if no panel). */ refresh(document: vscode.TextDocument): void { const key = document.uri.toString(); const panel = this.panels.get(key); if (!panel) return; const mode = this.mode.get(key) ?? "changes"; const current = document.getText(); if (mode === "authorship") { // F9: render the current buffer colored by F3 author, baseline-independent. const spans = this.attribution.spansFor(document); this.lastModel.set(key, diffBlocks(this.diffView.getBaseline(key)?.text ?? current, current)); void panel.webview.postMessage({ type: "render", mode, html: renderAuthorship(current, spans), legend: { claude: spans.some((s) => s.author === "claude"), human: spans.some((s) => s.author === "human"), }, }); return; } const baseline = this.diffView.getBaseline(key); const baselineText = baseline?.text ?? current; // no baseline → no marks const ops = diffBlocks(baselineText, current); this.lastModel.set(key, ops); const summary = { added: ops.filter((o) => o.kind === "added").length, removed: ops.filter((o) => o.kind === "removed").length, changed: ops.filter((o) => o.kind === "changed").length, }; void panel.webview.postMessage({ type: "render", mode, html: renderTrackChanges(baselineText, current), epoch: this.epochLabel(baseline), summary, }); } private epochLabel(baseline: { reason: string; capturedAt: string } | undefined): string { if (!baseline) return "opened (no baseline yet)"; const time = new Date(baseline.capturedAt).toLocaleTimeString(); switch (baseline.reason) { case "machine-landing": return `Claude landed ${time}`; case "pinned": return `pinned ${time}`; default: return `opened ${time}`; } } private shellHtml(webview: vscode.Webview): string { const nonce = randomBytes(16).toString("base64"); const scriptUri = webview.asWebviewUri( vscode.Uri.joinPath(this.extensionUri, "out", "media", "preview.js"), ); const styleUri = webview.asWebviewUri( vscode.Uri.joinPath(this.extensionUri, "out", "media", "preview.css"), ); // Sealed CSP (INV-21): no network. 'unsafe-inline' style is required only for // mermaid's dynamically injected