mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
528 lines
18 KiB
TypeScript
528 lines
18 KiB
TypeScript
import { useState, useMemo, useCallback, useEffect } from 'react';
|
|
import {
|
|
ChevronRight,
|
|
ChevronDown,
|
|
Folder,
|
|
FolderOpen,
|
|
FileCode,
|
|
Search,
|
|
Filter,
|
|
PanelLeftClose,
|
|
PanelLeft,
|
|
Box,
|
|
Braces,
|
|
Variable,
|
|
Hash,
|
|
Target,
|
|
} from 'lucide-react';
|
|
import { useAppState } from '../hooks/useAppState';
|
|
import { FILTERABLE_LABELS, NODE_COLORS, ALL_EDGE_TYPES, EDGE_INFO, type EdgeType } from '../lib/constants';
|
|
import { GraphNode, NodeLabel } from '../core/graph/types';
|
|
|
|
// 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<string, TreeNode>();
|
|
|
|
// 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<string>;
|
|
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
|
|
const filteredChildren = useMemo(() => {
|
|
if (!searchQuery) return node.children;
|
|
return node.children.filter(child =>
|
|
child.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
child.children.some(c => c.name.toLowerCase().includes(searchQuery.toLowerCase()))
|
|
);
|
|
}, [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 (
|
|
<div>
|
|
<button
|
|
onClick={handleClick}
|
|
className={`
|
|
w-full flex items-center gap-1.5 px-2 py-1 text-left text-sm
|
|
hover:bg-hover transition-colors rounded relative
|
|
${isSelected ? 'bg-amber-500/15 text-amber-300 border-l-2 border-amber-400' : 'text-text-secondary hover:text-text-primary border-l-2 border-transparent'}
|
|
${matchesSearch ? 'bg-accent/10' : ''}
|
|
`}
|
|
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
|
>
|
|
{/* Expand/collapse icon */}
|
|
{hasChildren ? (
|
|
isExpanded ? (
|
|
<ChevronDown className="w-3.5 h-3.5 shrink-0 text-text-muted" />
|
|
) : (
|
|
<ChevronRight className="w-3.5 h-3.5 shrink-0 text-text-muted" />
|
|
)
|
|
) : (
|
|
<span className="w-3.5" />
|
|
)}
|
|
|
|
{/* Node icon */}
|
|
{node.type === 'folder' ? (
|
|
isExpanded ? (
|
|
<FolderOpen className="w-4 h-4 shrink-0" style={{ color: NODE_COLORS.Folder }} />
|
|
) : (
|
|
<Folder className="w-4 h-4 shrink-0" style={{ color: NODE_COLORS.Folder }} />
|
|
)
|
|
) : (
|
|
<FileCode className="w-4 h-4 shrink-0" style={{ color: NODE_COLORS.File }} />
|
|
)}
|
|
|
|
{/* Name */}
|
|
<span className="truncate font-mono text-xs">{node.name}</span>
|
|
</button>
|
|
|
|
{/* Children */}
|
|
{isExpanded && filteredChildren.length > 0 && (
|
|
<div>
|
|
{filteredChildren.map(child => (
|
|
<TreeItem
|
|
key={child.id}
|
|
node={child}
|
|
depth={depth + 1}
|
|
searchQuery={searchQuery}
|
|
onNodeClick={onNodeClick}
|
|
expandedPaths={expandedPaths}
|
|
toggleExpanded={toggleExpanded}
|
|
selectedPath={selectedPath}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 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 'Import': return FileCode;
|
|
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<Set<string>>(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 (
|
|
<div className="h-full w-12 bg-surface border-r border-border-subtle flex flex-col items-center py-3 gap-2">
|
|
<button
|
|
onClick={() => setIsCollapsed(false)}
|
|
className="p-2 text-text-secondary hover:text-text-primary hover:bg-hover rounded transition-colors"
|
|
title="Expand Panel"
|
|
>
|
|
<PanelLeft className="w-5 h-5" />
|
|
</button>
|
|
<div className="w-6 h-px bg-border-subtle my-1" />
|
|
<button
|
|
onClick={() => { setIsCollapsed(false); setActiveTab('files'); }}
|
|
className={`p-2 rounded transition-colors ${activeTab === 'files' ? 'text-accent bg-accent/10' : 'text-text-secondary hover:text-text-primary hover:bg-hover'}`}
|
|
title="File Explorer"
|
|
>
|
|
<Folder className="w-5 h-5" />
|
|
</button>
|
|
<button
|
|
onClick={() => { setIsCollapsed(false); setActiveTab('filters'); }}
|
|
className={`p-2 rounded transition-colors ${activeTab === 'filters' ? 'text-accent bg-accent/10' : 'text-text-secondary hover:text-text-primary hover:bg-hover'}`}
|
|
title="Filters"
|
|
>
|
|
<Filter className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-full w-64 bg-surface border-r border-border-subtle flex flex-col animate-slide-in">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-3 py-2 border-b border-border-subtle">
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => setActiveTab('files')}
|
|
className={`px-2 py-1 text-xs rounded transition-colors ${activeTab === 'files'
|
|
? 'bg-accent/20 text-accent'
|
|
: 'text-text-secondary hover:text-text-primary hover:bg-hover'
|
|
}`}
|
|
>
|
|
Explorer
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('filters')}
|
|
className={`px-2 py-1 text-xs rounded transition-colors ${activeTab === 'filters'
|
|
? 'bg-accent/20 text-accent'
|
|
: 'text-text-secondary hover:text-text-primary hover:bg-hover'
|
|
}`}
|
|
>
|
|
Filters
|
|
</button>
|
|
</div>
|
|
<button
|
|
onClick={() => setIsCollapsed(true)}
|
|
className="p-1 text-text-muted hover:text-text-primary hover:bg-hover rounded transition-colors"
|
|
title="Collapse Panel"
|
|
>
|
|
<PanelLeftClose className="w-4 h-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{activeTab === 'files' && (
|
|
<>
|
|
{/* Search */}
|
|
<div className="px-3 py-2 border-b border-border-subtle">
|
|
<div className="relative">
|
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-text-muted" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search files..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full pl-8 pr-3 py-1.5 bg-elevated border border-border-subtle rounded text-xs text-text-primary placeholder:text-text-muted focus:outline-none focus:border-accent"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* File tree */}
|
|
<div className="flex-1 overflow-y-auto scrollbar-thin py-2">
|
|
{fileTree.length === 0 ? (
|
|
<div className="px-3 py-4 text-center text-text-muted text-xs">
|
|
No files loaded
|
|
</div>
|
|
) : (
|
|
fileTree.map(node => (
|
|
<TreeItem
|
|
key={node.id}
|
|
node={node}
|
|
depth={0}
|
|
searchQuery={searchQuery}
|
|
onNodeClick={handleNodeClick}
|
|
expandedPaths={expandedPaths}
|
|
toggleExpanded={toggleExpanded}
|
|
selectedPath={selectedPath}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
{activeTab === 'filters' && (
|
|
<div className="flex-1 overflow-y-auto scrollbar-thin p-3">
|
|
<div className="mb-3">
|
|
<h3 className="text-xs font-medium text-text-secondary uppercase tracking-wide mb-2">
|
|
Node Types
|
|
</h3>
|
|
<p className="text-[11px] text-text-muted mb-3">
|
|
Toggle visibility of node types in the graph
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
{FILTERABLE_LABELS.map((label) => {
|
|
const Icon = getNodeTypeIcon(label);
|
|
const isVisible = visibleLabels.includes(label);
|
|
|
|
return (
|
|
<button
|
|
key={label}
|
|
onClick={() => toggleLabelVisibility(label)}
|
|
className={`
|
|
flex items-center gap-2.5 px-2 py-1.5 rounded text-left transition-colors
|
|
${isVisible
|
|
? 'bg-elevated text-text-primary'
|
|
: 'text-text-muted hover:bg-hover hover:text-text-secondary'
|
|
}
|
|
`}
|
|
>
|
|
<div
|
|
className={`w-5 h-5 rounded flex items-center justify-center ${isVisible ? '' : 'opacity-40'}`}
|
|
style={{ backgroundColor: `${NODE_COLORS[label]}20` }}
|
|
>
|
|
<Icon className="w-3 h-3" style={{ color: NODE_COLORS[label] }} />
|
|
</div>
|
|
<span className="text-xs flex-1">{label}</span>
|
|
<div
|
|
className={`w-2 h-2 rounded-full transition-colors ${isVisible ? 'bg-accent' : 'bg-border-subtle'}`}
|
|
/>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Edge Type Toggles */}
|
|
<div className="mt-6 pt-4 border-t border-border-subtle">
|
|
<h3 className="text-xs font-medium text-text-secondary uppercase tracking-wide mb-2">
|
|
Edge Types
|
|
</h3>
|
|
<p className="text-[11px] text-text-muted mb-3">
|
|
Toggle visibility of relationship types
|
|
</p>
|
|
|
|
<div className="flex flex-col gap-1">
|
|
{ALL_EDGE_TYPES.map((edgeType) => {
|
|
const info = EDGE_INFO[edgeType];
|
|
const isVisible = visibleEdgeTypes.includes(edgeType);
|
|
|
|
return (
|
|
<button
|
|
key={edgeType}
|
|
onClick={() => toggleEdgeVisibility(edgeType)}
|
|
className={`
|
|
flex items-center gap-2.5 px-2 py-1.5 rounded text-left transition-colors
|
|
${isVisible
|
|
? 'bg-elevated text-text-primary'
|
|
: 'text-text-muted hover:bg-hover hover:text-text-secondary'
|
|
}
|
|
`}
|
|
>
|
|
<div
|
|
className={`w-6 h-1.5 rounded-full ${isVisible ? '' : 'opacity-40'}`}
|
|
style={{ backgroundColor: info.color }}
|
|
/>
|
|
<span className="text-xs flex-1">{info.label}</span>
|
|
<div
|
|
className={`w-2 h-2 rounded-full transition-colors ${isVisible ? 'bg-accent' : 'bg-border-subtle'}`}
|
|
/>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Depth Filter */}
|
|
<div className="mt-6 pt-4 border-t border-border-subtle">
|
|
<h3 className="text-xs font-medium text-text-secondary uppercase tracking-wide mb-2">
|
|
<Target className="w-3 h-3 inline mr-1.5" />
|
|
Focus Depth
|
|
</h3>
|
|
<p className="text-[11px] text-text-muted mb-3">
|
|
Show nodes within N hops of selection
|
|
</p>
|
|
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{[
|
|
{ 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 }) => (
|
|
<button
|
|
key={label}
|
|
onClick={() => setDepthFilter(value)}
|
|
className={`
|
|
px-2 py-1 text-xs rounded transition-colors
|
|
${depthFilter === value
|
|
? 'bg-accent text-white'
|
|
: 'bg-elevated text-text-secondary hover:bg-hover hover:text-text-primary'
|
|
}
|
|
`}
|
|
>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{depthFilter !== null && !selectedNode && (
|
|
<p className="mt-2 text-[10px] text-amber-400">
|
|
Select a node to apply depth filter
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Legend */}
|
|
<div className="mt-6 pt-4 border-t border-border-subtle">
|
|
<h3 className="text-xs font-medium text-text-secondary uppercase tracking-wide mb-3">
|
|
Color Legend
|
|
</h3>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{(['Folder', 'File', 'Class', 'Function', 'Interface', 'Method'] as NodeLabel[]).map(label => (
|
|
<div key={label} className="flex items-center gap-1.5">
|
|
<div
|
|
className="w-2.5 h-2.5 rounded-full"
|
|
style={{ backgroundColor: NODE_COLORS[label] }}
|
|
/>
|
|
<span className="text-[10px] text-text-muted">{label}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Stats footer */}
|
|
{graph && (
|
|
<div className="px-3 py-2 border-t border-border-subtle bg-elevated/50">
|
|
<div className="flex items-center justify-between text-[10px] text-text-muted">
|
|
<span>{graph.nodes.length} nodes</span>
|
|
<span>{graph.relationships.length} edges</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|