From a3f9688a916844dc955d7bf141e8c8244ecc6dbf Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 11 Jun 2026 08:45:18 -0700 Subject: [PATCH] feat(f7): sealed webview asset + esbuild preview bundle (#21) Co-Authored-By: Claude Opus 4.8 --- esbuild.mjs | 21 ++++++++++++++++-- media/preview.css | 53 ++++++++++++++++++++++++++++++++++++++++++++++ media/preview.ts | 54 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 126 insertions(+), 2 deletions(-) create mode 100644 media/preview.css create mode 100644 media/preview.ts diff --git a/esbuild.mjs b/esbuild.mjs index 35be90b..916f33a 100644 --- a/esbuild.mjs +++ b/esbuild.mjs @@ -30,13 +30,30 @@ const liveTurnOptions = { logLevel: "info", }; +/** @type {import('esbuild').BuildOptions} */ +const previewOptions = { + entryPoints: ["media/preview.ts"], + outfile: "out/media/preview.js", + bundle: true, + // The webview is a browser context; mermaid is bundled IN (and ONLY in) this + // asset so it never bloats the extension-host bundle (the @cline/sdk size + // discipline). No externals — everything is shipped to the sealed webview. + platform: "browser", + format: "iife", + target: "es2020", + sourcemap: true, + logLevel: "info", +}; + if (watch) { const ctx = await context(options); const ctxLive = await context(liveTurnOptions); - await Promise.all([ctx.watch(), ctxLive.watch()]); + const ctxPreview = await context(previewOptions); + await Promise.all([ctx.watch(), ctxLive.watch(), ctxPreview.watch()]); console.log("esbuild: watching…"); } else { await build(options); await build(liveTurnOptions); - console.log("esbuild: build complete → out/extension.cjs + out/liveTurn.mjs"); + await build(previewOptions); + console.log("esbuild: build complete → out/extension.cjs + out/liveTurn.mjs + out/media/preview.js"); } diff --git a/media/preview.css b/media/preview.css new file mode 100644 index 0000000..655e29c --- /dev/null +++ b/media/preview.css @@ -0,0 +1,53 @@ +/* F7 track-changes preview — theme-aware via VS Code webview CSS variables. */ +body { + font-family: var(--vscode-font-family); + font-size: var(--vscode-font-size); + color: var(--vscode-foreground); + background: var(--vscode-editor-background); + padding: 0 1.2rem 2rem; + line-height: 1.5; +} +#cw-header { + position: sticky; + top: 0; + background: var(--vscode-editor-background); + border-bottom: 1px solid var(--vscode-panel-border); + padding: 0.5rem 0; + font-size: 0.85em; + opacity: 0.85; + display: flex; + gap: 1rem; +} +#cw-summary .cw-add { color: var(--vscode-gitDecoration-addedResourceForeground); } +#cw-summary .cw-del { color: var(--vscode-gitDecoration-deletedResourceForeground); } +.cw-blk { position: relative; } +ins, .cw-added { + background: var(--vscode-diffEditor-insertedTextBackground, rgba(0, 255, 0, 0.15)); + text-decoration: none; +} +del, .cw-removed { + background: var(--vscode-diffEditor-removedTextBackground, rgba(255, 0, 0, 0.15)); + text-decoration: line-through; + opacity: 0.7; +} +.cw-changed { outline: 2px solid var(--vscode-diffEditor-insertedTextBackground, rgba(0, 255, 0, 0.25)); outline-offset: 2px; } +.cw-badge { + position: absolute; + top: 0; + right: 0; + font-size: 0.7em; + padding: 0 0.4em; + border-radius: 3px; + background: var(--vscode-badge-background); + color: var(--vscode-badge-foreground); +} +.cw-error { + border: 1px solid var(--vscode-inputValidation-errorBorder); + background: var(--vscode-inputValidation-errorBackground); + color: var(--vscode-errorForeground); + padding: 0.3rem 0.6rem; + border-radius: 3px; + font-size: 0.85em; +} +pre.mermaid { text-align: center; background: transparent; } +pre.mermaid[data-cw-error] { color: var(--vscode-errorForeground); } diff --git a/media/preview.ts b/media/preview.ts new file mode 100644 index 0000000..105f4a3 --- /dev/null +++ b/media/preview.ts @@ -0,0 +1,54 @@ +/** + * 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"; + +interface RenderMessage { + type: "render"; + html: string; + epoch: string; + summary: { added: number; removed: number; changed: number }; +} + +const body = document.getElementById("cw-body")!; +const header = document.getElementById("cw-epoch")!; +const summary = document.getElementById("cw-summary")!; + +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; + header.textContent = `Track changes since ${msg.epoch}`; + summary.innerHTML = + `+${msg.summary.added + msg.summary.changed} ` + + `−${msg.summary.removed + msg.summary.changed}`; + void renderMermaid(); +});