F7: rendered track-changes markdown preview (#21) #23
@@ -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<string, vscode.WebviewPanel>();
|
||||
private readonly lastModel = new Map<string, BlockOp[]>();
|
||||
private readonly debounces = new Map<string, NodeJS.Timeout>();
|
||||
|
||||
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 <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">
|
||||
<span id="cw-epoch">Track changes</span>
|
||||
<span id="cw-summary"></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);
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user