{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-tree",
  "type": "registry:ui",
  "title": "File tree",
  "description": "A code editor style file explorer with file icons, git status, a filter field and full tree keyboard support.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/file-tree.tsx",
      "type": "registry:ui",
      "target": "components/ui/file-tree.tsx",
      "content": "\"use client\"\nimport * as React from \"react\"\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\"\nimport {\n  ChevronRightIcon,\n  DatabaseIcon,\n  FileCodeIcon,\n  FileCogIcon,\n  FileIcon,\n  FileJsonIcon,\n  FileLockIcon,\n  FileTerminalIcon,\n  FileTextIcon,\n  FolderIcon,\n  FolderOpenIcon,\n  HashIcon,\n  ImageIcon,\n  SearchIcon,\n  XIcon,\n} from \"lucide-react\"\nimport { cn } from \"@/lib/utils\"\n\n/** A code editor style file explorer with file icons, git status, a filter field and full tree keyboard support. */\ntype GitStatus = \"M\" | \"A\" | \"D\" | \"U\"\n\ntype FileTreeNode = {\n  name: string\n  /** Present (even empty) for folders. */\n  children?: FileTreeNode[]\n  /** Git status letter: M modified, A added, D deleted, U untracked. */\n  status?: GitStatus\n}\n\ntype FileTreeProps = {\n  /** Nested files and folders. Paths are built from names joined by \"/\". */\n  data?: FileTreeNode[]\n  /** Folder paths open on first render. */\n  defaultExpanded?: string[]\n  /** Controlled selected path. */\n  selected?: string\n  defaultSelected?: string\n  /** The file open in the editor. Defaults to the last selected file. */\n  activePath?: string\n  /** Called with the path of a file or folder when it is selected. */\n  onSelect?: (path: string) => void\n  /** Show the filter field above the tree. */\n  searchable?: boolean\n  /** Show git status letters and folder change dots. */\n  showStatus?: boolean\n  /** Sort folders first, then names alphabetically. */\n  sort?: boolean\n  /** Header title, usually the project name. Pass an empty string to hide the header. */\n  title?: string\n  \"aria-label\"?: string\n  className?: string\n}\n\nconst SAMPLE: FileTreeNode[] = [\n  {\n    name: \"app\",\n    children: [\n      { name: \"api\", children: [{ name: \"invoices\", children: [{ name: \"route.ts\", status: \"A\" }] }] },\n      {\n        name: \"dashboard\",\n        children: [\n          { name: \"invoices\", children: [{ name: \"page.tsx\" }, { name: \"[id]\", children: [{ name: \"page.tsx\" }] }] },\n          { name: \"loading.tsx\" },\n          { name: \"page.tsx\", status: \"M\" },\n        ],\n      },\n      { name: \"favicon.ico\" },\n      { name: \"globals.css\", status: \"M\" },\n      { name: \"layout.tsx\" },\n      { name: \"page.tsx\" },\n    ],\n  },\n  {\n    name: \"components\",\n    children: [\n      {\n        name: \"ui\",\n        children: [\n          { name: \"button.tsx\" },\n          { name: \"card.tsx\" },\n          { name: \"dialog.tsx\", status: \"M\" },\n          { name: \"invoice-table.tsx\", status: \"A\" },\n        ],\n      },\n      { name: \"site-header.tsx\" },\n    ],\n  },\n  { name: \"lib\", children: [{ name: \"db.ts\" }, { name: \"legacy-auth.ts\", status: \"D\" }, { name: \"utils.ts\" }] },\n  { name: \"public\", children: [{ name: \"logo.svg\" }, { name: \"og.png\" }] },\n  { name: \".env.example\" },\n  { name: \"next.config.ts\" },\n  { name: \"package.json\", status: \"M\" },\n  { name: \"pnpm-lock.yaml\", status: \"M\" },\n  { name: \"README.md\" },\n  { name: \"tsconfig.json\" },\n]\n\nconst STATUS: Record<GitStatus, { text: string; dot: string; label: string }> = {\n  M: { text: \"text-[color-mix(in_oklch,var(--warning)_84%,var(--fg))]\", dot: \"bg-warning\", label: \"modified\" },\n  A: { text: \"text-success\", dot: \"bg-success\", label: \"added\" },\n  U: { text: \"text-success\", dot: \"bg-success\", label: \"untracked\" },\n  D: { text: \"text-danger\", dot: \"bg-danger\", label: \"deleted\" },\n}\n\nconst MORPH = { type: \"spring\", bounce: 0.16, duration: 0.5 } as const\nconst EXIT = { duration: 0.16, ease: [0.3, 0, 0.8, 0.15] } as const\nconst INDENT = 14\nconst PAD = 8\n\nfunction fileIcon(name: string): { Icon: React.ElementType; tone: string } {\n  const lower = name.toLowerCase()\n  const ext = lower.includes(\".\") ? lower.slice(lower.lastIndexOf(\".\") + 1) : \"\"\n  if (/(^|[.-])lock(\\.|$)|lock\\.ya?ml$|\\.lockb$/.test(lower)) return { Icon: FileLockIcon, tone: \"text-fg-subtle\" }\n  if (lower.startsWith(\".env\") || lower.startsWith(\".git\") || /\\.config\\.[cm]?[jt]s$/.test(lower) || [\"yaml\", \"yml\", \"toml\", \"ini\"].includes(ext))\n    return { Icon: FileCogIcon, tone: \"text-fg-subtle\" }\n  if ([\"ts\", \"tsx\", \"mts\", \"cts\"].includes(ext)) return { Icon: FileCodeIcon, tone: \"text-info\" }\n  if ([\"js\", \"jsx\", \"mjs\", \"cjs\"].includes(ext)) return { Icon: FileCodeIcon, tone: \"text-warning\" }\n  if ([\"json\", \"jsonc\"].includes(ext)) return { Icon: FileJsonIcon, tone: \"text-warning\" }\n  if ([\"css\", \"scss\", \"sass\", \"less\"].includes(ext)) return { Icon: HashIcon, tone: \"text-accent-fg\" }\n  if ([\"md\", \"mdx\", \"txt\", \"rst\"].includes(ext)) return { Icon: FileTextIcon, tone: \"text-fg-muted\" }\n  if ([\"png\", \"jpg\", \"jpeg\", \"gif\", \"svg\", \"webp\", \"avif\", \"ico\"].includes(ext)) return { Icon: ImageIcon, tone: \"text-success\" }\n  if ([\"sh\", \"bash\", \"zsh\"].includes(ext)) return { Icon: FileTerminalIcon, tone: \"text-fg-muted\" }\n  if ([\"sql\", \"prisma\", \"db\"].includes(ext)) return { Icon: DatabaseIcon, tone: \"text-info\" }\n  return { Icon: FileIcon, tone: \"text-fg-subtle\" }\n}\n\ntype Flat = {\n  path: string\n  name: string\n  depth: number\n  parent: string | null\n  isFolder: boolean\n  status?: GitStatus\n  /** Rolled up status of descendants, for folders. */\n  rollup?: GitStatus\n  posinset: number\n  setsize: number\n  children: Flat[]\n}\n\nfunction build(nodes: FileTreeNode[], sort: boolean, parent: string | null = null, depth = 1): Flat[] {\n  const list = sort\n    ? [...nodes].sort((a, b) => Number(!!b.children) - Number(!!a.children) || a.name.localeCompare(b.name, \"en\", { sensitivity: \"base\" }))\n    : nodes\n  return list.map((n, i) => {\n    const path = parent ? `${parent}/${n.name}` : n.name\n    const children = n.children ? build(n.children, sort, path, depth + 1) : []\n    const statuses = new Set<GitStatus>()\n    for (const c of children) {\n      if (c.status) statuses.add(c.status)\n      if (c.rollup) statuses.add(c.rollup)\n    }\n    const rollup: GitStatus | undefined = statuses.size === 0 ? undefined : statuses.size === 1 ? [...statuses][0] : \"M\"\n    return { path, name: n.name, depth, parent, isFolder: !!n.children, status: n.status, rollup, posinset: i + 1, setsize: list.length, children }\n  })\n}\n\n/** Keep matches and their ancestors. Returns the pruned tree plus folders to open. */\nfunction filterTree(nodes: Flat[], q: string, open: Set<string>): Flat[] {\n  const out: Flat[] = []\n  for (const n of nodes) {\n    const kids = filterTree(n.children, q, open)\n    const hit = n.name.toLowerCase().includes(q)\n    if (hit || kids.length) {\n      if (kids.length) open.add(n.path)\n      out.push({ ...n, children: kids })\n    }\n  }\n  return out\n}\n\nfunction Highlight({ text, query }: { text: string; query: string }) {\n  if (!query) return <>{text}</>\n  const i = text.toLowerCase().indexOf(query)\n  if (i < 0) return <>{text}</>\n  return (\n    <>\n      {text.slice(0, i)}\n      <mark className=\"rounded-sm bg-accent-soft text-accent-fg\">{text.slice(i, i + query.length)}</mark>\n      {text.slice(i + query.length)}\n    </>\n  )\n}\n\n/**\n * A code editor style file explorer. Folders spring open, files get icons by\n * extension and optional git status letters, indent guides light up the\n * branch you are in, and the filter field auto-expands every match. Full\n * WAI-ARIA tree keyboard support: arrows, Home and End, Enter and typeahead.\n */\nfunction FileTree({\n  data = SAMPLE,\n  defaultExpanded = [\"app\", \"app/dashboard\", \"components\"],\n  selected: selectedProp,\n  defaultSelected = \"app/dashboard/page.tsx\",\n  activePath: activeProp,\n  onSelect,\n  searchable = true,\n  showStatus = true,\n  sort = true,\n  title = \"lumen-web\",\n  \"aria-label\": ariaLabel = \"Files\",\n  className,\n}: FileTreeProps) {\n  const reduce = useReducedMotion()\n  const treeId = React.useId()\n  const tree = React.useMemo(() => build(data, sort), [data, sort])\n  const [expanded, setExpanded] = React.useState(() => new Set(defaultExpanded))\n  const [innerSelected, setInnerSelected] = React.useState(defaultSelected)\n  const selected = selectedProp ?? innerSelected\n  const [innerActive, setInnerActive] = React.useState(defaultSelected)\n  const active = activeProp ?? innerActive\n  const [query, setQuery] = React.useState(\"\")\n  const q = query.trim().toLowerCase()\n  const [focusPath, setFocusPath] = React.useState<string | null>(null)\n  const refs = React.useRef(new Map<string, HTMLLIElement>())\n  const typeahead = React.useRef({ buf: \"\", t: 0 as ReturnType<typeof setTimeout> | 0 })\n\n  const index = React.useMemo(() => {\n    const m = new Map<string, Flat>()\n    const walk = (ns: Flat[]) => ns.forEach((n) => (m.set(n.path, n), walk(n.children)))\n    walk(tree)\n    return m\n  }, [tree])\n\n  const { nodes, open } = React.useMemo(() => {\n    if (!q) return { nodes: tree, open: expanded }\n    const o = new Set<string>()\n    return { nodes: filterTree(tree, q, o), open: o }\n  }, [tree, q, expanded])\n\n  const visible = React.useMemo(() => {\n    const out: Flat[] = []\n    const walk = (ns: Flat[]) => {\n      for (const n of ns) {\n        out.push(n)\n        if (n.isFolder && open.has(n.path)) walk(n.children)\n      }\n    }\n    walk(nodes)\n    return out\n  }, [nodes, open])\n\n  const matchCount = React.useMemo(() => {\n    if (!q) return 0\n    let c = 0\n    const walk = (ns: Flat[]) => ns.forEach((n) => (n.name.toLowerCase().includes(q) && c++, walk(n.children)))\n    walk(nodes)\n    return c\n  }, [nodes, q])\n\n  const tabStop = visible.find((n) => n.path === focusPath)?.path ?? visible.find((n) => n.path === selected)?.path ?? visible[0]?.path\n  const branch = index.get(focusPath ?? selected)?.parent ?? null\n\n  const focus = (path: string | undefined) => {\n    if (!path) return\n    setFocusPath(path)\n    refs.current.get(path)?.focus()\n  }\n\n  const toggle = (path: string, to?: boolean) => {\n    if (q) return\n    const want = to ?? !expanded.has(path)\n    if (!want && focusPath?.startsWith(path + \"/\")) focus(path)\n    setExpanded((prev) => {\n      const next = new Set(prev)\n      if (want) next.add(path)\n      else next.delete(path)\n      return next\n    })\n  }\n\n  const choose = (n: Flat) => {\n    if (selectedProp === undefined) setInnerSelected(n.path)\n    if (!n.isFolder && activeProp === undefined) setInnerActive(n.path)\n    onSelect?.(n.path)\n  }\n\n  const onKeyDown = (e: React.KeyboardEvent<HTMLUListElement>) => {\n    const cur = visible.findIndex((n) => n.path === (focusPath ?? tabStop))\n    const n = visible[cur]\n    if (!n) return\n    const key = e.key\n    if (key === \"ArrowDown\") { e.preventDefault(); focus(visible[Math.min(visible.length - 1, cur + 1)]?.path) }\n    else if (key === \"ArrowUp\") { e.preventDefault(); focus(visible[Math.max(0, cur - 1)]?.path) }\n    else if (key === \"Home\") { e.preventDefault(); focus(visible[0]?.path) }\n    else if (key === \"End\") { e.preventDefault(); focus(visible[visible.length - 1]?.path) }\n    else if (key === \"ArrowRight\") {\n      e.preventDefault()\n      if (n.isFolder && !open.has(n.path)) toggle(n.path, true)\n      else if (n.isFolder && n.children.length) focus(visible[cur + 1]?.path)\n    } else if (key === \"ArrowLeft\") {\n      e.preventDefault()\n      if (n.isFolder && open.has(n.path) && !q) toggle(n.path, false)\n      else if (n.parent) focus(n.parent)\n    } else if (key === \"Enter\" || key === \" \") {\n      e.preventDefault()\n      choose(n)\n      if (n.isFolder) toggle(n.path)\n    } else if (key === \"*\") {\n      e.preventDefault()\n      const sibs = visible.filter((v) => v.parent === n.parent && v.isFolder).map((v) => v.path)\n      if (!q) setExpanded((prev) => new Set([...prev, ...sibs]))\n    } else if (key.length === 1 && !e.altKey && !e.ctrlKey && !e.metaKey && /\\S/.test(key)) {\n      const ta = typeahead.current\n      if (ta.t) clearTimeout(ta.t)\n      ta.buf += key.toLowerCase()\n      ta.t = setTimeout(() => (ta.buf = \"\"), 500)\n      const order = [...visible.slice(cur + (ta.buf.length === 1 ? 1 : 0)), ...visible.slice(0, cur + (ta.buf.length === 1 ? 1 : 0))]\n      const hit = order.find((v) => v.name.toLowerCase().startsWith(ta.buf))\n      if (hit) focus(hit.path)\n    }\n  }\n\n  const changes = React.useMemo(() => {\n    let c = 0\n    index.forEach((n) => n.status && c++)\n    return c\n  }, [index])\n\n  const renderNodes = (ns: Flat[]): React.ReactNode =>\n    ns.map((n) => {\n      const isOpen = n.isFolder && open.has(n.path)\n      const isSelected = n.path === selected\n      const isActive = !n.isFolder && n.path === active\n      const { Icon, tone } = n.isFolder ? { Icon: isOpen ? FolderOpenIcon : FolderIcon, tone: \"text-fg-subtle\" } : fileIcon(n.name)\n      const st = showStatus ? n.status : undefined\n      const roll = showStatus && n.isFolder && !isOpen ? n.rollup : undefined\n      const label = [n.name, st ? STATUS[st].label : roll ? \"contains changes\" : null].filter(Boolean).join(\", \")\n      return (\n        <li\n          key={n.path}\n          ref={(el) => {\n            if (el) refs.current.set(n.path, el)\n            else refs.current.delete(n.path)\n          }}\n          role=\"treeitem\"\n          aria-label={label}\n          aria-level={n.depth}\n          aria-posinset={n.posinset}\n          aria-setsize={n.setsize}\n          aria-expanded={n.isFolder ? isOpen : undefined}\n          aria-selected={isSelected}\n          aria-current={isActive ? \"page\" : undefined}\n          tabIndex={n.path === tabStop ? 0 : -1}\n          onFocus={(e) => {\n            if (e.target === e.currentTarget) setFocusPath(n.path)\n          }}\n          className=\"outline-none\"\n        >\n          <div\n            data-slot=\"file-tree-row\"\n            onClick={() => {\n              focus(n.path)\n              choose(n)\n              if (n.isFolder) toggle(n.path)\n            }}\n            className={cn(\n              \"relative flex h-7 cursor-pointer items-center gap-1 rounded-md pr-2 text-[0.8125rem] select-none\",\n              \"transition-colors duration-[70ms] ease-hairline\",\n              \"[:focus-visible>&]:ring-2 [:focus-visible>&]:ring-accent [:focus-visible>&]:ring-inset\",\n              isSelected ? \"bg-accent-soft text-fg\" : \"text-fg-muted hover:bg-sunken hover:text-fg\"\n            )}\n            style={{ paddingLeft: PAD + (n.depth - 1) * INDENT }}\n          >\n            {isActive ? <span aria-hidden className=\"absolute top-1.5 bottom-1.5 left-0.5 w-0.5 rounded-full bg-accent\" /> : null}\n            {n.isFolder ? (\n              <ChevronRightIcon\n                aria-hidden\n                className={cn(\n                  \"size-4 shrink-0 text-fg-subtle transition-transform duration-[140ms] ease-hairline motion-reduce:transition-none\",\n                  isOpen && \"rotate-90\"\n                )}\n              />\n            ) : (\n              <span aria-hidden className=\"size-4 shrink-0\" />\n            )}\n            <Icon aria-hidden className={cn(\"size-4 shrink-0\", isSelected && n.isFolder ? \"text-accent-fg\" : tone)} />\n            <span\n              className={cn(\n                \"ml-1 min-w-0 flex-1 truncate\",\n                isActive && \"font-medium text-fg\",\n                st && STATUS[st].text,\n                st === \"D\" && \"line-through decoration-1\"\n              )}\n            >\n              <Highlight text={n.name} query={q} />\n            </span>\n            {st ? (\n              <span aria-hidden title={STATUS[st].label} className={cn(\"w-3 shrink-0 text-center font-mono text-[11px] font-medium\", STATUS[st].text)}>\n                {st}\n              </span>\n            ) : roll ? (\n              <span aria-hidden className=\"grid w-3 shrink-0 place-items-center\">\n                <span className={cn(\"size-1.5 rounded-full\", STATUS[roll].dot)} />\n              </span>\n            ) : null}\n          </div>\n          {n.isFolder ? (\n            <AnimatePresence initial={false}>\n              {isOpen && n.children.length ? (\n                <motion.ul\n                  key=\"group\"\n                  role=\"group\"\n                  initial={{ height: 0, opacity: 0 }}\n                  animate={{ height: \"auto\", opacity: 1, transition: reduce ? { duration: 0 } : MORPH }}\n                  exit={{ height: 0, opacity: 0, transition: reduce ? { duration: 0 } : EXIT }}\n                  className=\"relative overflow-hidden\"\n                >\n                  <span\n                    aria-hidden\n                    data-slot=\"file-tree-guide\"\n                    className={cn(\n                      \"pointer-events-none absolute top-0 bottom-0 w-px transition-colors duration-[140ms]\",\n                      branch === n.path ? \"bg-accent/60\" : \"bg-fg/15\"\n                    )}\n                    style={{ left: PAD + (n.depth - 1) * INDENT + 8 }}\n                  />\n                  {renderNodes(n.children)}\n                </motion.ul>\n              ) : null}\n            </AnimatePresence>\n          ) : null}\n        </li>\n      )\n    })\n\n  return (\n    <div data-slot=\"file-tree\" className={cn(\"flex w-full max-w-72 flex-col rounded-xl border border-border bg-surface shadow-raised\", className)}>\n      {title ? (\n        <div className=\"flex h-10 items-center justify-between gap-2 border-b border-border px-3\">\n          <span className=\"truncate font-mono text-[11px] font-medium tracking-[0.06em] text-fg-muted uppercase\">{title}</span>\n          {showStatus && changes ? (\n            <span className=\"rounded-full bg-sunken px-2 py-0.5 font-mono text-[10px] text-fg-subtle tabular-nums\">\n              {changes} {changes === 1 ? \"change\" : \"changes\"}\n            </span>\n          ) : null}\n        </div>\n      ) : null}\n      {searchable ? (\n        <div className=\"px-2 pt-2\">\n          <div className=\"relative\">\n            <SearchIcon aria-hidden className=\"pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-fg-subtle\" />\n            <input\n              type=\"search\"\n              value={query}\n              placeholder=\"Filter files\"\n              aria-label=\"Filter files\"\n              aria-controls={treeId}\n              onChange={(e) => setQuery(e.target.value)}\n              onKeyDown={(e) => {\n                if (e.key === \"Escape\" && query) { e.preventDefault(); setQuery(\"\") }\n                if (e.key === \"ArrowDown\") { e.preventDefault(); focus(visible[0]?.path) }\n              }}\n              className={cn(\n                \"h-8 w-full rounded-lg border border-border bg-bg pr-8 pl-8 text-[0.8125rem] text-fg shadow-xs outline-none placeholder:text-fg-subtle\",\n                \"transition-[border-color,box-shadow] duration-[140ms] ease-hairline [&::-webkit-search-cancel-button]:appearance-none\",\n                \"focus-visible:border-accent focus-visible:shadow-[0_0_0_3px_var(--accent-soft)]\"\n              )}\n            />\n            {query ? (\n              <button\n                type=\"button\"\n                aria-label=\"Clear filter\"\n                onClick={() => setQuery(\"\")}\n                className=\"absolute top-1/2 right-1 grid size-6 -translate-y-1/2 place-items-center rounded-md text-fg-subtle outline-none hover:bg-sunken hover:text-fg focus-visible:ring-2 focus-visible:ring-accent\"\n              >\n                <XIcon className=\"size-3.5\" />\n              </button>\n            ) : null}\n          </div>\n          <p aria-live=\"polite\" className=\"sr-only\">\n            {q ? `${matchCount} ${matchCount === 1 ? \"match\" : \"matches\"}` : \"\"}\n          </p>\n        </div>\n      ) : null}\n      <div className=\"p-2\">\n        {visible.length ? (\n          <ul id={treeId} role=\"tree\" aria-label={ariaLabel} onKeyDown={onKeyDown} className=\"outline-none\">\n            {renderNodes(nodes)}\n          </ul>\n        ) : (\n          <p className=\"px-2 py-6 text-center text-[0.8125rem] text-fg-subtle\">No files match “{query.trim()}”</p>\n        )}\n      </div>\n    </div>\n  )\n}\n\nexport { FileTree }\nexport type { FileTreeProps, FileTreeNode, GitStatus }\n"
    }
  ]
}
