import { useMemo, useState } from "react"; import type { GraphNode } from "@understand-anything/core/types"; import { useDashboardStore } from "../store"; import { useI18n } from "../contexts/I18nContext"; interface FileEntry { name: string; path: string; type: "folder" | "file"; children: FileEntry[]; nodeId?: string; } function normalizeFilePath(filePath: string): string | null { const normalized = filePath.replace(/\\/g, "/").replace(/^\/+/, "").replace(/^\.\//, ""); if (!normalized || normalized === "." || normalized.includes("\0")) return null; if (normalized.split("/").some((part) => part === "..")) return null; return normalized; } function bestFileNode(existing: GraphNode | undefined, candidate: GraphNode): GraphNode { if (!existing) return candidate; if (existing.type !== "file" && candidate.type === "file") return candidate; return existing; } function buildFileTree(nodes: GraphNode[]): FileEntry[] { const files = new Map(); for (const node of nodes) { if (!node.filePath) continue; const filePath = normalizeFilePath(node.filePath); if (!filePath) continue; files.set(filePath, bestFileNode(files.get(filePath), node)); } const root: FileEntry = { name: "", path: "", type: "folder", children: [] }; const folders = new Map([["", root]]); for (const [filePath, node] of files) { const parts = filePath.split("/"); let parent = root; let currentPath = ""; for (let i = 0; i < parts.length; i += 1) { const name = parts[i]; currentPath = currentPath ? `${currentPath}/${name}` : name; const isFile = i === parts.length - 1; if (isFile) { parent.children.push({ name, path: currentPath, type: "file", children: [], nodeId: node.id, }); continue; } let folder = folders.get(currentPath); if (!folder) { folder = { name, path: currentPath, type: "folder", children: [] }; folders.set(currentPath, folder); parent.children.push(folder); } parent = folder; } } const sortEntries = (entries: FileEntry[]): FileEntry[] => entries .sort((a, b) => { if (a.type !== b.type) return a.type === "folder" ? -1 : 1; return a.name.localeCompare(b.name); }) .map((entry) => ({ ...entry, children: sortEntries(entry.children), })); return sortEntries(root.children); } function FileTreeRow({ entry, depth, expanded, toggleFolder, openFile, }: { entry: FileEntry; depth: number; expanded: Set; toggleFolder: (path: string) => void; openFile: (nodeId: string) => void; }) { const isExpanded = expanded.has(entry.path); const paddingLeft = 12 + depth * 14; if (entry.type === "folder") { return ( <> {isExpanded && entry.children.map((child) => ( ))} ); } return ( ); } export default function FileExplorer() { const graph = useDashboardStore((s) => s.graph); const openCodeViewer = useDashboardStore((s) => s.openCodeViewer); const navigateToNode = useDashboardStore((s) => s.navigateToNode); const { t } = useI18n(); const entries = useMemo(() => buildFileTree(graph?.nodes ?? []), [graph]); const [expanded, setExpanded] = useState>(() => new Set()); // Navigate the graph first (drills into layer + selects node, which clears the // code viewer), then re-open the viewer so the source panel stays visible. const handleOpenFile = (nodeId: string) => { navigateToNode(nodeId); openCodeViewer(nodeId); }; const toggleFolder = (folderPath: string) => { setExpanded((current) => { const next = new Set(current); if (next.has(folderPath)) { next.delete(folderPath); } else { next.add(folderPath); } return next; }); }; const totalFiles = useMemo(() => { const countFiles = (items: FileEntry[]): number => items.reduce( (count, item) => count + (item.type === "file" ? 1 : countFiles(item.children)), 0, ); return countFiles(entries); }, [entries]); if (!graph) { return (
{t.common.noGraphLoaded}
); } return (
{t.fileExplorer.analyzedFiles}
{totalFiles} {t.fileExplorer.filesFromGraph}
{entries.length === 0 ? (
{t.fileExplorer.noFilePathsFound}
) : ( entries.map((entry) => ( )) )}
); }