{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ink/file-picker",
  "title": "File Picker",
  "description": "Interactive file browser and picker",
  "dependencies": [
    "ink"
  ],
  "registryDependencies": [
    "https://termcn.dev/r/ink/interaction.json",
    "https://termcn.dev/r/ink/terminal-style.json",
    "https://termcn.dev/r/ink/use-theme.json",
    "https://termcn.dev/r/ink/use-unicode.json"
  ],
  "files": [
    {
      "path": "registry/ui/file-picker.tsx",
      "content": "import { readdirSync, statSync } from \"node:fs\";\nimport { join, resolve } from \"node:path\";\n\nimport { Box, Text } from \"ink\";\nimport React, { useState } from \"react\";\n\nimport { useInteraction } from \"@/hooks/use-interaction\";\nimport { useTheme } from \"@/hooks/use-theme\";\nimport { useUnicode } from \"@/hooks/use-unicode\";\nimport { resolveBorderStyle } from \"@/lib/terminal-style\";\n\nexport interface FilePickerProps {\n  value?: string;\n  defaultValue?: string;\n  onValueChange?: (path: string) => void;\n  onChange?: (path: string) => void;\n  onSubmit?: (path: string) => void;\n  label?: string;\n  startDir?: string;\n  extensions?: string[];\n  dirsOnly?: boolean;\n  autoFocus?: boolean;\n  id?: string;\n  width?: number;\n  maxVisible?: number;\n  isActive?: boolean;\n  disabled?: boolean;\n  \"aria-label\"?: string;\n}\n\ninterface FileEntry {\n  name: string;\n  path: string;\n  isDir: boolean;\n}\n\nconst readDir = (\n  dir: string,\n  extensions?: string[],\n  dirsOnly?: boolean\n): FileEntry[] => {\n  try {\n    const entries = readdirSync(dir).filter((e) => !e.startsWith(\".\"));\n    const result: FileEntry[] = [];\n\n    for (const name of entries) {\n      const fullPath = join(dir, name);\n      try {\n        const stat = statSync(fullPath);\n        const isDir = stat.isDirectory();\n        if (dirsOnly && !isDir) {\n          continue;\n        }\n        if (\n          extensions &&\n          !isDir &&\n          !extensions.some((ext) => name.endsWith(ext))\n        ) {\n          continue;\n        }\n        result.push({ isDir, name, path: fullPath });\n      } catch {\n        /* noop */\n      }\n    }\n\n    result.sort((a, b) => {\n      if (a.isDir && !b.isDir) {\n        return -1;\n      }\n      if (!a.isDir && b.isDir) {\n        return 1;\n      }\n      return a.name.localeCompare(b.name);\n    });\n\n    return result;\n  } catch {\n    return [];\n  }\n};\n\nexport const FilePicker = ({\n  value: controlledValue,\n  defaultValue = \"\",\n  onValueChange,\n  onChange,\n  onSubmit,\n  label,\n  startDir = process.cwd(),\n  extensions,\n  dirsOnly = false,\n  autoFocus = false,\n  id,\n  width = 50,\n  maxVisible = 8,\n  isActive = true,\n  disabled = false,\n  \"aria-label\": ariaLabel,\n}: FilePickerProps) => {\n  const unicode = useUnicode();\n  const theme = useTheme();\n  const [currentDir, setCurrentDir] = useState(resolve(startDir));\n  const [cursor, setCursor] = useState(0);\n  const [internalValue, setInternalValue] = useState(defaultValue);\n\n  const selected = controlledValue ?? internalValue;\n  const entries = readDir(currentDir, extensions, dirsOnly);\n\n  const parentEntry: FileEntry = {\n    isDir: true,\n    name: \"..\",\n    path: resolve(currentDir, \"..\"),\n  };\n  const allEntries: FileEntry[] = [parentEntry, ...entries];\n\n  const visibleStart = Math.max(0, cursor - maxVisible + 1);\n  const visible = allEntries.slice(visibleStart, visibleStart + maxVisible);\n  const applyChange = (nextPath: string) => {\n    const changeHandler = onValueChange ?? onChange;\n    if (changeHandler) {\n      changeHandler(nextPath);\n    } else {\n      setInternalValue(nextPath);\n    }\n  };\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(allEntries.length - 1, c + 1));\n      } else if (key.return) {\n        const entry = allEntries[cursor];\n        if (!entry) {\n          return;\n        }\n        if (entry.isDir) {\n          setCurrentDir(entry.path);\n          setCursor(0);\n        } else {\n          applyChange(entry.path);\n          onSubmit?.(entry.path);\n        }\n      } else if (input === \" \") {\n        const entry = allEntries[cursor];\n        if (entry && !entry.isDir) {\n          applyChange(entry.path);\n        }\n      } else if (key.home) {\n        setCursor(0);\n      } else if (key.end) {\n        setCursor(Math.max(0, allEntries.length - 1));\n      } else if (key.escape) {\n        setCurrentDir((d) => resolve(d, \"..\"));\n        setCursor(0);\n      }\n    },\n    { autoFocus, disabled, id, isActive }\n  );\n\n  return (\n    <Box flexDirection=\"column\">\n      <Text\n        aria-label={ariaLabel ?? label ?? \"File picker\"}\n        bold={Boolean(label)}\n      >\n        {label ?? \"\"}\n      </Text>\n\n      <Box\n        borderStyle={resolveBorderStyle(\"single\", unicode)}\n        borderColor={isFocused ? theme.colors.focusRing : theme.colors.border}\n        width={width}\n        paddingX={1}\n      >\n        <Text color={theme.colors.primary} bold>\n          {currentDir}\n        </Text>\n      </Box>\n\n      <Box\n        aria-role=\"listbox\"\n        aria-state={{ disabled }}\n        flexDirection=\"column\"\n        borderStyle={resolveBorderStyle(\"single\", unicode)}\n        borderColor={theme.colors.border}\n        width={width}\n      >\n        {visible.map((entry, idx) => {\n          const absIdx = visibleStart + idx;\n          const isCursor = absIdx === cursor;\n          const isSelected = entry.path === selected;\n\n          let entryColor: string;\n          if (entry.isDir) {\n            entryColor = theme.colors.primary;\n          } else if (isSelected) {\n            entryColor = theme.colors.accent;\n          } else {\n            entryColor = theme.colors.foreground;\n          }\n\n          return (\n            <Box\n              key={entry.path}\n              aria-label={`${entry.isDir ? \"Directory\" : \"File\"}: ${entry.name}`}\n              aria-role=\"option\"\n              aria-state={{ selected: isSelected }}\n              paddingX={1}\n            >\n              <Text\n                aria-hidden\n                color={isCursor ? theme.colors.selectionForeground : entryColor}\n                backgroundColor={isCursor ? theme.colors.selection : undefined}\n                bold={entry.isDir}\n              >\n                {entry.isDir ? (unicode ? \"▶ \" : \"> \") : unicode ? \"· \" : \"- \"}\n                {entry.name}\n                {entry.isDir ? \"/\" : \"\"}\n              </Text>\n            </Box>\n          );\n        })}\n        {allEntries.length > maxVisible && (\n          <Box paddingX={1}>\n            <Text color={theme.colors.mutedForeground} dimColor>\n              ... {allEntries.length - maxVisible} more\n            </Text>\n          </Box>\n        )}\n      </Box>\n\n      {selected && (\n        <Box paddingX={1}>\n          <Text color={theme.colors.mutedForeground}>Selected: </Text>\n          <Text color={theme.colors.success}>{selected}</Text>\n        </Box>\n      )}\n\n      {isFocused && (\n        <Text aria-hidden color={theme.colors.mutedForeground} dimColor>\n          {unicode\n            ? \"↑↓: navigate · Enter: open/select · Esc: up\"\n            : \"up/down: navigate - Enter: open/select - Esc: up\"}\n        </Text>\n      )}\n    </Box>\n  );\n};\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "forms"
  ],
  "type": "registry:ui"
}