Files
kandev/apps/web/components/shared/file-tree.tsx
T
2026-07-31 10:55:29 +01:00

186 lines
5.4 KiB
TypeScript

"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<string>;
onCheckedPathsChange?: (paths: Set<string>) => 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<string>): 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<FileTreeNode>({
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<FileTreeNode>({
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 (
<div
className="overflow-y-auto py-1 outline-none"
tabIndex={0}
role="tree"
onKeyDown={nav.handleKeyDown}
>
{visibleRows.map((row) => (
<FileTreeRow
key={row.path}
row={row}
isActive={!row.isDir && selectedPath === row.path}
isFocused={nav.focusedPath === row.path}
checkState={
showCheckboxes && checkedPaths ? getCheckState(row.node, checkedPaths) : undefined
}
showCheckboxes={showCheckboxes}
onClick={() => {
nav.setFocusedPath(row.path);
if (row.isDir) toggle(row.path);
else onSelectPath?.(row.path);
}}
onToggleCheck={() => handleToggleCheck(row.node)}
renderExtra={renderExtra}
/>
))}
</div>
);
}
interface FileTreeRowProps {
row: VisibleRow<FileTreeNode>;
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 (
<div
className={cn(
"flex items-center gap-1.5 border border-transparent px-2 py-1 text-sm cursor-pointer",
isActive ? "border-primary/50 bg-card" : "hover:bg-muted",
isFocused && "ring-1 ring-ring",
)}
style={{ paddingLeft: `${row.depth * 16 + 8}px` }}
onClick={onClick}
>
{showCheckboxes && checkState !== undefined && (
<Checkbox
checked={checkState}
onCheckedChange={onToggleCheck}
onClick={(e) => e.stopPropagation()}
className="cursor-pointer shrink-0"
/>
)}
{row.isDir && <ChevronIcon className="h-3.5 w-3.5 text-muted-foreground shrink-0" />}
{row.isDir ? (
<FolderIcon className="h-4 w-4 text-muted-foreground shrink-0" />
) : (
<IconFileText className="h-4 w-4 text-muted-foreground shrink-0" />
)}
<span className="truncate">{row.displayName}</span>
{renderExtra?.(row.node)}
</div>
);
}