import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useParams } from "react-router"; import type { BundledLanguage } from "@pierre/diffs"; import { graphTheme } from "../lib/graph-theme"; import { useRunGraph, useRunGraphSource, useRunStages } from "../lib/queries"; import { LoadingState } from "../components/state"; import { StageSidebar } from "../components/stage-sidebar"; import { GRAPH_DEFAULT_ZOOM_INDEX, GRAPH_ZOOM_STEPS, GraphToolbar, } from "../components/graph-toolbar"; import { CollapsibleFile } from "../components/collapsible-file"; import { registerDotLanguage } from "../data/register-dot-language"; import { mapRunStagesToSidebarStages } from "../lib/stage-sidebar"; export const handle = { wide: true }; type Direction = "LR" | "TB"; function buildDot(direction: Direction) { return `digraph sync { graph [label="Sync"] rankdir=${direction} bgcolor="transparent" pad=0.5 node [ fontname="ui-monospace, monospace" fontsize=11 fontcolor="${graphTheme.nodeText}" color="${graphTheme.edgeColor}" fillcolor="${graphTheme.nodeFill}" style=filled penwidth=1.2 ] edge [ fontname="ui-monospace, monospace" fontsize=9 fontcolor="${graphTheme.fontcolor}" color="${graphTheme.edgeColor}" arrowsize=0.7 penwidth=1.2 ] start [shape=Mdiamond, label="Start", fillcolor="${graphTheme.startFill}", color="${graphTheme.startBorder}", fontcolor="${graphTheme.startText}"] exit [shape=Msquare, label="Exit", fillcolor="${graphTheme.startFill}", color="${graphTheme.startBorder}", fontcolor="${graphTheme.startText}"] detect [label="Detect\\nDrift"] propose [label="Propose\\nChanges"] review [shape=hexagon, label="Review\\nChanges", fillcolor="${graphTheme.gateFill}", color="${graphTheme.gateBorder}", fontcolor="${graphTheme.gateText}"] apply [label="Apply\\nChanges"] start -> detect detect -> exit [label="No drift", style=dashed] detect -> propose [label="Drift found"] propose -> review review -> apply [label="Accept"] review -> propose [label="Revise", style=dashed] apply -> exit }`; } function stripGraphTitle(svg: SVGSVGElement) { const title = svg.querySelector(".graph > title"); if (!title) return; let sibling = title.nextElementSibling; while (sibling && sibling.tagName === "text") { const next = sibling.nextElementSibling; sibling.remove(); sibling = next; } title.remove(); } type View = "graph" | "source"; export default function RunGraph() { const { id } = useParams(); const [direction, setDirection] = useState("LR"); const [view, setView] = useState("graph"); const stagesQuery = useRunStages(id); const graphQuery = useRunGraph(id, direction); const sourceQuery = useRunGraphSource(id, view === "source"); const stages = useMemo( () => mapRunStagesToSidebarStages(stagesQuery.data), [stagesQuery.data], ); const graphSvg = graphQuery.data; const containerRef = useRef(null); const innerRef = useRef(null); const svgRef = useRef(null); const [error, setError] = useState(null); const [zoomIndex, setZoomIndex] = useState(GRAPH_DEFAULT_ZOOM_INDEX); const [pan, setPan] = useState({ x: 0, y: 0 }); const [dotReady, setDotReady] = useState(false); useEffect(() => { let cancelled = false; registerDotLanguage().then(() => { if (!cancelled) setDotReady(true); }); return () => { cancelled = true; }; }, []); const dragState = useRef<{ startX: number; startY: number; startPanX: number; startPanY: number } | null>(null); const zoom = GRAPH_ZOOM_STEPS[zoomIndex]; useEffect(() => { if (graphSvg === undefined && !graphQuery.error) return; let cancelled = false; async function render() { try { setError(null); if (graphQuery.error) { setError("Failed to load graph"); return; } let svg: SVGSVGElement; if (graphSvg) { const parser = new DOMParser(); const doc = parser.parseFromString(graphSvg, "image/svg+xml"); const parsed = doc.documentElement; if (!(parsed instanceof SVGSVGElement)) { setError("Invalid SVG from server"); return; } svg = parsed; } else { // Fall back to hardcoded demo graph rendered client-side. const { instance } = await import("@viz-js/viz"); const viz = await instance(); if (cancelled) return; svg = viz.renderSVGElement(buildDot(direction)); } stripGraphTitle(svg); svgRef.current = svg; if (innerRef.current) { innerRef.current.replaceChildren(svg); } } catch (e) { setError(e instanceof Error ? e.message : "Failed to render diagram"); } } setPan({ x: 0, y: 0 }); render(); return () => { cancelled = true; }; }, [direction, graphQuery.error, graphSvg]); const onPointerDown = useCallback((e: React.PointerEvent) => { if ((e.target as HTMLElement).closest("button")) return; e.currentTarget.setPointerCapture(e.pointerId); dragState.current = { startX: e.clientX, startY: e.clientY, startPanX: pan.x, startPanY: pan.y }; }, [pan]); const onPointerMove = useCallback((e: React.PointerEvent) => { const drag = dragState.current; if (!drag) return; setPan({ x: drag.startPanX + e.clientX - drag.startX, y: drag.startPanY + e.clientY - drag.startY, }); }, []); const onPointerUp = useCallback(() => { dragState.current = null; }, []); const fitToWindow = useCallback(() => { const svg = svgRef.current; const container = containerRef.current; if (!svg || !container) return; const svgW = svg.viewBox.baseVal.width || svg.getBoundingClientRect().width; const svgH = svg.viewBox.baseVal.height || svg.getBoundingClientRect().height; const padPx = 48; const containerW = container.clientWidth - padPx; const containerH = container.clientHeight - padPx; const fitPct = Math.min(containerW / svgW, containerH / svgH) * 100; let best = 0; for (let i = GRAPH_ZOOM_STEPS.length - 1; i >= 0; i--) { if (GRAPH_ZOOM_STEPS[i] <= fitPct) { best = i; break; } } setZoomIndex(best); setPan({ x: 0, y: 0 }); }, []); if (error) { return

{error}

; } return (
{view === "source" && ( )}
); } function ViewToggle({ view, setView }: { view: View; setView: (v: View) => void }) { const btn = "rounded px-3 py-1.5 text-xs font-medium transition-colors"; return (
); } function SourcePanel({ source, loading, dotReady, }: { source: string | null | undefined; loading: boolean; dotReady: boolean; }) { if (loading || !dotReady) { return (
); } if (!source) { return (

No graph source available for this run.

); } return ( ); }