diff --git a/packages/merman/src/flowchart/drawing.ts b/packages/merman/src/flowchart/drawing.ts index 9e6964e8920..51d7de3a8f2 100644 --- a/packages/merman/src/flowchart/drawing.ts +++ b/packages/merman/src/flowchart/drawing.ts @@ -163,9 +163,11 @@ function drawRoutedEdge(grid: FlowchartGrid, route: FlowchartEdgeRoute): void { cornerStyle: "rounded", lineStyle: edge.style === "thick" ? "heavy" : "single", }) - const end = points[points.length - 1]! - const arrowFrom = points[points.length - 2]! - grid.setCell(end.x, end.y, diagramArrowHeadBetween(arrowFrom, end), style) + if (edge.arrowhead !== false) { + const end = points[points.length - 1]! + const arrowFrom = points[points.length - 2]! + grid.setCell(end.x, end.y, diagramArrowHeadBetween(arrowFrom, end), style) + } if (edge.label) { drawEdgeLabel(grid, route, "label") } diff --git a/packages/merman/src/flowchart/flowchart.test.ts b/packages/merman/src/flowchart/flowchart.test.ts index 591d987db88..cd85dc8a960 100644 --- a/packages/merman/src/flowchart/flowchart.test.ts +++ b/packages/merman/src/flowchart/flowchart.test.ts @@ -424,6 +424,42 @@ flowchart TD ]) }) + test("parses chained undirected solid edges", () => { + const diagram = parseMermaidFlowchartDiagram(`flowchart LR + A --- B --- C`) + + expect(diagram.edges).toEqual([ + { from: "A", to: "B", label: "", arrowhead: false }, + { from: "B", to: "C", label: "", arrowhead: false }, + ]) + }) + + test("renders the volume persistence diagram with an undirected solid edge", () => { + const content = `flowchart LR + subgraph durable [Durable — survives everything] + V[(Volume ws-wor_abc
mounted at /workspace)] + R[our row: id, provider] + end + subgraph ephemeral [Ephemeral — dies freely] + S1[Sandbox #1] -. mounts .-> V + S2[Sandbox #2
Tuesday] -. mounts same .-> V + X[apt-get installs,
~/.cache, /tmp] + end + S1 --- X + style X stroke-dasharray: 5 5` + const diagram = parseMermaidFlowchartDiagram(content) + const layout = layoutParsedFlowchartDiagram(diagram, { compact: true }) + const grid = drawParsedFlowchartDiagramGrid(diagram, { compact: true }) + const output = renderFlowchartDiagram(content, { compact: true }) + const route = layout.routes.find((route) => route.edge.from === "S1" && route.edge.to === "X")! + const end = route.points.at(-1)! + + expect(diagram.edges.at(-1)).toEqual({ from: "S1", to: "X", label: "", arrowhead: false }) + expect(route.points.length).toBeGreaterThan(1) + expect(grid.getCell(end.x, end.y)?.char).not.toMatch(/[▶▼◀▲]/) + expectDiagram(output).toContainInOrder("Sandbox #1", "apt-get installs,", "~/.cache, /tmp") + }) + test("parses and renders inline dashed edge labels", () => { const content = `flowchart TD CS[conformance suite
same test cases pin every driver] -.verifies.-> LS diff --git a/packages/merman/src/flowchart/parser.ts b/packages/merman/src/flowchart/parser.ts index 80472dd443f..dc34d965d9e 100644 --- a/packages/merman/src/flowchart/parser.ts +++ b/packages/merman/src/flowchart/parser.ts @@ -29,7 +29,7 @@ const BOX_NODE_RE = new RegExp(`^(${ID_RE})\\[(.+)\\]$`) const ID_ONLY_RE = new RegExp(`^${ID_RE}$`) const EXPLICIT_NODE_SHAPE_RE = new RegExp(`^${ID_RE}(?:\\[|\\(|\\{)`) const EDGE_OPERATOR_RE = - /(-\.(?!->)(.+?)\.->)|(--|==|-\.)\s+(.+?)\s+(-->|==>|\.->|-\.->)|(-->|==>|-\.->|~~~)\s*(?:\|([^|]*)\|\s*)?/g + /(-\.(?!->)(.+?)\.->)|(--|==|-\.)\s+(.+?)\s+(-->|==>|\.->|-\.->)|(-->|==>|-\.->|---|~~~)\s*(?:\|([^|]*)\|\s*)?/g function normalizeDirection(value?: string): FlowchartDirection { const upper = value?.toUpperCase() @@ -116,8 +116,16 @@ function edgeStyleFromArrow(...arrows: string[]): FlowchartEdgeStyle | undefined return undefined } -function createEdge(from: string, to: string, label: string, style: FlowchartEdgeStyle | undefined): FlowchartEdge { - return style ? { from, to, label, style } : { from, to, label } +function createEdge( + from: string, + to: string, + label: string, + style: FlowchartEdgeStyle | undefined, + arrowhead: boolean, +): FlowchartEdge { + const edge: FlowchartEdge = style ? { from, to, label, style } : { from, to, label } + if (!arrowhead) edge.arrowhead = false + return edge } interface ParsedEdgeOperator { @@ -125,6 +133,7 @@ interface ParsedEdgeOperator { end: number label: string style: FlowchartEdgeStyle | undefined + arrowhead: boolean orderOnly: boolean } @@ -138,6 +147,7 @@ function parseEdgeOperators(line: string): ParsedEdgeOperator[] { end: match.index + match[0].length, label: (match[2] ?? match[4] ?? match[7] ?? "").trim(), style: edgeStyleFromArrow(startArrow, endArrow), + arrowhead: endArrow !== "---", orderOnly: endArrow === "~~~", } }) @@ -223,7 +233,13 @@ export function parseMermaidFlowchartDiagram(content: string): FlowchartDiagram } for (let index = 0; index < edgeOperators.length; index++) { const operator = edgeOperators[index]! - const edge = createEdge(chainNodeIds[index]!, chainNodeIds[index + 1]!, operator.label, operator.style) + const edge = createEdge( + chainNodeIds[index]!, + chainNodeIds[index + 1]!, + operator.label, + operator.style, + operator.arrowhead, + ) edges.push(operator.orderOnly ? { ...edge, orderOnly: true } : edge) } continue diff --git a/packages/merman/src/flowchart/types.ts b/packages/merman/src/flowchart/types.ts index b93c3165921..ea6ef1b74be 100644 --- a/packages/merman/src/flowchart/types.ts +++ b/packages/merman/src/flowchart/types.ts @@ -15,6 +15,7 @@ export interface FlowchartEdge { to: string label: string style?: FlowchartEdgeStyle + arrowhead?: false orderOnly?: boolean } diff --git a/packages/merman/src/state/diagram.test.ts b/packages/merman/src/state/diagram.test.ts index 5d040f6f982..c89fef02a45 100644 --- a/packages/merman/src/state/diagram.test.ts +++ b/packages/merman/src/state/diagram.test.ts @@ -162,6 +162,48 @@ stateDiagram-v2 expect(output).toContain("second") }) + test("keeps reciprocal multiline transition labels clear of routes", () => { + const output = renderStateDiagram(`stateDiagram-v2 + [*] --> Running: create from base image + Running --> Dormant: 📸 suspend hook fires
(WE must call it on idle) + Dormant --> Running: wake from snapshot image
(apt installs restored!) + Running --> Lost: 💥 sandbox dies BEFORE hook fires
(crash, our bug, race) + Lost --> Running: wake from LAST snapshot
⚠ files since then GONE`) + const labelLines = [ + "create from base image", + "📸 suspend hook fires", + "(WE must call it on idle)", + "wake from snapshot image", + "(apt installs restored!)", + "💥 sandbox dies BEFORE hook fires", + "(crash, our bug, race)", + "wake from LAST snapshot", + "⚠ files since then GONE", + ] + + for (const line of labelLines) expect(output.split(line)).toHaveLength(2) + expect(output).toMatchInlineSnapshot(` + " + create from base image ╭─────────╮ + ●───────────────────────▶│ Running │ + ╰──┬──────╯ 💥 sandbox dies BEFORE hook fires + ▲ │ ▲ (crash, our bug, race) + ╭────────┼─╰───┼───────╮ + ▼ ╭────┼─────╯ ▼ + ╭──────┴──╮ │ ╭──────╮ + │ Dormant │ │ │ Lost │ + ╰─────────╯ │ ╰───┬──╯ + │ │ + 📸 suspend hook fires │ │ + (WE must call it on idle)│ │ + ╰───────────────╯ + wake from snapshot image + (apt installs restored!) + wake from LAST snapshot + ⚠ files since then GONE" + `) + }) + test("renders a vertical state diagram", () => { const output = renderStateDiagram(` stateDiagram-v2 diff --git a/packages/merman/src/state/routing.ts b/packages/merman/src/state/routing.ts index 6788c56c736..2db7787c9f0 100644 --- a/packages/merman/src/state/routing.ts +++ b/packages/merman/src/state/routing.ts @@ -590,14 +590,103 @@ function createStateTransitionRenderPlan(route: StateTransitionRoutePlan): State return builder } +interface StateTransitionLabelRect { + left: number + top: number + width: number + height: number +} + +function labelRect(label: StateTransitionRenderLabel, width: number): StateTransitionLabelRect { + return { left: label.x, top: label.y, width, height: label.lines.length } +} + +function rectsOverlap(left: StateTransitionLabelRect, right: StateTransitionLabelRect): boolean { + return ( + left.left < right.left + right.width && + left.left + left.width > right.left && + left.top < right.top + right.height && + left.top + left.height > right.top + ) +} + +function placeStateTransitionLabels( + plans: readonly StateTransitionRenderPlan[], + diagram: StateVisibleDiagram, + bounds: ReadonlyMap, +): StateTransitionRenderPlan[] { + const routeCells = new Set(plans.flatMap((plan) => plan.cells.map((cell) => `${cell.x}:${cell.y}`))) + const placedLabels: StateTransitionLabelRect[] = [] + const stateRects = diagram.states.flatMap((state) => { + const bound = bounds.get(state.id) + return bound && !isHiddenCompositeMarker(state) + ? [{ left: bound.left, top: bound.top, width: bound.width, height: bound.height }] + : [] + }) + + return plans.map((plan) => { + if (!plan.label) return plan + const width = Math.max(...plan.label.lines.map(diagramTextWidth)) + if (plan.label.lines.length === 1) { + placedLabels.push(labelRect(plan.label, width)) + return plan + } + const statePadding = 1 + const isClear = (x: number, y: number): boolean => { + if (x < 0 || y < 0) return false + const rect = labelRect({ ...plan.label!, x, y }, width) + if ( + stateRects.some((state) => + rectsOverlap(rect, { + left: state.left - statePadding, + top: state.top - statePadding, + width: state.width + statePadding * 2, + height: state.height + statePadding * 2, + }), + ) + ) + return false + if (placedLabels.some((label) => rectsOverlap(rect, label))) return false + for (let row = rect.top; row < rect.top + rect.height; row++) { + for (let column = rect.left; column < rect.left + rect.width; column++) { + if (routeCells.has(`${column}:${row}`)) return false + } + } + return true + } + + let x = plan.label.x + let y = plan.label.y + if (!isClear(x, y)) { + search: for (let distance = 1; distance < 500; distance++) { + for (let dx = -distance; dx <= distance; dx++) { + const dy = distance - Math.abs(dx) + for (const candidateY of dy === 0 ? [y] : [y - dy, y + dy]) { + const candidateX = x + dx + if (!isClear(candidateX, candidateY)) continue + x = candidateX + y = candidateY + break search + } + } + } + } + + placedLabels.push(labelRect({ ...plan.label, x, y }, width)) + return { ...plan, label: { ...plan.label, x, y } } + }) +} + export function createStateTransitionRenderPlans( diagram: StateVisibleDiagram, bounds: ReadonlyMap, feedbackLaneY: number, feedbackTopY?: number, ): StateTransitionRenderPlan[] { - return createStateTransitionRoutePlans(diagram, bounds, feedbackLaneY, feedbackTopY).map( - createStateTransitionRenderPlan, + return placeStateTransitionLabels( + createStateTransitionRoutePlans(diagram, bounds, feedbackLaneY, feedbackTopY).map(createStateTransitionRenderPlan), + diagram, + bounds, ) } diff --git a/packages/merman/src/test/diagnostics.test.ts b/packages/merman/src/test/diagnostics.test.ts index 0142fd81208..4ce08404a75 100644 --- a/packages/merman/src/test/diagnostics.test.ts +++ b/packages/merman/src/test/diagnostics.test.ts @@ -22,8 +22,8 @@ describe("parser diagnostics", () => { expect(() => parseMermaidFlowchartDiagram(`flowchart LR A[Start] --> B[Done] - A --- B`), - ).toThrow('Unsupported syntax in flowchart diagram at line 3: "A --- B"') + A --o B`), + ).toThrow('Unsupported syntax in flowchart diagram at line 3: "A --o B"') }) test("exposes structured syntax errors through top-level rendering", () => { diff --git a/packages/tui/test/fixture/tui-client.ts b/packages/tui/test/fixture/tui-client.ts index 4c09d17c7bb..726a458b2ec 100644 --- a/packages/tui/test/fixture/tui-client.ts +++ b/packages/tui/test/fixture/tui-client.ts @@ -138,6 +138,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType {}