Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
39 KiB
F7.1 Intra-Diagram Mermaid Diffing Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Refine the F7 whole-diagram "changed" badge into a node/edge-level diff inside flowchart and sequence mermaid diagrams against the F6 baseline.
Architecture: Three new pure, vscode-free, DOM-free host modules under src/ parse a changed mermaid block's before/after source into a typed element model, diff it, and re-emit the current diagram source augmented with mermaid's own styling directives (flowchart: classDef/class/linkStyle; sequence: rect rgb(...) tinted runs), ghosting removed elements in place. The dispatcher falls back to the v1 whole-block badge for unsupported types or any parse failure. renderOp's changed-mermaid branch calls the dispatcher; the webview is unchanged (styling rides inside the mermaid source); media/preview.css gains a legend.
Tech Stack: TypeScript, diff (jsdiff) for sequence LCS, markdown-it (existing fence rule), vitest (unit), @vscode/test-electron + mocha (host E2E).
Spec: vscode-cowriting-plugin-content/specs/coauthoring-rendered-preview.md §11 (INV-29..31). Task benstull/vscode-cowriting-plugin#22.
File Structure
- Create
src/mermaidDiff.ts— dispatcher.detectDiagramType(source),diffMermaid(beforeSrc, currentSrc): MermaidDiffResult. Wraps the typed differ in try/catch →{kind:"fallback"}on any throw or unsupported type. Owns the shared color palette constants. - Create
src/mermaidFlowchartDiff.ts—parseFlowchart(source): FlowGraph,diffFlowchart(beforeSrc, currentSrc): string. - Create
src/mermaidSequenceDiff.ts—parseSequence(source): SeqDiagram,diffSequence(beforeSrc, currentSrc): string. - Modify
src/trackChangesModel.ts— inrenderOp, thechanged+atomicbranch: if the block is a mermaid fence, calldiffMermaidon the inner source;augmented→ render the augmented fence + legend (no single badge);fallback→ today's behavior. Add amermaidFenceBody(raw)helper. - Modify
media/preview.css—.cw-mermaid-legendswatch styles. - Create
test/mermaidFlowchartDiff.test.ts,test/mermaidSequenceDiff.test.ts,test/mermaidDiff.test.ts— unit tests. - Modify
test/trackChangesModel.test.ts— assertrenderTrackChangesHTML augments a changed flowchart/sequence and falls back otherwise. - Modify
test/e2e/suite/trackChangesPreview.test.ts— host E2E for a changed flowchart. - Create
docs/MANUAL-SMOKE-F7.1.md— manual webview-render smoke steps.
Shared palette (define once in src/mermaidDiff.ts, import elsewhere):
// Theme-neutral colors baked into emitted mermaid source (source can't read CSS vars).
export const CW_COLORS = {
added: "#2ea043",
changed: "#d29922",
removed: "#808080",
} as const;
Task 1: Dispatcher skeleton — detectDiagramType + diffMermaid fallback
Files:
-
Create:
src/mermaidDiff.ts -
Test:
test/mermaidDiff.test.ts -
Step 1: Write the failing test
// test/mermaidDiff.test.ts
import { describe, it, expect } from "vitest";
import { detectDiagramType, diffMermaid } from "../src/mermaidDiff";
describe("detectDiagramType", () => {
it("detects flowchart from `flowchart` / `graph` headers", () => {
expect(detectDiagramType("flowchart LR\n a-->b")).toBe("flowchart");
expect(detectDiagramType("graph TD\n a-->b")).toBe("flowchart");
expect(detectDiagramType(" \n%% c\ngraph TD")).toBe("flowchart");
});
it("detects sequence from `sequenceDiagram`", () => {
expect(detectDiagramType("sequenceDiagram\n A->>B: hi")).toBe("sequence");
});
it("returns `other` for unsupported types", () => {
expect(detectDiagramType("classDiagram\n class A")).toBe("other");
expect(detectDiagramType("stateDiagram-v2\n s1 --> s2")).toBe("other");
expect(detectDiagramType("")).toBe("other");
});
});
describe("diffMermaid", () => {
it("falls back for an unsupported diagram type", () => {
expect(diffMermaid("classDiagram\n class A", "classDiagram\n class B")).toEqual({ kind: "fallback" });
});
it("falls back (never throws) when the differ throws", () => {
// A deliberately malformed flowchart still degrades gracefully.
const r = diffMermaid("flowchart LR", "flowchart LR\n a -->");
expect(["augmented", "fallback"]).toContain(r.kind);
});
});
- Step 2: Run test to verify it fails
Run: npx vitest run test/mermaidDiff.test.ts
Expected: FAIL — Cannot find module '../src/mermaidDiff'.
- Step 3: Write minimal implementation
// src/mermaidDiff.ts
/**
* mermaidDiff — F7.1 (#22) dispatcher. Detects a mermaid diagram's type and
* routes a baseline→current diff to the matching pure differ, which re-emits the
* CURRENT diagram source augmented with mermaid styling directives (INV-29). Any
* unsupported type or parser surprise degrades to the v1 whole-block badge
* (INV-30) — this function never throws. vscode-free, DOM-free, deterministic.
*/
import { diffFlowchart } from "./mermaidFlowchartDiff";
import { diffSequence } from "./mermaidSequenceDiff";
export const CW_COLORS = {
added: "#2ea043",
changed: "#d29922",
removed: "#808080",
} as const;
export type DiagramType = "flowchart" | "sequence" | "other";
export interface MermaidDiffAugmented {
kind: "augmented";
source: string;
}
export interface MermaidDiffFallback {
kind: "fallback";
}
export type MermaidDiffResult = MermaidDiffAugmented | MermaidDiffFallback;
export function detectDiagramType(source: string): DiagramType {
for (const line of source.split(/\r?\n/)) {
const t = line.trim();
if (t === "" || t.startsWith("%%")) continue;
if (/^(flowchart|graph)\b/.test(t)) return "flowchart";
if (/^sequenceDiagram\b/.test(t)) return "sequence";
return "other";
}
return "other";
}
export function diffMermaid(beforeSrc: string, currentSrc: string): MermaidDiffResult {
const type = detectDiagramType(currentSrc);
try {
if (type === "flowchart") return { kind: "augmented", source: diffFlowchart(beforeSrc, currentSrc) };
if (type === "sequence") return { kind: "augmented", source: diffSequence(beforeSrc, currentSrc) };
} catch {
return { kind: "fallback" };
}
return { kind: "fallback" };
}
Also create minimal stubs so the module imports resolve (real impl in Tasks 2–5):
// src/mermaidFlowchartDiff.ts — stub, replaced in Tasks 2-3
export function diffFlowchart(_beforeSrc: string, currentSrc: string): string {
return currentSrc;
}
// src/mermaidSequenceDiff.ts — stub, replaced in Tasks 4-5
export function diffSequence(_beforeSrc: string, currentSrc: string): string {
return currentSrc;
}
- Step 4: Run test to verify it passes
Run: npx vitest run test/mermaidDiff.test.ts
Expected: PASS (5 tests).
- Step 5: Commit
git add src/mermaidDiff.ts src/mermaidFlowchartDiff.ts src/mermaidSequenceDiff.ts test/mermaidDiff.test.ts
git commit -m "feat(f7.1): mermaid diff dispatcher + type detection (#22)"
Task 2: Flowchart parser — parseFlowchart
Files:
-
Modify:
src/mermaidFlowchartDiff.ts(replace the stub) -
Test:
test/mermaidFlowchartDiff.test.ts -
Step 1: Write the failing test
// test/mermaidFlowchartDiff.test.ts
import { describe, it, expect } from "vitest";
import { parseFlowchart } from "../src/mermaidFlowchartDiff";
describe("parseFlowchart", () => {
it("captures the header and node declarations with labels + shapes", () => {
const g = parseFlowchart("flowchart LR\n A[Start] --> B(Middle)\n B --> C{End}");
expect(g.header).toBe("flowchart LR");
expect(g.nodes.get("A")).toMatchObject({ id: "A", label: "Start", open: "[", close: "]" });
expect(g.nodes.get("B")).toMatchObject({ id: "B", label: "Middle", open: "(", close: ")" });
expect(g.nodes.get("C")).toMatchObject({ id: "C", label: "End", open: "{", close: "}" });
});
it("captures edges in declaration order with from/to and index", () => {
const g = parseFlowchart("graph TD\n A-->B\n B-->C");
expect(g.edges.map((e) => [e.from, e.to, e.index])).toEqual([
["A", "B", 0],
["B", "C", 1],
]);
});
it("records a node referenced only in an edge (no explicit declaration)", () => {
const g = parseFlowchart("flowchart LR\n A-->B");
expect(g.nodes.has("A")).toBe(true);
expect(g.nodes.has("B")).toBe(true);
expect(g.nodes.get("A")?.label).toBeUndefined();
});
it("handles a labelled edge `A -->|yes| B`", () => {
const g = parseFlowchart("flowchart LR\n A -->|yes| B");
expect(g.edges[0]).toMatchObject({ from: "A", to: "B", label: "yes" });
});
it("ignores comments and blank lines", () => {
const g = parseFlowchart("flowchart LR\n%% a comment\n\n A-->B");
expect(g.edges).toHaveLength(1);
});
});
- Step 2: Run test to verify it fails
Run: npx vitest run test/mermaidFlowchartDiff.test.ts
Expected: FAIL — parseFlowchart is not exported.
- Step 3: Write minimal implementation
// src/mermaidFlowchartDiff.ts
/**
* mermaidFlowchartDiff — F7.1 (#22). Pure flowchart parser + diff/emit. Parses a
* `graph`/`flowchart` source into nodes (by id) and edges (by declaration order),
* diffs baseline vs current, and re-emits the CURRENT source augmented with
* `classDef`/`class`/`linkStyle` directives coloring added/changed elements and
* ghosting removed ones (INV-29/31). Deterministic; no vscode, no DOM.
*/
import { CW_COLORS } from "./mermaidDiff";
export interface FlowNode {
id: string;
label?: string;
open?: string;
close?: string;
/** Verbatim declaration token, e.g. `A[Start]`, for ghost re-injection. */
decl?: string;
}
export interface FlowEdge {
from: string;
to: string;
label?: string;
index: number;
/** Verbatim edge statement for ghost re-injection. */
raw: string;
}
export interface FlowGraph {
header: string;
nodes: Map<string, FlowNode>;
edges: FlowEdge[];
}
const NODE_TOKEN = /^([A-Za-z0-9_]+)(\[\[[^\]]*\]\]|\[[^\]]*\]|\(\([^)]*\)\)|\([^)]*\)|\{[^}]*\}|>[^\]]*\])?/;
// link operators: -->, ---, -.->, -.-, ==>, ===, --x, --o, ==x, ==o
const LINK = /(-->|---|-\.->|-\.-|==>|===|--[xo]|==[xo])(\|([^|]*)\|)?/;
function shapeOf(bracket: string): { label: string; open: string; close: string } {
const open = bracket[0];
if (bracket.startsWith("[[")) return { label: bracket.slice(2, -2), open: "[[", close: "]]" };
if (bracket.startsWith("((")) return { label: bracket.slice(2, -2), open: "((", close: "))" };
const close = bracket[bracket.length - 1];
return { label: bracket.slice(1, -1), open, close };
}
function recordNode(nodes: Map<string, FlowNode>, token: string): string | null {
const m = token.match(NODE_TOKEN);
if (!m) return null;
const id = m[1];
const existing = nodes.get(id) ?? { id };
if (m[2]) {
const s = shapeOf(m[2]);
existing.label = s.label;
existing.open = s.open;
existing.close = s.close;
existing.decl = `${id}${m[2]}`;
}
nodes.set(id, existing);
return id;
}
export function parseFlowchart(source: string): FlowGraph {
const lines = source.split(/\r?\n/);
let header = "";
const nodes = new Map<string, FlowNode>();
const edges: FlowEdge[] = [];
for (const rawLine of lines) {
const line = rawLine.trim();
if (line === "" || line.startsWith("%%")) continue;
if (!header && /^(flowchart|graph)\b/.test(line)) {
header = line;
continue;
}
// Walk a (possibly chained) edge statement: A[..] --> B -->|x| C
let rest = line;
let prevId: string | null = null;
let consumedEdge = false;
while (rest.length > 0) {
const nodeM = rest.match(NODE_TOKEN);
if (!nodeM) break;
const id = recordNode(nodes, nodeM[0]);
rest = rest.slice(nodeM[0].length).trimStart();
const linkM = rest.match(LINK);
if (linkM && prevId === null) {
// current token is the LHS; fall through to read RHS
}
if (linkM) {
const fromId = id!;
rest = rest.slice(linkM[0].length).trimStart();
const rhsM = rest.match(NODE_TOKEN);
if (!rhsM) break;
const toId = recordNode(nodes, rhsM[0]);
edges.push({
from: fromId,
to: toId!,
label: linkM[3] || undefined,
index: edges.length,
raw: `${fromId} ${linkM[1]}${linkM[2] ? linkM[2] : ""} ${toId}`,
});
rest = rest.slice(rhsM[0].length).trimStart();
prevId = toId;
consumedEdge = true;
// chained: the RHS may be the LHS of another link
const more = rest.match(LINK);
if (more) rest = `${toId} ${rest}`;
continue;
}
break;
}
void consumedEdge;
}
return { header: header || "flowchart TD", nodes, edges };
}
Note: the chained-edge handling (
A --> B --> C) is intentionally simple. The unit tests in this task pin the contract for the single-edge and two-statement cases; if a chained test is added later, themore/re-prefix branch is the place to harden. Keep the parser total — never throw on odd input; return whatever was parsed.
- Step 4: Run test to verify it passes
Run: npx vitest run test/mermaidFlowchartDiff.test.ts
Expected: PASS (5 tests).
- Step 5: Commit
git add src/mermaidFlowchartDiff.ts test/mermaidFlowchartDiff.test.ts
git commit -m "feat(f7.1): flowchart parser (#22)"
Task 3: Flowchart diff + augmented emission — diffFlowchart
Files:
-
Modify:
src/mermaidFlowchartDiff.ts -
Test:
test/mermaidFlowchartDiff.test.ts -
Step 1: Write the failing test
// append to test/mermaidFlowchartDiff.test.ts
import { diffFlowchart } from "../src/mermaidFlowchartDiff";
describe("diffFlowchart", () => {
it("colors an added node with the cwAdded class", () => {
const before = "flowchart LR\n A-->B";
const current = "flowchart LR\n A-->B\n B-->C";
const out = diffFlowchart(before, current);
expect(out).toContain("classDef cwAdded");
expect(out).toMatch(/class\s+C\s+cwAdded/);
});
it("colors a changed node (same id, new label) with cwChanged", () => {
const before = "flowchart LR\n A[Old]-->B";
const current = "flowchart LR\n A[New]-->B";
const out = diffFlowchart(before, current);
expect(out).toMatch(/class\s+A\s+cwChanged/);
});
it("styles an added edge via linkStyle on its current index", () => {
const before = "flowchart LR\n A-->B";
const current = "flowchart LR\n A-->B\n A-->C";
const out = diffFlowchart(before, current);
// edge index 1 (A-->C) is the added one
expect(out).toMatch(/linkStyle\s+1\s+stroke:#2ea043/);
});
it("ghosts a removed node in place with cwRemoved (re-injected decl)", () => {
const before = "flowchart LR\n A-->B\n B-->C[Gone]";
const current = "flowchart LR\n A-->B";
const out = diffFlowchart(before, current);
expect(out).toContain("C[Gone]"); // re-injected
expect(out).toMatch(/class\s+C\s+cwRemoved/);
});
it("is deterministic (same inputs → identical output)", () => {
const before = "flowchart LR\n A-->B";
const current = "flowchart LR\n A-->B\n B-->C\n A-->D";
expect(diffFlowchart(before, current)).toBe(diffFlowchart(before, current));
});
it("preserves the original current source as the prefix", () => {
const current = "flowchart LR\n A-->B\n B-->C";
const out = diffFlowchart("flowchart LR\n A-->B", current);
expect(out.startsWith(current)).toBe(true);
});
});
- Step 2: Run test to verify it fails
Run: npx vitest run test/mermaidFlowchartDiff.test.ts
Expected: FAIL — diffFlowchart returns the current source unchanged (stub), so classDef/class assertions fail.
- Step 3: Write the implementation
// append to src/mermaidFlowchartDiff.ts
function nodeChanged(a: FlowNode, b: FlowNode): boolean {
return (a.label ?? "") !== (b.label ?? "") || (a.open ?? "") !== (b.open ?? "");
}
const edgeKey = (e: FlowEdge): string => `${e.from}${e.to}`;
export function diffFlowchart(beforeSrc: string, currentSrc: string): string {
const before = parseFlowchart(beforeSrc);
const current = parseFlowchart(currentSrc);
const addedNodes: string[] = [];
const changedNodes: string[] = [];
for (const [id, cur] of current.nodes) {
const prev = before.nodes.get(id);
if (!prev) addedNodes.push(id);
else if (nodeChanged(prev, cur)) changedNodes.push(id);
}
const removedNodes: FlowNode[] = [];
for (const [id, prev] of before.nodes) {
if (!current.nodes.has(id)) removedNodes.push(prev);
}
const beforeEdgeKeys = new Set(before.edges.map(edgeKey));
const currentEdgeKeys = new Set(current.edges.map(edgeKey));
const addedEdgeIdx = current.edges.filter((e) => !beforeEdgeKeys.has(edgeKey(e))).map((e) => e.index);
const removedEdges = before.edges.filter((e) => !currentEdgeKeys.has(edgeKey(e)));
// Ghost-edge indices follow the current edge count, in deterministic order.
let nextIdx = current.edges.length;
const ghostEdgeLines: string[] = [];
const ghostEdgeIdx: number[] = [];
for (const e of removedEdges) {
ghostEdgeLines.push(` ${e.from} -.-> ${e.to}`);
ghostEdgeIdx.push(nextIdx++);
}
const out: string[] = [currentSrc.replace(/\s+$/, "")];
// Ghost removed nodes: re-inject their declaration (or bare id) so they appear.
for (const n of removedNodes) out.push(` ${n.decl ?? n.id}`);
out.push(...ghostEdgeLines);
// classDefs (always emit the three; harmless if a class is unused).
out.push(
` classDef cwAdded fill:${CW_COLORS.added}22,stroke:${CW_COLORS.added},stroke-width:2px;`,
` classDef cwChanged fill:${CW_COLORS.changed}22,stroke:${CW_COLORS.changed},stroke-width:2px;`,
` classDef cwRemoved fill:${CW_COLORS.removed}11,stroke:${CW_COLORS.removed},stroke-width:1px,stroke-dasharray:5 3,color:${CW_COLORS.removed};`,
);
if (addedNodes.length) out.push(` class ${addedNodes.join(",")} cwAdded;`);
if (changedNodes.length) out.push(` class ${changedNodes.join(",")} cwChanged;`);
if (removedNodes.length) out.push(` class ${removedNodes.map((n) => n.id).join(",")} cwRemoved;`);
for (const i of addedEdgeIdx) out.push(` linkStyle ${i} stroke:${CW_COLORS.added},stroke-width:2px;`);
for (const i of ghostEdgeIdx)
out.push(` linkStyle ${i} stroke:${CW_COLORS.removed},stroke-width:1px,stroke-dasharray:5 3;`);
return out.join("\n");
}
- Step 4: Run test to verify it passes
Run: npx vitest run test/mermaidFlowchartDiff.test.ts
Expected: PASS (all flowchart tests).
- Step 5: Commit
git add src/mermaidFlowchartDiff.ts test/mermaidFlowchartDiff.test.ts
git commit -m "feat(f7.1): flowchart node/edge diff + styling emission (#22)"
Task 4: Sequence parser — parseSequence
Files:
-
Modify:
src/mermaidSequenceDiff.ts(replace the stub) -
Test:
test/mermaidSequenceDiff.test.ts -
Step 1: Write the failing test
// test/mermaidSequenceDiff.test.ts
import { describe, it, expect } from "vitest";
import { parseSequence } from "../src/mermaidSequenceDiff";
describe("parseSequence", () => {
it("captures explicit participants in order", () => {
const d = parseSequence("sequenceDiagram\n participant A\n participant B\n A->>B: hi");
expect(d.participants).toEqual(["A", "B"]);
});
it("captures message statements verbatim (trimmed)", () => {
const d = parseSequence("sequenceDiagram\n A->>B: hi\n B-->>A: bye");
expect(d.statements).toEqual(["A->>B: hi", "B-->>A: bye"]);
});
it("infers participants from messages when not declared", () => {
const d = parseSequence("sequenceDiagram\n A->>B: hi\n B->>C: on");
expect(d.participants).toEqual(["A", "B", "C"]);
});
it("ignores comments and blank lines", () => {
const d = parseSequence("sequenceDiagram\n%% note\n\n A->>B: hi");
expect(d.statements).toEqual(["A->>B: hi"]);
});
});
- Step 2: Run test to verify it fails
Run: npx vitest run test/mermaidSequenceDiff.test.ts
Expected: FAIL — parseSequence is not exported.
- Step 3: Write the implementation
// src/mermaidSequenceDiff.ts
/**
* mermaidSequenceDiff — F7.1 (#22). Pure sequence-diagram parser + diff/emit.
* Mermaid sequence diagrams have NO per-message color directive; the only
* per-message hook is the `rect rgb(...) … end` background block. So the emitter
* rebuilds the message stream (ghosted-removed messages re-inserted at their
* baseline position) wrapping each added/changed/removed message in a one-message
* tinted rect, and re-declares removed participants (INV-29/31). Deterministic;
* no vscode, no DOM.
*/
import { diffArrays } from "diff";
import { CW_COLORS } from "./mermaidDiff";
export interface SeqDiagram {
header: string;
participants: string[];
/** Message/other statement lines, verbatim & trimmed, in order. */
statements: string[];
}
// `A->>B: text`, plus -->>, ->, -->, -x, --x, etc. Capture from/to.
const MSG = /^([A-Za-z0-9_]+)\s*(-?-(?:>>|>|x|\))|--?(?:>>|>|x|\)))\s*([A-Za-z0-9_]+)\s*:/;
const PARTICIPANT = /^(?:participant|actor)\s+([A-Za-z0-9_]+)/;
export function parseSequence(source: string): SeqDiagram {
const lines = source.split(/\r?\n/);
let header = "";
const declared: string[] = [];
const seen = new Set<string>();
const statements: string[] = [];
const addP = (p: string) => {
if (!seen.has(p)) {
seen.add(p);
declared.push(p);
}
};
for (const rawLine of lines) {
const line = rawLine.trim();
if (line === "" || line.startsWith("%%")) continue;
if (!header && /^sequenceDiagram\b/.test(line)) {
header = line;
continue;
}
const pm = line.match(PARTICIPANT);
if (pm) {
addP(pm[1]);
continue;
}
const mm = line.match(MSG);
if (mm) {
addP(mm[1]);
addP(mm[3]);
}
statements.push(line);
}
return { header: header || "sequenceDiagram", participants: declared, statements };
}
- Step 4: Run test to verify it passes
Run: npx vitest run test/mermaidSequenceDiff.test.ts
Expected: PASS (4 tests).
- Step 5: Commit
git add src/mermaidSequenceDiff.ts test/mermaidSequenceDiff.test.ts
git commit -m "feat(f7.1): sequence-diagram parser (#22)"
Task 5: Sequence diff + augmented emission — diffSequence
Files:
-
Modify:
src/mermaidSequenceDiff.ts -
Test:
test/mermaidSequenceDiff.test.ts -
Step 1: Write the failing test
// append to test/mermaidSequenceDiff.test.ts
import { diffSequence } from "../src/mermaidSequenceDiff";
const rectCount = (s: string, rgb: string) => s.split(`rect rgb(${rgb})`).length - 1;
describe("diffSequence", () => {
it("wraps an added message in a green rect", () => {
const before = "sequenceDiagram\n A->>B: hi";
const current = "sequenceDiagram\n A->>B: hi\n B->>C: on";
const out = diffSequence(before, current);
expect(rectCount(out, "46, 160, 67")).toBe(1); // CW_COLORS.added as rgb
expect(out).toContain("B->>C: on");
});
it("ghosts a removed message back in a grey rect", () => {
const before = "sequenceDiagram\n A->>B: hi\n B->>C: gone";
const current = "sequenceDiagram\n A->>B: hi";
const out = diffSequence(before, current);
expect(out).toContain("B->>C: gone");
expect(rectCount(out, "128, 128, 128")).toBe(1);
});
it("re-declares a removed participant so it still appears", () => {
const before = "sequenceDiagram\n participant A\n participant B\n participant C\n A->>C: x";
const current = "sequenceDiagram\n participant A\n participant B\n A->>B: y";
const out = diffSequence(before, current);
expect(out).toMatch(/participant C/);
});
it("keeps an unchanged message outside any rect", () => {
const doc = "sequenceDiagram\n A->>B: hi";
const out = diffSequence(doc, doc);
expect(out).not.toContain("rect rgb");
});
it("is deterministic", () => {
const before = "sequenceDiagram\n A->>B: hi";
const current = "sequenceDiagram\n A->>B: hi\n B->>A: bye";
expect(diffSequence(before, current)).toBe(diffSequence(before, current));
});
});
Note:
CW_COLORS.addedis#2ea043→ rgb46, 160, 67;changed#d29922→210, 153, 34;removed#808080→128, 128, 128.diffSequencemust emitrect rgb(r, g, b)from the hex. Add ahexToRgbhelper.
- Step 2: Run test to verify it fails
Run: npx vitest run test/mermaidSequenceDiff.test.ts
Expected: FAIL — diffSequence (stub) returns current unchanged; no rect emitted.
- Step 3: Write the implementation
// append to src/mermaidSequenceDiff.ts
function hexToRgb(hex: string): string {
const h = hex.replace("#", "");
const n = parseInt(h, 16);
return `${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}`;
}
function rectWrap(stmt: string, hex: string): string[] {
return [` rect rgb(${hexToRgb(hex)})`, ` ${stmt}`, ` end`];
}
export function diffSequence(beforeSrc: string, currentSrc: string): string {
const before = parseSequence(beforeSrc);
const current = parseSequence(currentSrc);
const out: string[] = [current.header];
// Participants: keep current declarations, then re-declare removed ones (ghosts).
for (const p of current.participants) out.push(` participant ${p}`);
const curSet = new Set(current.participants);
for (const p of before.participants) {
if (!curSet.has(p)) out.push(` participant ${p}`);
}
// Message stream diff via LCS over statements; pair adjacent removed+added as changed.
const parts = diffArrays(before.statements, current.statements);
for (let n = 0; n < parts.length; n++) {
const ch = parts[n];
if (!ch.added && !ch.removed) {
for (const s of ch.value) out.push(` ${s}`);
continue;
}
if (ch.removed) {
const next = parts[n + 1];
const addVals = next?.added ? next.value : [];
const paired = Math.min(ch.value.length, addVals.length);
for (let k = 0; k < paired; k++) out.push(...rectWrap(addVals[k], CW_COLORS.changed));
for (let k = paired; k < ch.value.length; k++) out.push(...rectWrap(ch.value[k], CW_COLORS.removed));
for (let k = paired; k < addVals.length; k++) out.push(...rectWrap(addVals[k], CW_COLORS.added));
if (next?.added) n++;
continue;
}
// lone added run
for (const s of ch.value) out.push(...rectWrap(s, CW_COLORS.added));
}
return out.join("\n");
}
- Step 4: Run test to verify it passes
Run: npx vitest run test/mermaidSequenceDiff.test.ts
Expected: PASS (all sequence tests).
- Step 5: Commit
git add src/mermaidSequenceDiff.ts test/mermaidSequenceDiff.test.ts
git commit -m "feat(f7.1): sequence participant/message diff + rect emission (#22)"
Task 6: Wire diffMermaid into renderOp + legend
Files:
-
Modify:
src/trackChangesModel.ts(thechangedcase inrenderOp, ~line 256-264; add amermaidFenceBodyhelper near the other helpers) -
Test:
test/trackChangesModel.test.ts -
Step 1: Write the failing test
// append to test/trackChangesModel.test.ts
import { renderTrackChanges as rtc2 } from "../src/trackChangesModel";
describe("renderTrackChanges — intra-diagram mermaid (#22)", () => {
it("augments a changed flowchart with styling directives (INV-29)", () => {
const base = "```mermaid\nflowchart LR\n A-->B\n```\n";
const cur = "```mermaid\nflowchart LR\n A-->B\n B-->C\n```\n";
const html = rtc2(base, cur);
expect(html).toContain("classDef cwAdded");
expect(html).toMatch(/class\s+C\s+cwAdded/);
expect(html).toContain('class="cw-mermaid-legend"');
// no single whole-block "changed" badge when we augmented
expect(html).not.toContain('<span class="cw-badge">changed</span>');
});
it("augments a changed sequence diagram", () => {
const base = "```mermaid\nsequenceDiagram\n A->>B: hi\n```\n";
const cur = "```mermaid\nsequenceDiagram\n A->>B: hi\n B->>C: on\n```\n";
const html = rtc2(base, cur);
expect(html).toContain("rect rgb(46, 160, 67)");
});
it("falls back to the v1 badge for an unsupported diagram type (INV-30)", () => {
const base = "```mermaid\nclassDiagram\n class A\n```\n";
const cur = "```mermaid\nclassDiagram\n class B\n```\n";
const html = rtc2(base, cur);
expect(html).toContain('<span class="cw-badge">changed</span>');
expect(html).not.toContain("classDef cwAdded");
});
});
- Step 2: Run test to verify it fails
Run: npx vitest run test/trackChangesModel.test.ts
Expected: FAIL — current changed-mermaid branch always emits the badge, never the directives.
- Step 3: Write the implementation
Add the import at the top of src/trackChangesModel.ts (with the other imports):
import { diffMermaid } from "./mermaidDiff";
Add a helper near wordMergedMarkdown (returns the inner mermaid source of a fence, or null if not a single mermaid fence):
/** Inner source of a ```mermaid fence (drops the fence lines), or null. */
function mermaidFenceBody(raw: string): string | null {
const lines = raw.split(/\r?\n/);
const open = lines[0]?.match(/^(\s*)(`{3,}|~{3,})\s*(\w+)?/);
if (!open || open[3]?.toLowerCase() !== "mermaid") return null;
const body = lines.slice(1);
if (body.length && /^(\s*)(`{3,}|~{3,})/.test(body[body.length - 1])) body.pop();
return body.join("\n");
}
Replace the changed case's atomic branch (currently):
if (op.atomic) {
inner = safe(op.block.raw); // the NEW block, whole (INV-23)
badge = '<span class="cw-badge">changed</span>';
} else {
with:
if (op.atomic) {
const curBody = op.block.type === "mermaid" ? mermaidFenceBody(op.block.raw) : null;
const beforeBody = op.before.type === "mermaid" ? mermaidFenceBody(op.before.raw) : null;
const md = curBody !== null && beforeBody !== null ? diffMermaid(beforeBody, curBody) : { kind: "fallback" as const };
if (md.kind === "augmented") {
// Re-render the augmented diagram as a mermaid fence; add a legend, drop the badge.
inner = safe("```mermaid\n" + md.source + "\n```") + MERMAID_LEGEND;
} else {
inner = safe(op.block.raw); // the NEW block, whole (INV-23 / INV-30 fallback)
badge = '<span class="cw-badge">changed</span>';
}
} else {
Add the legend constant near the top-level helpers:
const MERMAID_LEGEND =
'<div class="cw-mermaid-legend">' +
'<span class="cw-leg cw-leg-add">added</span>' +
'<span class="cw-leg cw-leg-chg">changed</span>' +
'<span class="cw-leg cw-leg-rem">removed</span>' +
"</div>";
- Step 4: Run test to verify it passes
Run: npx vitest run test/trackChangesModel.test.ts
Expected: PASS (existing + 3 new). Then run the whole unit suite: npm test — all green (no regressions to the existing a mermaid-fence change → an ATOMIC changed op test, which asserts the op, not the HTML).
- Step 5: Commit
git add src/trackChangesModel.ts test/trackChangesModel.test.ts
git commit -m "feat(f7.1): render augmented mermaid diff in changed-block branch (#22)"
Task 7: Legend CSS
Files:
-
Modify:
media/preview.css -
Step 1: Add the legend styles (append to
media/preview.css)
/* 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; }
- Step 2: Build to confirm esbuild emits the CSS
Run: npm run build
Expected: build succeeds; out/media/preview.css exists and contains cw-mermaid-legend.
Run: grep -c cw-mermaid-legend out/media/preview.css
Expected: 1 or more.
- Step 3: Commit
git add media/preview.css
git commit -m "feat(f7.1): mermaid diff legend styles (#22)"
Task 8: Host E2E — changed flowchart augments the emitted HTML
Files:
-
Modify:
test/e2e/suite/trackChangesPreview.test.ts -
Check fixture:
test/e2e/fixtures/workspace/docs/preview.md(read it first; the test edits a buffer in-place — follow the existing pattern in this file for opening the doc, showing the preview, capturing a baseline, applying aWorkspaceEdit, and readinggetLastModel). -
Step 1: Read the existing E2E test + fixture to match the exact harness
Run: sed -n '1,60p' test/e2e/suite/trackChangesPreview.test.ts
Note how api, docUri(), baseline capture (cowriting.pinDiffBaseline / proposal accept) and applyEdit are used; reuse them verbatim.
- Step 2: Write the failing test (add inside the existing
suite/describe)
test("a changed flowchart augments the emitted mermaid source (#22, INV-29)", async () => {
// Open a markdown doc containing a flowchart, show preview, pin baseline.
const doc = await openPreviewDoc("```mermaid\nflowchart LR\n A-->B\n```\n");
await vscode.commands.executeCommand("cowriting.showTrackChangesPreview");
await vscode.commands.executeCommand("cowriting.pinDiffBaseline");
// Edit: add a node C.
const edit = new vscode.WorkspaceEdit();
edit.insert(doc.uri, new vscode.Position(2, 7), "\n B-->C");
assert.ok(await vscode.workspace.applyEdit(edit), "edit applied");
await waitForRefresh();
const model = api.trackChangesPreviewController.getLastModel(doc.uri.toString()) ?? [];
const mermaidOp = model.find((o) => o.kind === "changed" && o.block.type === "mermaid");
assert.ok(mermaidOp, "the flowchart block is a changed op");
const html = api.trackChangesPreviewController.renderHtmlFor(doc.uri.toString());
assert.match(html, /classDef cwAdded/, "augmented with cwAdded classDef");
assert.match(html, /class\s+C\s+cwAdded/, "node C colored added");
});
Note:
openPreviewDoc,waitForRefresh, andrenderHtmlFormay not exist yet. Use whatever the file already provides (it has a doc-open helper and usesgetLastModel). If a direct HTML accessor is missing, add a tiny test-seam methodrenderHtmlFor(uriString): stringtoTrackChangesPreviewControllerthat returnsrenderTrackChanges(baselineText, current)for the doc (mirrorgetLastModel), and export it in the §6.4 test-seam block. If the harness can only assert the model (not the HTML), assert on the model instead and move the HTML assertion to the unit test in Task 6 (already covered there) — do not block E2E on webview internals.
- Step 3: Run E2E to verify it fails
Run: npm run test:e2e
Expected: FAIL on the new assertion (or on the missing seam — add it per the note).
- Step 4: Implement the seam if needed, then re-run
If you added renderHtmlFor, implement it in src/trackChangesPreview.ts test-seam section:
/** F7.1 test seam: the track-changes HTML the panel would post for a doc. */
renderHtmlFor(uriString: string): string {
const doc = vscode.workspace.textDocuments.find((d) => d.uri.toString() === uriString);
if (!doc) return "";
const current = doc.getText();
const baseline = this.diffView.getBaseline(uriString);
return renderTrackChanges(baseline?.text ?? current, current);
}
Run: npm run test:e2e
Expected: PASS (existing + new).
- Step 5: Commit
git add test/e2e/suite/trackChangesPreview.test.ts src/trackChangesPreview.ts
git commit -m "test(f7.1): host E2E — changed flowchart augments emitted source (#22)"
Task 9: Manual-smoke doc + full verification + PR
Files:
-
Create:
docs/MANUAL-SMOKE-F7.1.md -
Step 1: Write the manual-smoke doc (mirror the style of
docs/MANUAL-SMOKE-F7.md)
# Manual smoke — F7.1 intra-diagram mermaid diffing (#22)
Webview SVG rendering isn't covered by automated E2E (sealed sandbox, §6.8), so
verify the rendered colors by hand once.
1. Open a markdown doc with a flowchart:
```mermaid
flowchart LR
A[Start] --> B[Parse]
B --> C[Emit]
- Run Cowriting: Show Track-Changes Preview (
ctrl+alt+r). Pin the baseline (Cowriting: Pin Diff Baseline). - Edit the diagram: change
B[Parse]→B[Parse+Lint], addB --> D[Validate], delete theB --> Cedge and theCnode. - Expect in the preview:
Boutlined amber (changed),Dgreen (added),Cshown as a faded dashed ghost (removed), and a legendadded · changed · removedunder the diagram. - Repeat with a
sequenceDiagram: add a message (green rect), remove one (grey ghost rect), confirm a removed participant still appears. - Change a diagram to a
classDiagramand confirm it still shows the v1 whole-block "changed" badge (graceful fallback, INV-30).
- [ ] **Step 2: Full verification (run all gates)**
```bash
npm run typecheck && npm test && npm run test:e2e
Expected: typecheck clean; full vitest suite green (≈164+ tests); E2E green (≈38 tests). Record the counts.
- Step 3: Commit the smoke doc
git add docs/MANUAL-SMOKE-F7.1.md
git commit -m "docs(f7.1): manual webview-render smoke (#22)"
- Step 4: Push the branch and open the PR
git push -u origin <branch>
Open a PR titled F7.1: intra-diagram mermaid diffing (#22) whose body cites
spec §11, lists INV-29..31, the new modules, the unit/E2E counts, and links
Fixes #22. (The session uses SSH remotes; PR via Gitea per wgl-gitea-admin /
the session-finalize flow.)
Self-Review
Spec coverage (§11):
- §11.1 supported types (flowchart, sequence) + total fallback → Tasks 1, 6 (dispatch + fallback), 3, 5.
- §11.2 parsed-graph diff + ghost-in-place → Tasks 2-5.
- §11.3 styling asymmetry (flowchart classDef/linkStyle vs sequence rect) → Tasks 3, 5.
- §11.4 module tree + renderOp seam + webview-unchanged → Tasks 1, 6 (+ legend Task 7).
- §11.5 INV-29 (augment), INV-30 (fallback total), INV-31 (ghost completeness) → asserted in Tasks 3 (ghost), 5 (ghost), 6 (augment + fallback).
- §11.6 unit + host E2E → Tasks 2-6 (unit), Task 8 (E2E).
- §11.7 out-of-scope types not implemented → confirmed (only flowchart + sequence).
Placeholder scan: No TBD/TODO; every code step shows complete code; the one explicitly-flexible spot (E2E HTML seam, Task 8) gives a concrete fallback path.
Type consistency: MermaidDiffResult (augmented|fallback) used identically in diffMermaid (Task 1) and renderOp (Task 6). FlowGraph/FlowNode/FlowEdge defined once (Task 2) and consumed in Task 3. SeqDiagram defined Task 4, consumed Task 5. CW_COLORS defined once (Task 1), imported by both differs and (as hex) the legend CSS uses the same literals. diffFlowchart/diffSequence/parseFlowchart/parseSequence/detectDiagramType/diffMermaid names consistent across tasks.