Native surfaces migration — evolve the extension onto VS Code's native review surfaces (9-task plan, spec v0.2.1) (#72)

This commit was merged in pull request #72.
This commit is contained in:
2026-07-02 23:09:37 +00:00
parent 93eeaf13b8
commit 935fcc35ee
54 changed files with 3689 additions and 1995 deletions
+55
View File
@@ -0,0 +1,55 @@
/*
* Task 7 (D3/D21, PUC-3) — authorship + change annotations for the BUILT-IN VS
* Code Markdown preview (`markdown.previewStyles`). Same F10 vocabulary as the
* (sunsetting) custom webview's `media/preview.css`: style = operation
* (underline = inserted, strikethrough = removed), color = author (human
* green, Claude blue) — `cw-del` has no author variant (a single fixed
* struck-red mark; see `previewAnnotations.ts`/`trackChangesModel.ts`'s "del"
* sentinel tag). Light-theme defaults below; `body.vscode-dark` (also applied
* on high-contrast dark) overrides with the same palette the webview preview
* already ships, for continuity across both review surfaces.
*/
.cw-ins-claude {
background: rgba(9, 105, 218, 0.12);
border-bottom: 2px solid #0969da;
text-decoration: none;
}
.cw-ins-human {
background: rgba(26, 127, 55, 0.12);
border-bottom: 2px solid #1a7f37;
text-decoration: none;
}
.cw-del {
background: rgba(207, 34, 46, 0.1);
text-decoration: line-through;
text-decoration-color: #cf222e;
opacity: 0.75;
}
body.vscode-dark .cw-ins-claude,
body.vscode-high-contrast:not(.vscode-high-contrast-light) .cw-ins-claude {
background: rgba(88, 166, 255, 0.15);
border-bottom-color: #58a6ff;
}
body.vscode-dark .cw-ins-human,
body.vscode-high-contrast:not(.vscode-high-contrast-light) .cw-ins-human {
background: rgba(63, 185, 80, 0.14);
border-bottom-color: #3fb950;
}
body.vscode-dark .cw-del,
body.vscode-high-contrast:not(.vscode-high-contrast-light) .cw-del {
background: rgba(248, 81, 73, 0.11);
text-decoration-color: #f85149;
}
/*
* F7.1 (#22) intra-diagram mermaid diff legend, shown beneath a mermaid fence
* `previewAnnotations.ts` re-emitted through `mermaidDiff.ts` (Task 7 §2.6
* parity — same legend markup + colors as the sunset webview's
* `media/preview.css`).
*/
.cw-mermaid-legend { display: flex; gap: 0.6rem; font-size: 0.75em; opacity: 0.85; margin: 0.2rem 0 0.6rem; }
.cw-mermaid-legend .cw-leg { padding: 0 0.4em; border-radius: 3px; border: 1px solid; }
.cw-mermaid-legend .cw-leg-add { color: #2ea043; border-color: #2ea043; }
.cw-mermaid-legend .cw-leg-chg { color: #d29922; border-color: #d29922; }
.cw-mermaid-legend .cw-leg-rem { color: #808080; border-color: #808080; border-style: dashed; }
+57
View File
@@ -0,0 +1,57 @@
/**
* Task 7 (Q4 mermaid check, D3/D21): the built-in VS Code Markdown preview does
* not render mermaid fences on its own — `previewAnnotations.ts`'s
* `cowritingMarkdownItPlugin` teaches ```mermaid fences to render as
* `<pre class="mermaid">SRC</pre>` (via `options.highlight`, the same
* extension point the built-in preview's own fence rule already calls); this
* script (contributed via `markdown.previewScripts`) is what actually turns
* those into diagrams — mermaid needs a DOM, so it runs here, in the preview's
* webview, never in the extension host. Bundled by esbuild as a standalone
* IIFE → out/media/preview-mermaid.js, so mermaid never enters the host
* bundle (matching the sealed webview's `media/preview.ts` precedent).
*
* Contributed preview scripts are reloaded on every content change (VS Code
* docs), so running at module top-level is sufficient; the
* `vscode.markdown.updateContent` listener is added defensively to also cover
* in-place content updates that don't reload the script (mirrors the proven
* `bierner.markdown-mermaid` extension's own approach).
*
* RESOLVED SCOPE NOTE (Q4 finding, migration plan Task 7 Step 2.6; wired in a
* cross-task review follow-up): this script itself only turns `pre.mermaid`
* nodes into diagrams — it never sees a diff. The intra-diagram diff/legend
* augmentation (F7.1, INV-29..31) happens one step earlier, source-side,
* where it CAN be unit-tested without a DOM: `previewAnnotations.ts`'s
* `cowritingMarkdownItPlugin` re-emits a CHANGED mermaid fence's source
* through `mermaidDiff.ts` (`buildMermaidQueue`, block-level `diffBlocks`
* pairing against the F6/F7 baseline) before this script ever runs, so the
* `pre.mermaid` node this script hands to `mermaid.run` already carries the
* `classDef`/`class`/`linkStyle` styling directives — this script needn't (and
* can't, DOM-less-ly) know a diagram changed at all. Basic mermaid rendering
* (this file) is exercised by the same proven pattern as the real
* `bierner.markdown-mermaid` extension; the augmentation upstream is exercised
* by pure unit tests (`test/previewAnnotations.test.ts`).
*/
import mermaid from "mermaid";
function theme(): "dark" | "default" {
return document.body.classList.contains("vscode-dark") || document.body.classList.contains("vscode-high-contrast")
? "dark"
: "default";
}
async function run(): Promise<void> {
const nodes = Array.from(document.querySelectorAll<HTMLElement>("pre.mermaid"));
if (nodes.length === 0) return;
mermaid.initialize({ startOnLoad: false, theme: theme(), 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("vscode.markdown.updateContent", () => void run());
void run();
-118
View File
@@ -1,118 +0,0 @@
/* 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;
}
/* Author-colored track changes — style = operation, color = author.
underline = inserted, strikethrough = removed; human green/red, Claude blue/purple. */
#cw-summary .cw-add { color: #3fb950; }
#cw-summary .cw-del { color: #f85149; }
.cw-blk { position: relative; }
/* base ins/del carry the operation; author classes carry the color */
ins { text-decoration: none; }
del { text-decoration: line-through; opacity: 0.7; }
.cw-ins-human { background: rgba(63,185,80,0.14); border-bottom: 2px solid #3fb950; text-decoration: none; }
.cw-ins-claude { background: rgba(88,166,255,0.15); border-bottom: 2px solid #58a6ff; text-decoration: none; }
.cw-ins-none { background: var(--vscode-diffEditor-insertedTextBackground, rgba(63,185,80,0.14)); text-decoration: none; }
.cw-del-human { background: rgba(248,81,73,0.11); text-decoration: line-through; text-decoration-color: #f85149; }
.cw-del-claude { background: rgba(188,140,255,0.13); text-decoration: line-through; text-decoration-color: #bc8cff; }
.cw-del-none { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.11)); text-decoration: line-through; opacity: 0.7; }
/* Task 8 — overlap: if a future render path nests cw-ins-{A} inside cw-del-{B},
both marks must show. cw-ins-* sets text-decoration:none which would otherwise
suppress the parent del's strikethrough. `inherit` restores the parent's
line-through while the child's border-bottom underline is unaffected.
The engine currently emits SIBLING ins/del (never nested) so this rule is a
forward defensive guarantee only. See task-8-report.md for details. */
.cw-del-claude .cw-ins-human, .cw-del-human .cw-ins-claude { text-decoration: inherit; }
/* whole-block ops: an added block is an insertion (its author runs are emitted as
cw-ins-* via colorByAuthor with kind="ins"); a standalone removed block is neutral
struck (adjacency heuristic fallback) */
.cw-added { background: transparent; }
.cw-removed { background: var(--vscode-diffEditor-removedTextBackground, rgba(248,81,73,0.10)); text-decoration: line-through; opacity: 0.65; }
.cw-changed { outline: none; }
#cw-legend .cw-swatch { padding: 0 0.4em; border-radius: 3px; }
#cw-summary .cw-prop { opacity: 0.85; }
.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); }
/* F11 — preview toolbar buttons (Pin baseline; adaptive Ask Claude). Theme-aware. */
#cw-header button {
cursor: pointer;
font: inherit;
border: 1px solid var(--vscode-button-border, transparent);
border-radius: 3px;
padding: 0.1em 0.55em;
background: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
}
#cw-header button:hover:not(:disabled) { background: var(--vscode-button-secondaryHoverBackground); }
#cw-header button:disabled { opacity: 0.5; cursor: default; }
/* F10 interactive review — annotations toggle + ✓/✗ proposal blocks. */
#cw-toggle { display: inline-flex; align-items: center; gap: 0.35em; cursor: pointer; }
.cw-proposal {
position: relative;
border-left: 3px solid var(--vscode-panel-border, #555);
background: color-mix(in srgb, var(--vscode-foreground) 5%, transparent);
padding: 0.4em 0.6em; margin: 0.4em 0; border-radius: 3px;
}
.cw-proposal-unanchored { border-left-style: dashed; opacity: 0.85; }
.cw-actions { position: absolute; top: 0.2em; right: 0.4em; display: inline-flex; gap: 0.25em; }
.cw-actions button {
cursor: pointer; border: 1px solid var(--vscode-button-border, transparent);
border-radius: 3px; font-size: 0.9em; line-height: 1; padding: 0.1em 0.35em;
background: var(--vscode-button-secondaryBackground); color: var(--vscode-button-secondaryForeground);
}
.cw-accept:hover { background: var(--vscode-testing-iconPassed, #2ea043); color: #fff; }
.cw-reject:hover { background: var(--vscode-errorForeground, #f14c4c); color: #fff; }
.cw-btngroup { display: inline-flex; }
.cw-btngroup .cw-caret { border-left: none; padding: 0.1em 0.25em; }
.cw-actions .cw-accept { font-weight: 600; }
.cw-accept:hover, .cw-btngroup:has(.cw-accept) .cw-caret:hover { background: var(--vscode-testing-iconPassed, #2ea043); color: #fff; }
.cw-reject:hover, .cw-btngroup:has(.cw-reject) .cw-caret:hover { background: var(--vscode-errorForeground, #f14c4c); color: #fff; }
/* F7.1 (#22) intra-diagram mermaid diff legend. */
.cw-mermaid-legend { display: flex; gap: 0.6rem; font-size: 0.75em; opacity: 0.85; margin: 0.2rem 0 0.6rem; }
.cw-mermaid-legend .cw-leg { padding: 0 0.4em; border-radius: 3px; border: 1px solid; }
.cw-mermaid-legend .cw-leg-add { color: #2ea043; border-color: #2ea043; }
.cw-mermaid-legend .cw-leg-chg { color: #d29922; border-color: #d29922; }
.cw-mermaid-legend .cw-leg-rem { color: #808080; border-color: #808080; border-style: dashed; }
-157
View File
@@ -1,157 +0,0 @@
/**
* 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" });
});
// F12/#64: Accept/Reject (+ caret → accept-all/reject-all) on a proposal block.
body.addEventListener("click", (e) => {
const btn = (e.target as HTMLElement)?.closest<HTMLElement>(".cw-actions button");
if (!btn) return;
const id = btn.closest<HTMLElement>(".cw-proposal")?.dataset.proposalId;
const action = btn.dataset.action;
if (action === "acceptAll") return void vscodeApi.postMessage({ type: "acceptAll" });
if (action === "rejectAll") return void vscodeApi.postMessage({ type: "rejectAll" });
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();
});