Native surfaces migration — evolve the extension onto VS Code's native review surfaces (9-task plan, spec v0.2.1) #72
+66
-40
@@ -155,50 +155,64 @@ export function annotateSource(src: string, inputs: AnnotationInputs): string {
|
|||||||
return injectAllSentinels(text, marks);
|
return injectAllSentinels(text, marks);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** One `buildMermaidQueue` entry: a changed mermaid fence's own body (for
|
||||||
|
* matching, `mermaidFenceBody` normalization — no trailing newline) paired
|
||||||
|
* with its `diffMermaid`-augmented replacement source. `consumed` is reset by
|
||||||
|
* the `render` wrapper (see `cowritingMarkdownItPlugin`) so the SAME entries
|
||||||
|
* can back a re-render without a fresh parse. */
|
||||||
|
interface MermaidQueueEntry {
|
||||||
|
readonly curBody: string;
|
||||||
|
readonly source: string;
|
||||||
|
consumed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-render queue of mermaid-fence overrides, one entry per ```mermaid fence
|
* Per-render queue of mermaid-fence overrides: one entry per `changed` atomic
|
||||||
* the CURRENT (landed) text contains, in document order — the same order
|
* mermaid op (Task 7 §2.6 parity: reuses `diffBlocks`' baseline/current block
|
||||||
* `options.highlight` encounters them while markdown-it renders fence tokens
|
* pairing, the SAME pairing `renderReview`/`renderOp` used in the sunset
|
||||||
* (Task 7 §2.6 parity: reuses `diffBlocks`' baseline/current block pairing,
|
* webview, so a changed mermaid fence is re-emitted through `diffMermaid`
|
||||||
* the SAME pairing `renderReview`/`renderOp` used in the sunset webview, so a
|
* exactly as it was there — INV-23 fences stay atomic, INV-29..31 styling
|
||||||
* changed mermaid fence is re-emitted through `diffMermaid` exactly as it was
|
* directives) that has a diffable before/current pair AND a successful
|
||||||
* there — INV-23 fences stay atomic, INV-29..31 styling directives). An entry
|
* `diffMermaid` augmentation. `[]` when there is no baseline to diff against,
|
||||||
* is the augmented source string when the fence is a `changed` atomic mermaid
|
* annotations are disabled, or the landed text equals the baseline (covers
|
||||||
* op with a diffable before/current pair, else `null` (unchanged/added fence,
|
* the #48 pinned-zero-diff case too, since `diffBlocks` on identical text
|
||||||
* or a fallback pair `diffMermaid` couldn't diff — render the fence verbatim).
|
* yields no `changed` ops).
|
||||||
* `[]` 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
|
* Diffed against `landedText` (proposals reverted), matching the "committed
|
||||||
* change since baseline" boundary `annotateSource`'s own `wordEditHunks(src,
|
* change since baseline" boundary `annotateSource`'s own `wordEditHunks(src,
|
||||||
* baselineText)` gate already draws (a pending, not-yet-accepted proposal
|
* baselineText)` gate already draws (a pending, not-yet-accepted proposal
|
||||||
* isn't a landed change) — a fence a pending proposal's anchor overlaps is
|
* 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
|
* left unaugmented, same as the rest of that gate.
|
||||||
* and ORDER match between `src` (what markdown-it actually parses fences
|
*
|
||||||
* from) and `landedText` (what this function diffs) — true whenever no
|
* NOT positional: `src` (what markdown-it actually parses fences from) and
|
||||||
* pending proposal adds/removes a whole mermaid block; a rare edge case
|
* `landedText` (what this function diffs) can disagree on mermaid-fence COUNT
|
||||||
* accepted as a known scope boundary (mirrors the built-in preview's other
|
* or ORDER — e.g. a pending proposal optimistically applied in `src` (F12,
|
||||||
* documented Q4/scope notes), not something the built-in preview's
|
* INV-48) inserts a whole new mermaid fence that `landedText` (proposals
|
||||||
* proposal-block accounting handles elsewhere either.
|
* reverted) doesn't have, or `annotateSource`'s committed-deletion reinsert
|
||||||
|
* splices an extra fence into the rendered source. `options.highlight`
|
||||||
|
* (`cowritingMarkdownItPlugin`) matches each queue entry to a fence by BODY,
|
||||||
|
* not by position — a fence with no matching entry renders verbatim (the
|
||||||
|
* queue is a bag of `changed` fences to find, not a strict per-fence
|
||||||
|
* override list), so misalignment degrades to verbatim exactly as intended,
|
||||||
|
* never substitutes the wrong diagram's augmentation into the wrong fence.
|
||||||
*
|
*
|
||||||
* Pure, vscode-free (INV-6).
|
* Pure, vscode-free (INV-6).
|
||||||
*/
|
*/
|
||||||
function buildMermaidQueue(src: string, inputs: AnnotationInputs): (string | null)[] {
|
function buildMermaidQueue(src: string, inputs: AnnotationInputs): MermaidQueueEntry[] {
|
||||||
if (!inputs.enabled || inputs.baselineText === undefined) return [];
|
if (!inputs.enabled || inputs.baselineText === undefined) return [];
|
||||||
const baselineText = inputs.baselineText;
|
const baselineText = inputs.baselineText;
|
||||||
const landedText = landedTextOf(src, inputs.proposals ?? []);
|
const landedText = landedTextOf(src, inputs.proposals ?? []);
|
||||||
if (landedText === baselineText) return [];
|
if (landedText === baselineText) return [];
|
||||||
return diffBlocks(baselineText, landedText)
|
const entries: MermaidQueueEntry[] = [];
|
||||||
.filter((op) => op.kind !== "removed" && op.block.type === "mermaid")
|
for (const op of diffBlocks(baselineText, landedText)) {
|
||||||
.map((op) => {
|
if (op.kind !== "changed" || op.block.type !== "mermaid") continue; // unchanged/added/removed: no override needed
|
||||||
if (op.kind !== "changed") return null; // unchanged/added: render verbatim
|
|
||||||
const curBody = mermaidFenceBody(op.block.raw);
|
const curBody = mermaidFenceBody(op.block.raw);
|
||||||
const beforeBody = op.before.type === "mermaid" ? mermaidFenceBody(op.before.raw) : null;
|
const beforeBody = op.before.type === "mermaid" ? mermaidFenceBody(op.before.raw) : null;
|
||||||
if (curBody === null || beforeBody === null) return null;
|
if (curBody === null || beforeBody === null) continue;
|
||||||
const result = diffMermaid(beforeBody, curBody);
|
const result = diffMermaid(beforeBody, curBody);
|
||||||
return result.kind === "augmented" ? result.source : null; // INV-30 fallback: verbatim
|
if (result.kind === "augmented") entries.push({ curBody, source: result.source, consumed: false }); // INV-30 fallback: verbatim
|
||||||
});
|
}
|
||||||
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -217,36 +231,48 @@ function buildMermaidQueue(src: string, inputs: AnnotationInputs): (string | nul
|
|||||||
* diagram diff (INV-29..31), not just the current diagram. `options.highlight`
|
* 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
|
* 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
|
* threaded through a closure variable set by the core rule (which DOES see
|
||||||
* `state.env`) and consumed in fence-encounter order by `highlight` —
|
* `state.env`) and consumed BY BODY MATCH, not fence position (see
|
||||||
* markdown-it's `parse` (core rules) always completes before `renderer.render`
|
* `buildMermaidQueue`), by `highlight`: each fence's `code` is matched against
|
||||||
* invokes `highlight`, and a single md instance never renders concurrently, so
|
* unconsumed queue entries in order, the first match is consumed (so a later
|
||||||
* this is safe.
|
* fence with the same body still finds its own entry), and a fence with no
|
||||||
|
* match renders verbatim WITHOUT consuming anything (so a later fence can
|
||||||
|
* still match). markdown-it's `parse` (core rules) always completes before
|
||||||
|
* `renderer.render` invokes `highlight`, and a single md instance never
|
||||||
|
* renders concurrently, so the closure handoff itself is safe — but VS Code
|
||||||
|
* may call `md.parse()` and `md.renderer.render()` separately and re-render
|
||||||
|
* cached tokens without a fresh parse (the same caller pattern the module
|
||||||
|
* docstring notes for scroll-sync), which would otherwise replay a queue whose
|
||||||
|
* entries were already marked consumed by the prior render; the `render`
|
||||||
|
* wrapper resets every entry's `consumed` flag so each render pass starts with
|
||||||
|
* the full queue available again.
|
||||||
*/
|
*/
|
||||||
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 mermaidQueue: MermaidQueueEntry[] = [];
|
||||||
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) : [];
|
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);
|
||||||
});
|
});
|
||||||
|
|
||||||
const baseRender = md.renderer.render.bind(md.renderer);
|
const baseRender = md.renderer.render.bind(md.renderer);
|
||||||
md.renderer.render = (tokens, options, env) => sentinelsToSpans(baseRender(tokens, options, env), "ins");
|
md.renderer.render = (tokens, options, env) => {
|
||||||
|
for (const entry of mermaidQueue) entry.consumed = false; // re-render-without-reparse safety net
|
||||||
|
return sentinelsToSpans(baseRender(tokens, options, env), "ins");
|
||||||
|
};
|
||||||
|
|
||||||
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") {
|
||||||
const override = mermaidIdx < mermaidQueue.length ? mermaidQueue[mermaidIdx] : null;
|
const normalized = code.replace(/\n+$/, ""); // token.content keeps a trailing LF; mermaidFenceBody doesn't
|
||||||
mermaidIdx++;
|
const entry = mermaidQueue.find((e) => !e.consumed && e.curBody === normalized);
|
||||||
const legend = override !== null ? MERMAID_LEGEND : "";
|
if (entry) entry.consumed = true;
|
||||||
return `<pre class="mermaid" style="all: unset;">${escapeMermaidSource(override ?? code)}</pre>${legend}`;
|
const legend = entry ? MERMAID_LEGEND : "";
|
||||||
|
return `<pre class="mermaid" style="all: unset;">${escapeMermaidSource(entry?.source ?? code)}</pre>${legend}`;
|
||||||
}
|
}
|
||||||
return baseHighlight?.(code, lang, attrs) ?? md.utils.escapeHtml(code);
|
return baseHighlight?.(code, lang, attrs) ?? md.utils.escapeHtml(code);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -131,4 +131,49 @@ describe("intra-diagram mermaid diff in the built-in preview (plan T7 §2.6, INV
|
|||||||
expect(html).not.toContain("cw-mermaid-legend");
|
expect(html).not.toContain("cw-mermaid-legend");
|
||||||
expect(html).toContain('class="language-js"');
|
expect(html).toContain('class="language-js"');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Code-review hardening (705de31 follow-up): the queue used to be consumed
|
||||||
|
// POSITIONALLY per mermaid fence encountered, so a fence the queue doesn't
|
||||||
|
// know about (e.g. a pending proposal's optimistic apply, F12/INV-48,
|
||||||
|
// inserting a whole new mermaid fence ABOVE a changed one) shifted every
|
||||||
|
// later fence's override by one, substituting the wrong diagram's augmented
|
||||||
|
// source into the wrong fence instead of the documented verbatim fallback.
|
||||||
|
// The fix matches queue entries to fences by BODY: a fence with no matching
|
||||||
|
// entry renders verbatim without consuming anything, so a later matching
|
||||||
|
// fence still finds its own entry.
|
||||||
|
it("misalignment: an extra proposal-inserted mermaid fence above a changed one renders verbatim, and the changed fence still gets its own augmentation", () => {
|
||||||
|
const extraFence = "```mermaid\nsequenceDiagram\n A->>B: hi\n```\n";
|
||||||
|
const src = extraFence + "\n" + flowchartAfter;
|
||||||
|
const html = render(src, {
|
||||||
|
...base,
|
||||||
|
baselineText: flowchartBefore,
|
||||||
|
spans: [],
|
||||||
|
proposals: [{ id: "p1", anchorStart: 0, anchorEnd: extraFence.length, replaced: "", replacement: extraFence }],
|
||||||
|
});
|
||||||
|
const fences = html.split('<pre class="mermaid"').slice(1); // one chunk per fence, document order
|
||||||
|
expect(fences).toHaveLength(2);
|
||||||
|
// the extra fence has no matching queue entry: verbatim, no wrong-diagram substitution
|
||||||
|
expect(fences[0]).toContain("sequenceDiagram");
|
||||||
|
expect(fences[0]).not.toContain("cwAdded");
|
||||||
|
// the changed fence still finds its own augmentation despite the extra fence ahead of it in document order
|
||||||
|
expect(fences[1]).toContain("classDef cwAdded");
|
||||||
|
expect(fences[1]).toMatch(/class\s+c\s+cwAdded/);
|
||||||
|
expect((html.match(/cw-mermaid-legend/g) ?? []).length).toBe(1); // legend only on the augmented fence
|
||||||
|
});
|
||||||
|
|
||||||
|
// Body-match must not collapse two changed fences that happen to render the
|
||||||
|
// SAME current diagram into a single queue entry — each occurrence gets its
|
||||||
|
// own independent augmentation (consume-first-match, not a body->entry map).
|
||||||
|
it("two identical-body changed mermaid fences each get their own augmentation", () => {
|
||||||
|
const baselineText = flowchartBefore + "\n" + flowchartBefore;
|
||||||
|
const src = flowchartAfter + "\n" + flowchartAfter;
|
||||||
|
const html = render(src, { ...base, baselineText, spans: [] });
|
||||||
|
const fences = html.split('<pre class="mermaid"').slice(1);
|
||||||
|
expect(fences).toHaveLength(2);
|
||||||
|
for (const f of fences) {
|
||||||
|
expect(f).toContain("classDef cwAdded");
|
||||||
|
expect(f).toMatch(/class\s+c\s+cwAdded/);
|
||||||
|
}
|
||||||
|
expect((html.match(/cw-mermaid-legend/g) ?? []).length).toBe(2);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user