186 lines
5.4 KiB
TypeScript
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>
|
|
);
|
|
}
|