"use client"; import { useCallback, type ReactNode } from "react"; import { IconChevronRight, IconChevronDown, IconFolder, IconFolderOpen, IconFileText, } from "@tabler/icons-react"; import { Checkbox } from "@kandev/ui/checkbox"; import { cn } from "@/lib/utils"; import { useTree, type VisibleRow } from "@/hooks/use-tree"; import { useTreeKeyboardNav } from "@/hooks/use-tree-keyboard-nav"; export interface FileTreeNode { name: string; path: string; isDir: boolean; children: FileTreeNode[]; content?: string; } // Module-level adapter constants so they keep a stable identity across // renders. Without this, useTree's visibleRows useMemo would recompute on // every parent render — the adapters land in its dependency array. const FILE_TREE_GET_PATH = (n: FileTreeNode) => n.path; const FILE_TREE_GET_CHILDREN = (n: FileTreeNode) => n.children; const FILE_TREE_IS_DIR = (n: FileTreeNode) => n.isDir; interface FileTreeProps { nodes: FileTreeNode[]; selectedPath?: string | null; onSelectPath?: (path: string) => void; checkedPaths?: Set; onCheckedPathsChange?: (paths: Set) => void; showCheckboxes?: boolean; renderExtra?: (node: FileTreeNode) => ReactNode; defaultExpanded?: boolean; } /** Collect all leaf (file) paths under a node. */ function getLeafPaths(node: FileTreeNode): string[] { if (!node.isDir) return [node.path]; return node.children.flatMap(getLeafPaths); } type CheckState = boolean | "indeterminate"; function getCheckState(node: FileTreeNode, checkedPaths: Set): CheckState { const leaves = getLeafPaths(node); // An empty leaves array would make .every() vacuously true and the dir // would render as checked. A dir with no file leaves can't be "all checked". if (leaves.length === 0) return false; if (leaves.every((p) => checkedPaths.has(p))) return true; if (leaves.some((p) => checkedPaths.has(p))) return "indeterminate"; return false; } export function FileTree({ nodes, selectedPath, onSelectPath, checkedPaths, onCheckedPathsChange, showCheckboxes = false, renderExtra, defaultExpanded = false, }: FileTreeProps) { const tree = useTree({ nodes, getPath: FILE_TREE_GET_PATH, getChildren: FILE_TREE_GET_CHILDREN, isDir: FILE_TREE_IS_DIR, chainCollapse: true, defaultExpanded: defaultExpanded ? "all" : undefined, }); const { visibleRows, toggle } = tree; const nav = useTreeKeyboardNav({ visibleRows, toggle, expand: tree.expand, collapse: tree.collapse, isExpanded: tree.isExpanded, onActivate: (row) => onSelectPath?.(row.path), }); const handleToggleCheck = useCallback( (node: FileTreeNode) => { if (!checkedPaths || !onCheckedPathsChange) return; const paths = getLeafPaths(node); const allChecked = paths.every((p) => checkedPaths.has(p)); const next = new Set(checkedPaths); for (const p of paths) { if (allChecked) next.delete(p); else next.add(p); } onCheckedPathsChange(next); }, [checkedPaths, onCheckedPathsChange], ); return (
{visibleRows.map((row) => ( { nav.setFocusedPath(row.path); if (row.isDir) toggle(row.path); else onSelectPath?.(row.path); }} onToggleCheck={() => handleToggleCheck(row.node)} renderExtra={renderExtra} /> ))}
); } interface FileTreeRowProps { row: VisibleRow; isActive: boolean; isFocused: boolean; checkState?: CheckState; showCheckboxes: boolean; onClick: () => void; onToggleCheck: () => void; renderExtra?: (node: FileTreeNode) => ReactNode; } function FileTreeRow({ row, isActive, isFocused, checkState, showCheckboxes, onClick, onToggleCheck, renderExtra, }: FileTreeRowProps) { const FolderIcon = row.isExpanded ? IconFolderOpen : IconFolder; const ChevronIcon = row.isExpanded ? IconChevronDown : IconChevronRight; return (
{showCheckboxes && checkState !== undefined && ( e.stopPropagation()} className="cursor-pointer shrink-0" /> )} {row.isDir && } {row.isDir ? ( ) : ( )} {row.displayName} {renderExtra?.(row.node)}
); }