import { useState, useMemo, useCallback, useEffect } from 'react'; import { ChevronRight, ChevronDown, Folder, FolderOpen, FileCode, Search, Filter, PanelLeftClose, PanelLeft, Box, Braces, Variable, Hash, Target, List, AtSign, Type, } from '@/lib/lucide-icons'; import { useAppState } from '../hooks/useAppState'; import { FILTERABLE_LABELS, NODE_COLORS, ALL_EDGE_TYPES, EDGE_INFO } from '../lib/constants'; import type { GraphNode, NodeLabel } from 'gitnexus-shared'; // Tree node structure interface TreeNode { id: string; name: string; type: 'folder' | 'file'; path: string; children: TreeNode[]; graphNode?: GraphNode; } // Build tree from graph nodes const buildFileTree = (nodes: GraphNode[]): TreeNode[] => { const root: TreeNode[] = []; const pathMap = new Map(); // Filter to only folders and files const fileNodes = nodes.filter((n) => n.label === 'Folder' || n.label === 'File'); // Sort by path to ensure parents come before children fileNodes.sort((a, b) => a.properties.filePath.localeCompare(b.properties.filePath)); fileNodes.forEach((node) => { const parts = node.properties.filePath.split('/').filter(Boolean); let currentPath = ''; let currentLevel = root; parts.forEach((part, index) => { currentPath = currentPath ? `${currentPath}/${part}` : part; let existing = pathMap.get(currentPath); if (!existing) { const isLastPart = index === parts.length - 1; const isFile = isLastPart && node.label === 'File'; existing = { id: isLastPart ? node.id : currentPath, name: part, type: isFile ? 'file' : 'folder', path: currentPath, children: [], graphNode: isLastPart ? node : undefined, }; pathMap.set(currentPath, existing); currentLevel.push(existing); } currentLevel = existing.children; }); }); return root; }; // Tree item component interface TreeItemProps { node: TreeNode; depth: number; searchQuery: string; onNodeClick: (node: TreeNode) => void; expandedPaths: Set; toggleExpanded: (path: string) => void; selectedPath: string | null; } const TreeItem = ({ node, depth, searchQuery, onNodeClick, expandedPaths, toggleExpanded, selectedPath, }: TreeItemProps) => { const isExpanded = expandedPaths.has(node.path); const isSelected = selectedPath === node.path; const hasChildren = node.children.length > 0; // Filter children based on search (recursive) const filteredChildren = useMemo(() => { if (!searchQuery) return node.children; const searchLower = searchQuery.toLowerCase(); const matchesSearch = (node: TreeNode, query: string): boolean => { if (node.name.toLowerCase().includes(query)) return true; return node.children?.some((child) => matchesSearch(child, query)) ?? false; }; return node.children.filter((child) => matchesSearch(child, searchLower)); }, [node.children, searchQuery]); // Check if this node matches search const matchesSearch = searchQuery && node.name.toLowerCase().includes(searchQuery.toLowerCase()); const handleClick = () => { if (hasChildren) { toggleExpanded(node.path); } onNodeClick(node); }; return (
{/* Children */} {isExpanded && filteredChildren.length > 0 && (
{filteredChildren.map((child) => ( ))}
)}
); }; // Icon for node types const getNodeTypeIcon = (label: NodeLabel) => { switch (label) { case 'Folder': return Folder; case 'File': return FileCode; case 'Class': return Box; case 'Function': return Braces; case 'Method': return Braces; case 'Interface': return Hash; case 'Enum': return List; case 'Type': return Type; case 'Decorator': return AtSign; case 'Import': return FileCode; case 'Variable': return Variable; default: return Variable; } }; interface FileTreePanelProps { onFocusNode: (nodeId: string) => void; } export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => { const { graph, visibleLabels, toggleLabelVisibility, visibleEdgeTypes, toggleEdgeVisibility, selectedNode, setSelectedNode, openCodePanel, depthFilter, setDepthFilter, } = useAppState(); const [isCollapsed, setIsCollapsed] = useState(false); const [searchQuery, setSearchQuery] = useState(''); const [expandedPaths, setExpandedPaths] = useState>(new Set()); const [activeTab, setActiveTab] = useState<'files' | 'filters'>('files'); // Build file tree from graph const fileTree = useMemo(() => { if (!graph) return []; return buildFileTree(graph.nodes); }, [graph]); // Auto-expand first level on initial load useEffect(() => { if (fileTree.length > 0 && expandedPaths.size === 0) { const firstLevel = new Set(fileTree.map((n) => n.path)); setExpandedPaths(firstLevel); } }, [fileTree.length]); // Only run when tree first loads // Auto-expand to selected file when selectedNode changes (e.g., from graph click) useEffect(() => { const path = selectedNode?.properties?.filePath; if (!path) return; // Expand all parent folders leading to this file const parts = path.split('/').filter(Boolean); const pathsToExpand: string[] = []; let currentPath = ''; // Build all parent paths (exclude the last part if it's a file) for (let i = 0; i < parts.length - 1; i++) { currentPath = currentPath ? `${currentPath}/${parts[i]}` : parts[i]; pathsToExpand.push(currentPath); } if (pathsToExpand.length > 0) { setExpandedPaths((prev) => { const next = new Set(prev); pathsToExpand.forEach((p) => next.add(p)); return next; }); } }, [selectedNode?.id]); // Trigger when selected node changes const toggleExpanded = useCallback((path: string) => { setExpandedPaths((prev) => { const next = new Set(prev); if (next.has(path)) { next.delete(path); } else { next.add(path); } return next; }); }, []); const handleNodeClick = useCallback( (treeNode: TreeNode) => { if (treeNode.graphNode) { // Only focus if selecting a different node const isSameNode = selectedNode?.id === treeNode.graphNode.id; setSelectedNode(treeNode.graphNode); openCodePanel(); if (!isSameNode) { onFocusNode(treeNode.graphNode.id); } } }, [setSelectedNode, openCodePanel, onFocusNode, selectedNode], ); const selectedPath = selectedNode?.properties.filePath || null; if (isCollapsed) { return (
); } return (
{/* Header */}
{activeTab === 'files' && ( <> {/* Search */}
setSearchQuery(e.target.value)} className="w-full rounded border border-border-subtle bg-elevated py-1.5 pr-3 pl-8 text-xs text-text-primary placeholder:text-text-muted focus:border-accent focus:outline-none" />
{/* File tree */}
{fileTree.length === 0 ? (
No files loaded
) : ( fileTree.map((node) => ( )) )}
)} {activeTab === 'filters' && (

Node Types

Toggle visibility of node types in the graph

{FILTERABLE_LABELS.map((label) => { const Icon = getNodeTypeIcon(label); const isVisible = visibleLabels.includes(label); return ( ); })}
{/* Edge Type Toggles */}

Edge Types

Toggle visibility of relationship types

{ALL_EDGE_TYPES.map((edgeType) => { const info = EDGE_INFO[edgeType]; const isVisible = visibleEdgeTypes.includes(edgeType); return ( ); })}
{/* Depth Filter */}

Focus Depth

Show nodes within N hops of selection

{[ { value: null, label: 'All' }, { value: 1, label: '1 hop' }, { value: 2, label: '2 hops' }, { value: 3, label: '3 hops' }, { value: 5, label: '5 hops' }, ].map(({ value, label }) => ( ))}
{depthFilter !== null && !selectedNode && (

Select a node to apply depth filter

)}
{/* Legend */}

Color Legend

{( [ 'Folder', 'File', 'Class', 'Interface', 'Enum', 'Type', 'Function', 'Method', 'Variable', 'Decorator', ] as NodeLabel[] ).map((label) => (
{label}
))}
)} {/* Stats footer */} {graph && (
{graph.nodes.length} nodes {graph.relationships.length} edges
)}
); };