{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ai-file-tree",
  "title": "AI: File Tree",
  "description": "Expandable file and folder navigation with separate keyboard selection and expansion controls.",
  "dependencies": [
    "@untitledui/icons@^0.0.23"
  ],
  "registryDependencies": [
    "@nx-ui/utils",
    "@nx-ui/collapsible"
  ],
  "files": [
    {
      "path": "components/ai/file-tree.tsx",
      "content": "// Ported from vercel/ai-elements@6a9d5b1822ffb10bba4bd97175f01edd7d8651cd.\n\"use client\";\n\nimport {\n  Collapsible,\n  CollapsibleContent,\n  CollapsibleTrigger,\n} from \"@/components/ui/collapsible\";\nimport { cn } from \"@/lib/utils\";\nimport {\n  ChevronRight as ChevronRightIcon,\n  File01 as FileIcon,\n  Folder as FolderIcon,\n  Folder as FolderOpenIcon,\n} from \"@untitledui/icons\";\nimport type { HTMLAttributes, KeyboardEvent, ReactNode } from \"react\";\nimport {\n  createContext,\n  useCallback,\n  useContext,\n  useMemo,\n  useRef,\n  useEffect,\n  useState,\n} from \"react\";\n\ninterface FileTreeContextType {\n  expandedPaths: Set<string>;\n  togglePath: (path: string) => void;\n  selectedPath?: string;\n  onSelect?: (path: string) => void;\n  focusedPath: string | null;\n}\n\n// Default noop for context default value\n// oxlint-disable-next-line eslint(no-empty-function)\nconst noop = () => {};\n\nconst FileTreeContext = createContext<FileTreeContextType>({\n  // oxlint-disable-next-line eslint-plugin-unicorn(no-new-builtin)\n  expandedPaths: new Set(),\n  togglePath: noop,\n  focusedPath: null,\n});\n\nexport type FileTreeProps = Omit<HTMLAttributes<HTMLDivElement>, \"onSelect\"> & {\n  expanded?: Set<string>;\n  defaultExpanded?: Set<string>;\n  selectedPath?: string;\n  onSelect?: (path: string) => void;\n  onExpandedChange?: (expanded: Set<string>) => void;\n};\n\nexport const FileTree = ({\n  expanded: controlledExpanded,\n  defaultExpanded = new Set(),\n  selectedPath,\n  onSelect,\n  onExpandedChange,\n  className,\n  children,\n  onKeyDown,\n  onFocusCapture,\n  ...props\n}: FileTreeProps) => {\n  const treeRef = useRef<HTMLDivElement>(null);\n  const [internalExpanded, setInternalExpanded] = useState(defaultExpanded);\n  const [focusedPath, setFocusedPath] = useState<string | null>(selectedPath ?? null);\n  const expandedPaths = controlledExpanded ?? internalExpanded;\n\n  const visibleItems = useCallback(() =>\n    Array.from(treeRef.current?.querySelectorAll<HTMLElement>('[role=\"treeitem\"][data-tree-path]') ?? [])\n      .filter((item) =>\n        item.getClientRects().length > 0 &&\n        !item.parentElement?.closest('[role=\"treeitem\"][aria-expanded=\"false\"]')\n      ), []);\n\n  useEffect(() => {\n    const items = visibleItems();\n    if (!items.length) return;\n    if (items.some((item) => item.dataset.treePath === focusedPath)) return;\n    const focusedItem = treeRef.current?.querySelector<HTMLElement>('[role=\"treeitem\"][tabindex=\"0\"]');\n    const ancestor = focusedItem?.closest('[role=\"group\"]')?.closest<HTMLElement>('[role=\"treeitem\"]');\n    setFocusedPath(ancestor?.dataset.treePath ?? items[0]?.dataset.treePath ?? null);\n  }, [children, expandedPaths, focusedPath, visibleItems]);\n\n  useEffect(() => {\n    const tree = treeRef.current;\n    if (!tree) return;\n\n    const reconcileItems = () => {\n      const items = visibleItems();\n      setFocusedPath((previous) => {\n        if (items.some((item) => item.dataset.treePath === previous)) return previous;\n        if (!items.length) return null;\n        const previousItem = Array.from(tree.querySelectorAll<HTMLElement>('[role=\"treeitem\"][data-tree-path]'))\n          .find((item) => item.dataset.treePath === previous);\n        const ancestor = previousItem?.closest('[role=\"group\"]')?.closest<HTMLElement>('[role=\"treeitem\"]');\n        return ancestor && items.includes(ancestor)\n          ? ancestor.dataset.treePath ?? null\n          : items[0]?.dataset.treePath ?? null;\n      });\n    };\n\n    const observer = new MutationObserver(reconcileItems);\n    observer.observe(tree, { childList: true, subtree: true });\n    reconcileItems();\n    return () => observer.disconnect();\n  }, [visibleItems]);\n\n  const togglePath = useCallback(\n    (path: string) => {\n      const newExpanded = new Set(expandedPaths);\n      if (newExpanded.has(path)) {\n        newExpanded.delete(path);\n      } else {\n        newExpanded.add(path);\n      }\n      setInternalExpanded(newExpanded);\n      onExpandedChange?.(newExpanded);\n    },\n    [expandedPaths, onExpandedChange]\n  );\n\n  const contextValue = useMemo(\n    () => ({ expandedPaths, focusedPath, onSelect, selectedPath, togglePath }),\n    [expandedPaths, focusedPath, onSelect, selectedPath, togglePath]\n  );\n\n  const handleKeyDown = useCallback((event: KeyboardEvent<HTMLDivElement>) => {\n    const item = (event.target as HTMLElement).closest<HTMLElement>('[role=\"treeitem\"][data-tree-path]');\n    if (!item || !treeRef.current?.contains(item)) {\n      onKeyDown?.(event);\n      return;\n    }\n    const path = item.dataset.treePath;\n    if (!path) return;\n    const items = visibleItems();\n    const index = items.indexOf(item);\n    const focusItem = (target?: HTMLElement) => {\n      if (!target) return;\n      setFocusedPath(target.dataset.treePath ?? null);\n      target.focus();\n    };\n    const isFolder = item.hasAttribute('aria-expanded');\n    const isExpanded = item.getAttribute('aria-expanded') === 'true';\n    let handled = true;\n    switch (event.key) {\n      case 'ArrowDown': focusItem(items[index + 1]); break;\n      case 'ArrowUp': focusItem(items[index - 1]); break;\n      case 'Home': focusItem(items[0]); break;\n      case 'End': focusItem(items[items.length - 1]); break;\n      case 'ArrowRight': {\n        if (isFolder && !isExpanded) togglePath(path);\n        else if (isFolder) focusItem(items[index + 1]?.closest('[role=\"group\"]') === item.querySelector('[role=\"group\"]') ? items[index + 1] : undefined);\n        break;\n      }\n      case 'ArrowLeft': {\n        if (isFolder && isExpanded) togglePath(path);\n        else focusItem(item.closest('[role=\"group\"]')?.closest<HTMLElement>('[role=\"treeitem\"]') ?? undefined);\n        break;\n      }\n      case 'Enter':\n      case ' ': {\n        if ((event.target as HTMLElement).closest('button')) { handled = false; break; }\n        onSelect?.(path);\n        break;\n      }\n      default: handled = false;\n    }\n    if (handled) event.preventDefault();\n    onKeyDown?.(event);\n  }, [onKeyDown, onSelect, togglePath, visibleItems]);\n\n  return (\n    <FileTreeContext.Provider value={contextValue}>\n      <div\n        className={cn(\n          \"rounded-lg border bg-background font-mono text-sm\",\n          className\n        )}\n        role=\"tree\"\n        ref={treeRef}\n        onKeyDown={handleKeyDown}\n        onFocusCapture={(event) => {\n          const item = (event.target as HTMLElement).closest<HTMLElement>('[role=\"treeitem\"][data-tree-path]');\n          if (item && treeRef.current?.contains(item)) setFocusedPath(item.dataset.treePath ?? null);\n          onFocusCapture?.(event);\n        }}\n        {...props}\n      >\n        <div className=\"p-2\">{children}</div>\n      </div>\n    </FileTreeContext.Provider>\n  );\n};\n\nexport type FileTreeIconProps = HTMLAttributes<HTMLSpanElement>;\n\nexport const FileTreeIcon = ({\n  className,\n  children,\n  ...props\n}: FileTreeIconProps) => (\n  <span className={cn(\"shrink-0\", className)} {...props}>\n    {children}\n  </span>\n);\n\nexport type FileTreeNameProps = HTMLAttributes<HTMLSpanElement>;\n\nexport const FileTreeName = ({\n  className,\n  children,\n  ...props\n}: FileTreeNameProps) => (\n  <span className={cn(\"truncate\", className)} {...props}>\n    {children}\n  </span>\n);\n\ninterface FileTreeFolderContextType {\n  path: string;\n  name: string;\n  isExpanded: boolean;\n}\n\nconst FileTreeFolderContext = createContext<FileTreeFolderContextType>({\n  isExpanded: false,\n  name: \"\",\n  path: \"\",\n});\n\nexport type FileTreeFolderProps = HTMLAttributes<HTMLDivElement> & {\n  path: string;\n  name: string;\n};\n\nexport const FileTreeFolder = ({\n  path,\n  name,\n  className,\n  children,\n  ...props\n}: FileTreeFolderProps) => {\n  const { expandedPaths, togglePath, selectedPath, onSelect, focusedPath } =\n    useContext(FileTreeContext);\n  const isExpanded = expandedPaths.has(path);\n  const isSelected = selectedPath === path;\n\n  const handleOpenChange = useCallback(() => {\n    togglePath(path);\n  }, [togglePath, path]);\n\n  const handleSelect = useCallback(() => {\n    onSelect?.(path);\n  }, [onSelect, path]);\n\n  const folderContextValue = useMemo(\n    () => ({ isExpanded, name, path }),\n    [isExpanded, name, path]\n  );\n\n  return (\n    <FileTreeFolderContext.Provider value={folderContextValue}>\n      <Collapsible onOpenChange={handleOpenChange} open={isExpanded}>\n        <div\n          className={cn(\"\", className)}\n          role=\"treeitem\"\n          aria-label={name}\n          aria-expanded={isExpanded}\n          aria-selected={isSelected}\n          data-tree-path={path}\n          tabIndex={focusedPath === path ? 0 : -1}\n          {...props}\n        >\n          <div\n            className={cn(\n              \"flex w-full items-center gap-1 rounded px-2 py-1 text-left transition-colors hover:bg-muted/50\",\n              isSelected && \"bg-muted\"\n            )}\n          >\n            <CollapsibleTrigger\n              className=\"flex shrink-0 cursor-pointer items-center border-none bg-transparent p-0\"\n              render={<button type=\"button\" />}\n              aria-label={`${isExpanded ? \"Collapse\" : \"Expand\"} ${name}`}\n              tabIndex={-1}\n            >\n                <ChevronRightIcon\n                  className={cn(\n                    \"size-4 shrink-0 text-muted-foreground transition-transform\",\n                    isExpanded && \"rotate-90\"\n                  )}\n                />\n            </CollapsibleTrigger>\n            <button\n              className=\"flex min-w-0 flex-1 cursor-pointer items-center gap-1 border-none bg-transparent p-0 text-left\"\n              onClick={handleSelect}\n              tabIndex={-1}\n              type=\"button\"\n            >\n              <FileTreeIcon>\n                {isExpanded ? (\n                  <FolderOpenIcon className=\"size-4 text-blue-500\" />\n                ) : (\n                  <FolderIcon className=\"size-4 text-blue-500\" />\n                )}\n              </FileTreeIcon>\n              <FileTreeName>{name}</FileTreeName>\n            </button>\n          </div>\n          <CollapsibleContent>\n            <div className=\"ml-4 border-l pl-2\" role=\"group\">{children}</div>\n          </CollapsibleContent>\n        </div>\n      </Collapsible>\n    </FileTreeFolderContext.Provider>\n  );\n};\n\ninterface FileTreeFileContextType {\n  path: string;\n  name: string;\n}\n\nconst FileTreeFileContext = createContext<FileTreeFileContextType>({\n  name: \"\",\n  path: \"\",\n});\n\nexport type FileTreeFileProps = HTMLAttributes<HTMLDivElement> & {\n  path: string;\n  name: string;\n  icon?: ReactNode;\n};\n\nexport const FileTreeFile = ({\n  path,\n  name,\n  icon,\n  className,\n  children,\n  ...props\n}: FileTreeFileProps) => {\n  const { selectedPath, onSelect, focusedPath } = useContext(FileTreeContext);\n  const isSelected = selectedPath === path;\n\n  const handleClick = useCallback(() => {\n    onSelect?.(path);\n  }, [onSelect, path]);\n\n  const fileContextValue = useMemo(() => ({ name, path }), [name, path]);\n\n  return (\n    <FileTreeFileContext.Provider value={fileContextValue}>\n      <div\n        className={cn(\n          \"flex cursor-pointer items-center gap-1 rounded px-2 py-1 transition-colors hover:bg-muted/50\",\n          isSelected && \"bg-muted\",\n          className\n        )}\n        onClick={handleClick}\n        role=\"treeitem\"\n        aria-label={name}\n        aria-selected={isSelected}\n        data-tree-path={path}\n        tabIndex={focusedPath === path ? 0 : -1}\n        {...props}\n      >\n        {children ?? (\n          <>\n            {/* Spacer for alignment */}\n            <span className=\"size-4 shrink-0\" />\n            <FileTreeIcon>\n              {icon ?? <FileIcon className=\"size-4 text-muted-foreground\" />}\n            </FileTreeIcon>\n            <FileTreeName>{name}</FileTreeName>\n          </>\n        )}\n      </div>\n    </FileTreeFileContext.Provider>\n  );\n};\n\nexport type FileTreeActionsProps = HTMLAttributes<HTMLDivElement>;\n\nconst stopPropagation = (e: React.SyntheticEvent) => e.stopPropagation();\n\nexport const FileTreeActions = ({\n  className,\n  children,\n  ...props\n}: FileTreeActionsProps) => (\n  <div\n    className={cn(\"ml-auto flex items-center gap-1\", className)}\n    onClick={stopPropagation}\n    onKeyDown={stopPropagation}\n    role=\"group\"\n    {...props}\n  >\n    {children}\n  </div>\n);\n",
      "type": "registry:ui",
      "target": "components/ai/file-tree.tsx"
    }
  ],
  "type": "registry:ui"
}