Browse Source

fix(merman): support undirected edges and place multiline state labels (#41171)

Kit Langton 3 days ago
parent
commit
2cf65c025a

+ 5 - 3
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")
   }

+ 36 - 0
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<br/>mounted at /workspace)]
+        R[our row: id, provider]
+    end
+    subgraph ephemeral [Ephemeral — dies freely]
+        S1[Sandbox #1] -. mounts .-> V
+        S2[Sandbox #2<br/>Tuesday] -. mounts same .-> V
+        X[apt-get installs,<br/>~/.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<br/>same test cases pin every driver] -.verifies.-> LS

+ 20 - 4
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

+ 1 - 0
packages/merman/src/flowchart/types.ts

@@ -15,6 +15,7 @@ export interface FlowchartEdge {
   to: string
   label: string
   style?: FlowchartEdgeStyle
+  arrowhead?: false
   orderOnly?: boolean
 }
 

+ 42 - 0
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<br/>(WE must call it on idle)
+    Dormant --> Running: wake from snapshot image<br/>(apt installs restored!)
+    Running --> Lost: 💥 sandbox dies BEFORE hook fires<br/>(crash, our bug, race)
+    Lost --> Running: wake from LAST snapshot<br/>⚠ 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

+ 91 - 2
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<string, BoxBounds>,
+): 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<string, BoxBounds>,
   feedbackLaneY: number,
   feedbackTopY?: number,
 ): StateTransitionRenderPlan[] {
-  return createStateTransitionRoutePlans(diagram, bounds, feedbackLaneY, feedbackTopY).map(
-    createStateTransitionRenderPlan,
+  return placeStateTransitionLabels(
+    createStateTransitionRoutePlans(diagram, bounds, feedbackLaneY, feedbackTopY).map(createStateTransitionRenderPlan),
+    diagram,
+    bounds,
   )
 }
 

+ 2 - 2
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", () => {

+ 1 - 0
packages/tui/test/fixture/tui-client.ts

@@ -138,6 +138,7 @@ export function createFetch(override?: FetchHandler, events?: ReturnType<typeof
     if (url.pathname === "/provider") return json({ all: [], default: {}, connected: [] })
     if (url.pathname === "/session") return json([])
     if (url.pathname === "/vcs") return json({ branch: "main" })
+    if (url.pathname === "/api/experimental/migration/v1") return json({ status: "completed" })
     throw new Error(`unexpected request: ${url.pathname}`)
   }
   fetch.preconnect = () => {}