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:
@@ -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; }
|
||||
@@ -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();
|
||||
@@ -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; }
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user