mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-29 01:41:42 +00:00
* feat(graph-view): add tree and circles layout modes
Add alternate graph layouts to the web viewer with new graph view state, canvas controls, adapters, and Sigma layout logic for tree and concentric-circle rendering. Include layout and adapter tests plus tree-view E2E coverage aligned with the English UI labels, and tune node visibility, edge layering, large-graph behavior, and tree-layer spacing so the new views stay readable. Follow up the tree-view work by keeping noisy variables hidden by default and mapping Property/Const icons so filter coverage stays in sync with the expanded node taxonomy.
Co-authored-by: OpenAI Codex <noreply@openai.com>
AI-model: GPT-5 Codex
* fix(web): cap tree layout spring iterations and remove unused variable
Finding A (blocker): calculateTreeLayout runs 14 synchronous spring
iterations over all edges and nodes — O(N×E×14) + O(N log N) per layer
per iteration — with no size guard. At 10K+ nodes this freezes the
main thread for several seconds.
Fix: make SPRING_ITERATIONS adaptive:
- N > 10 000 → 0 iterations (proportional initial layout only)
- N > 3 000 → 4 iterations
- otherwise → 14 iterations (unchanged behaviour for small graphs)
Also removes the unused `const r` at useSigma.ts:1314, which was a
leftover after the radial-resistance decomposition was removed.
This clears the CodeQL "unused variable" warning (Finding G).
Co-authored-by: Claude <noreply@anthropic.com>
AI-model: claude-sonnet-4-6
* test(graph-adapter): add circles adapter tests and tree layout perf bound
Finding B (high): knowledgeGraphToCirclesGraphology had zero test
coverage. Adds three new tests:
- ring placement: verifies Folder→ring 0, File→ring 1, Function→ring 3
and confirms circles-specific attributes (circlesRing, circlesAnchorX/Y)
are set while tree attributes (treeAnchorX/Y) are absent.
- edge styling: CONTAINS is marked isHierarchyEdge=true with the
hierarchy colour; CALLS is cross-cutting with its own colour.
- CALLS cross-cutting: a lone CALLS edge between two Functions is
correctly identified as a non-hierarchy edge.
Also adds a performance-bound test for the tree adapter at 2 000 nodes /
4 000 edges (the adaptive 14-iteration path) asserting completion within
2 s — catches regressions to the O(N×E×iterations) main-thread blocking
that Finding A identified.
Co-authored-by: Claude <noreply@anthropic.com>
AI-model: claude-sonnet-4-6
* refactor(web): rename Tree View → Sequential Layout, Circles → Radial Layout
Aligns the UI labels with standard graph layout terminology from the
Cambridge Intelligence taxonomy (cambridge-intelligence.com/blog/automatic-graph-layouts):
Tree View → Sequential Layout (顺序布局)
Circles → Radial Layout (径向布局)
Force Graph → Force Graph (unchanged)
Internal graphViewMode keys ('tree', 'circles', 'force') are unchanged —
only the displayed strings in en/zh-CN locales and the E2E button selectors
are updated.
Co-authored-by: Claude <noreply@anthropic.com>
AI-model: claude-sonnet-4-6
* perf(web): add adaptive large-graph guards to sequential layout physics
For graphs with N > 5 000 nodes, each rAF frame of runTreeLayout was
doing O(N log N) sort + O(N × k) repulsion pair comparisons (k ≈ 2 400
for a 20 K-node graph spread across 1 080 px at range 130). At that
scale each frame took hundreds of ms, making the canvas appear completely
frozen even though the physics loop was still running.
Fix mirrors the circles layout adaptive strategy:
N > 5 000 (large):
- Skip repulsion pass (O(N × k) → 0)
- Skip spread-force sort (O(N log N) → 0)
- Velocity cap raised to ±12 / ±6 px so nodes cover ground faster
- Damping 0.58, 1 sim step/frame, 30 s max duration
- Looser early-stop thresholds (max v 0.05, avg v 0.03, active 2 %)
N > 1 500 (medium):
- Velocity cap raised to ±6 / ±3 px
- 24 s max duration
- Repulsion and spread still active
N ≤ 1 500 (small):
- Unchanged behaviour (velocity ±3/±2, 18 s, all forces active)
Layer gravity (O(N)) and edge springs (O(E)) run for all graph sizes —
they provide the structural pull that replaces repulsion at large N.
Co-authored-by: Claude <noreply@anthropic.com>
AI-model: claude-sonnet-4-6
* fix(web): fix stale closure in sigma event handlers breaking node selection
The sigma 'clickNode', 'clickStage', 'enterNode', and 'leaveNode' handlers
are registered in a one-time useEffect (empty dep array). They captured
options.onNodeClick via closure, so they always called the initial version
of handleNodeClick — the one created before the graph loaded where
`if (!graph) return` exits immediately.
Consequence: clicking a node in the canvas never updated the app-level
selectedNode state. This broke:
- The Focus Depth filter (warning "Select a node to apply depth filter"
persisted even after a canvas click)
- The depth hop filter not applying (selectedNode was always null)
- The code panel not opening on canvas node click
Fix: store the three callback props in refs (onNodeClickRef, onNodeHoverRef,
onStageClickRef) and update them synchronously on every render. The sigma
event handlers now read from the refs, so they always invoke the latest
version of the callbacks without needing to re-register.
Co-authored-by: Claude <noreply@anthropic.com>
AI-model: claude-sonnet-4-6
* fix(web): address three code-review bugs in graph rendering
Bug 1 (useSigma.ts): forces in the tree physics loop were computed once
before the sub-steps loop and reused for every step, causing 2× displacement
on slow frames (>64ms, simulationSteps>1). Fix: move forceX/forceY Maps and
all force accumulation (layer gravity, edge springs, repulsion, spread) inside
the loop so each sub-step integrates from current node positions.
Bug 2 (graph-adapter.ts): all three adapters used `graph.hasEdge(src,tgt)`
as a dedup guard, which silently drops any second edge between the same node
pair. A CALLS relationship between nodes that also have a CONTAINS edge was
always lost. Fix: switch from `new Graph()` to `new MultiGraph()` (allows
multiple edges per pair) and dedup by `rel.id` instead of by node pair.
Bug 3 (graph-adapter.test.ts): the cross-cutting edge styling test never
executed its CALLS branch because Bug 2 dropped the CALLS edge before the
assertion ran. Fix: assert `sigmaGraph.size === 2` and verify both edges
individually after collecting attrs by relationType.
Co-authored-by: Claude <noreply@anthropic.com>
AI-model: claude-sonnet-4-5
* fix(web): address three code-review bugs in graph rendering
- Move radial layout force accumulation inside the sub-step loop so
forces are recomputed from updated node positions each iteration
instead of using stale forces computed before the loop began
- Revert knowledgeGraphToGraphology from MultiGraph back to Graph with
node-pair deduplication to prevent ForceAtlas2 from double-applying
spring forces for node pairs that share multiple relation types
- Add Target to the lucide-icons import in FileTreePanel.tsx so the
Const node type icon resolves without a ReferenceError
Co-authored-by: Claude <noreply@anthropic.com>
AI-model: claude-sonnet-4-6
* fix(web): address four more PR review comments
Edge visibility (useSigma.ts): HAS_METHOD / HAS_PROPERTY edges were hidden
when any edge-type filter was active because those types are not in the EdgeType
union. Normalize HAS_METHOD → DEFINES and HAS_PROPERTY → CONTAINS before the
visibleTypes.includes() guard so Kotlin/Java hierarchy edges follow the same
filter logic as their semantic equivalents.
Force-mode edge styles (graph-adapter.ts): HAS_METHOD / HAS_PROPERTY fell back
to the default gray color in the force-graph adapter because EDGE_STYLES had no
entries for them. Added explicit entries using the same hues as DEFINES/CONTAINS
so force mode renders Kotlin/Java hierarchy edges consistently with tree/circles.
Accessibility (GraphCanvas.tsx, locales): the layout-mode switcher (Force /
Tree / Circles) had no ARIA semantics. Added role="tablist" on the container
and role="tab" + aria-selected on each button. Added the viewModes.label i18n
key (used as aria-label on the tablist) to en and zh-CN locale files.
Flaky test (graph-adapter.test.ts): replaced the hard 2 s wall-clock assertion
with a structural check (node count + edge count) that is deterministic across
CI hardware. Timing tests are inherently flaky and provide no correctness signal.
Co-authored-by: Claude <noreply@anthropic.com>
AI-model: claude-sonnet-4-5
---------
Co-authored-by: OpenAI Codex <noreply@openai.com>
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
571 lines
19 KiB
TypeScript
571 lines
19 KiB
TypeScript
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 { useTranslation } from 'react-i18next';
|
|
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<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 (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 (
|
|
<div>
|
|
<button
|
|
onClick={handleClick}
|
|
className={`relative flex w-full items-center gap-1.5 rounded px-2 py-1 text-left text-sm transition-colors hover:bg-hover ${isSelected ? 'border-l-2 border-amber-400 bg-amber-500/15 text-amber-300' : 'border-l-2 border-transparent text-text-secondary hover:text-text-primary'} ${matchesSearch ? 'bg-accent/10' : ''} `}
|
|
style={{ paddingLeft: `${depth * 12 + 8}px` }}
|
|
>
|
|
{/* Expand/collapse icon */}
|
|
{hasChildren ? (
|
|
isExpanded ? (
|
|
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-text-muted" />
|
|
) : (
|
|
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-text-muted" />
|
|
)
|
|
) : (
|
|
<span className="w-3.5" />
|
|
)}
|
|
|
|
{/* Node icon */}
|
|
{node.type === 'folder' ? (
|
|
isExpanded ? (
|
|
<FolderOpen className="h-4 w-4 shrink-0" style={{ color: NODE_COLORS.Folder }} />
|
|
) : (
|
|
<Folder className="h-4 w-4 shrink-0" style={{ color: NODE_COLORS.Folder }} />
|
|
)
|
|
) : (
|
|
<FileCode className="h-4 w-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 'Enum':
|
|
return List;
|
|
case 'Type':
|
|
return Type;
|
|
case 'Decorator':
|
|
return AtSign;
|
|
case 'Import':
|
|
return FileCode;
|
|
case 'Variable':
|
|
case 'Property':
|
|
return Variable;
|
|
case 'Const':
|
|
return Target;
|
|
default:
|
|
return Variable;
|
|
}
|
|
};
|
|
|
|
interface FileTreePanelProps {
|
|
onFocusNode: (nodeId: string) => void;
|
|
}
|
|
|
|
export const FileTreePanel = ({ onFocusNode }: FileTreePanelProps) => {
|
|
const { t } = useTranslation(['common', 'graph']);
|
|
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="flex h-full w-12 flex-col items-center gap-2 border-r border-border-subtle bg-surface py-3">
|
|
<button
|
|
onClick={() => setIsCollapsed(false)}
|
|
className="rounded p-2 text-text-secondary transition-colors hover:bg-hover hover:text-text-primary"
|
|
title={t('graph:fileTree.expandPanel')}
|
|
>
|
|
<PanelLeft className="h-5 w-5" />
|
|
</button>
|
|
<div className="my-1 h-px w-6 bg-border-subtle" />
|
|
<button
|
|
onClick={() => {
|
|
setIsCollapsed(false);
|
|
setActiveTab('files');
|
|
}}
|
|
className={`rounded p-2 transition-colors ${activeTab === 'files' ? 'bg-accent/10 text-accent' : 'text-text-secondary hover:bg-hover hover:text-text-primary'}`}
|
|
title={t('graph:fileTree.fileExplorer')}
|
|
>
|
|
<Folder className="h-5 w-5" />
|
|
</button>
|
|
<button
|
|
onClick={() => {
|
|
setIsCollapsed(false);
|
|
setActiveTab('filters');
|
|
}}
|
|
className={`rounded p-2 transition-colors ${activeTab === 'filters' ? 'bg-accent/10 text-accent' : 'text-text-secondary hover:bg-hover hover:text-text-primary'}`}
|
|
title={t('graph:fileTree.filters')}
|
|
>
|
|
<Filter className="h-5 w-5" />
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full w-64 animate-slide-in flex-col border-r border-border-subtle bg-surface">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between border-b border-border-subtle px-3 py-2">
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => setActiveTab('files')}
|
|
className={`rounded px-2 py-1 text-xs transition-colors ${
|
|
activeTab === 'files'
|
|
? 'bg-accent/20 text-accent'
|
|
: 'text-text-secondary hover:bg-hover hover:text-text-primary'
|
|
}`}
|
|
>
|
|
{t('graph:fileTree.explorer')}
|
|
</button>
|
|
<button
|
|
onClick={() => setActiveTab('filters')}
|
|
className={`rounded px-2 py-1 text-xs transition-colors ${
|
|
activeTab === 'filters'
|
|
? 'bg-accent/20 text-accent'
|
|
: 'text-text-secondary hover:bg-hover hover:text-text-primary'
|
|
}`}
|
|
>
|
|
{t('graph:fileTree.filters')}
|
|
</button>
|
|
</div>
|
|
<button
|
|
onClick={() => setIsCollapsed(true)}
|
|
className="rounded p-1 text-text-muted transition-colors hover:bg-hover hover:text-text-primary"
|
|
title={t('graph:fileTree.collapsePanel')}
|
|
>
|
|
<PanelLeftClose className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{activeTab === 'files' && (
|
|
<>
|
|
{/* Search */}
|
|
<div className="border-b border-border-subtle px-3 py-2">
|
|
<div className="relative">
|
|
<Search className="absolute top-1/2 left-2.5 h-3.5 w-3.5 -translate-y-1/2 text-text-muted" />
|
|
<input
|
|
type="text"
|
|
placeholder={t('graph:fileTree.searchFiles')}
|
|
value={searchQuery}
|
|
onChange={(e) => 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"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* File tree */}
|
|
<div className="scrollbar-thin flex-1 overflow-y-auto py-2">
|
|
{fileTree.length === 0 ? (
|
|
<div className="px-3 py-4 text-center text-xs text-text-muted">
|
|
{t('graph:fileTree.noFilesLoaded')}
|
|
</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="scrollbar-thin flex-1 overflow-y-auto p-3">
|
|
<div className="mb-3">
|
|
<h3 className="mb-2 text-xs font-medium tracking-wide text-text-secondary uppercase">
|
|
{t('graph:fileTree.nodeTypes')}
|
|
</h3>
|
|
<p className="mb-3 text-[11px] text-text-muted">{t('graph:fileTree.nodeTypesDesc')}</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 rounded px-2 py-1.5 text-left transition-colors ${
|
|
isVisible
|
|
? 'bg-elevated text-text-primary'
|
|
: 'text-text-muted hover:bg-hover hover:text-text-secondary'
|
|
} `}
|
|
>
|
|
<div
|
|
className={`flex h-5 w-5 items-center justify-center rounded ${isVisible ? '' : 'opacity-40'}`}
|
|
style={{ backgroundColor: `${NODE_COLORS[label]}20` }}
|
|
>
|
|
<Icon className="h-3 w-3" style={{ color: NODE_COLORS[label] }} />
|
|
</div>
|
|
<span className="flex-1 text-xs">{label}</span>
|
|
<div
|
|
className={`h-2 w-2 rounded-full transition-colors ${isVisible ? 'bg-accent' : 'bg-border-subtle'}`}
|
|
/>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Edge Type Toggles */}
|
|
<div className="mt-6 border-t border-border-subtle pt-4">
|
|
<h3 className="mb-2 text-xs font-medium tracking-wide text-text-secondary uppercase">
|
|
{t('graph:fileTree.edgeTypes')}
|
|
</h3>
|
|
<p className="mb-3 text-[11px] text-text-muted">{t('graph:fileTree.edgeTypesDesc')}</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 rounded px-2 py-1.5 text-left transition-colors ${
|
|
isVisible
|
|
? 'bg-elevated text-text-primary'
|
|
: 'text-text-muted hover:bg-hover hover:text-text-secondary'
|
|
} `}
|
|
>
|
|
<div
|
|
className={`h-1.5 w-6 rounded-full ${isVisible ? '' : 'opacity-40'}`}
|
|
style={{ backgroundColor: info.color }}
|
|
/>
|
|
<span className="flex-1 text-xs">{info.label}</span>
|
|
<div
|
|
className={`h-2 w-2 rounded-full transition-colors ${isVisible ? 'bg-accent' : 'bg-border-subtle'}`}
|
|
/>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Depth Filter */}
|
|
<div className="mt-6 border-t border-border-subtle pt-4">
|
|
<h3 className="mb-2 text-xs font-medium tracking-wide text-text-secondary uppercase">
|
|
<Target className="mr-1.5 inline h-3 w-3" />
|
|
{t('graph:fileTree.focusDepth')}
|
|
</h3>
|
|
<p className="mb-3 text-[11px] text-text-muted">{t('graph:fileTree.focusDepthDesc')}</p>
|
|
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{[
|
|
{ value: null, label: t('graph:fileTree.all') },
|
|
{ value: 1, label: t('graph:fileTree.hops', { count: 1 }) },
|
|
{ value: 2, label: t('graph:fileTree.hops', { count: 2 }) },
|
|
{ value: 3, label: t('graph:fileTree.hops', { count: 3 }) },
|
|
{ value: 5, label: t('graph:fileTree.hops', { count: 5 }) },
|
|
].map(({ value, label }) => (
|
|
<button
|
|
key={label}
|
|
onClick={() => setDepthFilter(value)}
|
|
className={`rounded px-2 py-1 text-xs 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">
|
|
{t('graph:fileTree.selectNodeDepth')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Legend */}
|
|
<div className="mt-6 border-t border-border-subtle pt-4">
|
|
<h3 className="mb-3 text-xs font-medium tracking-wide text-text-secondary uppercase">
|
|
{t('graph:fileTree.colorLegend')}
|
|
</h3>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
{(
|
|
[
|
|
'Folder',
|
|
'File',
|
|
'Class',
|
|
'Interface',
|
|
'Enum',
|
|
'Type',
|
|
'Function',
|
|
'Method',
|
|
'Variable',
|
|
'Decorator',
|
|
] as NodeLabel[]
|
|
).map((label) => (
|
|
<div key={label} className="flex items-center gap-1.5">
|
|
<div
|
|
className="h-2.5 w-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="border-t border-border-subtle bg-elevated/50 px-3 py-2">
|
|
<div className="flex items-center justify-between text-[10px] text-text-muted">
|
|
<span>{t('common:counts.nodes', { count: graph.nodes.length })}</span>
|
|
<span>{t('common:counts.edges', { count: graph.relationships.length })}</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|