Skip to content

Commit 56450f1

Browse files
authored
Merge pull request #238 from DevanshuNEU/fix/dependency-graph-click-bug
fix: prevent dependency graph from going blank when clicking nodes
2 parents 2b54b9b + 67808c5 commit 56450f1

4 files changed

Lines changed: 83 additions & 32 deletions

File tree

frontend/src/components/DependencyGraph/DirectoryNode.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ const STATE_STYLES: Record<DirectoryNodeData['state'], string> = {
2121
selected: 'border-indigo-500 bg-indigo-50 dark:bg-indigo-950 ring-2 ring-indigo-500/50 shadow-lg shadow-indigo-500/20',
2222
direct: 'border-rose-500 bg-rose-50 dark:bg-rose-950 ring-1 ring-rose-500/30',
2323
transitive: 'border-amber-500 bg-amber-50 dark:bg-amber-950 ring-1 ring-amber-500/30',
24-
dimmed: 'border-zinc-200 bg-zinc-100/50 opacity-40 dark:border-zinc-800 dark:bg-zinc-900/50',
24+
dimmed: 'border-zinc-300 bg-zinc-100 opacity-50 dark:border-zinc-600 dark:bg-zinc-800/80',
2525
}
2626

2727
const RISK_STYLES: Record<RiskLevel, string> = {

frontend/src/components/DependencyGraph/GraphNode.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ const STATE_STYLES: Record<GraphNodeData['state'], string> = {
5454
selected: 'border-indigo-500 bg-white dark:bg-zinc-900 ring-2 ring-indigo-500/50 shadow-lg shadow-indigo-500/20',
5555
direct: 'border-rose-500 bg-white dark:bg-zinc-900 ring-1 ring-rose-500/30',
5656
transitive: 'border-amber-500 bg-white dark:bg-zinc-900 ring-1 ring-amber-500/30',
57-
dimmed: 'border-zinc-200 bg-zinc-50/50 opacity-40 dark:border-zinc-800 dark:bg-zinc-900/50',
57+
dimmed: 'border-zinc-300 bg-zinc-100 opacity-50 dark:border-zinc-600 dark:bg-zinc-800/80',
5858
}
5959

6060
const RISK_CONFIG: Record<RiskLevel, { variant: 'default' | 'secondary' | 'destructive' | 'outline'; label: string; className: string }> = {

frontend/src/components/DependencyGraph/ImpactPanel.tsx

Lines changed: 0 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -115,11 +115,6 @@ function CollapsibleSection({
115115
}) {
116116
const [isOpen, setIsOpen] = useState(defaultOpen)
117117

118-
// Reset collapse state when file selection changes
119-
useEffect(() => {
120-
setIsOpen(defaultOpen)
121-
}, [defaultOpen, files])
122-
123118
const variantStyles = {
124119
direct: 'text-rose-600 dark:text-rose-400',
125120
transitive: 'text-amber-600 dark:text-amber-400',

frontend/src/components/DependencyGraph/index.tsx

Lines changed: 81 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect, useState, useCallback, useMemo } from 'react'
1+
import { useEffect, useState, useCallback, useMemo, useRef } from 'react'
22
import ReactFlow, {
33
Controls,
44
Background,
@@ -44,6 +44,11 @@ const LAYOUT_CONFIG = {
4444
const DEFAULT_VISIBLE_COUNT = 15
4545

4646
function getLayoutedElements(nodes: Node[], edges: Edge[]) {
47+
// Guard: if no nodes, return empty
48+
if (nodes.length === 0) {
49+
return { nodes: [], edges: [] }
50+
}
51+
4752
const dagreGraph = new dagre.graphlib.Graph()
4853
dagreGraph.setDefaultEdgeLabel(() => ({}))
4954
dagreGraph.setGraph({
@@ -59,19 +64,26 @@ function getLayoutedElements(nodes: Node[], edges: Edge[]) {
5964
})
6065
})
6166

67+
// Only add edges where both source and target exist in nodes
68+
const nodeIds = new Set(nodes.map(n => n.id))
6269
edges.forEach((edge) => {
63-
dagreGraph.setEdge(edge.source, edge.target)
70+
if (nodeIds.has(edge.source) && nodeIds.has(edge.target)) {
71+
dagreGraph.setEdge(edge.source, edge.target)
72+
}
6473
})
6574

6675
dagre.layout(dagreGraph)
6776

6877
const layoutedNodes = nodes.map((node) => {
6978
const nodeWithPosition = dagreGraph.node(node.id)
79+
// Guard: if dagre failed to position node, use fallback
80+
const x = nodeWithPosition?.x ?? 0
81+
const y = nodeWithPosition?.y ?? 0
7082
return {
7183
...node,
7284
position: {
73-
x: nodeWithPosition.x - LAYOUT_CONFIG.nodeWidth / 2,
74-
y: nodeWithPosition.y - LAYOUT_CONFIG.nodeHeight / 2,
85+
x: x - LAYOUT_CONFIG.nodeWidth / 2,
86+
y: y - LAYOUT_CONFIG.nodeHeight / 2,
7587
},
7688
}
7789
})
@@ -113,6 +125,7 @@ function DependencyGraphInner({ repoId, apiUrl, apiKey }: DependencyGraphProps)
113125
const [clusterByDir, setClusterByDir] = useState(false)
114126
const [expandedDirs, setExpandedDirs] = useState<Set<string>>(new Set())
115127
const [rawGraphData, setRawGraphData] = useState<any>(null)
128+
const [renderKey, setRenderKey] = useState(0) // Force re-render key
116129

117130
const { fitView } = useReactFlow()
118131
const { resolvedTheme } = useTheme()
@@ -125,6 +138,23 @@ function DependencyGraphInner({ repoId, apiUrl, apiKey }: DependencyGraphProps)
125138
if (data) setRawGraphData(data)
126139
}, [data])
127140

141+
// Handle tab visibility changes - force re-render when tab becomes visible
142+
useEffect(() => {
143+
const handleVisibilityChange = () => {
144+
if (document.visibilityState === 'visible') {
145+
// Force re-render by updating key
146+
setRenderKey(k => k + 1)
147+
// Also trigger fitView after a short delay
148+
setTimeout(() => {
149+
fitView({ padding: 0.2, duration: 200 })
150+
}, 100)
151+
}
152+
}
153+
154+
document.addEventListener('visibilitychange', handleVisibilityChange)
155+
return () => document.removeEventListener('visibilitychange', handleVisibilityChange)
156+
}, [fitView])
157+
128158
const visibleNodeIds = useMemo(() => {
129159
if (!rawGraphData || !impact.isReady) return new Set<string>()
130160

@@ -229,9 +259,17 @@ function DependencyGraphInner({ repoId, apiUrl, apiKey }: DependencyGraphProps)
229259
let flowNodes: Node[] = []
230260
let flowEdges: Edge[] = []
231261

232-
if (clusterByDir && clusteredData) {
262+
// Only use cluster mode if we have clustered data ready
263+
const useClusterMode = clusterByDir && clusteredData && clusteredData.dirNodes.length > 0
264+
265+
if (useClusterMode) {
266+
// Safety: only apply selection highlighting if the selected node is actually visible
267+
const effectiveSelectedIdCluster = selectedNodeId &&
268+
(clusteredData!.visibleFiles.has(selectedNodeId) || selectedNodeId.startsWith('dir:'))
269+
? selectedNodeId : null
270+
233271
// Add directory nodes
234-
clusteredData.dirNodes.forEach(dir => {
272+
clusteredData!.dirNodes.forEach(dir => {
235273
flowNodes.push({
236274
id: dir.id,
237275
type: 'directory',
@@ -242,19 +280,17 @@ function DependencyGraphInner({ repoId, apiUrl, apiKey }: DependencyGraphProps)
242280

243281
// Add visible file nodes
244282
rawGraphData.nodes
245-
.filter((node: any) => clusteredData.visibleFiles.has(node.id))
283+
.filter((node: any) => clusteredData!.visibleFiles.has(node.id))
246284
.forEach((node: any) => {
247285
const fileName = node.label || node.id.split('/').pop()
248286
const metrics = impact.getFileMetrics(node.id)
249287

288+
// Simplified state - only highlight, don't dim
250289
let state: GraphNodeData['state'] = 'default'
251-
if (selectedNodeId === node.id) state = 'selected'
290+
if (effectiveSelectedIdCluster === node.id) state = 'selected'
252291
else if (selectedImpact?.directDependents.includes(node.id)) state = 'direct'
253292
else if (selectedImpact?.transitiveDependents.includes(node.id)) state = 'transitive'
254-
else if (selectedNodeId && !selectedNodeId.startsWith('dir:')) state = 'dimmed'
255-
256-
// Hover highlighting in clustered mode
257-
if (hoveredFileId === node.id && state === 'dimmed') state = 'direct'
293+
// Don't dim - keep as default
258294

259295
flowNodes.push({
260296
id: node.id,
@@ -274,7 +310,7 @@ function DependencyGraphInner({ repoId, apiUrl, apiKey }: DependencyGraphProps)
274310
})
275311

276312
// Add edges
277-
clusteredData.edges.forEach(([source, target]) => {
313+
clusteredData!.edges.forEach(([source, target]) => {
278314
flowEdges.push({
279315
id: `${source}-${target}`,
280316
source,
@@ -284,22 +320,24 @@ function DependencyGraphInner({ repoId, apiUrl, apiKey }: DependencyGraphProps)
284320
})
285321
} else {
286322
// Non-clustered mode (original logic)
323+
// Safety: only apply selection highlighting if the selected node is actually visible
324+
const effectiveSelectedId = selectedNodeId && visibleNodeIds.has(selectedNodeId) ? selectedNodeId : null
325+
287326
flowNodes = rawGraphData.nodes
288327
.filter((node: any) => visibleNodeIds.has(node.id))
289328
.map((node: any) => {
290329
const fileName = node.label || node.id.split('/').pop()
291330
const metrics = impact.getFileMetrics(node.id)
292331

332+
// Simplified state - only highlight selected and dependents, don't dim others
293333
let state: GraphNodeData['state'] = 'default'
294-
if (selectedNodeId) {
295-
if (node.id === selectedNodeId) state = 'selected'
334+
if (effectiveSelectedId) {
335+
if (node.id === effectiveSelectedId) state = 'selected'
296336
else if (selectedImpact?.directDependents.includes(node.id)) state = 'direct'
297337
else if (selectedImpact?.transitiveDependents.includes(node.id)) state = 'transitive'
298-
else state = 'dimmed'
338+
// Don't dim - keep as default for visibility
299339
}
300340

301-
if (hoveredFileId === node.id && state === 'dimmed') state = 'direct'
302-
303341
return {
304342
id: node.id,
305343
type: 'custom',
@@ -320,11 +358,12 @@ function DependencyGraphInner({ repoId, apiUrl, apiKey }: DependencyGraphProps)
320358
flowEdges = rawGraphData.edges
321359
.filter((edge: any) => visibleNodeIds.has(edge.source) && visibleNodeIds.has(edge.target))
322360
.map((edge: any) => {
361+
// Simplified - only highlight connected edges, don't dim others
323362
let edgeState: 'default' | 'highlighted' | 'dimmed' | 'incoming' | 'outgoing' = 'default'
324-
if (selectedNodeId) {
325-
if (edge.source === selectedNodeId) edgeState = 'outgoing'
326-
else if (edge.target === selectedNodeId) edgeState = 'incoming'
327-
else edgeState = 'dimmed'
363+
if (effectiveSelectedId) {
364+
if (edge.source === effectiveSelectedId) edgeState = 'outgoing'
365+
else if (edge.target === effectiveSelectedId) edgeState = 'incoming'
366+
// Don't dim - keep as default
328367
}
329368

330369
return {
@@ -342,12 +381,14 @@ function DependencyGraphInner({ repoId, apiUrl, apiKey }: DependencyGraphProps)
342381
setEdges(layoutedEdges)
343382
}, [rawGraphData, impact.isReady, visibleNodeIds, selectedNodeId, selectedImpact, hoveredFileId, isDark, clusterByDir, clusteredData])
344383

384+
// Fit view when nodes change or panel opens/closes
345385
useEffect(() => {
346386
if (nodes.length > 0) {
347387
const minZoom = nodes.length > 20 ? 0.5 : 0.3
348-
setTimeout(() => fitView({ padding: 0.2, duration: 300, minZoom }), 100)
388+
// Delay to allow container resize when panel opens/closes
389+
setTimeout(() => fitView({ padding: 0.2, duration: 300, minZoom }), 150)
349390
}
350-
}, [showAll, showTests, clusterByDir, expandedDirs])
391+
}, [nodes.length, selectedNodeId, showAll, showTests, clusterByDir, expandedDirs, fitView])
351392

352393
const handleNodeClick = useCallback((_: any, node: Node) => {
353394
// Toggle directory expansion
@@ -369,12 +410,26 @@ function DependencyGraphInner({ repoId, apiUrl, apiKey }: DependencyGraphProps)
369410
}, [])
370411

371412
const handlePanelFileClick = useCallback((fileId: string) => {
413+
// Only select if the file is currently visible in the graph
414+
// Otherwise clicking on a non-visible dependent breaks the view
415+
if (!visibleNodeIds.has(fileId)) {
416+
// If not visible, enable "show all" to make it visible first
417+
if (!showAll) {
418+
setShowAll(true)
419+
// Small delay to let the nodes render, then select
420+
setTimeout(() => {
421+
setSelectedNodeId(fileId)
422+
}, 100)
423+
return
424+
}
425+
}
426+
372427
setSelectedNodeId(fileId)
373428
const node = nodes.find(n => n.id === fileId)
374429
if (node) {
375430
fitView({ nodes: [node], padding: 0.5, duration: 300 })
376431
}
377-
}, [nodes, fitView])
432+
}, [nodes, fitView, visibleNodeIds, showAll])
378433

379434
const handleResetView = useCallback(() => {
380435
setSelectedNodeId(null)
@@ -460,6 +515,7 @@ function DependencyGraphInner({ repoId, apiUrl, apiKey }: DependencyGraphProps)
460515
<div className="flex overflow-hidden" style={{ height: '600px' }}>
461516
<div className="relative" style={{ flex: 1, height: '600px' }}>
462517
<ReactFlow
518+
key={renderKey}
463519
nodes={nodes}
464520
edges={edges}
465521
onNodesChange={onNodesChange}

0 commit comments

Comments
 (0)