File Tree
alphav0.1.3VS Code-style file tree — format-aware icons, full CRUD, drag-and-drop, lazy children, and multi-select.
Context
Use anywhere a hierarchical-node array needs an interactive tree — code editors, document workspaces, asset libraries, schema browsers, low-code builders, or as the sidebar inside a dual-pane Finder layout. Controlled-data; consumer owns the `nodes` array; component fires object-shape callbacks on every operation.
Installation
pnpm dlx shadcn@latest initpnpm dlx shadcn@latest add @ilinxa/file-treeAdd -fixtures for dummy data:
pnpm dlx shadcn@latest add @ilinxa/file-tree-fixturesCLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:
"registries": {
"@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}Preview
my-app30
Demo source
"use client"; import { useCallback, useState } from "react";import { Code2, FileSpreadsheet, ImageIcon } from "lucide-react";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { FileTree } from "./file-tree";import { dummyFsNodes, dummyShallowNodes, dummyLazyChildren, largeDummyFsNodes,} from "./dummy-data";import { mergeLoadedChildren } from "./lib/tree-utils";import type { FsNode } from "./types"; function Frame({ children }: { children: React.ReactNode }) { return ( <div className="h-105 w-full overflow-hidden rounded-lg border border-border bg-card"> {children} </div> );} function ReadOnlyDemo() { return ( <Frame> <FileTree nodes={dummyFsNodes} title="my-app" showNewFile={false} showNewFolder={false} showRefresh={false} /> </Frame> );} function FullCrudDemo() { const [nodes, setNodes] = useState<FsNode[]>(dummyFsNodes); const [counter, setCounter] = useState(1); const handleCreate = useCallback( (args: { parentId: string | null; type: "file" | "folder" }) => { const id = `new-${args.type}-${counter}`; setCounter((n) => n + 1); const newNode: FsNode = { id, name: args.type === "file" ? `untitled-${counter}.txt` : `New Folder ${counter}`, type: args.type, parentId: args.parentId, children: args.type === "folder" ? [] : undefined, }; const insert = (list: FsNode[]): FsNode[] => { if (args.parentId === null) return [...list, newNode]; return list.map((n) => { if (n.id === args.parentId) { return { ...n, children: [...(n.children ?? []), newNode] }; } if (n.children) { return { ...n, children: insert(n.children) }; } return n; }); }; setNodes(insert); }, [counter], ); const handleRename = useCallback( (args: { id: string; nextName: string }) => { const rename = (list: FsNode[]): FsNode[] => list.map((n) => { if (n.id === args.id) return { ...n, name: args.nextName }; if (n.children) return { ...n, children: rename(n.children) }; return n; }); setNodes(rename); }, [], ); const handleDelete = useCallback((args: { ids: string[] }) => { const ids = new Set(args.ids); const remove = (list: FsNode[]): FsNode[] => list .filter((n) => !ids.has(n.id)) .map((n) => n.children ? { ...n, children: remove(n.children) } : n, ); setNodes(remove); }, []); const handleMove = useCallback( (args: { ids: string[]; targetId: string | null; position: "before" | "inside" | "after" }) => { const moving = new Set(args.ids); // remove from current locations const collect: FsNode[] = []; const removeAndCollect = (list: FsNode[]): FsNode[] => list .filter((n) => { if (moving.has(n.id)) { collect.push(n); return false; } return true; }) .map((n) => n.children ? { ...n, children: removeAndCollect(n.children) } : n, ); const removed = removeAndCollect(nodes); // insert at target if (args.position === "inside" && args.targetId) { const insertInside = (list: FsNode[]): FsNode[] => list.map((n) => { if (n.id === args.targetId) { return { ...n, children: [...(n.children ?? []), ...collect], }; } if (n.children) { return { ...n, children: insertInside(n.children) }; } return n; }); setNodes(insertInside(removed)); } else if (args.targetId) { // before/after: insert relative to target's siblings const insertSiblings = (list: FsNode[]): FsNode[] => { const idx = list.findIndex((n) => n.id === args.targetId); if (idx >= 0) { const at = args.position === "after" ? idx + 1 : idx; return [...list.slice(0, at), ...collect, ...list.slice(at)]; } return list.map((n) => n.children ? { ...n, children: insertSiblings(n.children) } : n, ); }; setNodes(insertSiblings(removed)); } else { // root append setNodes([...removed, ...collect]); } }, [nodes], ); return ( <Frame> <FileTree nodes={nodes} title="Full CRUD" onCreate={handleCreate} onRename={handleRename} onDelete={handleDelete} onMove={handleMove} onOpen={({ node }) => alert(`Open: ${node.name}`)} /> </Frame> );} function LazyLoadDemo() { const [nodes, setNodes] = useState<FsNode[]>(dummyShallowNodes); const handleLoad = useCallback( async ({ nodeId }: { nodeId: string; node: FsNode }): Promise<FsNode[]> => { await new Promise((r) => setTimeout(r, 350)); const kids = dummyLazyChildren[nodeId] ?? []; setNodes((prev) => mergeLoadedChildren(prev, nodeId, kids)); return kids; }, [], ); return ( <Frame> <FileTree nodes={nodes} title="Lazy load" showNewFile={false} showNewFolder={false} onLoadChildren={handleLoad} onOpen={({ node }) => alert(`Open: ${node.name}`)} /> </Frame> );} function MultiSelectDemo() { const [nodes, setNodes] = useState<FsNode[]>(dummyFsNodes); const handleDelete = useCallback((args: { ids: string[] }) => { const ids = new Set(args.ids); const remove = (list: FsNode[]): FsNode[] => list .filter((n) => !ids.has(n.id)) .map((n) => n.children ? { ...n, children: remove(n.children) } : n, ); setNodes(remove); }, []); return ( <Frame> <FileTree nodes={nodes} title="Multi-select" selectionMode="multi" showNewFile={false} showNewFolder={false} showRefresh={false} onDelete={handleDelete} /> </Frame> );} function resolveCustomIcon({ node }: { node: FsNode }) { if (node.type === "folder") return null; const ext = node.ext ?? node.name.split(".").pop()?.toLowerCase() ?? ""; if (["png", "jpg", "jpeg", "svg"].includes(ext)) { return <ImageIcon className="size-4 text-violet-500" />; } if (["json", "yaml", "yml", "toml"].includes(ext)) { return <FileSpreadsheet className="size-4 text-emerald-500" />; } if (["ts", "tsx", "js", "jsx"].includes(ext)) { return <Code2 className="size-4 text-sky-500" />; } return null;} function CustomIconsDemo() { return ( <Frame> <FileTree nodes={dummyFsNodes} title="Custom icons" iconForNode={resolveCustomIcon} showNewFile={false} showNewFolder={false} showRefresh={false} /> </Frame> );} function VirtualizedDemo() { return ( <Frame> <FileTree nodes={largeDummyFsNodes} title="250 nodes (virtualized)" showNewFile={false} showNewFolder={false} showRefresh={false} /> </Frame> );} export default function FileTreeDemo() { return ( <Tabs defaultValue="readonly" className="w-full"> <SwipeTabsList> <TabsTrigger value="readonly">Read-only</TabsTrigger> <TabsTrigger value="crud">Full CRUD</TabsTrigger> <TabsTrigger value="lazy">Lazy load</TabsTrigger> <TabsTrigger value="multi">Multi-select</TabsTrigger> <TabsTrigger value="icons">Custom icons</TabsTrigger> <TabsTrigger value="virtual">Virtualized</TabsTrigger> </SwipeTabsList> <TabsContent value="readonly"> <ReadOnlyDemo /> </TabsContent> <TabsContent value="crud"> <FullCrudDemo /> </TabsContent> <TabsContent value="lazy"> <LazyLoadDemo /> </TabsContent> <TabsContent value="multi"> <MultiSelectDemo /> </TabsContent> <TabsContent value="icons"> <CustomIconsDemo /> </TabsContent> <TabsContent value="virtual"> <VirtualizedDemo /> </TabsContent> </Tabs> );} Usage
When to use
FileTree is a vertical, expand-collapse tree for hierarchical content — file system, project structure, schema namespace, asset library. Reach for it whenever your sidebar (or picker dialog, or schema browser) needs the VS Code shape: chevrons, format-aware icons, keyboard nav, optional CRUD. Pair it with folder-manager as the dual-pane Finder layout.
Data shape
The component is fully controlled — consumer owns the nodes array, mutations happen via callbacks, consumer updates state. children has three semantically distinct values: undefined = not yet loaded (triggers onLoadChildren), [] = known-empty, FsNode[] = pre-loaded.
type FsNode = {
id: string; // stable across renders
name: string; // displayed label
type: "file" | "folder";
parentId?: string | null;
children?: FsNode[]; // undefined | [] | FsNode[]
ext?: string; // explicit extension (else derived from name)
size?: number;
modifiedAt?: string;
icon?: ReactNode; // pre-rendered override
meta?: Record<string, unknown>;
}Lazy loading
Provide onLoadChildrenfor folders whose children aren't pre-fetched. The hook sets loadingFolderIds while the promise pends and shows the inline spinner; when it resolves, you splice the new children into nodes (we export mergeLoadedChildren() to do the immutable splice).
import { mergeLoadedChildren } from "./file-tree"
<FileTree
nodes={nodes}
onLoadChildren={async ({ nodeId }) => {
const kids = await fs.list(nodeId);
setNodes((prev) => mergeLoadedChildren(prev, nodeId, kids));
return kids;
}}
/>Selection + keyboard
- Single by default;
selectionMode="multi"for Cmd/Ctrl+click toggle and Shift+click range. - ↑ / ↓ moves focus among visible rows; → / ← expands/collapses or moves into/out of a folder.
- Enter opens a file (
onOpen) or toggles a folder; Space toggles selection; F2 renames; Delete deletes (with confirm); Cmd/Ctrl+A selects all visible rows; Esc clears selection.
Drag-and-drop
- Within the tree: drag a row onto a folder (drop indicator = ring) or above/below another row (line). Cycle and self-drop are pre-validated —
onMoveonly fires for legal drops. Name-collision is your call (handle it insideonMove). - From the desktop: drop OS files onto the tree to fire
onExternalDrop. Wire your upload flow there.
Custom chrome
Replace the default header wholesale via renderHeader (which gets a typed context with actions and the same flags), or compose subsets using the standalone parts: FileTreeHeader, FileTreeNewFileButton, FileTreeNewFolderButton, FileTreeRefreshButton, FileTreeCollapseAllButton. They read from useFileTree().
Gotchas
onMovefires after structural validation but before you've updatednodes— you must apply the move yourself.onLoadChildrenrejection shows an inline error row + retry button under the folder. Throw a realErrorwith a useful message.- Cut / copy / paste are not in v0.1.0 — they land with
folder-managervia a shared clipboard. Use drag-to-move and right-click delete in the meantime. showHiddendefaults tofalse; nodes whose name starts with.are filtered. Override withisHiddenfor app-specific rules.
Features
- Arbitrary-depth nesting with chevron expand/collapse
- Format-aware Lucide icons (override per-node or via `iconForNode`)
- Controlled or uncontrolled selection + expansion
- Single + multi-select with Cmd/Ctrl+click and Shift+click range
- Right-click menu with default actions + `renderContextMenu` slot
- Inline rename via F2 / double-click + optional `validateRename`
- Drag-and-drop reorder with cycle / self-drop pre-validation
- Drag-from-OS support — `onExternalDrop` fires with files + targetId
- Lazy children loading via `onLoadChildren` + exported `mergeLoadedChildren` helper
- Auto-virtualization at ≥200 visible rows (TanStack Virtual)
- Indent guides + sticky header + sortable + hide-dotfiles
- Built-in delete confirmation dialog (replaceable via slot)
- Standalone header parts for custom chrome composition
- Object-shape callbacks (F-cross-12-correct from day one)
- WCAG 2.1 AA — `role=tree`, `aria-level/setsize/posinset/expanded/selected`, focus-visible