c94b9ccfe7
Document-edit flow SLICE-3 — completes reach→review→accept
(specs/coauthoring-document-edit-flow.md §7.2, INV-42). A single "Accept all"
gesture applies every pending proposal on the current document through the
existing F4 acceptById seam (block proposals take the INV-40 word-precise path
automatically), in descending anchor order so an earlier accept never invalidates
a later one, skipping (never force-applying) proposals that can't anchor and
reporting applied-vs-skipped. Batched application of the existing accept path — no
new mechanism; the webview posts intent only (INV-35). No confirmation dialog
(undo restores).
- proposalController.ts: acceptAllProposals(document) → {applied, skipped}
(descending order, orphan-skip); accept/acceptById gain a silent opt so the
batch suppresses N per-proposal orphan warnings in favour of one report.
- trackChangesPreview.ts: ToolbarMsg += {type:"acceptAll"}; handleWebviewMessage
routes it to a public acceptAll(document) that batches + reports.
- extension.ts + package.json: cowriting.acceptAllProposals command (active doc,
markdown-gated palette entry) for the non-webview path.
- media/preview.ts + shellHtml: "✓✓ Accept all" toolbar button, posting the
intent, shown only with ≥2 pending proposals (authorable, on-state).
- trackChangesModel.ts: diffToBlockHunks now emits one block-aligned hunk per
CHANGED block even when changed blocks are ADJACENT (treats changed blocks as
1:1 anchors alongside unchanged ones; gap-spans only cover add/remove runs
between anchors) — fixes adjacent changed blocks collapsing into one proposal.
- f12Accept host E2E (apply-all reconstructs; orphan skip + report; single
proposal; command registered/gated); MANUAL-SMOKE-F12 §3.
214 unit + 73/5 host E2E green. Completes the document-edit-flow cluster
(#42 reach + #47 review + #46 accept).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
157 lines
6.4 KiB
TypeScript
157 lines
6.4 KiB
TypeScript
/**
|
||
* 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<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
|
||
// 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 =
|
||
`<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();
|
||
});
|