/** * 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 type { ProposalController } from "./proposalController"; import { renderReview, renderPlain, diffBlocks, landedTextOf, type BlockOp } from "./trackChangesModel"; import { isAuthorable } from "./workspacePath"; import type { EditFlow, EditTarget } from "./editFlow"; const VIEW_TYPE = "cowriting.trackChangesPreview"; const DEBOUNCE_MS = 150; /** * Inbound webview→host messages (intent only — the sealed webview never mutates, * INV-21/35). F10 carried the annotations toggle + ✓/✗ proposal decisions; F11 * adds the toolbar intents (pin baseline / ask Claude). */ type ToolbarMsg = | { type: "setMode"; mode: "on" | "off" } | { type: "accept"; proposalId: string } | { type: "reject"; proposalId: string } | { type: "pinBaseline" } | { type: "askClaude"; scope: "document" } | { type: "askClaude"; scope: "selection"; start: number; end: number } | { type: "acceptAll" } | { type: "rejectAll" }; 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(); /** F10: per-panel annotations mode — on (default) shows review marks, off is clean. */ private readonly mode = new Map(); /** F10 (PUC-6): off-panel indicator of pending proposals on the active doc. */ private readonly statusItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right, 88); constructor( private readonly diffView: DiffViewController, private readonly extensionUri: vscode.Uri, private readonly attribution: AttributionController, private readonly proposals: ProposalController, private readonly editFlow: EditFlow, ) { this.disposables.push( // F11 (SLICE-5): the editor/title gateway passes the tab's resource Uri; // the palette / keybinding pass nothing → fall back to the active editor. // #41: the explorer/tab right-click also passes the clicked Uri, which may // not be an open document yet — open it so we preview the clicked file, not // whatever happens to be the active editor. vscode.commands.registerCommand("cowriting.showTrackChangesPreview", async (uri?: vscode.Uri) => { if (uri) { const open = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri.toString()); this.show(open ?? (await vscode.workspace.openTextDocument(uri))); return; } this.show(vscode.window.activeTextEditor?.document); }), vscode.workspace.onDidChangeTextDocument((e) => this.onEdit(e.document)), this.diffView.onDidChangeBaseline(({ uri }) => this.refreshByUri(uri)), this.proposals.onDidChangeProposals(({ uri }) => { this.refreshByUri(uri); this.updateStatus(uri); }), this.statusItem, ); this.statusItem.command = "cowriting.showTrackChangesPreview"; } 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, `Review: ${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); // A panel is gone: re-show the off-panel indicator if proposals remain. this.updateStatus(key); }, null, this.disposables, ); // F10/F11: the webview posts the annotations toggle, ✓/✗ proposal decisions, // and (F11) the toolbar intents (pin baseline / ask Claude) back to the host. panel.webview.onDidReceiveMessage( (m: ToolbarMsg) => this.handleWebviewMessage(document, m), null, this.disposables, ); this.panels.set(key, panel); // A panel is now open for this doc — the off-panel indicator is redundant. this.hideStatus(); this.refresh(document); } /** * Route an inbound webview intent through the existing seams (INV-35): the * annotations toggle + ✓/✗ proposal decisions (F10) and the toolbar gestures * (F11). Pin targets the PREVIEWED document (`DiffViewController.pin`), not the * active editor — the preview knows its bound doc (§6.7). */ private handleWebviewMessage(document: vscode.TextDocument, m: ToolbarMsg): void { const key = document.uri.toString(); if (m?.type === "setMode" && (m.mode === "on" || m.mode === "off")) { this.mode.set(key, m.mode); this.refresh(document); } else if (m?.type === "accept" && m.proposalId) { void this.proposals .acceptById(this.proposals.keyFor(document), m.proposalId) .then(() => this.refresh(document)); } else if (m?.type === "reject" && m.proposalId) { void this.proposals.rejectByIdInPlace(this.proposals.keyFor(document), m.proposalId).then(() => this.refresh(document)); } else if (m?.type === "pinBaseline") { // F6 baseline store re-render arrives via the onDidChangeBaseline subscription. this.diffView.pin(document); } else if (m?.type === "askClaude") { const target: EditTarget = m.scope === "selection" ? { kind: "range", start: m.start, end: m.end } : { kind: "document" }; void this.editFlow.askClaude(document, target); } else if (m?.type === "acceptAll") { // #46 (INV-42): batch-accept every pending proposal on this doc, then report. void this.acceptAll(document); } else if (m?.type === "rejectAll") { void this.rejectAll(document); } } /** * #46 (INV-42): apply every pending proposal on the document through the F4 * accept seam (orphan-skip) and report the applied-vs-skipped tally. No * confirmation dialog — VS Code undo restores (parity with single accept). * Public so the `cowriting.acceptAllProposals` command can reach it for the * active doc (not only the webview button). */ async acceptAll(document: vscode.TextDocument): Promise { const { applied, skipped } = await this.proposals.acceptAllProposals(document); this.refresh(document); if (applied === 0 && skipped === 0) return; const skipNote = skipped > 0 ? `, ${skipped} skipped (target text changed — undo or reject)` : ""; void vscode.window.showInformationMessage( `Cowriting: accepted ${applied} proposal${applied === 1 ? "" : "s"}${skipNote}.`, ); } /** #64 (INV-53): revert every pending proposal on the document; report the count. */ async rejectAll(document: vscode.TextDocument): Promise { const { reverted } = await this.proposals.rejectAll(document); this.refresh(document); if (reverted > 0) { void vscode.window.showInformationMessage( `Cowriting: rejected ${reverted} proposal${reverted === 1 ? "" : "s"}.`, ); } } 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) ?? "on"; const current = document.getText(); const baseline = this.diffView.getBaseline(key); const baselineText = baseline?.text ?? current; // no baseline → no change-marks const ops = diffBlocks(baselineText, current); this.lastModel.set(key, ops); // F11 (PUC-1/7): edit controls are inert on a non-authorable doc (reading stays allowed). const authorable = isAuthorable(document.uri.scheme); if (mode === "off") { void panel.webview.postMessage({ type: "render", mode, html: renderPlain(current), authorable }); return; } const spans = this.attribution.spansFor(document); const proposals = this.proposals.listProposals(document); // F12/#64 (INV-50): count added/removed against the LANDED text (current minus // pending proposals), matching the body — a pending change shows once, as a // proposal, and is not also tallied as a landed add/remove. const landedOps = diffBlocks(baselineText, landedTextOf(current, proposals)); const summary = { added: landedOps.filter((o) => o.kind === "added").length, removed: landedOps.filter((o) => o.kind === "removed").length, proposals: proposals.length, }; void panel.webview.postMessage({ type: "render", mode, html: renderReview(baselineText, current, spans, proposals, { pinned: baseline?.reason === "pinned" }), epoch: this.epochLabel(baseline), summary, authorable, }); } /** F10 (PUC-6): off-panel proposal indicator on the active doc. Hidden when a panel is open. */ private updateStatus(uri: string): void { const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uri); if (!doc) { this.hideStatus(); return; } const n = this.proposals.listProposals(doc).length; if (n === 0 || this.panels.has(uri)) { this.hideStatus(); return; } this.statusItem.text = `$(comment-discussion) ${n} Claude proposal${n === 1 ? "" : "s"}`; this.statusItem.tooltip = "Cowriting: open the review preview to accept/reject Claude's proposals"; this.statusItem.show(); } /** * Hide the off-panel indicator AND clear its text, so the `statusText()` seam * is honest: a hidden indicator reports `undefined` (not its stale last value). */ private hideStatus(): void { this.statusItem.text = ""; this.statusItem.hide(); } private epochLabel(baseline: { reason: string; capturedAt: string } | undefined): string { if (!baseline) return "not coediting (no baseline yet)"; const time = new Date(baseline.capturedAt).toLocaleTimeString(); switch (baseline.reason) { case "head": return `HEAD ${time}`; case "pinned": return `reviewed ${time}`; default: return `entered ${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