{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-tree",
  "title": "File Tree",
  "description": "An expandable tree of folders and files with full keyboard navigation.",
  "dependencies": [
    "lucide-react@^1.31.0"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/default/file-tree/file-tree.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\nimport { ChevronRight, File, Folder, FolderOpen } from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\nexport type FileTreeNode = {\n  id: string\n  name: string\n  kind?: \"file\" | \"folder\"\n  children?: FileTreeNode[]\n  meta?: React.ReactNode\n  icon?: React.ReactNode\n}\n\nexport type FileTreeProps = Omit<\n  React.HTMLAttributes<HTMLDivElement>,\n  \"children\" | \"onSelect\"\n> & {\n  nodes: FileTreeNode[]\n  expandedIds?: string[]\n  defaultExpandedIds?: string[]\n  onExpandedChange?: (ids: string[]) => void\n  selectedId?: string | null\n  defaultSelectedId?: string | null\n  onSelect?: (node: FileTreeNode) => void\n  label?: string\n}\n\ntype FlatNode = {\n  node: FileTreeNode\n  level: number\n  isFolder: boolean\n  isExpanded: boolean\n}\n\nfunction isFolder(node: FileTreeNode) {\n  return node.kind === \"folder\" || Array.isArray(node.children)\n}\n\nfunction flatten(\n  nodes: FileTreeNode[],\n  expanded: Set<string>,\n  level = 1\n): FlatNode[] {\n  return nodes.flatMap((node) => {\n    const folder = isFolder(node)\n    const open = folder && expanded.has(node.id)\n    const self: FlatNode = { node, level, isFolder: folder, isExpanded: open }\n\n    if (!open || !node.children) return [self]\n\n    return [self, ...flatten(node.children, expanded, level + 1)]\n  })\n}\n\nexport function FileTree({\n  nodes,\n  expandedIds,\n  defaultExpandedIds = [],\n  onExpandedChange,\n  selectedId,\n  defaultSelectedId = null,\n  onSelect,\n  label = \"Files\",\n  className,\n  ...rootProps\n}: FileTreeProps) {\n  const [uncontrolledExpanded, setUncontrolledExpanded] =\n    React.useState<string[]>(defaultExpandedIds)\n  const [uncontrolledSelected, setUncontrolledSelected] = React.useState<\n    string | null\n  >(defaultSelectedId)\n\n  const expandedList = expandedIds ?? uncontrolledExpanded\n  const expanded = React.useMemo(() => new Set(expandedList), [expandedList])\n  const selected = selectedId === undefined ? uncontrolledSelected : selectedId\n\n  const rows = React.useMemo(() => flatten(nodes, expanded), [nodes, expanded])\n\n  const containerRef = React.useRef<HTMLDivElement>(null)\n\n  const setExpanded = (ids: string[]) => {\n    if (expandedIds === undefined) setUncontrolledExpanded(ids)\n    onExpandedChange?.(ids)\n  }\n\n  const toggle = (id: string, open?: boolean) => {\n    const shouldOpen = open ?? !expanded.has(id)\n    if (shouldOpen === expanded.has(id)) return\n\n    setExpanded(\n      shouldOpen\n        ? [...expandedList, id]\n        : expandedList.filter((entry) => entry !== id)\n    )\n  }\n\n  const select = (node: FileTreeNode) => {\n    if (selectedId === undefined) setUncontrolledSelected(node.id)\n    onSelect?.(node)\n  }\n\n  const focusRow = (index: number) => {\n    const target = rows[index]\n    if (!target) return\n\n    containerRef.current\n      ?.querySelector<HTMLElement>(\n        `[data-node=\"${CSS.escape(target.node.id)}\"]`\n      )\n      ?.focus()\n  }\n\n  const onKeyDown = (\n    event: React.KeyboardEvent<HTMLDivElement>,\n    row: FlatNode,\n    index: number\n  ) => {\n    switch (event.key) {\n      case \"ArrowDown\":\n        event.preventDefault()\n        focusRow(Math.min(index + 1, rows.length - 1))\n        break\n      case \"ArrowUp\":\n        event.preventDefault()\n        focusRow(Math.max(index - 1, 0))\n        break\n      case \"ArrowRight\":\n        event.preventDefault()\n        if (row.isFolder && !row.isExpanded) toggle(row.node.id, true)\n        else if (row.isFolder) focusRow(index + 1)\n        break\n      case \"ArrowLeft\": {\n        event.preventDefault()\n        if (row.isFolder && row.isExpanded) {\n          toggle(row.node.id, false)\n          break\n        }\n        const parent = rows\n          .slice(0, index)\n          .reverse()\n          .findIndex((entry) => entry.level < row.level)\n        if (parent >= 0) focusRow(index - 1 - parent)\n        break\n      }\n      case \"Home\":\n        event.preventDefault()\n        focusRow(0)\n        break\n      case \"End\":\n        event.preventDefault()\n        focusRow(rows.length - 1)\n        break\n      case \"Enter\":\n      case \" \":\n        event.preventDefault()\n        if (row.isFolder) toggle(row.node.id)\n        select(row.node)\n        break\n      default:\n    }\n  }\n\n  const activeIndex = Math.max(\n    rows.findIndex((row) => row.node.id === selected),\n    0\n  )\n\n  return (\n    <div\n      ref={containerRef}\n      data-slot=\"file-tree\"\n      className={cn(\n        \"border-border bg-card text-card-foreground rounded-[var(--radius)] border p-1.5\",\n        className\n      )}\n      {...rootProps}\n    >\n      <div role=\"tree\" aria-label={label}>\n        {rows.map((row, index) => {\n          const isSelected = row.node.id === selected\n          const Icon = row.isFolder\n            ? row.isExpanded\n              ? FolderOpen\n              : Folder\n            : File\n\n          return (\n            <div\n              key={row.node.id}\n              role=\"treeitem\"\n              data-node={row.node.id}\n              data-slot=\"file-tree-item\"\n              aria-level={row.level}\n              aria-selected={isSelected}\n              aria-expanded={row.isFolder ? row.isExpanded : undefined}\n              tabIndex={index === activeIndex ? 0 : -1}\n              className={cn(\n                \"focus-visible:ring-ring flex min-h-9 cursor-default items-center gap-1.5 rounded-[calc(var(--radius)-0.35rem)] pr-2 text-sm focus-visible:ring-2 focus-visible:outline-none\",\n                isSelected\n                  ? \"bg-muted font-semibold\"\n                  : \"hover:bg-muted/60 transition-colors duration-150 motion-reduce:transition-none\"\n              )}\n              style={{ paddingLeft: `${(row.level - 1) * 0.85 + 0.35}rem` }}\n              onClick={() => {\n                if (row.isFolder) toggle(row.node.id)\n                select(row.node)\n              }}\n              onKeyDown={(event) => onKeyDown(event, row, index)}\n            >\n              <span className=\"flex size-4 shrink-0 items-center justify-center\">\n                {row.isFolder ? (\n                  <ChevronRight\n                    aria-hidden=\"true\"\n                    size={13}\n                    className={cn(\n                      \"text-muted-foreground transition-transform duration-150 motion-reduce:transition-none\",\n                      row.isExpanded && \"rotate-90\"\n                    )}\n                  />\n                ) : null}\n              </span>\n\n              <span\n                aria-hidden=\"true\"\n                className=\"text-muted-foreground shrink-0\"\n              >\n                {row.node.icon ?? <Icon size={15} />}\n              </span>\n\n              <span className=\"min-w-0 flex-1 truncate\">{row.node.name}</span>\n\n              {row.node.meta ? (\n                <span className=\"text-muted-foreground shrink-0 text-xs\">\n                  {row.node.meta}\n                </span>\n              ) : null}\n            </div>\n          )\n        })}\n      </div>\n    </div>\n  )\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "document",
    "tree",
    "files",
    "navigation"
  ],
  "type": "registry:ui"
}