{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-tree",
  "title": "File Tree",
  "description": "A keyboard-navigable file tree with collapsible folders, git change badges, and change counts rolled up to folders.",
  "dependencies": [
    "@solar-icons/react@^2.2.0",
    "cn@^0.3.0",
    "motion@^13.4.0"
  ],
  "registryDependencies": [
    "https://pastaui.com/r/pastaui-styles.json"
  ],
  "files": [
    {
      "path": "src/components/ai/file-tree.tsx",
      "content": "import { AltArrowRightIcon as ChevronRightIcon } from '@solar-icons/react/linear/alt-arrow-right'\nimport { FileCodeIcon as FileCode2Icon } from '@solar-icons/react/linear/file-code'\nimport { FileIcon } from '@solar-icons/react/linear/file'\nimport { FolderIcon } from '@solar-icons/react/linear/folder'\nimport { FolderOpenIcon } from '@solar-icons/react/linear/folder-open'\nimport { AnimatePresence, MotionConfig, motion } from 'motion/react'\nimport * as React from 'react'\n\nimport { cn } from 'cn'\n\ninterface FileTreeItem {\n  depth?: number\n  expanded?: boolean\n  id: string\n  kind: 'file' | 'folder'\n  name: string\n  status?: 'added' | 'deleted' | 'modified'\n}\n\ninterface FileTreeProps extends Omit<React.ComponentProps<'section'>, 'onSelect'> {\n  defaultSelectedId?: string\n  items: FileTreeItem[]\n  onSelect?: (id: string) => void\n  selectedId?: string\n  title?: string\n}\n\ninterface TreeNode extends FileTreeItem {\n  children: TreeNode[]\n  changes: number\n  parentId?: string\n}\n\nconst statusCopy = { added: 'A', deleted: 'D', modified: 'M' }\nconst statusLabel = { added: 'Added', deleted: 'Deleted', modified: 'Modified' }\nconst ease = [0.23, 1, 0.32, 1] as const\n\nfunction buildTree(items: FileTreeItem[]) {\n  const roots: TreeNode[] = []\n  const stack: TreeNode[] = []\n  for (const item of items) {\n    const depth = item.depth ?? 0\n    stack.length = Math.min(stack.length, depth)\n    const parent = stack[depth - 1]\n    const node: TreeNode = {\n      ...item,\n      children: [],\n      changes: item.status ? 1 : 0,\n      parentId: parent?.id,\n    }\n    ;(parent ? parent.children : roots).push(node)\n    stack[depth] = node\n  }\n  const count = (node: TreeNode): number => {\n    if (node.kind === 'folder')\n      node.changes = node.children.reduce((sum, child) => sum + count(child), 0)\n    return node.changes\n  }\n  roots.forEach(count)\n  return roots\n}\n\nfunction FileTree({\n  className,\n  defaultSelectedId,\n  items,\n  onSelect,\n  selectedId,\n  title = 'Files',\n  ...props\n}: FileTreeProps) {\n  const tree = React.useMemo(() => buildTree(items), [items])\n  const [expanded, setExpanded] = React.useState(\n    () =>\n      new Set(\n        items.filter((item) => item.kind === 'folder' && item.expanded).map((item) => item.id),\n      ),\n  )\n  const [internalSelected, setInternalSelected] = React.useState(defaultSelectedId)\n  const selected = selectedId ?? internalSelected\n  const [focusedId, setFocusedId] = React.useState(selected ?? items[0]?.id)\n  const refs = React.useRef(new Map<string, HTMLLIElement>())\n  const rows = React.useRef(new Map<string, HTMLDivElement>())\n  const listRef = React.useRef<HTMLDivElement>(null)\n  const indicatorRef = React.useRef<HTMLSpanElement>(null)\n  const [hoveredId, setHoveredId] = React.useState<string>()\n  const [indicator, setIndicator] = React.useState<{ height: number; top: number }>()\n  const totalChanges = tree.reduce((sum, node) => sum + node.changes, 0)\n\n  const visible: TreeNode[] = []\n  const walk = (nodes: TreeNode[]) => {\n    for (const node of nodes) {\n      visible.push(node)\n      if (node.kind === 'folder' && expanded.has(node.id)) walk(node.children)\n    }\n  }\n  walk(tree)\n\n  const indicatorId = hoveredId ?? selected\n\n  const measure = React.useCallback(() => {\n    const container = listRef.current\n    const row = indicatorId ? rows.current.get(indicatorId) : undefined\n    if (!container || !row) return setIndicator(undefined)\n    const top = row.getBoundingClientRect().top - container.getBoundingClientRect().top\n    setIndicator({ height: row.offsetHeight, top })\n  }, [indicatorId])\n\n  React.useLayoutEffect(() => {\n    measure()\n  }, [measure, expanded, items])\n\n  React.useEffect(() => {\n    const container = listRef.current\n    if (!container) return\n    const observer = new ResizeObserver(() => measure())\n    observer.observe(container.firstElementChild ?? container)\n    return () => observer.disconnect()\n  }, [measure])\n\n  React.useEffect(() => {\n    const frame = requestAnimationFrame(() =>\n      indicatorRef.current?.setAttribute('data-ready', 'true'),\n    )\n    return () => cancelAnimationFrame(frame)\n  }, [])\n\n  function toggle(id: string, open?: boolean) {\n    setExpanded((current) => {\n      const next = new Set(current)\n      if (open ?? !next.has(id)) next.add(id)\n      else next.delete(id)\n      return next\n    })\n  }\n\n  function select(node: TreeNode) {\n    if (node.kind === 'folder') return toggle(node.id)\n    if (selectedId === undefined) setInternalSelected(node.id)\n    onSelect?.(node.id)\n  }\n\n  function focus(id: string | undefined) {\n    if (!id) return\n    setFocusedId(id)\n    refs.current.get(id)?.focus()\n  }\n\n  function onKeyDown(event: React.KeyboardEvent, node: TreeNode) {\n    const index = visible.findIndex((item) => item.id === node.id)\n    const open = node.kind === 'folder' && expanded.has(node.id)\n    const keys: Record<string, () => void> = {\n      ArrowDown: () => focus(visible[index + 1]?.id),\n      ArrowUp: () => focus(visible[index - 1]?.id),\n      Home: () => focus(visible[0]?.id),\n      End: () => focus(visible.at(-1)?.id),\n      ArrowRight: () =>\n        node.kind !== 'folder'\n          ? undefined\n          : open\n            ? focus(node.children[0]?.id)\n            : toggle(node.id, true),\n      ArrowLeft: () => (open ? toggle(node.id, false) : focus(node.parentId)),\n      Enter: () => select(node),\n      ' ': () => select(node),\n    }\n    if (!keys[event.key]) return\n    event.preventDefault()\n    keys[event.key]()\n  }\n\n  function renderNodes(nodes: TreeNode[]): React.ReactNode {\n    return nodes.map((node) => {\n      const depth = node.depth ?? 0\n      const isFolder = node.kind === 'folder'\n      const isOpen = isFolder && expanded.has(node.id)\n      const isSelected = node.id === selected\n      const tabbable =\n        node.id === (visible.some((item) => item.id === focusedId) ? focusedId : visible[0]?.id)\n      const FileGlyph = /\\.tsx?$/.test(node.name) ? FileCode2Icon : FileIcon\n\n      return (\n        <li\n          aria-expanded={isFolder ? isOpen : undefined}\n          aria-level={depth + 1}\n          aria-selected={isFolder ? undefined : isSelected}\n          className=\"outline-none [&:focus-visible>div]:ring-2 [&:focus-visible>div]:ring-ring/30\"\n          key={node.id}\n          onBlur={(event) => {\n            event.stopPropagation()\n            if (!event.currentTarget.closest('[role=\"tree\"]')?.contains(event.relatedTarget))\n              setHoveredId(undefined)\n          }}\n          onFocus={(event) => {\n            event.stopPropagation()\n            setFocusedId(node.id)\n            if (event.currentTarget.matches(':focus-visible')) setHoveredId(node.id)\n          }}\n          onKeyDown={(event) => {\n            event.stopPropagation()\n            onKeyDown(event, node)\n          }}\n          ref={(element) => {\n            if (element) refs.current.set(node.id, element)\n            else refs.current.delete(node.id)\n          }}\n          role=\"treeitem\"\n          tabIndex={tabbable ? 0 : -1}\n        >\n          {/* Keyboard input is handled by the treeitem; the row only adds pointer selection. */}\n          {/* oxlint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}\n          <div\n            className={cn(\n              'relative z-10 flex w-full cursor-default items-center gap-1.5 rounded-md py-1.5 pr-2 text-left text-sm transition-[color] duration-150 ease-[ease] hover:text-foreground',\n              isSelected && 'text-foreground',\n            )}\n            onMouseEnter={() => setHoveredId(node.id)}\n            ref={(element) => {\n              if (element) rows.current.set(node.id, element)\n              else rows.current.delete(node.id)\n            }}\n            onClick={() => {\n              setFocusedId(node.id)\n              select(node)\n            }}\n            style={{ paddingLeft: `${depth * 16 + 8}px` }}\n          >\n            {isFolder ? (\n              <>\n                <ChevronRightIcon\n                  aria-hidden=\"true\"\n                  className={cn(\n                    'size-3.5 text-muted-foreground transition-[rotate] duration-200 ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:transition-none',\n                    isOpen && 'rotate-90',\n                  )}\n                />\n                {isOpen ? (\n                  <FolderOpenIcon aria-hidden=\"true\" className=\"size-4 text-muted-foreground\" />\n                ) : (\n                  <FolderIcon aria-hidden=\"true\" className=\"size-4 text-muted-foreground\" />\n                )}\n              </>\n            ) : (\n              <>\n                <span className=\"w-3.5\" />\n                <FileGlyph aria-hidden=\"true\" className=\"size-4 text-muted-foreground\" />\n              </>\n            )}\n            <span className=\"min-w-0 flex-1 truncate\">{node.name}</span>\n            {node.status ? (\n              <span\n                className={cn(\n                  'text-[10px] font-medium',\n                  node.status === 'added' && 'text-emerald-600',\n                  node.status === 'deleted' && 'text-destructive',\n                  node.status === 'modified' && 'text-amber-600',\n                )}\n              >\n                <span aria-hidden=\"true\">{statusCopy[node.status]}</span>\n                <span className=\"sr-only\">{statusLabel[node.status]}</span>\n              </span>\n            ) : (\n              isFolder &&\n              node.changes > 0 &&\n              !isOpen && (\n                <span className=\"text-[10px] font-medium tabular-nums\">\n                  {node.changes}\n                  <span className=\"sr-only\"> changes</span>\n                </span>\n              )\n            )}\n          </div>\n          {isFolder && (\n            <AnimatePresence initial={false}>\n              {isOpen && (\n                <motion.div\n                  animate={{ height: 'auto', opacity: 1 }}\n                  className=\"overflow-hidden\"\n                  exit={{ height: 0, opacity: 0 }}\n                  initial={{ height: 0, opacity: 0 }}\n                  transition={{ duration: 0.2, ease }}\n                >\n                  {/* role=\"group\" is the ARIA tree pattern for nested items. */}\n                  {/* oxlint-disable-next-line jsx-a11y/prefer-tag-over-role */}\n                  <ul className=\"space-y-0.5 pt-0.5\" role=\"group\">\n                    {renderNodes(node.children)}\n                  </ul>\n                </motion.div>\n              )}\n            </AnimatePresence>\n          )}\n        </li>\n      )\n    })\n  }\n\n  return (\n    <MotionConfig reducedMotion=\"user\">\n      <section\n        data-slot=\"file-tree\"\n        className={cn(\n          'rounded-[16px] bg-card p-2 font-sans text-sm font-[450] tracking-[-0.05px] text-muted-foreground shadow-card',\n          className,\n        )}\n        {...props}\n      >\n        <header className=\"flex items-center gap-2 px-2 py-1.5\">\n          <h3 className=\"card-heading text-muted-foreground\">{title}</h3>\n          {totalChanges > 0 && (\n            <span className=\"ml-auto text-xs leading-5 tabular-nums\">\n              {totalChanges} {totalChanges === 1 ? 'change' : 'changes'}\n            </span>\n          )}\n        </header>\n        <div className=\"relative mt-1\" onMouseLeave={() => setHoveredId(undefined)} ref={listRef}>\n          <ul aria-label={title} className=\"space-y-0.5\" role=\"tree\">\n            {renderNodes(tree)}\n          </ul>\n          <span\n            aria-hidden=\"true\"\n            className=\"pointer-events-none absolute inset-x-0 top-0 z-0 rounded-md bg-[color-mix(in_oklch,var(--foreground)_7%,transparent)] data-[ready=true]:transition-[top,height,opacity] data-[ready=true]:duration-[220ms] data-[ready=true]:ease-[cubic-bezier(0.23,1,0.32,1)] motion-reduce:data-[ready=true]:transition-opacity dark:bg-muted\"\n            ref={indicatorRef}\n            style={{\n              height: indicator?.height ?? 0,\n              opacity: indicator ? 1 : 0,\n              top: indicator?.top ?? 0,\n            }}\n          />\n        </div>\n      </section>\n    </MotionConfig>\n  )\n}\n\nexport { FileTree }\nexport type { FileTreeItem, FileTreeProps }\n",
      "type": "registry:component",
      "target": "@components/ai/file-tree.tsx"
    }
  ],
  "type": "registry:component"
}