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

Merged
benstull merged 18 commits from session-0065 into main 2026-07-02 23:09:38 +00:00
5 changed files with 167 additions and 16 deletions
Showing only changes of commit 705de3169b - Show all commits
+12
View File
@@ -41,3 +41,15 @@ body.vscode-high-contrast:not(.vscode-high-contrast-light) .cw-del {
background: rgba(248, 81, 73, 0.11); background: rgba(248, 81, 73, 0.11);
text-decoration-color: #f85149; 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; }
+14 -10
View File
@@ -16,16 +16,20 @@
* in-place content updates that don't reload the script (mirrors the proven * in-place content updates that don't reload the script (mirrors the proven
* `bierner.markdown-mermaid` extension's own approach). * `bierner.markdown-mermaid` extension's own approach).
* *
* SCOPE NOTE (Q4 finding, recorded per the migration plan's Task 7 Step 2.6): * RESOLVED SCOPE NOTE (Q4 finding, migration plan Task 7 Step 2.6; wired in a
* this renders the CURRENT diagram only — it does NOT re-emit a changed * cross-task review follow-up): this script itself only turns `pre.mermaid`
* diagram through `mermaidDiff.ts`'s intra-diagram diff/legend augmentation * nodes into diagrams — it never sees a diff. The intra-diagram diff/legend
* (the F7.1 feature the sealed webview preview has). That augmentation needs a * augmentation (F7.1, INV-29..31) happens one step earlier, source-side,
* block-level (not word-hunk-level) diff pass wired into `annotateSource`, * where it CAN be unit-tested without a DOM: `previewAnnotations.ts`'s
* which was judged out of scope for this increment — it cannot be verified by * `cowritingMarkdownItPlugin` re-emits a CHANGED mermaid fence's source
* the automated E2E harness (the built-in preview's DOM/CSP sandbox isn't * through `mermaidDiff.ts` (`buildMermaidQueue`, block-level `diffBlocks`
* queryable from a host test) and shipping it unverified risked a silent * pairing against the F6/F7 baseline) before this script ever runs, so the
* regression. Basic mermaid rendering (this file) IS wired and is exercised by * `pre.mermaid` node this script hands to `mermaid.run` already carries the
* the same proven pattern as the real `bierner.markdown-mermaid` extension. * `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"; import mermaid from "mermaid";
+69 -2
View File
@@ -35,9 +35,13 @@
* `lastActiveCoedited`). * `lastActiveCoedited`).
*/ */
import type MarkdownIt from "markdown-it"; import type MarkdownIt from "markdown-it";
import { diffMermaid } from "./mermaidDiff";
import { import {
diffBlocks,
injectSentinels, injectSentinels,
landedTextOf, landedTextOf,
MERMAID_LEGEND,
mermaidFenceBody,
sentinelsToSpans, sentinelsToSpans,
splitBlocksWithRanges, splitBlocksWithRanges,
wordEditHunks, wordEditHunks,
@@ -151,6 +155,52 @@ export function annotateSource(src: string, inputs: AnnotationInputs): string {
return injectAllSentinels(text, marks); return injectAllSentinels(text, marks);
} }
/**
* Per-render queue of mermaid-fence overrides, one entry per ```mermaid fence
* the CURRENT (landed) text contains, in document order — the same order
* `options.highlight` encounters them while markdown-it renders fence tokens
* (Task 7 §2.6 parity: reuses `diffBlocks`' baseline/current block pairing,
* the SAME pairing `renderReview`/`renderOp` used in the sunset webview, so a
* changed mermaid fence is re-emitted through `diffMermaid` exactly as it was
* there — INV-23 fences stay atomic, INV-29..31 styling directives). An entry
* is the augmented source string when the fence is a `changed` atomic mermaid
* op with a diffable before/current pair, else `null` (unchanged/added fence,
* or a fallback pair `diffMermaid` couldn't diff — render the fence verbatim).
* `[]` when there is no baseline to diff against, annotations are disabled, or
* the landed text equals the baseline (covers the #48 pinned-zero-diff case
* too, since `diffBlocks` on identical text yields no `changed` ops).
*
* Diffed against `landedText` (proposals reverted), matching the "committed
* change since baseline" boundary `annotateSource`'s own `wordEditHunks(src,
* baselineText)` gate already draws (a pending, not-yet-accepted proposal
* isn't a landed change) — a fence a pending proposal's anchor overlaps is
* left unaugmented, same as the rest of that gate. This assumes fence COUNT
* and ORDER match between `src` (what markdown-it actually parses fences
* from) and `landedText` (what this function diffs) — true whenever no
* pending proposal adds/removes a whole mermaid block; a rare edge case
* accepted as a known scope boundary (mirrors the built-in preview's other
* documented Q4/scope notes), not something the built-in preview's
* proposal-block accounting handles elsewhere either.
*
* Pure, vscode-free (INV-6).
*/
function buildMermaidQueue(src: string, inputs: AnnotationInputs): (string | null)[] {
if (!inputs.enabled || inputs.baselineText === undefined) return [];
const baselineText = inputs.baselineText;
const landedText = landedTextOf(src, inputs.proposals ?? []);
if (landedText === baselineText) return [];
return diffBlocks(baselineText, landedText)
.filter((op) => op.kind !== "removed" && op.block.type === "mermaid")
.map((op) => {
if (op.kind !== "changed") return null; // unchanged/added: render verbatim
const curBody = mermaidFenceBody(op.block.raw);
const beforeBody = op.before.type === "mermaid" ? mermaidFenceBody(op.before.raw) : null;
if (curBody === null || beforeBody === null) return null;
const result = diffMermaid(beforeBody, curBody);
return result.kind === "augmented" ? result.source : null; // INV-30 fallback: verbatim
});
}
/** /**
* markdown-it plugin factory: the host supplies `AnnotationInputs` per render * markdown-it plugin factory: the host supplies `AnnotationInputs` per render
* (`host.inputsFor(env)`; `undefined` = no coediting context for this render — * (`host.inputsFor(env)`; `undefined` = no coediting context for this render —
@@ -161,14 +211,28 @@ export function annotateSource(src: string, inputs: AnnotationInputs): string {
* (Q4/Task 7 Step 2.6) via `options.highlight` — the same extension point the * (Q4/Task 7 Step 2.6) via `options.highlight` — the same extension point the
* built-in preview's own fence rule already calls for syntax highlighting — so * built-in preview's own fence rule already calls for syntax highlighting — so
* the bundled `media/preview-mermaid.js` previewScript has something to run * the bundled `media/preview-mermaid.js` previewScript has something to run
* mermaid over (the built-in preview does not render mermaid on its own). * mermaid over (the built-in preview does not render mermaid on its own). A
* CHANGED mermaid fence's source is re-emitted through `diffMermaid` first
* (`buildMermaidQueue`, Task 7 §2.6 parity) so mermaid renders the intra-
* diagram diff (INV-29..31), not just the current diagram. `options.highlight`
* has no `env`/`state` parameter of its own, so the per-render queue is
* threaded through a closure variable set by the core rule (which DOES see
* `state.env`) and consumed in fence-encounter order by `highlight` —
* markdown-it's `parse` (core rules) always completes before `renderer.render`
* invokes `highlight`, and a single md instance never renders concurrently, so
* this is safe.
*/ */
export function cowritingMarkdownItPlugin( export function cowritingMarkdownItPlugin(
md: MarkdownIt, md: MarkdownIt,
host: { inputsFor(env: unknown): AnnotationInputs | undefined }, host: { inputsFor(env: unknown): AnnotationInputs | undefined },
): MarkdownIt { ): MarkdownIt {
let mermaidQueue: (string | null)[] = [];
let mermaidIdx = 0;
md.core.ruler.before("normalize", "cowriting-annotate", (state) => { md.core.ruler.before("normalize", "cowriting-annotate", (state) => {
const inputs = host.inputsFor(state.env); const inputs = host.inputsFor(state.env);
mermaidQueue = inputs ? buildMermaidQueue(state.src, inputs) : [];
mermaidIdx = 0;
if (!inputs) return; if (!inputs) return;
state.src = annotateSource(state.src, inputs); state.src = annotateSource(state.src, inputs);
}); });
@@ -179,7 +243,10 @@ export function cowritingMarkdownItPlugin(
const baseHighlight = md.options.highlight; const baseHighlight = md.options.highlight;
md.options.highlight = (code, lang, attrs) => { md.options.highlight = (code, lang, attrs) => {
if (lang?.trim().toLowerCase() === "mermaid") { if (lang?.trim().toLowerCase() === "mermaid") {
return `<pre class="mermaid" style="all: unset;">${escapeMermaidSource(code)}</pre>`; const override = mermaidIdx < mermaidQueue.length ? mermaidQueue[mermaidIdx] : null;
mermaidIdx++;
const legend = override !== null ? MERMAID_LEGEND : "";
return `<pre class="mermaid" style="all: unset;">${escapeMermaidSource(override ?? code)}</pre>${legend}`;
} }
return baseHighlight?.(code, lang, attrs) ?? md.utils.escapeHtml(code); return baseHighlight?.(code, lang, attrs) ?? md.utils.escapeHtml(code);
}; };
+13 -4
View File
@@ -469,8 +469,13 @@ md.renderer.rules.fence = (tokens, idx, options, env, self) => {
return defaultFence(tokens, idx, options, env, self); return defaultFence(tokens, idx, options, env, self);
}; };
/** Inner source of a ```mermaid fence (drops the fence lines), or null. */ /**
function mermaidFenceBody(raw: string): string | null { * Inner source of a ```mermaid fence (drops the fence lines), or null. Exported
* so `previewAnnotations.ts` can pair a `diffBlocks` mermaid `changed` op's
* before/current fence bodies for `diffMermaid` without reinventing this
* extraction (Task 7 §2.6 parity).
*/
export function mermaidFenceBody(raw: string): string | null {
const lines = raw.split(/\r?\n/); const lines = raw.split(/\r?\n/);
const open = lines[0]?.match(/^(\s*)(`{3,}|~{3,})\s*(\w+)?/); const open = lines[0]?.match(/^(\s*)(`{3,}|~{3,})\s*(\w+)?/);
if (!open || open[3]?.toLowerCase() !== "mermaid") return null; if (!open || open[3]?.toLowerCase() !== "mermaid") return null;
@@ -479,8 +484,12 @@ function mermaidFenceBody(raw: string): string | null {
return body.join("\n"); return body.join("\n");
} }
/** F7.1 (#22): legend shown beneath an intra-diagram-diffed mermaid block. */ /**
const MERMAID_LEGEND = * F7.1 (#22): legend shown beneath an intra-diagram-diffed mermaid block.
* Exported for reuse by `previewAnnotations.ts`'s built-in-preview mermaid
* diff wiring (Task 7 §2.6 parity — same legend markup, both surfaces).
*/
export const MERMAID_LEGEND =
'<div class="cw-mermaid-legend">' + '<div class="cw-mermaid-legend">' +
'<span class="cw-leg cw-leg-add">added</span>' + '<span class="cw-leg cw-leg-add">added</span>' +
'<span class="cw-leg cw-leg-chg">changed</span>' + '<span class="cw-leg cw-leg-chg">changed</span>' +
+59
View File
@@ -73,3 +73,62 @@ describe("preview annotations (PUC-3/D21)", () => {
expect(html).not.toContain("cw-"); expect(html).not.toContain("cw-");
}); });
}); });
// Task 7 §2.6 / cross-task review follow-up (plan-mandated, INV-29..31): the
// built-in preview's mermaid fence rendering reuses the F7.1 (#22) pure
// re-emit, same as the sunset webview did — a changed mermaid diagram gets
// intra-diagram change styling, not just the plain current diagram. Ports the
// substance of the E2E assertions Task 8 deleted with the webview
// (`git show 2170a0d^:test/e2e/suite/trackChangesPreview.test.ts`, "a changed
// flowchart augments the emitted mermaid source (#22, INV-29)") as pure unit
// tests here instead — no DOM/webview needed to verify SOURCE augmentation.
describe("intra-diagram mermaid diff in the built-in preview (plan T7 §2.6, INV-29)", () => {
const flowchartBefore = "```mermaid\nflowchart LR\n a --> b\n```\n";
const flowchartAfter = "```mermaid\nflowchart LR\n a --> b\n a --> c\n```\n";
it("a changed flowchart fence is re-emitted with cwAdded styling + a legend", () => {
const html = render(flowchartAfter, { ...base, baselineText: flowchartBefore, spans: [] });
expect(html).toMatch(/classDef cwAdded/);
expect(html).toMatch(/class\s+c\s+cwAdded/);
expect(html).toContain("cw-mermaid-legend");
expect(html).toContain('class="mermaid"');
});
it("an unchanged mermaid fence passes through byte-identical (no diff markup)", () => {
const html = render(flowchartBefore, { ...base, baselineText: flowchartBefore, spans: [] });
expect(html).not.toContain("cwAdded");
expect(html).not.toContain("cw-mermaid-legend");
expect(html).toContain("flowchart LR");
expect(html).toContain("a --&gt; b");
});
it("annotations disabled: a changed mermaid fence renders the plain current diagram", () => {
const html = render(flowchartAfter, { ...base, enabled: false, baselineText: flowchartBefore, spans: [] });
expect(html).not.toContain("cwAdded");
expect(html).not.toContain("cw-mermaid-legend");
expect(html).toContain("a --&gt; c");
});
it("pinned baseline with zero landed diff: mermaid fence renders the plain current diagram (INV-33/#48 parity)", () => {
const html = render(flowchartAfter, {
...base, baselineReason: "pinned", baselineText: flowchartAfter, spans: [],
});
expect(html).not.toContain("cwAdded");
expect(html).not.toContain("cw-mermaid-legend");
});
it("no baseline (no coediting context established): mermaid fence renders the plain current diagram", () => {
const html = render(flowchartAfter, { ...base, baselineText: undefined, spans: [] });
expect(html).not.toContain("cwAdded");
expect(html).not.toContain("cw-mermaid-legend");
});
it("a non-mermaid changed fence is untouched by the mermaid queue (fence-kind gate)", () => {
const before = "```js\nconst a = 1;\n```\n";
const after = "```js\nconst a = 2;\n```\n";
const html = render(after, { ...base, baselineText: before, spans: [] });
expect(html).not.toContain("cwAdded");
expect(html).not.toContain("cw-mermaid-legend");
expect(html).toContain('class="language-js"');
});
});