Files
vscode-cowriting-plugin/media/preview.ts
T
Ben Stull 03b61ed43e feat(f11): SLICE-4 — single adaptive Ask-Claude button + selection mapping (#43, INV-37)
The one Ask-Claude toolbar button now adapts: its label flips on selectionchange
(Edit Selection when live text is selected in the preview, Edit Document
otherwise), and a click resolves the preview selection to a SOURCE range via the
nearest data-src ancestors (INV-36) — the webview's sole mapping duty. A
selection touching no live-source block falls back to document scope. Per spec
§6.5 PUC-2/3 / §7.2 SLICE-4.

- webview (media/preview.ts): nearestSrc() DOM walk + selectionSrcRange()
  block-union; updateAskLabel() on selectionchange + after each render; the
  adaptive click posts { askClaude, scope:"selection", start, end } or falls back
  to document scope. Sealed (INV-21): reads data-src only, posts intent.
- host: runEditAndPropose's range branch (already shared from SLICE-3) records one
  single-range F4 proposal over the resolved block-union.
- host E2E: stubbed selection turn → exactly one proposal over the resolved range
  (turn receives exactly the selected source; replaced == the range; doc
  untouched); an unchanged replacement proposes nothing. (The webview DOM
  selection→data-src lookup is sealed-sandbox → manual smoke, spec §6.8.)

205 unit + 51 host E2E green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 13:53:14 -07:00

142 lines
5.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };
}
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;
// 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" });
}
});
// F10: delegated ✓/✗ accept/reject of pending proposals (routed back to the F4 seam).
body.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement)?.closest<HTMLElement>(".cw-actions button");
if (!btn) return;
const block = btn.closest<HTMLElement>(".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<void> {
const nodes = Array.from(body.querySelectorAll<HTMLElement>("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<RenderMessage>) => {
const msg = event.data;
if (msg?.type !== "render") return;
body.innerHTML = msg.html;
updateAskLabel(); // new content clears any selection → reset the adaptive label
const on = msg.mode === "on";
if (annotationsEl) annotationsEl.checked = on;
// 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 =
`<span class="cw-add">+${msg.summary?.added ?? 0}</span> ` +
`<span class="cw-del">${msg.summary?.removed ?? 0}</span> ` +
`<span class="cw-prop">${msg.summary?.proposals ?? 0} proposal${(msg.summary?.proposals ?? 0) === 1 ? "" : "s"}</span>`;
}
void renderMermaid();
});