Skip to content

Commit b134aba

Browse files
committed
feat: add toggleable dependency graph view
- DependencyGraphView: ASCII visualization of layer dependencies - dependencyGraph: dagre-based layout engine with cycle/orphan detection - Graph displays above service panels, toggle with 'g' key - Shows layer boxes in rows, dependency arrows, orphan/cycle markers
1 parent 41c7b44 commit b134aba

8 files changed

Lines changed: 907 additions & 17 deletions

File tree

bun.lock

Lines changed: 10 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,12 +43,14 @@
4343
"@effect/platform-node": "^0.103.0",
4444
"@opentui/core": "^0.1.60",
4545
"@opentui/solid": "^0.1.60",
46+
"dagre": "^0.8.5",
4647
"effect": "^3.19.10",
4748
"solid-js": "1.9.9",
4849
"ws": "^8.18.3"
4950
},
5051
"devDependencies": {
5152
"@types/bun": "latest",
53+
"@types/dagre": "^0.7.52",
5254
"@types/node": "~24.1.0",
5355
"@types/ws": "^8.18.1",
5456
"recast": "^0.23.11",

src/DependencyGraphView.tsx

Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
/**
2+
* Dependency Graph View Component
3+
*
4+
* Displays the Effect Layer dependency graph using ASCII art with dagre layout.
5+
*/
6+
7+
import { createMemo, For, Show } from "solid-js";
8+
import { useStore } from "./store";
9+
import type { LayerDefinition } from "./layerResolverCore";
10+
import {
11+
layoutGraph,
12+
renderToAscii,
13+
detectCycles,
14+
findOrphans,
15+
} from "./dependencyGraph";
16+
17+
// Colors (Tokyo Night theme)
18+
const COLORS = {
19+
primary: "#7aa2f7",
20+
success: "#9ece6a",
21+
warning: "#e0af68",
22+
error: "#f7768e",
23+
text: "#c0caf5",
24+
muted: "#565f89",
25+
background: "#1a1b26",
26+
} as const;
27+
28+
interface DependencyGraphViewProps {
29+
layers: LayerDefinition[];
30+
selectedNode?: string;
31+
onSelectNode?: (name: string) => void;
32+
}
33+
34+
/**
35+
* Header with graph statistics
36+
*/
37+
function GraphHeader(props: {
38+
layerCount: number;
39+
cycleCount: number;
40+
orphanCount: number;
41+
}) {
42+
return (
43+
<box flexDirection="column" marginBottom={1}>
44+
<box flexDirection="row" gap={2}>
45+
<text style={{ fg: COLORS.primary }}>
46+
Dependency Graph ({props.layerCount} layers)
47+
</text>
48+
</box>
49+
50+
<box flexDirection="row" gap={3} marginTop={1}>
51+
<Show when={props.cycleCount > 0}>
52+
<text style={{ fg: COLORS.error }}>
53+
* {props.cycleCount} circular dep{props.cycleCount > 1 ? "s" : ""}
54+
</text>
55+
</Show>
56+
<Show when={props.orphanCount > 0}>
57+
<text style={{ fg: COLORS.warning }}>
58+
? {props.orphanCount} orphan{props.orphanCount > 1 ? "s" : ""}
59+
</text>
60+
</Show>
61+
<Show when={props.cycleCount === 0 && props.orphanCount === 0}>
62+
<text style={{ fg: COLORS.success }}>No issues detected</text>
63+
</Show>
64+
</box>
65+
</box>
66+
);
67+
}
68+
69+
/**
70+
* Legend explaining the symbols
71+
*/
72+
function GraphLegend() {
73+
return (
74+
<box flexDirection="row" gap={3} marginBottom={1}>
75+
<text style={{ fg: COLORS.muted }}>Legend:</text>
76+
<text style={{ fg: COLORS.error }}>* Cycle</text>
77+
<text style={{ fg: COLORS.warning }}>? Orphan</text>
78+
<text style={{ fg: COLORS.text }}>--- Provides</text>
79+
</box>
80+
);
81+
}
82+
83+
/**
84+
* Renders a single line of the graph with proper coloring
85+
*/
86+
function GraphLine(props: {
87+
line: string;
88+
cycles: Set<string>;
89+
orphans: Set<string>;
90+
}) {
91+
// Check if this line contains any special markers
92+
const hasCycleMarker = props.line.includes("[cycle]");
93+
const hasOrphanMarker = props.line.includes("[orphan]");
94+
const hasAsterisk = props.line.startsWith("*") || props.line.includes(" *");
95+
96+
const color =
97+
hasCycleMarker || hasAsterisk
98+
? COLORS.error
99+
: hasOrphanMarker
100+
? COLORS.warning
101+
: COLORS.text;
102+
103+
return <text style={{ fg: color }}>{props.line}</text>;
104+
}
105+
106+
/**
107+
* Main Dependency Graph View component
108+
*/
109+
export function DependencyGraphView(props: DependencyGraphViewProps) {
110+
// Analyze the graph
111+
const cycles = createMemo(() => detectCycles(props.layers));
112+
const orphans = createMemo(() => findOrphans(props.layers));
113+
const cycleNodes = createMemo(() => new Set(cycles().flat()));
114+
const orphanNodes = createMemo(() => new Set(orphans()));
115+
116+
// Generate the graph visualization
117+
const graphLines = createMemo(() => {
118+
if (props.layers.length === 0) {
119+
return ["No layers found. Run analysis first."];
120+
}
121+
122+
const layout = layoutGraph(props.layers);
123+
if (!layout) {
124+
return ["Failed to layout graph"];
125+
}
126+
return renderToAscii(layout, {
127+
selectedNode: props.selectedNode,
128+
maxWidth: 72, // Conservative width for typical 80-col terminals with padding
129+
});
130+
});
131+
132+
return (
133+
<box
134+
flexDirection="column"
135+
width="100%"
136+
height="100%"
137+
paddingLeft={2}
138+
paddingRight={2}
139+
>
140+
<GraphHeader
141+
layerCount={props.layers.length}
142+
cycleCount={cycles().length}
143+
orphanCount={orphans().length}
144+
/>
145+
146+
<GraphLegend />
147+
148+
<scrollbox flexGrow={1} focused>
149+
<For each={graphLines()}>
150+
{(line) => (
151+
<GraphLine
152+
line={line}
153+
cycles={cycleNodes()}
154+
orphans={orphanNodes()}
155+
/>
156+
)}
157+
</For>
158+
</scrollbox>
159+
</box>
160+
);
161+
}
162+
163+
/**
164+
* Standalone graph panel that can be used in dialogs or overlays
165+
*/
166+
export function DependencyGraphPanel() {
167+
const { store } = useStore();
168+
169+
const layers = createMemo((): LayerDefinition[] => {
170+
// Get layers from analysis results if available
171+
const results = store.ui.layerAnalysisResults;
172+
if (!results) return [];
173+
174+
// Prefer allLayers if available (has full LayerDefinition structure)
175+
if (results.allLayers && results.allLayers.length > 0) {
176+
return results.allLayers as LayerDefinition[];
177+
}
178+
179+
// Fallback: build from candidates (need to add provides field)
180+
if (results.candidates) {
181+
const allLayers: LayerDefinition[] = [];
182+
for (const candidate of results.candidates) {
183+
for (const layer of candidate.layers) {
184+
// Avoid duplicates
185+
if (!allLayers.find((l) => l.name === layer.name)) {
186+
allLayers.push({
187+
...layer,
188+
provides: candidate.service, // The service this candidate provides
189+
});
190+
}
191+
}
192+
}
193+
return allLayers;
194+
}
195+
196+
return [];
197+
});
198+
199+
return (
200+
<Show
201+
when={layers().length > 0}
202+
fallback={
203+
<box
204+
flexDirection="column"
205+
width="100%"
206+
height="100%"
207+
paddingLeft={2}
208+
paddingTop={2}
209+
>
210+
<text style={{ fg: COLORS.text }} marginBottom={2}>
211+
No layer data available
212+
</text>
213+
<text style={{ fg: COLORS.muted }}>
214+
Run analysis first with [a] to discover layers
215+
</text>
216+
</box>
217+
}
218+
>
219+
<DependencyGraphView layers={layers()} />
220+
</Show>
221+
);
222+
}

0 commit comments

Comments
 (0)