|
| 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