Skip to content

Commit 61816bb

Browse files
committed
feat(dependency-graph): add directory clustering with expand/collapse
1 parent 78216dd commit 61816bb

4 files changed

Lines changed: 443 additions & 66 deletions

File tree

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { memo } from 'react'
2+
import { Handle, Position } from 'reactflow'
3+
import type { NodeProps } from 'reactflow'
4+
import { Folder, FolderOpen, ChevronRight } from 'lucide-react'
5+
import { cn } from '@/lib/utils'
6+
import { Badge } from '@/components/ui/badge'
7+
import type { RiskLevel } from './hooks/useImpactAnalysis'
8+
9+
export interface DirectoryNodeData {
10+
label: string
11+
fullPath: string
12+
fileCount: number
13+
totalDependents: number
14+
maxRisk: RiskLevel
15+
isExpanded: boolean
16+
state: 'default' | 'selected' | 'direct' | 'transitive' | 'dimmed'
17+
}
18+
19+
const STATE_STYLES: Record<DirectoryNodeData['state'], string> = {
20+
default: 'border-zinc-300 bg-zinc-50 dark:border-zinc-700 dark:bg-zinc-800/90',
21+
selected: 'border-indigo-500 bg-indigo-50 dark:bg-indigo-950 ring-2 ring-indigo-500/50 shadow-lg shadow-indigo-500/20',
22+
direct: 'border-rose-500 bg-rose-50 dark:bg-rose-950 ring-1 ring-rose-500/30',
23+
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',
25+
}
26+
27+
const RISK_STYLES: Record<RiskLevel, string> = {
28+
low: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-500/10 dark:text-emerald-400',
29+
medium: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-500/10 dark:text-yellow-400',
30+
high: 'bg-orange-100 text-orange-700 dark:bg-orange-500/10 dark:text-orange-400',
31+
critical: 'bg-rose-100 text-rose-700 dark:bg-rose-500/10 dark:text-rose-400',
32+
}
33+
34+
function DirectoryNodeComponent({ data }: NodeProps<DirectoryNodeData>) {
35+
const stateStyle = STATE_STYLES[data.state]
36+
const FolderIcon = data.isExpanded ? FolderOpen : Folder
37+
38+
return (
39+
<>
40+
<Handle
41+
type="target"
42+
position={Position.Left}
43+
className="!bg-zinc-400 dark:!bg-zinc-600 !w-2 !h-2 !border-0"
44+
/>
45+
46+
<div
47+
className={cn(
48+
'px-3 py-2.5 rounded-lg border-2 transition-all duration-200 min-w-[180px]',
49+
'hover:scale-[1.02] hover:shadow-md cursor-pointer',
50+
stateStyle
51+
)}
52+
>
53+
<div className="flex items-center gap-2 mb-1">
54+
<FolderIcon className="w-4 h-4 text-amber-500 dark:text-amber-400 flex-shrink-0" />
55+
<span
56+
className="font-semibold text-sm text-zinc-800 dark:text-zinc-100 truncate flex-1"
57+
title={data.fullPath}
58+
>
59+
{data.label}/
60+
</span>
61+
<ChevronRight className={cn(
62+
'w-3.5 h-3.5 text-zinc-400 transition-transform',
63+
data.isExpanded && 'rotate-90'
64+
)} />
65+
</div>
66+
67+
<div className="flex items-center gap-3 text-[11px] text-zinc-500 dark:text-zinc-400">
68+
<span className="font-medium">
69+
{data.fileCount} file{data.fileCount !== 1 ? 's' : ''}
70+
</span>
71+
<span></span>
72+
<span className={cn(
73+
'font-medium',
74+
data.totalDependents >= 30 ? 'text-rose-600 dark:text-rose-400' :
75+
data.totalDependents >= 10 ? 'text-amber-600 dark:text-amber-400' :
76+
'text-zinc-500 dark:text-zinc-400'
77+
)}>
78+
{data.totalDependents} deps
79+
</span>
80+
{data.maxRisk !== 'low' && (
81+
<Badge variant="secondary" className={cn('text-[10px] px-1.5 py-0 h-5 ml-auto', RISK_STYLES[data.maxRisk])}>
82+
{data.maxRisk === 'critical' ? 'Crit' : data.maxRisk === 'high' ? 'High' : 'Med'}
83+
</Badge>
84+
)}
85+
</div>
86+
</div>
87+
88+
<Handle
89+
type="source"
90+
position={Position.Right}
91+
className="!bg-zinc-400 dark:!bg-zinc-600 !w-2 !h-2 !border-0"
92+
/>
93+
</>
94+
)
95+
}
96+
97+
export const DirectoryNode = memo(DirectoryNodeComponent)

frontend/src/components/DependencyGraph/GraphToolbar.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { memo } from 'react'
2-
import { RotateCcw, Maximize2, Filter, Eye, EyeOff } from 'lucide-react'
2+
import { RotateCcw, Maximize2, Filter, Eye, EyeOff, FolderTree } from 'lucide-react'
33
import { cn } from '@/lib/utils'
44
import { Button } from '@/components/ui/button'
55

