Files
vscode-cowriting-plugin/src/trackChangesPreview.ts
T
Ben Stull 3b45e784c1 test(f7.1): host E2E — changed flowchart augments emitted source (#22)
Adds a renderHtmlFor test seam to TrackChangesPreviewController.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-11 16:34:42 -07:00

242 lines
8.9 KiB
TypeScript

/**
* 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<string, vscode.WebviewPanel>();
private readonly lastModel = new Map<string, BlockOp[]>();
private readonly debounces = new Map<string, NodeJS.Timeout>();
/** F9: per-panel view mode — track-changes (default) or authorship. */
private readonly mode = new Map<string, "changes" | "authorship">();
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 <style> tags; scripts are nonce-gated and
// strictly local (no remote/CDN script source).
const csp =
`default-src 'none'; ` +
`img-src ${webview.cspSource} data:; ` +
`font-src ${webview.cspSource}; ` +
`style-src ${webview.cspSource} 'unsafe-inline'; ` +
`script-src 'nonce-${nonce}';`;
return `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Content-Security-Policy" content="${csp}" />
<link href="${styleUri}" rel="stylesheet" />
<title>Track changes</title>
</head>
<body>
<div id="cw-header">
<div id="cw-mode" role="group">
<button id="cw-mode-changes" class="cw-seg cw-seg-on" data-mode="changes">Track changes</button>
<button id="cw-mode-authorship" class="cw-seg" data-mode="authorship">Authorship</button>
</div>
<span id="cw-epoch">Track changes</span>
<span id="cw-summary"></span>
<span id="cw-legend" hidden></span>
</div>
<div id="cw-body"></div>
<script nonce="${nonce}" src="${scriptUri}"></script>
</body>
</html>`;
}
// ---- test seam (§6.4) ----
isOpen(uriString: string): boolean {
return this.panels.has(uriString);
}
getLastModel(uriString: string): BlockOp[] | undefined {
return this.lastModel.get(uriString);
}
/** F7.1 (#22) test seam: the track-changes HTML the panel would post for a doc. */
renderHtmlFor(uriString: string): string {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
if (!doc) return "";
const current = doc.getText();
const baseline = this.diffView.getBaseline(uriString);
return renderTrackChanges(baseline?.text ?? current, current);
}
/** F9: current view mode for a panel (default track-changes). */
getMode(uriString: string): "changes" | "authorship" {
return this.mode.get(uriString) ?? "changes";
}
/** F9: set the view mode and re-render (the programmatic twin of the header toggle). */
setMode(uriString: string, mode: "changes" | "authorship"): void {
this.mode.set(uriString, mode);
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
if (doc) this.refresh(doc);
}
dispose(): void {
for (const t of this.debounces.values()) clearTimeout(t);
for (const p of this.panels.values()) p.dispose();
for (const d of this.disposables) d.dispose();
}
}