Files
vscode-cowriting-plugin/media/preview.ts
T
Ben Stull 0d1a5635cb feat(f11): SLICE-3 — Edit Document button + per-hunk proposal path (#43, INV-37)
A whole-document Ask-Claude rewrite is diffed into hunks and surfaced as N
independent F4 proposals (one per changed hunk) — reusing the F4 single-range
model N times, no new model. Per spec §6.4/§7.2 SLICE-3.

- trackChangesModel: pure `diffToHunks(currentText, rewrittenText)` →
  EditHunk[] (vscode-free, deterministic; diffWordsWithSpace, coalescing
  adjacent add/remove runs; offsets index currentText).
- trackChangesPreview: `runEditAndPropose(document, target, instruction)` — the
  shared host routine (selection → one single-range propose; document → diff →
  one propose per hunk; never mutates the doc, INV-10); `askClaude` UI wrapper
  (host showInputBox keeps LLM/secrets out of the sealed webview, INV-8/35);
  injectable `editTurn` + `setEditTurnForTest` seam (no LLM in CI); the
  `askClaude` inbound message branch; `cowriting.editDocument` command for #42
  reuse.
- package.json: register cowriting.editDocument, palette-guarded on markdown.
- webview: ✦ Ask Claude to Edit Document button → { askClaude, scope:"document" }.
- unit: diffToHunks fixtures (zero/one/multi-hunk, wholesale, determinism).
- host E2E: stubbed multi-hunk rewrite → N matching proposals, doc untouched;
  editDocument command registered + markdown-guarded.

205 unit + 49 host E2E green.

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

101 lines
3.9 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-3): Ask Claude to Edit Document — post the document-scope intent; the
// host prompts for the instruction + runs the turn (INV-8). SLICE-4 makes this
// button adaptive (Edit Selection when text is selected in the preview).
askEl?.addEventListener("click", () => {
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;
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();
});