/** * F7 preview webview client (sealed sandbox, INV-21). Receives annotated HTML * from the extension host and swaps it in; runs mermaid over `.mermaid` blocks * (mermaid needs a DOM, so it runs here, not in the host). Bundled by esbuild as * a standalone IIFE → out/media/preview.js, so mermaid never enters the host * bundle. No network, no LLM. */ import mermaid from "mermaid"; // Imported so esbuild emits the sibling out/media/preview.css (the controller // links it into the sealed shell via asWebviewUri). import "./preview.css"; declare function acquireVsCodeApi(): { postMessage(m: unknown): void }; interface RenderMessage { type: "render"; mode: "on" | "off"; html: string; epoch?: string; summary?: { added: number; removed: number; proposals: number }; /** F11: false on a non-authorable doc → Pin + Ask-Claude controls disabled. */ authorable?: boolean; } const vscodeApi = acquireVsCodeApi(); const body = document.getElementById("cw-body")!; const header = document.getElementById("cw-epoch")!; const summary = document.getElementById("cw-summary")!; const legend = document.getElementById("cw-legend")!; const annotationsEl = document.getElementById("cw-annotations") as HTMLInputElement | null; const pinEl = document.getElementById("cw-pin") as HTMLButtonElement | null; const askEl = document.getElementById("cw-ask") as HTMLButtonElement | null; const acceptAllEl = document.getElementById("cw-acceptall") as HTMLButtonElement | null; // F10: the annotations on/off toggle. annotationsEl?.addEventListener("change", () => { vscodeApi.postMessage({ type: "setMode", mode: annotationsEl.checked ? "on" : "off" }); }); // F11 (SLICE-1): Pin baseline — post intent; the host pins via the F6 store (INV-35). pinEl?.addEventListener("click", () => { vscodeApi.postMessage({ type: "pinBaseline" }); }); // F11 (SLICE-4): the single adaptive Ask-Claude button. Its label flips on // `selectionchange` (Edit Selection when live text is selected in the preview, // Edit Document otherwise), and a click resolves the selection to a SOURCE range // via the nearest `data-src` ancestors (INV-36) — the webview's sole mapping // duty. A selection that resolves to no live block falls back to document scope. /** Walk up from a DOM node to the nearest block carrying data-src offsets (INV-36). */ function nearestSrc(node: Node | null): HTMLElement | null { let el: HTMLElement | null = node instanceof HTMLElement ? node : (node?.parentElement ?? null); while (el && el !== body) { if (el.dataset.srcStart !== undefined && el.dataset.srcEnd !== undefined) return el; el = el.parentElement; } return null; } /** The source [start,end) union of the live blocks a non-empty body selection touches, or null. */ function selectionSrcRange(): { start: number; end: number } | null { const sel = window.getSelection(); if (!sel || sel.isCollapsed || sel.rangeCount === 0) return null; const ends = [nearestSrc(sel.anchorNode), nearestSrc(sel.focusNode)].filter( (e): e is HTMLElement => e !== null, ); if (ends.length === 0) return null; // selection touches no live-source block const starts = ends.map((e) => Number(e.dataset.srcStart)); const stops = ends.map((e) => Number(e.dataset.srcEnd)); return { start: Math.min(...starts), end: Math.max(...stops) }; } function updateAskLabel(): void { if (!askEl) return; askEl.textContent = selectionSrcRange() ? "✦ Ask Claude to Edit Selection" : "✦ Ask Claude to Edit Document"; } document.addEventListener("selectionchange", updateAskLabel); askEl?.addEventListener("click", () => { const range = selectionSrcRange(); if (range) { vscodeApi.postMessage({ type: "askClaude", scope: "selection", start: range.start, end: range.end }); } else { vscodeApi.postMessage({ type: "askClaude", scope: "document" }); } }); // #46 (INV-42): Accept all — batch-accept every pending proposal (intent only). acceptAllEl?.addEventListener("click", () => { vscodeApi.postMessage({ type: "acceptAll" }); }); // F10: delegated ✓/✗ accept/reject of pending proposals (routed back to the F4 seam). body.addEventListener("click", (e) => { const btn = (e.target as HTMLElement)?.closest(".cw-actions button"); if (!btn) return; const block = btn.closest(".cw-proposal"); const id = block?.dataset.proposalId; const action = btn.dataset.action; if (id && (action === "accept" || action === "reject")) { vscodeApi.postMessage({ type: action, proposalId: id }); } }); function themeFor(): "dark" | "default" { return document.body.classList.contains("vscode-dark") || document.body.classList.contains("vscode-high-contrast") ? "dark" : "default"; } async function renderMermaid(): Promise { const nodes = Array.from(body.querySelectorAll("pre.mermaid")); if (nodes.length === 0) return; mermaid.initialize({ startOnLoad: false, theme: themeFor(), securityLevel: "strict" }); try { await mermaid.run({ nodes }); } catch { // mermaid.run already marks failed nodes; ensure a visible chip per failure. for (const n of nodes) { if (!n.querySelector("svg")) n.setAttribute("data-cw-error", "true"); } } } window.addEventListener("message", (event: MessageEvent) => { const msg = event.data; if (msg?.type !== "render") return; body.innerHTML = msg.html; updateAskLabel(); // new content clears any selection → reset the adaptive label // F11 (PUC-1/7): disable edit controls on a non-authorable doc (reading stays on). const authorable = msg.authorable !== false; if (pinEl) pinEl.disabled = !authorable; if (askEl) askEl.disabled = !authorable; const on = msg.mode === "on"; if (annotationsEl) annotationsEl.checked = on; // #46: Accept all shows only with ≥2 pending proposals, on an authorable doc, // in the annotated (on) state — a single proposal is just a ✓ in place. if (acceptAllEl) acceptAllEl.hidden = !on || !authorable || (msg.summary?.proposals ?? 0) < 2; // Off-state is a clean preview: hide the review chrome. header.hidden = !on; summary.hidden = !on; legend.hidden = true; if (on) { header.textContent = `Review since ${msg.epoch ?? ""}`; summary.innerHTML = `+${msg.summary?.added ?? 0} ` + `−${msg.summary?.removed ?? 0} ` + `${msg.summary?.proposals ?? 0} proposal${(msg.summary?.proposals ?? 0) === 1 ? "" : "s"}`; } void renderMermaid(); });