{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ink/directory-tree",
  "title": "Directory Tree",
  "description": "Filesystem browser with expand/collapse",
  "dependencies": [
    "ink"
  ],
  "registryDependencies": [
    "https://termcn.dev/r/ink/interaction.json",
    "https://termcn.dev/r/ink/terminal-symbols.json",
    "https://termcn.dev/r/ink/use-theme.json",
    "https://termcn.dev/r/ink/use-unicode.json"
  ],
  "files": [
    {
      "path": "registry/ui/directory-tree.tsx",
      "content": "import { readdirSync, statSync } from \"node:fs\";\nimport { join } from \"node:path\";\n\nimport { Box, Text } from \"ink\";\nimport React, { useEffect, useState } from \"react\";\n\nimport { useInteraction } from \"@/hooks/use-interaction\";\nimport type { InteractionProps } from \"@/hooks/use-interaction\";\nimport { useTheme } from \"@/hooks/use-theme\";\nimport { useUnicode } from \"@/hooks/use-unicode\";\nimport { resolveTerminalSymbol } from \"@/lib/terminal-symbols\";\n\nexport interface DirectoryTreeProps extends InteractionProps {\n  rootPath?: string;\n  onSelect?: (path: string) => void;\n  maxDepth?: number;\n  showHidden?: boolean;\n  label?: string;\n  \"aria-label\"?: string;\n}\n\ninterface TreeEntry {\n  path: string;\n  name: string;\n  depth: number;\n  isDir: boolean;\n  isLast: boolean;\n}\n\nconst readEntries = (\n  dir: string,\n  depth: number,\n  maxDepth: number,\n  expanded: Set<string>,\n  showHidden: boolean\n): TreeEntry[] => {\n  const result: TreeEntry[] = [];\n  let entries: string[];\n\n  try {\n    entries = readdirSync(dir).filter((e) =>\n      showHidden ? true : !e.startsWith(\".\")\n    );\n  } catch {\n    return result;\n  }\n\n  entries.sort((a, b) => {\n    try {\n      const aIsDir = statSync(join(dir, a)).isDirectory();\n      const bIsDir = statSync(join(dir, b)).isDirectory();\n      if (aIsDir && !bIsDir) {\n        return -1;\n      }\n      if (!aIsDir && bIsDir) {\n        return 1;\n      }\n    } catch {\n      /* noop */\n    }\n    return a.localeCompare(b);\n  });\n\n  for (let i = 0; i < entries.length; i += 1) {\n    const name = entries[i];\n    const fullPath = join(dir, name);\n    const isLast = i === entries.length - 1;\n    let isDir = false;\n\n    try {\n      isDir = statSync(fullPath).isDirectory();\n    } catch {\n      /* noop */\n    }\n\n    result.push({ depth, isDir, isLast, name, path: fullPath });\n\n    if (isDir && depth < maxDepth && expanded.has(fullPath)) {\n      result.push(\n        ...readEntries(fullPath, depth + 1, maxDepth, expanded, showHidden)\n      );\n    }\n  }\n\n  return result;\n};\n\nexport const DirectoryTree = ({\n  rootPath = process.cwd(),\n  onSelect,\n  maxDepth = 2,\n  showHidden = false,\n  label,\n  id,\n  autoFocus,\n  isActive,\n  disabled,\n  \"aria-label\": ariaLabel = label ?? \"Directory tree\",\n}: DirectoryTreeProps) => {\n  const theme = useTheme();\n  const unicode = useUnicode();\n  const [expanded, setExpanded] = useState<Set<string>>(new Set([rootPath]));\n  const [cursor, setCursor] = useState(0);\n\n  const entries = readEntries(rootPath, 0, maxDepth + 1, expanded, showHidden);\n\n  const { isFocused } = useInteraction(\n    (input, key) => {\n      if (key.upArrow) {\n        setCursor((c) => Math.max(0, c - 1));\n      } else if (key.downArrow) {\n        setCursor((c) => Math.min(entries.length - 1, c + 1));\n      } else if (key.home) {\n        setCursor(0);\n      } else if (key.end) {\n        setCursor(Math.max(0, entries.length - 1));\n      } else if (key.return || input === \" \") {\n        const entry = entries[cursor];\n        if (!entry) {\n          return;\n        }\n        if (entry.isDir) {\n          setExpanded((prev) => {\n            const next = new Set(prev);\n            if (next.has(entry.path)) {\n              next.delete(entry.path);\n            } else {\n              next.add(entry.path);\n            }\n            return next;\n          });\n        } else {\n          onSelect?.(entry.path);\n        }\n      }\n    },\n    { autoFocus, disabled, id, isActive }\n  );\n\n  useEffect(() => {\n    setCursor((current) => Math.min(current, Math.max(0, entries.length - 1)));\n  }, [entries.length]);\n\n  return (\n    <Box flexDirection=\"column\" aria-role=\"list\">\n      <Text aria-label={`${ariaLabel}. Root: ${rootPath}`}>{\"\"}</Text>\n      {label && <Text bold>{label}</Text>}\n      <Text color={theme.colors.primary} bold>\n        {rootPath}\n      </Text>\n      {entries.map((entry, idx) => {\n        const isCursor = idx === cursor;\n        const isExpanded = entry.isDir && expanded.has(entry.path);\n\n        let icon: string;\n        if (entry.isDir) {\n          icon = isExpanded\n            ? resolveTerminalSymbol(unicode, \"▼ \", \"v \")\n            : resolveTerminalSymbol(unicode, \"▶ \", \"> \");\n        } else {\n          icon = resolveTerminalSymbol(unicode, \"· \", \"- \");\n        }\n\n        const indent = \"  \".repeat(entry.depth);\n\n        let entryColor: string;\n        if (isCursor) {\n          entryColor = theme.colors.selectionForeground;\n        } else if (entry.isDir) {\n          entryColor = theme.colors.primary;\n        } else {\n          entryColor = theme.colors.foreground;\n        }\n\n        return (\n          <Box\n            key={entry.path}\n            aria-role=\"listitem\"\n            aria-label={`${entry.name}, ${entry.isDir ? \"directory\" : \"file\"}, level ${entry.depth + 1}${\n              entry.isDir ? `, ${isExpanded ? \"expanded\" : \"collapsed\"}` : \"\"\n            }${isCursor && isFocused ? \", current\" : \"\"}`}\n            aria-state={{\n              expanded: entry.isDir ? isExpanded : undefined,\n              selected: isCursor && isFocused,\n            }}\n          >\n            <Text\n              color={entryColor}\n              backgroundColor={isCursor ? theme.colors.selection : undefined}\n              bold={entry.isDir}\n            >\n              {indent}\n              {isCursor ? \"[\" : \"\"}\n              {icon}\n              {entry.name}\n              {isCursor ? \"]\" : \"\"}\n            </Text>\n          </Box>\n        );\n      })}\n      <Text aria-hidden color={theme.colors.mutedForeground} dimColor>\n        {unicode\n          ? \"↑↓: navigate · Space/Enter: expand/select\"\n          : \"up/down: navigate - Space/Enter: expand/select\"}\n      </Text>\n    </Box>\n  );\n};\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "data"
  ],
  "type": "registry:ui"
}