@@ -8,8 +8,10 @@ interface GraphToolbarProps {
88
visibleFiles: number
99
showAll: boolean
1010
showTests: boolean
11+
clusterByDir: boolean
1112
onToggleShowAll: () => void
1213
onToggleTests: () => void
14+
onToggleCluster: () => void
1315
onResetView: () => void
1416
onFullscreen?: () => void
1517
}
@@ -19,8 +21,10 @@ function GraphToolbarComponent({
1921
visibleFiles,
2022
showAll,
2123
showTests,
24+
clusterByDir,
2225
onToggleShowAll,
2326
onToggleTests,
27+
onToggleCluster,
2428
onResetView,
2529
onFullscreen,
2630
}: GraphToolbarProps) {
@@ -37,6 +41,17 @@ function GraphToolbarComponent({
3741
</div>
3842

3943
<div className="flex items-center gap-2">
44+
<Button
45+
variant={clusterByDir ? 'default' : 'secondary'}
46+
size="sm"
47+
onClick={onToggleCluster}
48+
className="h-8"
49+
title="Group files by directory"
50+
>
51+
<FolderTree className="w-3.5 h-3.5 mr-1.5" />
52+
Cluster
53+
</Button>
54+
4055
<Button
4156
variant={showAll ? 'default' : 'secondary'}
4257
size="sm"
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
import { useMemo, useCallback } from 'react'
2+
3+
export interface DirectoryCluster {
4+
path: string
5+
name: string
6+
files: string[]
7+
childDirs: string[]
8+
totalFiles: number
9+
totalDependents: number
10+
maxRisk: 'low' | 'medium' | 'high' | 'critical'
11+
isExpanded: boolean
12+
}
13+
14+
interface ClusteringResult {
15+
clusters: Map<string, DirectoryCluster>
16+
rootDirs: string[]
17+
getClusterForFile: (fileId: string) => string
18+
toggleCluster: (dirPath: string) => void
19+
}
20+
21+
// Extract directory path from file path
22+
function getDirectoryPath(filePath: string): string {
23+
const parts = filePath.split('/')
24+
parts.pop()
25+
return parts.join('/') || '/'
26+
}
27+
28+
// Get parent directory
29+
function getParentDir(dirPath: string): string | null {
30+
if (dirPath === '/' || dirPath === '') return null
31+
const parts = dirPath.split('/')
32+
parts.pop()
33+
return parts.join('/') || '/'
34+
}
35+
36+
// Calculate max risk from an array of risks
37+
function getMaxRisk(risks: Array<'low' | 'medium' | 'high' | 'critical'>): 'low' | 'medium' | 'high' | 'critical' {
38+
const priority = { critical: 4, high: 3, medium: 2, low: 1 }
39+
return risks.reduce((max, risk) => priority[risk] > priority[max] ? risk : max, 'low' as const)
40+
}
41+
42+
export function useDirectoryClustering(
43+
nodes: Array<{ id: string; riskLevel: 'low' | 'medium' | 'high' | 'critical'; dependentCount: number }>,
44+
expandedDirs: Set<string>,
45+
onToggleDir: (dirPath: string) => void
46+
): ClusteringResult {
47+
48+
const clusters = useMemo(() => {
49+
const clusterMap = new Map<string, DirectoryCluster>()
50+
51+
// Group files by directory
52+
const dirFiles = new Map<string, string[]>()
53+
nodes.forEach(node => {
54+
const dirPath = getDirectoryPath(node.id)
55+
if (!dirFiles.has(dirPath)) {
56+
dirFiles.set(dirPath, [])
57+
}
58+
dirFiles.get(dirPath)!.push(node.id)
59+
})
60+
61+
// Create clusters for each directory
62+
dirFiles.forEach((files, dirPath) => {
63+
const dirName = dirPath.split('/').pop() || dirPath
64+
const nodeData = files.map(f => nodes.find(n => n.id === f)!).filter(Boolean)
65+
66+
clusterMap.set(dirPath, {
67+
path: dirPath,
68+
name: dirName,
69+
files,
70+
childDirs: [],
71+
totalFiles: files.length,
72+
totalDependents: nodeData.reduce((sum, n) => sum + n.dependentCount, 0),
73+
maxRisk: getMaxRisk(nodeData.map(n => n.riskLevel)),
74+
isExpanded: expandedDirs.has(dirPath),
75+
})
76+
})
77+
78+
// Build directory hierarchy
79+
clusterMap.forEach((cluster, dirPath) => {
80+
const parentPath = getParentDir(dirPath)
81+
if (parentPath && clusterMap.has(parentPath)) {
82+
clusterMap.get(parentPath)!.childDirs.push(dirPath)
83+
}
84+
})
85+
86+
return clusterMap
87+
}, [nodes, expandedDirs])
88+
89+
const rootDirs = useMemo(() => {
90+
const roots: string[] = []
91+
clusters.forEach((cluster, dirPath) => {
92+
const parentPath = getParentDir(dirPath)
93+
if (!parentPath || !clusters.has(parentPath)) {
94+
roots.push(dirPath)
95+
}
96+
})
97+
return roots.sort()
98+
}, [clusters])
99+
100+
const getClusterForFile = useCallback((fileId: string): string => {
101+
return getDirectoryPath(fileId)
102+
}, [])
103+
104+
const toggleCluster = useCallback((dirPath: string) => {
105+
onToggleDir(dirPath)
106+
}, [onToggleDir])
107+
108+
return {
109+
clusters,
110+
rootDirs,
111+
getClusterForFile,
112+
toggleCluster,
113+
}
114+
}

0 commit comments

Comments
 (0)