Skip to content
ilinxa/pro-ui

File Tree

alphav0.1.3

VS Code-style file tree — format-aware icons, full CRUD, drag-and-drop, lazy children, and multi-select.

Category: NavigationUpdated: 2026-08-11Created: 2026-05-10Author: ilinxa

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

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Install the component
pnpm dlx shadcn@latest add @ilinxa/file-tree

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/file-tree-fixtures

CLI 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

demo.tsxtsx
"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 — onMove only 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 updated nodes — you must apply the move yourself.
  • onLoadChildren rejection shows an inline error row + retry button under the folder. Throw a real Error with a useful message.
  • Cut / copy / paste are not in v0.1.0 — they land with folder-manager via a shared clipboard. Use drag-to-move and right-click delete in the meantime.
  • showHidden defaults to false; nodes whose name starts with . are filtered. Override with isHidden for 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

Tags

treenavigationfilesystemexplorerhierarchyfilefoldervscode

Dependencies

shadcn primitives: alert-dialog, button, context-menu, tooltip
npm peer deps: @tanstack/react-virtual@^3.13.24, lucide-react@^1.11.0