diff --git a/src/trackChangesPreview.ts b/src/trackChangesPreview.ts new file mode 100644 index 0000000..1320432 --- /dev/null +++ b/src/trackChangesPreview.ts @@ -0,0 +1,186 @@ +/** + * 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 { renderTrackChanges, 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(); + + constructor( + private readonly diffView: DiffViewController, + private readonly extensionUri: vscode.Uri, + ) { + 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); + }, + 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 (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 baseline = this.diffView.getBaseline(key); + const baselineText = baseline?.text ?? document.getText(); // no baseline → no marks + const current = document.getText(); + 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, + }; + const epoch = this.epochLabel(baseline); + void panel.webview.postMessage({ + type: "render", + html: renderTrackChanges(baselineText, current), + epoch, + 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