mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-10 03:30:59 +00:00
Reuse the existing @pierre/diffs Shiki highlighter and registered DOT grammar (already used on the workflow definition page) so the Source view renders workflow.fabro with proper highlighting instead of plain monospace text. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
307 lines
9.5 KiB
TypeScript
307 lines
9.5 KiB
TypeScript
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<Direction>("LR");
|
|
const [view, setView] = useState<View>("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<HTMLDivElement>(null);
|
|
const innerRef = useRef<HTMLDivElement>(null);
|
|
const svgRef = useRef<SVGSVGElement | null>(null);
|
|
const [error, setError] = useState<string | null>(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 <p className="text-sm text-coral">{error}</p>;
|
|
}
|
|
|
|
return (
|
|
<div className="flex gap-6">
|
|
<StageSidebar stages={stages} runId={id!} activeLink="graph" />
|
|
|
|
<div className="min-w-0 flex-1 space-y-3">
|
|
<div className="flex justify-end">
|
|
<ViewToggle view={view} setView={setView} />
|
|
</div>
|
|
|
|
<div
|
|
className="graph-svg relative rounded-md border border-line bg-panel-alt"
|
|
hidden={view !== "graph"}
|
|
>
|
|
<GraphToolbar
|
|
direction={direction}
|
|
setDirection={setDirection}
|
|
fitToWindow={fitToWindow}
|
|
zoomIndex={zoomIndex}
|
|
setZoomIndex={setZoomIndex}
|
|
/>
|
|
|
|
<div
|
|
ref={containerRef}
|
|
className="overflow-hidden p-6"
|
|
style={{ cursor: dragState.current ? "grabbing" : "grab" }}
|
|
onPointerDown={onPointerDown}
|
|
onPointerMove={onPointerMove}
|
|
onPointerUp={onPointerUp}
|
|
onPointerCancel={onPointerUp}
|
|
>
|
|
<div
|
|
ref={innerRef}
|
|
className="flex items-center justify-center"
|
|
style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoom / 100})`, transformOrigin: "center center" }}
|
|
>
|
|
<p className="text-sm text-fg-muted">Loading diagram...</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{view === "source" && (
|
|
<SourcePanel
|
|
source={sourceQuery.data}
|
|
loading={sourceQuery.data === undefined && !sourceQuery.error}
|
|
dotReady={dotReady}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 (
|
|
<div role="group" aria-label="Graph view" className="inline-flex rounded-md border border-line bg-panel/80 p-0.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => setView("graph")}
|
|
aria-pressed={view === "graph"}
|
|
className={`${btn} ${view === "graph" ? "bg-overlay text-teal-500" : "text-fg-muted hover:text-fg-3"}`}
|
|
>
|
|
Graph
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setView("source")}
|
|
aria-pressed={view === "source"}
|
|
className={`${btn} ${view === "source" ? "bg-overlay text-teal-500" : "text-fg-muted hover:text-fg-3"}`}
|
|
>
|
|
Source
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function SourcePanel({
|
|
source,
|
|
loading,
|
|
dotReady,
|
|
}: {
|
|
source: string | null | undefined;
|
|
loading: boolean;
|
|
dotReady: boolean;
|
|
}) {
|
|
if (loading || !dotReady) {
|
|
return (
|
|
<div className="rounded-md border border-line bg-panel-alt p-4">
|
|
<LoadingState label="Loading graph source…" />
|
|
</div>
|
|
);
|
|
}
|
|
if (!source) {
|
|
return (
|
|
<div className="rounded-md border border-line bg-panel-alt p-4">
|
|
<p className="text-sm text-fg-muted">No graph source available for this run.</p>
|
|
</div>
|
|
);
|
|
}
|
|
return (
|
|
<CollapsibleFile
|
|
file={{ name: "workflow.fabro", contents: source, lang: "dot" as BundledLanguage }}
|
|
/>
|
|
);
|
|
}
|