{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-tree",
  "title": "File Tree",
  "author": "ilinxa",
  "description": "VS Code-style file tree — format-aware icons, full CRUD, drag-and-drop, lazy children, and multi-select.",
  "dependencies": [
    "@tanstack/react-virtual",
    "lucide-react"
  ],
  "registryDependencies": [
    "alert-dialog",
    "button",
    "context-menu",
    "tooltip"
  ],
  "files": [
    {
      "path": "src/registry/components/navigation/file-tree/file-tree.tsx",
      "content": "\"use client\";\n\nimport {\n  forwardRef,\n  useCallback,\n  useEffect,\n  useImperativeHandle,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport type { MouseEvent } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport { TooltipProvider } from \"@/components/ui/tooltip\";\nimport {\n  DEFAULT_FILE_TREE_LABELS,\n  type FileTreeHandle,\n  type FileTreeHeaderContext,\n  type FileTreeProps,\n  type FileTreeRow,\n  type FsNode,\n} from \"./types\";\nimport {\n  FileTreeContext,\n  type FileTreeContextValue,\n} from \"./hooks/use-file-tree-context\";\nimport { useTreeState } from \"./hooks/use-tree-state\";\nimport { useTreeFlatten } from \"./hooks/use-tree-flatten\";\nimport { useLazyLoad } from \"./hooks/use-lazy-load\";\nimport { useTreeVirtual } from \"./hooks/use-tree-virtual\";\nimport { useTreeDrag } from \"./hooks/use-tree-drag\";\nimport { useTreeKeyboard } from \"./hooks/use-tree-keyboard\";\nimport { findNode, indexNodes } from \"./lib/tree-utils\";\nimport { validateNodes } from \"./lib/validation\";\nimport { FileTreeHeader } from \"./parts/file-tree-header\";\nimport { FileTreeRowList } from \"./parts/file-tree-row-list\";\nimport { FileTreeEmpty } from \"./parts/file-tree-empty\";\nimport { FileTreeLoading } from \"./parts/file-tree-loading\";\nimport { FileTreeDragOverlay } from \"./parts/file-tree-drag-overlay\";\nimport { FileTreeDeleteConfirm } from \"./parts/file-tree-delete-confirm\";\nimport { FileTreeContextMenu } from \"./parts/file-tree-context-menu\";\n\nexport const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(\n  function FileTree(props, ref) {\n    const {\n      nodes,\n      loading = false,\n      selectionMode = \"single\",\n      selectedIds,\n      defaultSelectedIds,\n      onSelectedChange,\n      expandedIds,\n      defaultExpandedIds,\n      onExpandedChange,\n      onLoadChildren,\n      onOpen,\n      onCreate,\n      onRename,\n      onDelete,\n      onMove,\n      onRefresh,\n      onExternalDrop,\n      validateRename,\n      iconForNode,\n      renderRow,\n      renderHeader,\n      renderContextMenu,\n      renderEmpty,\n      renderLoading,\n      renderDeleteConfirm,\n      header = true,\n      title,\n      showNewFile = true,\n      showNewFolder = true,\n      showRefresh = true,\n      showCollapseAll = true,\n      contextMenu = true,\n      contextMenuActions,\n      showHidden = false,\n      isHidden,\n      sortNodes,\n      indentGuides = true,\n      rowHeight = 28,\n      indent = 18,\n      confirmDelete = true,\n      enableInternalDrag = true,\n      enableExternalDrop = true,\n      virtualize = \"auto\",\n      virtualizeThreshold = 200,\n      className,\n      style,\n      labels: labelsOverride,\n    } = props;\n\n    const labels = useMemo(\n      () => ({ ...DEFAULT_FILE_TREE_LABELS, ...labelsOverride }),\n      [labelsOverride],\n    );\n\n    // ── Pending delete (alertdialog) ───────────────────────────────────────\n    const [pendingDeleteIds, setPendingDeleteIds] = useState<string[]>([]);\n    const requestDeleteConfirmation = useCallback(\n      (ids: string[]) => setPendingDeleteIds(ids),\n      [],\n    );\n\n    // ── State + actions ────────────────────────────────────────────────────\n    const stateHook = useTreeState({\n      nodes,\n      selectionMode,\n      selectedIds,\n      defaultSelectedIds,\n      onSelectedChange,\n      expandedIds,\n      defaultExpandedIds,\n      onExpandedChange,\n      onCreate,\n      onDelete,\n      onRefresh,\n      confirmDelete,\n      requestDeleteConfirmation,\n    });\n\n    // ── Lazy load wiring ───────────────────────────────────────────────────\n    const lazy = useLazyLoad({\n      onLoadChildren,\n      setLoadingFolderIds: stateHook.setLoadingFolderIds,\n    });\n\n    // Auto-fire onLoadChildren when consumer expands a folder w/ children=undefined.\n    useEffect(() => {\n      if (!onLoadChildren) return;\n      for (const id of stateHook.state.expandedIds) {\n        const node = findNode(id, nodes);\n        if (\n          node &&\n          node.type === \"folder\" &&\n          node.children === undefined &&\n          !lazy.errors.has(node.id)\n        ) {\n          lazy.load(node);\n        }\n      }\n      // We intentionally exclude `lazy` from deps to avoid re-firing on\n      // every error-state update. The fields we care about are above.\n      // eslint-disable-next-line react-hooks/exhaustive-deps\n    }, [stateHook.state.expandedIds, nodes, onLoadChildren]);\n\n    // ── Visible-row flatten ────────────────────────────────────────────────\n    const rows = useTreeFlatten({\n      nodes,\n      expandedIds: stateHook.state.expandedIds,\n      selectedIds: stateHook.state.selectedIds,\n      focusedId: stateHook.state.focusedId,\n      loadingFolderIds: stateHook.state.loadingFolderIds,\n      showHidden,\n      isHidden,\n      sortNodes,\n    });\n\n    // ── Drag ───────────────────────────────────────────────────────────────\n    const drag = useTreeDrag({\n      nodes,\n      enableInternalDrag,\n      enableExternalDrop,\n      selectedIds: stateHook.state.selectedIds,\n      selectionMode,\n      onMove,\n      onExternalDrop,\n      selectIds: stateHook.actions.select,\n    });\n\n    // ── Keyboard ───────────────────────────────────────────────────────────\n    const kb = useTreeKeyboard({\n      rows,\n      focusedId: stateHook.state.focusedId,\n      renamingId: stateHook.state.renamingId,\n      selectionMode,\n      selectedIds: stateHook.state.selectedIds,\n      actions: stateHook.actions,\n      onOpen,\n    });\n\n    // ── Validate nodes (dev) ───────────────────────────────────────────────\n    useEffect(() => {\n      if (process.env.NODE_ENV === \"production\") return;\n      const warnings = validateNodes(nodes);\n      for (const w of warnings) {\n        console.warn(\"[file-tree]\", w);\n      }\n    }, [nodes]);\n\n    // ── Imperative handle ──────────────────────────────────────────────────\n    useImperativeHandle(\n      ref,\n      () => ({\n        state: stateHook.state,\n        actions: stateHook.actions,\n      }),\n      [stateHook.state, stateHook.actions],\n    );\n\n    // ── Index for context-menu target lookup ───────────────────────────────\n    const nodeIndex = useMemo(() => indexNodes(nodes), [nodes]);\n\n    // ── Roving tabindex → real DOM focus (v0.1.1, review 5.6) ──────────────\n    // The roving tabindex was state-only: arrow keys updated `focusedId` but\n    // never moved DOM focus, so screen readers heard nothing and the\n    // focus-visible ring never rendered. Mirrors app-sidebar's pattern.\n    // Only steals focus while the user is already interacting with the tree.\n    const rootElRef = useRef<HTMLDivElement>(null);\n    const focusedIdForDom = stateHook.state.focusedId;\n    useEffect(() => {\n      if (!focusedIdForDom) return;\n      if (typeof document === \"undefined\") return;\n      const root = rootElRef.current;\n      if (!root) return;\n      if (!root.contains(document.activeElement)) return;\n      const escaped = window.CSS.escape(focusedIdForDom);\n      const target = root.querySelector<HTMLElement>(\n        `[data-row-id=\"${escaped}\"]`,\n      );\n      if (target) {\n        if (document.activeElement !== target) target.focus();\n      } else {\n        // Virtualized mode: the focused row may not be mounted — fall back\n        // to the container so keyboard navigation keeps working.\n        root.focus({ preventScroll: true });\n      }\n    }, [focusedIdForDom]);\n\n    // ── Row click + double-click + chevron + context-menu ──────────────────\n    const onRowClick = useCallback(\n      (e: MouseEvent, row: FileTreeRow) => {\n        if (selectionMode === \"multi\" && (e.metaKey || e.ctrlKey)) {\n          const next = new Set(stateHook.state.selectedIds);\n          if (next.has(row.node.id)) next.delete(row.node.id);\n          else next.add(row.node.id);\n          stateHook.actions.select(Array.from(next));\n          stateHook.setSelectionAnchorId(row.node.id);\n        } else if (selectionMode === \"multi\" && e.shiftKey) {\n          const anchor = stateHook.getSelectionAnchorId();\n          if (!anchor) {\n            stateHook.actions.select(row.node.id);\n            stateHook.setSelectionAnchorId(row.node.id);\n          } else {\n            const aIdx = rows.findIndex((r) => r.node.id === anchor);\n            const bIdx = rows.findIndex((r) => r.node.id === row.node.id);\n            if (aIdx !== -1 && bIdx !== -1) {\n              const [from, to] = aIdx < bIdx ? [aIdx, bIdx] : [bIdx, aIdx];\n              const ids = rows.slice(from, to + 1).map((r) => r.node.id);\n              stateHook.actions.select(ids);\n            }\n          }\n        } else {\n          stateHook.actions.select(row.node.id);\n          stateHook.setSelectionAnchorId(row.node.id);\n        }\n        stateHook.actions.focusNode(row.node.id);\n      },\n      [selectionMode, stateHook, rows],\n    );\n\n    const onChevronClick = useCallback(\n      (_e: MouseEvent, row: FileTreeRow) => {\n        if (row.node.type !== \"folder\") return;\n        stateHook.actions.toggleExpand(row.node.id);\n      },\n      [stateHook.actions],\n    );\n\n    const onRowDoubleClick = useCallback(\n      (_e: MouseEvent, row: FileTreeRow) => {\n        if (row.node.type === \"folder\") {\n          stateHook.actions.toggleExpand(row.node.id);\n        } else {\n          onOpen?.({ node: row.node });\n        }\n      },\n      [stateHook.actions, onOpen],\n    );\n\n    const onRowContextMenu = useCallback(\n      (_e: MouseEvent, row: FileTreeRow) => {\n        if (!stateHook.state.selectedIds.has(row.node.id)) {\n          stateHook.actions.select(row.node.id);\n        }\n        stateHook.actions.focusNode(row.node.id);\n      },\n      [stateHook.state.selectedIds, stateHook.actions],\n    );\n\n    const onRenameCommit = useCallback(\n      (id: string, nextName: string) => {\n        const node = findNode(id, nodes);\n        if (node && onRename) onRename({ id, node, nextName });\n        stateHook.actions.cancelRename();\n      },\n      [nodes, onRename, stateHook.actions],\n    );\n\n    const onRenameCancel = useCallback(() => {\n      stateHook.actions.cancelRename();\n    }, [stateHook.actions]);\n\n    // ── Header context (for renderHeader slot) ─────────────────────────────\n    const totalCount = stateHook.totalCount;\n    const visibleCount = rows.length;\n\n    const headerCtx: FileTreeHeaderContext = useMemo(\n      () => ({\n        state: stateHook.state,\n        actions: stateHook.actions,\n        totalCount,\n        visibleCount,\n        showNewFile,\n        showNewFolder,\n        showRefresh,\n        showCollapseAll,\n        title,\n        labels,\n      }),\n      [\n        stateHook.state,\n        stateHook.actions,\n        totalCount,\n        visibleCount,\n        showNewFile,\n        showNewFolder,\n        showRefresh,\n        showCollapseAll,\n        title,\n        labels,\n      ],\n    );\n\n    // ── Public Context value ───────────────────────────────────────────────\n    const ctxValue: FileTreeContextValue = useMemo(\n      () => ({\n        state: stateHook.state,\n        actions: stateHook.actions,\n        rows,\n        totalCount,\n        visibleCount,\n        showNewFile,\n        showNewFolder,\n        showRefresh,\n        showCollapseAll,\n        title,\n        labels,\n      }),\n      [\n        stateHook.state,\n        stateHook.actions,\n        rows,\n        totalCount,\n        visibleCount,\n        showNewFile,\n        showNewFolder,\n        showRefresh,\n        showCollapseAll,\n        title,\n        labels,\n      ],\n    );\n\n    // ── Virtualization ─────────────────────────────────────────────────────\n    const scrollRef = useRef<HTMLDivElement>(null);\n    const v = useTreeVirtual({\n      rows,\n      scrollRef,\n      mode: virtualize,\n      threshold: virtualizeThreshold,\n      rowHeight,\n    });\n\n    // ── Body content ───────────────────────────────────────────────────────\n    let body: React.ReactNode;\n    if (loading) {\n      body = renderLoading?.() ?? <FileTreeLoading />;\n    } else if (nodes.length === 0) {\n      body =\n        renderEmpty?.({\n          actions: stateHook.actions,\n          showNewFile,\n          showNewFolder,\n          labels,\n        }) ?? <FileTreeEmpty />;\n    } else {\n      const list = (\n        <div role=\"presentation\" className=\"relative h-full min-h-0 flex-1\">\n          <FileTreeRowList\n            rows={rows}\n            rowHeight={rowHeight}\n            indent={indent}\n            indentGuides={indentGuides}\n            iconForNode={iconForNode}\n            renamingId={stateHook.state.renamingId}\n            validateRename={validateRename}\n            enableInternalDrag={enableInternalDrag}\n            dragOver={drag.dragOver}\n            loadErrors={lazy.errors}\n            onRetryLoad={(nodeId) => {\n              const node = findNode(nodeId, nodes);\n              if (node) {\n                lazy.clearError(nodeId);\n                lazy.load(node);\n              }\n            }}\n            labels={labels}\n            virtualizer={v.active ? v.virtualizer : undefined}\n            scrollRef={scrollRef}\n            renderRow={renderRow}\n            onRowClick={onRowClick}\n            onChevronClick={onChevronClick}\n            onRowDoubleClick={onRowDoubleClick}\n            onRowContextMenu={onRowContextMenu}\n            onRenameCommit={onRenameCommit}\n            onRenameCancel={onRenameCancel}\n            onRowDragStart={drag.onRowDragStart}\n            onRowDragOver={drag.onRowDragOver}\n            onRowDragLeave={drag.onRowDragLeave}\n            onRowDrop={drag.onRowDrop}\n            onRowDragEnd={drag.onRowDragEnd}\n          />\n        </div>\n      );\n      body = (\n        <FileTreeContextMenu\n          state={stateHook.state}\n          actions={stateHook.actions}\n          enabled={contextMenu}\n          labels={labels}\n          gates={{\n            open: contextMenuActions?.open ?? true,\n            newFile: contextMenuActions?.newFile ?? true,\n            newFolder: contextMenuActions?.newFolder ?? true,\n            rename: contextMenuActions?.rename ?? true,\n            delete: contextMenuActions?.delete ?? true,\n            refresh: contextMenuActions?.refresh ?? true,\n          }}\n          wired={{\n            onOpen: !!onOpen,\n            onCreate: !!onCreate,\n            onRename: !!onRename,\n            onDelete: !!onDelete,\n            onRefresh: !!onRefresh,\n          }}\n          nodeIndex={nodeIndex}\n          renderContextMenu={renderContextMenu}\n        >\n          {list}\n        </FileTreeContextMenu>\n      );\n    }\n\n    // ── Pending-delete dialog ──────────────────────────────────────────────\n    const pendingNodes: FsNode[] = useMemo(\n      () =>\n        pendingDeleteIds\n          .map((id) => nodeIndex.get(id))\n          .filter((n): n is FsNode => Boolean(n)),\n      [pendingDeleteIds, nodeIndex],\n    );\n\n    const onConfirmDelete = useCallback(() => {\n      if (pendingDeleteIds.length === 0) return;\n      onDelete?.({ ids: pendingDeleteIds });\n      setPendingDeleteIds([]);\n    }, [onDelete, pendingDeleteIds]);\n\n    const onCancelDelete = useCallback(() => {\n      setPendingDeleteIds([]);\n    }, []);\n\n    return (\n      <FileTreeContext.Provider value={ctxValue}>\n      {/* No delayDuration — Radix-only prop; Base UI's TooltipProvider rejects it (F-cross-13). */}\n      <TooltipProvider>\n        <div\n          ref={rootElRef}\n          role=\"tree\"\n          tabIndex={stateHook.state.focusedId === null ? 0 : -1}\n          aria-multiselectable={selectionMode === \"multi\" ? true : undefined}\n          aria-label={title ?? labels.title}\n          className={cn(\n            \"relative flex h-full w-full flex-col overflow-hidden rounded-md border border-border/60 bg-background text-foreground outline-none\",\n            \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background\",\n            className,\n          )}\n          style={style}\n          onKeyDown={kb.onKeyDown}\n          onDragEnter={drag.onContainerDragEnter}\n          onDragOver={drag.onContainerDragOver}\n          onDragLeave={drag.onContainerDragLeave}\n          onDrop={drag.onContainerDrop}\n        >\n          {header\n            ? (renderHeader ? renderHeader(headerCtx) : <FileTreeHeader />)\n            : null}\n          {body}\n          <FileTreeDragOverlay\n            visible={drag.isExternalDragging}\n            labels={labels}\n          />\n          {confirmDelete && pendingDeleteIds.length > 0 ? (\n            renderDeleteConfirm ? (\n              renderDeleteConfirm({\n                ids: pendingDeleteIds,\n                nodes: pendingNodes,\n                onConfirm: onConfirmDelete,\n                onCancel: onCancelDelete,\n                labels,\n              })\n            ) : (\n              <FileTreeDeleteConfirm\n                open={true}\n                ids={pendingDeleteIds}\n                nodes={pendingNodes}\n                labels={labels}\n                onConfirm={onConfirmDelete}\n                onCancel={onCancelDelete}\n              />\n            )\n          ) : null}\n        </div>\n       </TooltipProvider>\n      </FileTreeContext.Provider>\n    );\n  },\n);\n\nFileTree.displayName = \"FileTree\";\n",
      "type": "registry:component",
      "target": "components/file-tree/file-tree.tsx"
    },
    {
      "path": "src/registry/components/navigation/file-tree/index.ts",
      "content": "export { FileTree } from \"./file-tree\";\nexport { FileTreeHeader } from \"./parts/file-tree-header\";\nexport { FileTreeNewFileButton } from \"./parts/file-tree-new-file-button\";\nexport { FileTreeNewFolderButton } from \"./parts/file-tree-new-folder-button\";\nexport { FileTreeRefreshButton } from \"./parts/file-tree-refresh-button\";\nexport { FileTreeCollapseAllButton } from \"./parts/file-tree-collapse-all-button\";\nexport { useFileTree } from \"./hooks/use-file-tree-context\";\nexport { mergeLoadedChildren } from \"./lib/tree-utils\";\nexport { iconForExtension } from \"./lib/icons\";\nexport { DEFAULT_FILE_TREE_LABELS } from \"./types\";\nexport type {\n  FsNode,\n  FsNodeType,\n  FileTreeProps,\n  FileTreeHandle,\n  FileTreeState,\n  FileTreeActions,\n  FileTreeRow,\n  FileTreeSelectionMode,\n  FileTreeDropPosition,\n  FileTreeVirtualizeMode,\n  FileTreeOpenArgs,\n  FileTreeCreateArgs,\n  FileTreeRenameArgs,\n  FileTreeDeleteArgs,\n  FileTreeMoveArgs,\n  FileTreeRefreshArgs,\n  FileTreeExternalDropArgs,\n  FileTreeLoadChildrenArgs,\n  FileTreeSelectionChangeArgs,\n  FileTreeExpandedChangeArgs,\n  FileTreeValidateRenameArgs,\n  FileTreeIconForNodeArgs,\n  FileTreeContextMenuItem,\n  FileTreeContextMenuItemId,\n  FileTreeHeaderContext,\n  FileTreeContextMenuContext,\n  FileTreeEmptyContext,\n  FileTreeRowRenderArgs,\n  FileTreeDeleteConfirmContext,\n  FileTreeLabels,\n} from \"./types\";\n",
      "type": "registry:component",
      "target": "components/file-tree/index.ts"
    },
    {
      "path": "src/registry/components/navigation/file-tree/types.ts",
      "content": "import type { CSSProperties, ReactNode, Ref } from \"react\";\n\nexport type FsNodeType = \"file\" | \"folder\";\n\n/**\n * Hierarchical-node shape consumed by `<FileTree>`.\n *\n * `children` semantics:\n *  - `undefined` — unknown / not yet loaded → triggers `onLoadChildren` on first expand\n *  - `[]`        — known-empty folder → renders \"(empty)\" inline placeholder\n *  - `FsNode[]`  — pre-loaded children\n */\nexport interface FsNode {\n  id: string;\n  name: string;\n  type: FsNodeType;\n  parentId?: string | null;\n  children?: FsNode[];\n  ext?: string;\n  size?: number;\n  modifiedAt?: string;\n  icon?: ReactNode;\n  meta?: Record<string, unknown>;\n}\n\nexport type FileTreeSelectionMode = \"single\" | \"multi\";\nexport type FileTreeDropPosition = \"before\" | \"inside\" | \"after\";\nexport type FileTreeVirtualizeMode = \"auto\" | \"always\" | \"never\";\n\n/** Visible-row descriptor produced by the flattening pass. */\nexport interface FileTreeRow {\n  node: FsNode;\n  depth: number;\n  expanded: boolean;\n  selected: boolean;\n  focused: boolean;\n  hasChildren: boolean;\n  childrenLoaded: boolean;\n  loadingChildren: boolean;\n  /** Position within the visible-rows array. */\n  index: number;\n  /** Number of visible siblings at this depth (for aria-setsize). */\n  siblingCount: number;\n  /** 1-based position among siblings at this depth (for aria-posinset). */\n  siblingIndex: number;\n}\n\n/** Read-only state surfaced to slots and the imperative ref. */\nexport interface FileTreeState {\n  expandedIds: ReadonlySet<string>;\n  selectedIds: ReadonlySet<string>;\n  focusedId: string | null;\n  renamingId: string | null;\n  loadingFolderIds: ReadonlySet<string>;\n}\n\n/** Imperative actions exposed to slots and the imperative ref. */\nexport interface FileTreeActions {\n  expand: (id: string) => void;\n  collapse: (id: string) => void;\n  toggleExpand: (id: string) => void;\n  expandAll: () => void;\n  collapseAll: () => void;\n  select: (idOrIds: string | string[]) => void;\n  clearSelection: () => void;\n  focusNode: (id: string) => void;\n  startRename: (id: string) => void;\n  cancelRename: () => void;\n  triggerCreate: (parentId: string | null, type: FsNodeType) => void;\n  triggerDelete: (ids: string[]) => void;\n  refresh: (nodeId?: string | null) => void;\n}\n\nexport interface FileTreeHandle {\n  state: FileTreeState;\n  actions: FileTreeActions;\n}\n\n// ─── Callback arg shapes (object-shape per F-cross-12) ──────────────────────\n\nexport interface FileTreeOpenArgs {\n  node: FsNode;\n}\nexport interface FileTreeCreateArgs {\n  parentId: string | null;\n  type: FsNodeType;\n}\nexport interface FileTreeRenameArgs {\n  id: string;\n  node: FsNode;\n  nextName: string;\n}\nexport interface FileTreeDeleteArgs {\n  ids: string[];\n}\nexport interface FileTreeMoveArgs {\n  ids: string[];\n  targetId: string | null;\n  position: FileTreeDropPosition;\n}\nexport interface FileTreeRefreshArgs {\n  nodeId: string | null;\n}\nexport interface FileTreeExternalDropArgs {\n  files: File[];\n  targetId: string | null;\n}\nexport interface FileTreeLoadChildrenArgs {\n  nodeId: string;\n  node: FsNode;\n}\nexport interface FileTreeSelectionChangeArgs {\n  ids: Set<string>;\n}\nexport interface FileTreeExpandedChangeArgs {\n  ids: Set<string>;\n}\nexport interface FileTreeValidateRenameArgs {\n  node: FsNode;\n  nextName: string;\n}\nexport interface FileTreeIconForNodeArgs {\n  node: FsNode;\n}\n\n// ─── Default labels (full set; consumers override partially via `labels` prop) ─\n\nexport interface FileTreeLabels {\n  title: string;\n  newFile: string;\n  newFolder: string;\n  refresh: string;\n  collapseAll: string;\n  contextOpen: string;\n  contextNewFile: string;\n  contextNewFolder: string;\n  contextRename: string;\n  contextDelete: string;\n  contextRefresh: string;\n  deleteConfirmTitle: string;\n  deleteConfirmDescription: string;\n  deleteConfirmAction: string;\n  deleteConfirmCancel: string;\n  emptyTitle: string;\n  loading: string;\n  externalDropOverlay: string;\n  nodeCount: string;\n  loadError: string;\n  retry: string;\n}\n\n// ─── Slot contexts ──────────────────────────────────────────────────────────\n\nexport interface FileTreeHeaderContext {\n  state: FileTreeState;\n  actions: FileTreeActions;\n  totalCount: number;\n  visibleCount: number;\n  showNewFile: boolean;\n  showNewFolder: boolean;\n  showRefresh: boolean;\n  showCollapseAll: boolean;\n  title?: string;\n  labels: FileTreeLabels;\n}\n\nexport type FileTreeContextMenuItemId =\n  | \"open\"\n  | \"new-file\"\n  | \"new-folder\"\n  | \"rename\"\n  | \"delete\"\n  | \"refresh\";\n\nexport interface FileTreeContextMenuItem {\n  id: FileTreeContextMenuItemId;\n  label: string;\n  onSelect: () => void;\n  disabled?: boolean;\n  destructive?: boolean;\n  icon?: ReactNode;\n}\n\nexport interface FileTreeContextMenuContext {\n  state: FileTreeState;\n  actions: FileTreeActions;\n  /** The node under the right-click target. `null` when right-clicking empty whitespace. */\n  node: FsNode | null;\n  defaultActions: FileTreeContextMenuItem[];\n  position: { x: number; y: number };\n  labels: FileTreeLabels;\n}\n\nexport interface FileTreeEmptyContext {\n  actions: FileTreeActions;\n  showNewFile: boolean;\n  showNewFolder: boolean;\n  labels: FileTreeLabels;\n}\n\nexport interface FileTreeRowRenderArgs {\n  row: FileTreeRow;\n  defaultRow: ReactNode;\n}\n\nexport interface FileTreeDeleteConfirmContext {\n  ids: string[];\n  nodes: FsNode[];\n  onConfirm: () => void;\n  onCancel: () => void;\n  labels: FileTreeLabels;\n}\n\n// ─── Top-level props ────────────────────────────────────────────────────────\n\nexport interface FileTreeProps {\n  // Data\n  nodes: FsNode[];\n  loading?: boolean;\n\n  // Selection\n  selectionMode?: FileTreeSelectionMode;\n  selectedIds?: Set<string>;\n  defaultSelectedIds?: Set<string>;\n  onSelectedChange?: (args: FileTreeSelectionChangeArgs) => void;\n\n  // Expansion\n  expandedIds?: Set<string>;\n  defaultExpandedIds?: Set<string>;\n  onExpandedChange?: (args: FileTreeExpandedChangeArgs) => void;\n\n  // Lazy loading\n  onLoadChildren?: (args: FileTreeLoadChildrenArgs) => Promise<FsNode[]>;\n\n  // Operations\n  onOpen?: (args: FileTreeOpenArgs) => void;\n  onCreate?: (args: FileTreeCreateArgs) => void;\n  onRename?: (args: FileTreeRenameArgs) => void;\n  onDelete?: (args: FileTreeDeleteArgs) => void;\n  onMove?: (args: FileTreeMoveArgs) => void;\n  onRefresh?: (args: FileTreeRefreshArgs) => void;\n  onExternalDrop?: (args: FileTreeExternalDropArgs) => void;\n  validateRename?: (args: FileTreeValidateRenameArgs) => string | null;\n\n  // Rendering\n  iconForNode?: (args: FileTreeIconForNodeArgs) => ReactNode;\n  renderRow?: (args: FileTreeRowRenderArgs) => ReactNode;\n  renderHeader?: (ctx: FileTreeHeaderContext) => ReactNode;\n  renderContextMenu?: (ctx: FileTreeContextMenuContext) => ReactNode;\n  renderEmpty?: (ctx: FileTreeEmptyContext) => ReactNode;\n  renderLoading?: () => ReactNode;\n  renderDeleteConfirm?: (ctx: FileTreeDeleteConfirmContext) => ReactNode;\n\n  // Header config\n  header?: boolean;\n  title?: string;\n  showNewFile?: boolean;\n  showNewFolder?: boolean;\n  showRefresh?: boolean;\n  showCollapseAll?: boolean;\n\n  // Context-menu config\n  contextMenu?: boolean;\n  contextMenuActions?: {\n    open?: boolean;\n    newFile?: boolean;\n    newFolder?: boolean;\n    rename?: boolean;\n    delete?: boolean;\n    refresh?: boolean;\n  };\n\n  // Behavior\n  showHidden?: boolean;\n  isHidden?: (node: FsNode) => boolean;\n  sortNodes?: ((a: FsNode, b: FsNode) => number) | false;\n  indentGuides?: boolean;\n  rowHeight?: number;\n  indent?: number;\n\n  // Confirmation\n  confirmDelete?: boolean;\n\n  // DnD\n  enableInternalDrag?: boolean;\n  enableExternalDrop?: boolean;\n\n  // Virtualization\n  virtualize?: FileTreeVirtualizeMode;\n  virtualizeThreshold?: number;\n\n  // Polymorphic\n  className?: string;\n  style?: CSSProperties;\n\n  // Imperative handle\n  ref?: Ref<FileTreeHandle>;\n\n  // i18n labels\n  labels?: Partial<FileTreeLabels>;\n}\n\n/** Default labels — exported so consumers can build complete label objects. */\nexport const DEFAULT_FILE_TREE_LABELS: FileTreeLabels = {\n  title: \"Files\",\n  newFile: \"New File\",\n  newFolder: \"New Folder\",\n  refresh: \"Refresh\",\n  collapseAll: \"Collapse All\",\n  contextOpen: \"Open\",\n  contextNewFile: \"New File\",\n  contextNewFolder: \"New Folder\",\n  contextRename: \"Rename\",\n  contextDelete: \"Delete\",\n  contextRefresh: \"Refresh\",\n  deleteConfirmTitle: \"Delete items?\",\n  deleteConfirmDescription: \"This action cannot be undone.\",\n  deleteConfirmAction: \"Delete\",\n  deleteConfirmCancel: \"Cancel\",\n  emptyTitle: \"No items\",\n  loading: \"Loading…\",\n  externalDropOverlay: \"Drop files here\",\n  nodeCount: \"items\",\n  loadError: \"Failed to load\",\n  retry: \"Retry\",\n};\n",
      "type": "registry:component",
      "target": "components/file-tree/types.ts"
    },
    {
      "path": "src/registry/components/navigation/file-tree/hooks/use-file-tree-context.ts",
      "content": "\"use client\";\n\nimport { createContext, useContext } from \"react\";\nimport type {\n  FileTreeActions,\n  FileTreeLabels,\n  FileTreeRow,\n  FileTreeState,\n} from \"../types\";\n\nexport interface FileTreeContextValue {\n  state: FileTreeState;\n  actions: FileTreeActions;\n  rows: FileTreeRow[];\n  totalCount: number;\n  visibleCount: number;\n  /** Header config (read by standalone parts). */\n  showNewFile: boolean;\n  showNewFolder: boolean;\n  showRefresh: boolean;\n  showCollapseAll: boolean;\n  title?: string;\n  labels: FileTreeLabels;\n}\n\nexport const FileTreeContext = createContext<FileTreeContextValue | null>(null);\n\n/**\n * Public consumer hook. Exposes state, actions, and header-config for\n * consumers building a custom header inside `renderHeader` or composing\n * standalone parts.\n *\n * Throws if called outside `<FileTree>`.\n */\nexport function useFileTree(): FileTreeContextValue {\n  const ctx = useContext(FileTreeContext);\n  if (!ctx) {\n    throw new Error(\"useFileTree() must be called inside <FileTree>\");\n  }\n  return ctx;\n}\n",
      "type": "registry:component",
      "target": "components/file-tree/hooks/use-file-tree-context.ts"
    },
    {
      "path": "src/registry/components/navigation/file-tree/hooks/use-tree-state.ts",
      "content": "\"use client\";\n\nimport { useCallback, useMemo, useRef, useState } from \"react\";\nimport type {\n  FileTreeActions,\n  FileTreeExpandedChangeArgs,\n  FileTreeSelectionChangeArgs,\n  FileTreeSelectionMode,\n  FileTreeState,\n  FsNode,\n  FsNodeType,\n} from \"../types\";\nimport {\n  countAllNodes,\n  deriveRootExpandedIds,\n  indexNodes,\n} from \"../lib/tree-utils\";\n\ninterface UseTreeStateArgs {\n  nodes: FsNode[];\n  selectionMode: FileTreeSelectionMode;\n  selectedIds: Set<string> | undefined;\n  defaultSelectedIds: Set<string> | undefined;\n  onSelectedChange?: (args: FileTreeSelectionChangeArgs) => void;\n  expandedIds: Set<string> | undefined;\n  defaultExpandedIds: Set<string> | undefined;\n  onExpandedChange?: (args: FileTreeExpandedChangeArgs) => void;\n  onCreate?: (args: { parentId: string | null; type: FsNodeType }) => void;\n  onDelete?: (args: { ids: string[] }) => void;\n  onRefresh?: (args: { nodeId: string | null }) => void;\n  confirmDelete: boolean;\n  /**\n   * Callback used by `triggerDelete` when `confirmDelete` is `true`. The\n   * orchestrator implements this and shows the AlertDialog. When the dialog\n   * confirms, it calls `onDelete` directly. When `confirmDelete` is `false`,\n   * `triggerDelete` skips this and fires `onDelete` immediately.\n   */\n  requestDeleteConfirmation: (ids: string[]) => void;\n}\n\nexport interface UseTreeStateResult {\n  state: FileTreeState;\n  actions: FileTreeActions;\n  totalCount: number;\n  /** Read the current selection anchor (use only inside event handlers, not during render). */\n  getSelectionAnchorId: () => string | null;\n  setSelectionAnchorId: (id: string | null) => void;\n  /** Internal write helpers used by other hooks. */\n  setLoadingFolderIds: (next: Set<string>) => void;\n}\n\nexport function useTreeState(args: UseTreeStateArgs): UseTreeStateResult {\n  const {\n    nodes,\n    selectionMode,\n    selectedIds: controlledSelected,\n    defaultSelectedIds,\n    onSelectedChange,\n    expandedIds: controlledExpanded,\n    defaultExpandedIds,\n    onExpandedChange,\n    onCreate,\n    onDelete,\n    onRefresh,\n    confirmDelete,\n    requestDeleteConfirmation,\n  } = args;\n\n  // Initial uncontrolled state. `useState`'s lazy initializer keeps the\n  // root-derive call O(roots) on first render only.\n  const [internalExpanded, setInternalExpanded] = useState<Set<string>>(\n    () => defaultExpandedIds ?? deriveRootExpandedIds(nodes),\n  );\n  const [internalSelected, setInternalSelected] = useState<Set<string>>(\n    () => defaultSelectedIds ?? new Set(),\n  );\n  const [focusedId, setFocusedId] = useState<string | null>(null);\n  const [renamingId, setRenamingId] = useState<string | null>(null);\n  const [loadingFolderIds, setLoadingFolderIdsRaw] = useState<Set<string>>(\n    () => new Set(),\n  );\n  const selectionAnchorRef = useRef<string | null>(null);\n\n  const expandedIds = controlledExpanded ?? internalExpanded;\n  const selectedIds = controlledSelected ?? internalSelected;\n\n  const setExpanded = useCallback(\n    (next: Set<string>) => {\n      if (controlledExpanded === undefined) setInternalExpanded(next);\n      onExpandedChange?.({ ids: next });\n    },\n    [controlledExpanded, onExpandedChange],\n  );\n\n  const setSelected = useCallback(\n    (next: Set<string>) => {\n      if (controlledSelected === undefined) setInternalSelected(next);\n      onSelectedChange?.({ ids: next });\n    },\n    [controlledSelected, onSelectedChange],\n  );\n\n  const expand = useCallback(\n    (id: string) => {\n      if (expandedIds.has(id)) return;\n      const next = new Set(expandedIds);\n      next.add(id);\n      setExpanded(next);\n    },\n    [expandedIds, setExpanded],\n  );\n\n  const collapse = useCallback(\n    (id: string) => {\n      if (!expandedIds.has(id)) return;\n      const next = new Set(expandedIds);\n      next.delete(id);\n      setExpanded(next);\n    },\n    [expandedIds, setExpanded],\n  );\n\n  const toggleExpand = useCallback(\n    (id: string) => {\n      const next = new Set(expandedIds);\n      if (next.has(id)) next.delete(id);\n      else next.add(id);\n      setExpanded(next);\n    },\n    [expandedIds, setExpanded],\n  );\n\n  const expandAll = useCallback(() => {\n    const next = new Set<string>();\n    const walk = (list: FsNode[]) => {\n      for (const node of list) {\n        if (node.type === \"folder\") next.add(node.id);\n        if (node.children) walk(node.children);\n      }\n    };\n    walk(nodes);\n    setExpanded(next);\n  }, [nodes, setExpanded]);\n\n  const collapseAll = useCallback(() => {\n    setExpanded(new Set());\n  }, [setExpanded]);\n\n  const select = useCallback(\n    (idOrIds: string | string[]) => {\n      const ids = Array.isArray(idOrIds) ? idOrIds : [idOrIds];\n      if (selectionMode === \"single\") {\n        setSelected(new Set(ids.slice(0, 1)));\n      } else {\n        setSelected(new Set(ids));\n      }\n      if (ids.length > 0) selectionAnchorRef.current = ids[0];\n    },\n    [selectionMode, setSelected],\n  );\n\n  const clearSelection = useCallback(() => {\n    setSelected(new Set());\n    selectionAnchorRef.current = null;\n  }, [setSelected]);\n\n  const focusNode = useCallback((id: string) => {\n    setFocusedId(id);\n  }, []);\n\n  const startRename = useCallback((id: string) => {\n    setRenamingId(id);\n  }, []);\n\n  const cancelRename = useCallback(() => {\n    setRenamingId(null);\n  }, []);\n\n  const triggerCreate = useCallback(\n    (parentId: string | null, type: FsNodeType) => {\n      onCreate?.({ parentId, type });\n    },\n    [onCreate],\n  );\n\n  const triggerDelete = useCallback(\n    (ids: string[]) => {\n      if (ids.length === 0) return;\n      if (confirmDelete) {\n        requestDeleteConfirmation(ids);\n        return;\n      }\n      onDelete?.({ ids });\n    },\n    [confirmDelete, requestDeleteConfirmation, onDelete],\n  );\n\n  const refresh = useCallback(\n    (nodeId: string | null = null) => {\n      onRefresh?.({ nodeId });\n    },\n    [onRefresh],\n  );\n\n  const setSelectionAnchorId = useCallback((id: string | null) => {\n    selectionAnchorRef.current = id;\n  }, []);\n\n  const setLoadingFolderIds = useCallback((next: Set<string>) => {\n    setLoadingFolderIdsRaw(next);\n  }, []);\n\n  const totalCount = useMemo(() => countAllNodes(nodes), [nodes]);\n\n  // Drop selection ids that no longer reference real nodes\n  const sanitizedSelectedIds = useMemo(() => {\n    if (selectedIds.size === 0) return selectedIds;\n    const idx = indexNodes(nodes);\n    let dirty = false;\n    const next = new Set<string>();\n    for (const id of selectedIds) {\n      if (idx.has(id)) next.add(id);\n      else dirty = true;\n    }\n    return dirty ? next : selectedIds;\n  }, [nodes, selectedIds]);\n\n  const state: FileTreeState = useMemo(\n    () => ({\n      expandedIds,\n      selectedIds: sanitizedSelectedIds,\n      focusedId,\n      renamingId,\n      loadingFolderIds,\n    }),\n    [\n      expandedIds,\n      sanitizedSelectedIds,\n      focusedId,\n      renamingId,\n      loadingFolderIds,\n    ],\n  );\n\n  const actions: FileTreeActions = useMemo(\n    () => ({\n      expand,\n      collapse,\n      toggleExpand,\n      expandAll,\n      collapseAll,\n      select,\n      clearSelection,\n      focusNode,\n      startRename,\n      cancelRename,\n      triggerCreate,\n      triggerDelete,\n      refresh,\n    }),\n    [\n      expand,\n      collapse,\n      toggleExpand,\n      expandAll,\n      collapseAll,\n      select,\n      clearSelection,\n      focusNode,\n      startRename,\n      cancelRename,\n      triggerCreate,\n      triggerDelete,\n      refresh,\n    ],\n  );\n\n  const getSelectionAnchorId = useCallback(\n    () => selectionAnchorRef.current,\n    [],\n  );\n\n  return {\n    state,\n    actions,\n    totalCount,\n    getSelectionAnchorId,\n    setSelectionAnchorId,\n    setLoadingFolderIds,\n  };\n}\n",
      "type": "registry:component",
      "target": "components/file-tree/hooks/use-tree-state.ts"
    },
    {
      "path": "src/registry/components/navigation/file-tree/hooks/use-tree-flatten.ts",
      "content": "\"use client\";\n\nimport { useMemo } from \"react\";\nimport type { FileTreeRow, FsNode } from \"../types\";\nimport { defaultSort } from \"../lib/tree-utils\";\n\ninterface UseTreeFlattenArgs {\n  nodes: FsNode[];\n  expandedIds: ReadonlySet<string>;\n  selectedIds: ReadonlySet<string>;\n  focusedId: string | null;\n  loadingFolderIds: ReadonlySet<string>;\n  showHidden: boolean;\n  isHidden?: (node: FsNode) => boolean;\n  sortNodes: ((a: FsNode, b: FsNode) => number) | false | undefined;\n}\n\nconst defaultIsHidden = (node: FsNode) => node.name.startsWith(\".\");\n\n/**\n * Walks the tree, applies sort + visibility, returns the visible-row array\n * the virtualizer (and naive renderer) consume.\n */\nexport function useTreeFlatten(args: UseTreeFlattenArgs): FileTreeRow[] {\n  const {\n    nodes,\n    expandedIds,\n    selectedIds,\n    focusedId,\n    loadingFolderIds,\n    showHidden,\n    isHidden,\n    sortNodes,\n  } = args;\n\n  return useMemo(() => {\n    const rows: FileTreeRow[] = [];\n    const hiddenFn = isHidden ?? defaultIsHidden;\n    const sorter = sortNodes === false ? null : sortNodes ?? defaultSort;\n\n    const walk = (list: FsNode[], depth: number) => {\n      const filtered = showHidden\n        ? list\n        : list.filter((node) => !hiddenFn(node));\n      const sorted = sorter ? [...filtered].sort(sorter) : filtered;\n      const siblingCount = sorted.length;\n      sorted.forEach((node, siblingIdx) => {\n        const isFolder = node.type === \"folder\";\n        const expanded = isFolder && expandedIds.has(node.id);\n        const childrenLoaded = isFolder && node.children !== undefined;\n        const hasChildren =\n          isFolder &&\n          (node.children === undefined || (node.children?.length ?? 0) > 0);\n        rows.push({\n          node,\n          depth,\n          expanded,\n          selected: selectedIds.has(node.id),\n          focused: focusedId === node.id,\n          hasChildren,\n          childrenLoaded,\n          loadingChildren: loadingFolderIds.has(node.id),\n          index: rows.length,\n          siblingCount,\n          siblingIndex: siblingIdx + 1,\n        });\n        if (expanded && node.children?.length) {\n          walk(node.children, depth + 1);\n        }\n        // Empty (`children: []`) folders render an \"(empty)\" placeholder row\n        // inline; the row component handles that, not the flatten.\n      });\n    };\n    walk(nodes, 0);\n    return rows;\n  }, [\n    nodes,\n    expandedIds,\n    selectedIds,\n    focusedId,\n    loadingFolderIds,\n    showHidden,\n    isHidden,\n    sortNodes,\n  ]);\n}\n",
      "type": "registry:component",
      "target": "components/file-tree/hooks/use-tree-flatten.ts"
    },
    {
      "path": "src/registry/components/navigation/file-tree/hooks/use-tree-keyboard.ts",
      "content": "\"use client\";\n\nimport { useCallback } from \"react\";\nimport type { KeyboardEvent } from \"react\";\nimport type {\n  FileTreeActions,\n  FileTreeOpenArgs,\n  FileTreeRow,\n  FileTreeSelectionMode,\n} from \"../types\";\n\ninterface UseTreeKeyboardArgs {\n  rows: FileTreeRow[];\n  focusedId: string | null;\n  renamingId: string | null;\n  selectionMode: FileTreeSelectionMode;\n  selectedIds: ReadonlySet<string>;\n  actions: FileTreeActions;\n  onOpen?: (args: FileTreeOpenArgs) => void;\n}\n\nexport interface UseTreeKeyboardResult {\n  onKeyDown: (e: KeyboardEvent<HTMLDivElement>) => void;\n}\n\nconst META_KEYS = [\"INPUT\", \"TEXTAREA\", \"SELECT\"];\n\nexport function useTreeKeyboard(\n  args: UseTreeKeyboardArgs,\n): UseTreeKeyboardResult {\n  const {\n    rows,\n    focusedId,\n    renamingId,\n    selectionMode,\n    selectedIds,\n    actions,\n    onOpen,\n  } = args;\n\n  const onKeyDown = useCallback(\n    (e: KeyboardEvent<HTMLDivElement>) => {\n      // If user is typing in the rename input or any other text field, bail.\n      const target = e.target as HTMLElement | null;\n      if (target && META_KEYS.includes(target.tagName)) return;\n      // If a rename is active anywhere, the rename input intercepts its own keys.\n      // Anything else here just passes through.\n      if (renamingId) return;\n\n      const isMod = e.metaKey || e.ctrlKey;\n\n      // Find current row index by focusedId\n      const idx = rows.findIndex((r) => r.node.id === focusedId);\n      const noFocus = idx === -1;\n\n      switch (e.key) {\n        case \"ArrowDown\": {\n          e.preventDefault();\n          const next = noFocus ? 0 : Math.min(rows.length - 1, idx + 1);\n          if (rows[next]) actions.focusNode(rows[next].node.id);\n          return;\n        }\n        case \"ArrowUp\": {\n          e.preventDefault();\n          // APG: ArrowUp with no prior focus lands on the LAST row.\n          const next = noFocus ? rows.length - 1 : Math.max(0, idx - 1);\n          if (rows[next]) actions.focusNode(rows[next].node.id);\n          return;\n        }\n        case \"ArrowRight\": {\n          e.preventDefault();\n          if (noFocus) {\n            if (rows[0]) actions.focusNode(rows[0].node.id);\n            return;\n          }\n          const row = rows[idx];\n          if (row.node.type === \"folder\" && row.hasChildren) {\n            if (row.expanded) {\n              // move to first child\n              const childRow = rows[idx + 1];\n              if (childRow && childRow.depth > row.depth) {\n                actions.focusNode(childRow.node.id);\n              }\n            } else {\n              actions.expand(row.node.id);\n            }\n          }\n          return;\n        }\n        case \"ArrowLeft\": {\n          e.preventDefault();\n          if (noFocus) {\n            if (rows[0]) actions.focusNode(rows[0].node.id);\n            return;\n          }\n          const row = rows[idx];\n          if (row.node.type === \"folder\" && row.expanded) {\n            actions.collapse(row.node.id);\n          } else {\n            // move to parent\n            for (let i = idx - 1; i >= 0; i--) {\n              if (rows[i].depth < row.depth) {\n                actions.focusNode(rows[i].node.id);\n                break;\n              }\n            }\n          }\n          return;\n        }\n        case \"Home\": {\n          e.preventDefault();\n          if (rows[0]) actions.focusNode(rows[0].node.id);\n          return;\n        }\n        case \"End\": {\n          e.preventDefault();\n          const last = rows[rows.length - 1];\n          if (last) actions.focusNode(last.node.id);\n          return;\n        }\n        case \"Enter\": {\n          if (noFocus) return;\n          e.preventDefault();\n          const row = rows[idx];\n          if (row.node.type === \"folder\") {\n            actions.toggleExpand(row.node.id);\n          } else {\n            onOpen?.({ node: row.node });\n          }\n          return;\n        }\n        case \" \": // Space\n        case \"Spacebar\": {\n          if (noFocus) return;\n          e.preventDefault();\n          const row = rows[idx];\n          if (selectionMode === \"multi\") {\n            const next = new Set(selectedIds);\n            if (next.has(row.node.id)) next.delete(row.node.id);\n            else next.add(row.node.id);\n            actions.select(Array.from(next));\n          } else {\n            actions.select(row.node.id);\n          }\n          return;\n        }\n        case \"F2\": {\n          if (noFocus) return;\n          e.preventDefault();\n          actions.startRename(rows[idx].node.id);\n          return;\n        }\n        case \"Delete\":\n        case \"Backspace\": {\n          if (selectedIds.size === 0) return;\n          e.preventDefault();\n          actions.triggerDelete(Array.from(selectedIds));\n          return;\n        }\n        case \"Escape\": {\n          if (selectedIds.size > 0) {\n            e.preventDefault();\n            actions.clearSelection();\n          }\n          return;\n        }\n        case \"a\":\n        case \"A\": {\n          if (!isMod) return;\n          if (selectionMode !== \"multi\") return;\n          e.preventDefault();\n          actions.select(rows.map((r) => r.node.id));\n          return;\n        }\n        default:\n          return;\n      }\n    },\n    [rows, focusedId, renamingId, selectionMode, selectedIds, actions, onOpen],\n  );\n\n  return { onKeyDown };\n}\n",
      "type": "registry:component",
      "target": "components/file-tree/hooks/use-tree-keyboard.ts"
    },
    {
      "path": "src/registry/components/navigation/file-tree/hooks/use-tree-drag.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useMemo, useRef, useState } from \"react\";\nimport type { DragEvent } from \"react\";\nimport type { DragOverState } from \"../parts/file-tree-row-list\";\nimport type {\n  FileTreeDropPosition,\n  FileTreeMoveArgs,\n  FileTreeRow,\n  FsNode,\n} from \"../types\";\nimport { isLegalDrop } from \"../lib/validation\";\nimport { indexNodes } from \"../lib/tree-utils\";\n\nconst INTERNAL_MIME = \"application/x-ilinxa-file-tree\";\n\ninterface UseTreeDragArgs {\n  nodes: FsNode[];\n  enableInternalDrag: boolean;\n  enableExternalDrop: boolean;\n  selectedIds: ReadonlySet<string>;\n  selectionMode: \"single\" | \"multi\";\n  onMove?: (args: FileTreeMoveArgs) => void;\n  onExternalDrop?: (args: { files: File[]; targetId: string | null }) => void;\n  /** Replace selection with a single id (used at drag-start when source isn't selected). */\n  selectIds: (idOrIds: string | string[]) => void;\n}\n\nexport interface UseTreeDragResult {\n  dragOver: DragOverState | null;\n  isExternalDragging: boolean;\n  isInternalDragging: boolean;\n  // Row-level handlers\n  onRowDragStart: (e: DragEvent, row: FileTreeRow) => void;\n  onRowDragOver: (e: DragEvent, row: FileTreeRow) => void;\n  onRowDragLeave: (e: DragEvent, row: FileTreeRow) => void;\n  onRowDrop: (e: DragEvent, row: FileTreeRow) => void;\n  onRowDragEnd: (e: DragEvent, row: FileTreeRow) => void;\n  // Container-level handlers (root + scroll area)\n  onContainerDragEnter: (e: DragEvent) => void;\n  onContainerDragOver: (e: DragEvent) => void;\n  onContainerDragLeave: (e: DragEvent) => void;\n  onContainerDrop: (e: DragEvent) => void;\n  /** Map of `clientY` → scroll-direction; only active during drag near edges. */\n  startAutoScroll: (\n    container: HTMLElement | null,\n    clientY: number,\n  ) => void;\n  stopAutoScroll: () => void;\n}\n\nconst AUTO_SCROLL_EDGE_PX = 24;\nconst AUTO_SCROLL_PX_PER_FRAME = 4;\n\nexport function useTreeDrag(args: UseTreeDragArgs): UseTreeDragResult {\n  const {\n    nodes,\n    enableInternalDrag,\n    enableExternalDrop,\n    selectedIds,\n    selectionMode,\n    onMove,\n    onExternalDrop,\n    selectIds,\n  } = args;\n\n  const [dragOver, setDragOver] = useState<DragOverState | null>(null);\n  const [isExternalDragging, setIsExternalDragging] = useState(false);\n  const isInternalDraggingRef = useRef(false);\n  const [isInternalDragging, setIsInternalDragging] = useState(false);\n  const nodeIndex = useMemo(() => indexNodes(nodes), [nodes]);\n  // Track external-drag enter/leave with a refcount (events fire per nested element)\n  const externalEnterCountRef = useRef(0);\n  const dragSourceIdsRef = useRef<string[]>([]);\n  const autoScrollRef = useRef<{\n    container: HTMLElement | null;\n    direction: number;\n    raf: number | null;\n  }>({ container: null, direction: 0, raf: null });\n\n  const stopAutoScroll = useCallback(() => {\n    const ref = autoScrollRef.current;\n    if (ref.raf !== null) {\n      cancelAnimationFrame(ref.raf);\n      ref.raf = null;\n    }\n    ref.direction = 0;\n    ref.container = null;\n  }, []);\n\n  const startAutoScroll = useCallback(\n    (container: HTMLElement | null, clientY: number) => {\n      if (!container) return;\n      const rect = container.getBoundingClientRect();\n      let dir = 0;\n      if (clientY - rect.top < AUTO_SCROLL_EDGE_PX) dir = -1;\n      else if (rect.bottom - clientY < AUTO_SCROLL_EDGE_PX) dir = 1;\n      const ref = autoScrollRef.current;\n      ref.container = container;\n      if (dir !== ref.direction) {\n        ref.direction = dir;\n        if (ref.raf !== null) {\n          cancelAnimationFrame(ref.raf);\n          ref.raf = null;\n        }\n        if (dir !== 0) {\n          const tick = () => {\n            const r = autoScrollRef.current;\n            if (r.container && r.direction !== 0) {\n              r.container.scrollTop +=\n                r.direction * AUTO_SCROLL_PX_PER_FRAME;\n              r.raf = requestAnimationFrame(tick);\n            } else {\n              r.raf = null;\n            }\n          };\n          ref.raf = requestAnimationFrame(tick);\n        }\n      }\n    },\n    [],\n  );\n\n  // Cleanup on unmount\n  useEffect(() => {\n    return () => stopAutoScroll();\n  }, [stopAutoScroll]);\n\n  const computePosition = (\n    e: DragEvent,\n    row: FileTreeRow,\n  ): FileTreeDropPosition => {\n    const target = e.currentTarget as HTMLElement;\n    const rect = target.getBoundingClientRect();\n    const y = e.clientY - rect.top;\n    const h = rect.height;\n    if (row.node.type === \"folder\") {\n      // three zones\n      if (y < h * 0.25) return \"before\";\n      if (y > h * 0.75) return \"after\";\n      return \"inside\";\n    }\n    // file: two zones\n    return y < h * 0.5 ? \"before\" : \"after\";\n  };\n\n  const onRowDragStart = useCallback(\n    (e: DragEvent, row: FileTreeRow) => {\n      if (!enableInternalDrag) return;\n      // Ensure dragged node is part of selection. If it's already in\n      // multi-select, drag the whole selection. Otherwise replace selection.\n      let ids: string[];\n      if (selectionMode === \"multi\" && selectedIds.has(row.node.id)) {\n        ids = Array.from(selectedIds);\n      } else {\n        ids = [row.node.id];\n        selectIds(row.node.id);\n      }\n      dragSourceIdsRef.current = ids;\n      isInternalDraggingRef.current = true;\n      setIsInternalDragging(true);\n      try {\n        e.dataTransfer.effectAllowed = \"move\";\n        // Write a sentinel value so we can recognize own drag in dragenter\n        e.dataTransfer.setData(INTERNAL_MIME, \"1\");\n      } catch {\n        // some envs throw on synthetic events\n      }\n    },\n    [enableInternalDrag, selectionMode, selectedIds, selectIds],\n  );\n\n  const onRowDragEnd = useCallback(() => {\n    isInternalDraggingRef.current = false;\n    setIsInternalDragging(false);\n    dragSourceIdsRef.current = [];\n    setDragOver(null);\n    stopAutoScroll();\n  }, [stopAutoScroll]);\n\n  const onRowDragOver = useCallback(\n    (e: DragEvent, row: FileTreeRow) => {\n      // External-drag is handled by container handlers; row-level is internal-only.\n      if (!isInternalDraggingRef.current) return;\n      e.preventDefault();\n      const position = computePosition(e, row);\n      // For position 'before'/'after', cycle check uses target's parent\n      const idx = nodeIndex;\n      const valid = isLegalDrop({\n        srcIds: dragSourceIdsRef.current,\n        targetId: row.node.id,\n        position,\n        index: idx,\n      });\n      e.dataTransfer.dropEffect = valid ? \"move\" : \"none\";\n      setDragOver({ rowId: row.node.id, position, valid });\n      // auto-scroll near edges\n      const scroller = (e.currentTarget as HTMLElement).closest(\n        \"[data-file-tree-scroller]\",\n      ) as HTMLElement | null;\n      startAutoScroll(scroller, e.clientY);\n    },\n    [nodeIndex, startAutoScroll],\n  );\n\n  const onRowDragLeave = useCallback(\n    (_e: DragEvent, row: FileTreeRow) => {\n      // Only clear if leaving the current target\n      setDragOver((prev) => (prev?.rowId === row.node.id ? null : prev));\n    },\n    [],\n  );\n\n  const onRowDrop = useCallback(\n    (e: DragEvent, row: FileTreeRow) => {\n      if (!isInternalDraggingRef.current) return;\n      e.preventDefault();\n      const position = computePosition(e, row);\n      const idx = nodeIndex;\n      const ids = dragSourceIdsRef.current;\n      const valid = isLegalDrop({\n        srcIds: ids,\n        targetId: row.node.id,\n        position,\n        index: idx,\n      });\n      if (valid && onMove) {\n        const targetId =\n          position === \"inside\" ? row.node.id : (row.node.parentId ?? null);\n        onMove({ ids, targetId, position });\n      }\n      setDragOver(null);\n      stopAutoScroll();\n      isInternalDraggingRef.current = false;\n      setIsInternalDragging(false);\n      dragSourceIdsRef.current = [];\n    },\n    [nodeIndex, onMove, stopAutoScroll],\n  );\n\n  // ── External-drop (OS files) handlers on the container ────────────────────\n\n  const hasFiles = (e: DragEvent) =>\n    Array.from(e.dataTransfer.types).includes(\"Files\");\n\n  const onContainerDragEnter = useCallback(\n    (e: DragEvent) => {\n      if (!enableExternalDrop) return;\n      if (!hasFiles(e)) return;\n      externalEnterCountRef.current += 1;\n      setIsExternalDragging(true);\n    },\n    [enableExternalDrop],\n  );\n\n  const onContainerDragOver = useCallback(\n    (e: DragEvent) => {\n      if (!enableExternalDrop) return;\n      if (!hasFiles(e)) return;\n      e.preventDefault();\n      e.dataTransfer.dropEffect = \"copy\";\n    },\n    [enableExternalDrop],\n  );\n\n  const onContainerDragLeave = useCallback(\n    (e: DragEvent) => {\n      if (!enableExternalDrop) return;\n      if (!hasFiles(e)) return;\n      externalEnterCountRef.current = Math.max(\n        0,\n        externalEnterCountRef.current - 1,\n      );\n      if (externalEnterCountRef.current === 0) {\n        setIsExternalDragging(false);\n      }\n    },\n    [enableExternalDrop],\n  );\n\n  const onContainerDrop = useCallback(\n    (e: DragEvent) => {\n      if (!enableExternalDrop) return;\n      if (!hasFiles(e)) return;\n      e.preventDefault();\n      externalEnterCountRef.current = 0;\n      setIsExternalDragging(false);\n      const target = e.target as HTMLElement | null;\n      const rowEl = target?.closest(\"[data-row-id]\") as HTMLElement | null;\n      const rowId = rowEl?.getAttribute(\"data-row-id\") ?? null;\n      const idx = nodeIndex;\n      let targetId: string | null = null;\n      if (rowId) {\n        const node = idx.get(rowId);\n        if (node) {\n          targetId = node.type === \"folder\" ? rowId : (node.parentId ?? null);\n        }\n      }\n      const files = Array.from(e.dataTransfer.files ?? []);\n      if (files.length > 0) {\n        onExternalDrop?.({ files, targetId });\n      }\n    },\n    [enableExternalDrop, nodeIndex, onExternalDrop],\n  );\n\n  return {\n    dragOver,\n    isExternalDragging,\n    isInternalDragging,\n    onRowDragStart,\n    onRowDragOver,\n    onRowDragLeave,\n    onRowDrop,\n    onRowDragEnd,\n    onContainerDragEnter,\n    onContainerDragOver,\n    onContainerDragLeave,\n    onContainerDrop,\n    startAutoScroll,\n    stopAutoScroll,\n  };\n}\n",
      "type": "registry:component",
      "target": "components/file-tree/hooks/use-tree-drag.ts"
    },
    {
      "path": "src/registry/components/navigation/file-tree/hooks/use-tree-virtual.ts",
      "content": "\"use client\";\n\nimport { useVirtualizer } from \"@tanstack/react-virtual\";\nimport type { RefObject } from \"react\";\nimport type { FileTreeRow, FileTreeVirtualizeMode } from \"../types\";\n\ninterface UseTreeVirtualArgs {\n  rows: FileTreeRow[];\n  scrollRef: RefObject<HTMLDivElement | null>;\n  mode: FileTreeVirtualizeMode;\n  threshold: number;\n  rowHeight: number;\n}\n\nexport interface UseTreeVirtualResult {\n  /** `true` when virtualization is active. */\n  active: boolean;\n  /** Virtualizer instance — only meaningful when `active`. */\n  virtualizer: ReturnType<typeof useVirtualizer<HTMLDivElement, HTMLDivElement>>;\n}\n\nexport function useTreeVirtual(\n  args: UseTreeVirtualArgs,\n): UseTreeVirtualResult {\n  const { rows, scrollRef, mode, threshold, rowHeight } = args;\n  const active =\n    mode === \"always\" || (mode === \"auto\" && rows.length >= threshold);\n\n  const virtualizer = useVirtualizer<HTMLDivElement, HTMLDivElement>({\n    count: rows.length,\n    getScrollElement: () => scrollRef.current,\n    estimateSize: () => rowHeight,\n    overscan: 8,\n    enabled: active,\n  });\n\n  return { active, virtualizer };\n}\n",
      "type": "registry:component",
      "target": "components/file-tree/hooks/use-tree-virtual.ts"
    },
    {
      "path": "src/registry/components/navigation/file-tree/hooks/use-lazy-load.ts",
      "content": "\"use client\";\n\nimport { useCallback, useRef, useState } from \"react\";\nimport type { FileTreeLoadChildrenArgs, FsNode } from \"../types\";\n\ninterface UseLazyLoadArgs {\n  onLoadChildren?: (args: FileTreeLoadChildrenArgs) => Promise<FsNode[]>;\n  setLoadingFolderIds: (next: Set<string>) => void;\n}\n\nexport interface UseLazyLoadResult {\n  /** Per-folder error message, if the most recent load rejected. */\n  errors: ReadonlyMap<string, string>;\n  /** Trigger a load. Bumps `loadingFolderIds`, resolves cleanly, stores errors. */\n  load: (node: FsNode) => Promise<void>;\n  /** Clear an error for retry. */\n  clearError: (nodeId: string) => void;\n}\n\n/**\n * Manages async-children loading state. Does NOT merge resolved children\n * into the tree — that's the consumer's job (we expose `mergeLoadedChildren`\n * helper for that). Tracks loading + error state per folder id.\n */\nexport function useLazyLoad(args: UseLazyLoadArgs): UseLazyLoadResult {\n  const { onLoadChildren, setLoadingFolderIds } = args;\n  const loadingRef = useRef<Set<string>>(new Set());\n  const [errors, setErrors] = useState<Map<string, string>>(() => new Map());\n\n  const setLoading = useCallback(\n    (mutator: (prev: Set<string>) => Set<string>) => {\n      const next = mutator(new Set(loadingRef.current));\n      loadingRef.current = next;\n      setLoadingFolderIds(next);\n    },\n    [setLoadingFolderIds],\n  );\n\n  const load = useCallback(\n    async (node: FsNode) => {\n      if (!onLoadChildren) return;\n      if (loadingRef.current.has(node.id)) return;\n      setLoading((prev) => {\n        prev.add(node.id);\n        return prev;\n      });\n      // Clear any previous error for this id\n      setErrors((prev) => {\n        if (!prev.has(node.id)) return prev;\n        const next = new Map(prev);\n        next.delete(node.id);\n        return next;\n      });\n      try {\n        await onLoadChildren({ nodeId: node.id, node });\n      } catch (err) {\n        const msg = err instanceof Error ? err.message : \"Failed to load\";\n        setErrors((prev) => {\n          const next = new Map(prev);\n          next.set(node.id, msg);\n          return next;\n        });\n      } finally {\n        setLoading((prev) => {\n          prev.delete(node.id);\n          return prev;\n        });\n      }\n    },\n    [onLoadChildren, setLoading],\n  );\n\n  const clearError = useCallback((nodeId: string) => {\n    setErrors((prev) => {\n      if (!prev.has(nodeId)) return prev;\n      const next = new Map(prev);\n      next.delete(nodeId);\n      return next;\n    });\n  }, []);\n\n  return { errors, load, clearError };\n}\n",
      "type": "registry:component",
      "target": "components/file-tree/hooks/use-lazy-load.ts"
    },
    {
      "path": "src/registry/components/navigation/file-tree/lib/icons.tsx",
      "content": "import type { ReactNode } from \"react\";\nimport {\n  File,\n  FileArchive,\n  FileAudio,\n  FileCode,\n  FileCode2,\n  FileImage,\n  FileJson,\n  FileText,\n  FileVideo,\n  Folder,\n  FolderOpen,\n} from \"lucide-react\";\n\nimport type { FsNode } from \"../types\";\n\n/**\n * Mapping of common file-extension categories to their default Lucide icon.\n * Order of definition matters only for documentation; lookup is by the\n * extension string itself.\n */\nconst EXT_TO_ICON: Record<string, typeof File> = {\n  // Code (general)\n  ts: FileCode,\n  tsx: FileCode,\n  js: FileCode,\n  jsx: FileCode,\n  mjs: FileCode,\n  cjs: FileCode,\n  py: FileCode,\n  rb: FileCode,\n  go: FileCode,\n  rs: FileCode,\n  java: FileCode,\n  kt: FileCode,\n  swift: FileCode,\n  cpp: FileCode,\n  c: FileCode,\n  h: FileCode,\n  cs: FileCode,\n  php: FileCode,\n  lua: FileCode,\n  // CSS family\n  css: FileCode,\n  scss: FileCode,\n  sass: FileCode,\n  less: FileCode,\n  // Markup\n  html: FileCode2,\n  htm: FileCode2,\n  xml: FileCode2,\n  // JSON\n  json: FileJson,\n  jsonc: FileJson,\n  // Text / docs\n  md: FileText,\n  mdx: FileText,\n  markdown: FileText,\n  txt: FileText,\n  rtf: FileText,\n  // Images\n  png: FileImage,\n  jpg: FileImage,\n  jpeg: FileImage,\n  gif: FileImage,\n  webp: FileImage,\n  bmp: FileImage,\n  ico: FileImage,\n  svg: FileImage,\n  // Archives\n  zip: FileArchive,\n  tar: FileArchive,\n  gz: FileArchive,\n  \"7z\": FileArchive,\n  rar: FileArchive,\n  // Video / audio\n  mp4: FileVideo,\n  mov: FileVideo,\n  webm: FileVideo,\n  mkv: FileVideo,\n  avi: FileVideo,\n  mp3: FileAudio,\n  wav: FileAudio,\n  ogg: FileAudio,\n  flac: FileAudio,\n  // Config / dotfiles\n  lock: File,\n  env: File,\n  gitignore: File,\n  gitattributes: File,\n  editorconfig: File,\n};\n\n/**\n * Derive an extension string from an FsNode. Prefers the explicit `ext` field,\n * else falls back to the substring after the last dot. Files with no dot\n * (e.g. `Dockerfile`) return an empty string.\n */\nexport function getNodeExtension(node: FsNode): string {\n  if (node.ext) return node.ext.replace(/^\\./, \"\").toLowerCase();\n  const idx = node.name.lastIndexOf(\".\");\n  if (idx <= 0 || idx === node.name.length - 1) return \"\";\n  return node.name.slice(idx + 1).toLowerCase();\n}\n\n/**\n * Public consumer helper. Returns a JSX element for the given extension\n * string, falling back to a generic `<File>`. Does NOT take an FsNode —\n * consumers building custom registries can chain on this for their unknown\n * types.\n */\nexport function iconForExtension(\n  ext: string,\n  className = \"size-4\",\n): ReactNode {\n  const normalized = ext.replace(/^\\./, \"\").toLowerCase();\n  const Icon = EXT_TO_ICON[normalized] ?? File;\n  return <Icon className={className} />;\n}\n\n/** Default icon for a file node — internal use. */\nexport function defaultFileIcon(node: FsNode, className = \"size-4\"): ReactNode {\n  return iconForExtension(getNodeExtension(node), className);\n}\n\n/** Default icon for a folder node — internal use. */\nexport function defaultFolderIcon(\n  expanded: boolean,\n  className = \"size-4\",\n): ReactNode {\n  const Icon = expanded ? FolderOpen : Folder;\n  return <Icon className={className} />;\n}\n",
      "type": "registry:component",
      "target": "components/file-tree/lib/icons.tsx"
    },
    {
      "path": "src/registry/components/navigation/file-tree/lib/tree-utils.ts",
      "content": "import type { FsNode } from \"../types\";\n\n/**\n * Flat-index a hierarchical array by id. Walks every node once, ignoring\n * cycles produced by malformed input (a node already in the index is skipped).\n */\nexport function indexNodes(nodes: FsNode[]): Map<string, FsNode> {\n  const map = new Map<string, FsNode>();\n  const walk = (list: FsNode[]) => {\n    for (const node of list) {\n      if (map.has(node.id)) continue;\n      map.set(node.id, node);\n      if (node.children) walk(node.children);\n    }\n  };\n  walk(nodes);\n  return map;\n}\n\n/** Find one node by id by walking the tree. Stops at first hit. */\nexport function findNode(\n  id: string,\n  nodes: FsNode[],\n): FsNode | undefined {\n  for (const node of nodes) {\n    if (node.id === id) return node;\n    if (node.children) {\n      const hit = findNode(id, node.children);\n      if (hit) return hit;\n    }\n  }\n  return undefined;\n}\n\n/** Returns the chain of ancestor nodes (root → ... → parent) for `nodeId`. */\nexport function getParentChain(\n  nodeId: string,\n  index: Map<string, FsNode>,\n): FsNode[] {\n  const chain: FsNode[] = [];\n  let current = index.get(nodeId);\n  while (current?.parentId) {\n    const parent = index.get(current.parentId);\n    if (!parent) break;\n    chain.unshift(parent);\n    current = parent;\n  }\n  return chain;\n}\n\n/** All descendant ids of `nodeId` (excluding itself). */\nexport function getDescendantIds(\n  nodeId: string,\n  index: Map<string, FsNode>,\n): Set<string> {\n  const out = new Set<string>();\n  const node = index.get(nodeId);\n  if (!node?.children) return out;\n  const walk = (list: FsNode[]) => {\n    for (const child of list) {\n      if (out.has(child.id)) continue;\n      out.add(child.id);\n      if (child.children) walk(child.children);\n    }\n  };\n  walk(node.children);\n  return out;\n}\n\n/**\n * Default sort: folders before files, then case-insensitive locale alpha by name.\n */\nexport function defaultSort(a: FsNode, b: FsNode): number {\n  if (a.type !== b.type) return a.type === \"folder\" ? -1 : 1;\n  return a.name.localeCompare(b.name, undefined, { sensitivity: \"base\" });\n}\n\n/** Initial uncontrolled-mode expanded set: every top-level folder id. */\nexport function deriveRootExpandedIds(nodes: FsNode[]): Set<string> {\n  const set = new Set<string>();\n  for (const node of nodes) {\n    if (node.type === \"folder\") set.add(node.id);\n  }\n  return set;\n}\n\n/**\n * Recursive, immutable update — replaces a folder's `children` everywhere\n * in the tree without mutating the input array. Used by both\n * `mergeLoadedChildren` and other consumer helpers.\n */\nfunction replaceChildren(\n  nodes: FsNode[],\n  parentId: string,\n  next: FsNode[],\n): FsNode[] {\n  let changed = false;\n  const out = nodes.map((node) => {\n    if (node.id === parentId) {\n      changed = true;\n      return { ...node, children: next };\n    }\n    if (node.children) {\n      const replaced = replaceChildren(node.children, parentId, next);\n      if (replaced !== node.children) {\n        changed = true;\n        return { ...node, children: replaced };\n      }\n    }\n    return node;\n  });\n  return changed ? out : nodes;\n}\n\n/**\n * Public consumer helper. After `onLoadChildren` resolves, splice the new\n * children into the right folder immutably.\n *\n *   onLoadChildren={async ({ nodeId }) => {\n *     const kids = await fs.list(nodeId);\n *     setNodes((prev) => mergeLoadedChildren(prev, nodeId, kids));\n *     return kids;\n *   }}\n */\nexport function mergeLoadedChildren(\n  nodes: FsNode[],\n  parentId: string,\n  children: FsNode[],\n): FsNode[] {\n  return replaceChildren(nodes, parentId, children);\n}\n\n/** Total node count (recursive). */\nexport function countAllNodes(nodes: FsNode[]): number {\n  let total = 0;\n  const walk = (list: FsNode[]) => {\n    for (const node of list) {\n      total += 1;\n      if (node.children) walk(node.children);\n    }\n  };\n  walk(nodes);\n  return total;\n}\n",
      "type": "registry:component",
      "target": "components/file-tree/lib/tree-utils.ts"
    },
    {
      "path": "src/registry/components/navigation/file-tree/lib/validation.ts",
      "content": "import type { FsNode, FileTreeDropPosition } from \"../types\";\n\n/** True iff any source id equals the target id. */\nexport function isSelfDrop(\n  srcIds: string[],\n  targetId: string | null,\n): boolean {\n  if (!targetId) return false;\n  return srcIds.includes(targetId);\n}\n\n/**\n * True iff dropping `srcIds` into / next-to `targetId` would create a cycle.\n * Walks the parent chain of the target; if any ancestor is in `srcIds`, the\n * move is illegal.\n */\nexport function isCycle(\n  srcIds: string[],\n  targetId: string | null,\n  index: Map<string, FsNode>,\n): boolean {\n  if (!targetId) return false;\n  const set = new Set(srcIds);\n  let current: FsNode | undefined = index.get(targetId);\n  while (current) {\n    if (set.has(current.id)) return true;\n    const parentId = current.parentId;\n    if (!parentId) break;\n    current = index.get(parentId);\n  }\n  return false;\n}\n\nexport interface IsLegalDropArgs {\n  srcIds: string[];\n  targetId: string | null;\n  position: FileTreeDropPosition;\n  index: Map<string, FsNode>;\n}\n\n/**\n * Combined legality check. The tree refuses cycle / self-drop universally;\n * name-collision is a consumer-policy concern and not blocked here.\n */\nexport function isLegalDrop({\n  srcIds,\n  targetId,\n  position,\n  index,\n}: IsLegalDropArgs): boolean {\n  if (srcIds.length === 0) return false;\n  if (isSelfDrop(srcIds, targetId)) return false;\n  if (position === \"inside\" && isCycle(srcIds, targetId, index)) return false;\n  if (position !== \"inside\" && targetId) {\n    // before/after: cycle check uses the target's parent\n    const target = index.get(targetId);\n    const parentId = target?.parentId ?? null;\n    if (isCycle(srcIds, parentId, index)) return false;\n  }\n  return true;\n}\n\n/**\n * Soft-validates a node tree on mount. Returns an array of warnings; never\n * throws. Caller logs to `console.warn` in development. Catches: duplicate\n * ids, missing parents (parentId references a node not in the tree), and\n * structural cycles (a node ancestor-of-itself).\n */\nexport function validateNodes(nodes: FsNode[]): string[] {\n  const warnings: string[] = [];\n  const ids = new Set<string>();\n  const seenInPath = new Set<string>();\n  const walk = (list: FsNode[], path: Set<string>) => {\n    for (const node of list) {\n      if (ids.has(node.id)) {\n        warnings.push(`duplicate node id: \"${node.id}\"`);\n        continue;\n      }\n      ids.add(node.id);\n      if (path.has(node.id)) {\n        warnings.push(`cycle detected at node: \"${node.id}\"`);\n        continue;\n      }\n      if (node.children) {\n        const next = new Set(path);\n        next.add(node.id);\n        walk(node.children, next);\n      }\n    }\n  };\n  walk(nodes, seenInPath);\n  // parent-id sanity (advisory — parentId is optional)\n  for (const node of ids) {\n    void node;\n  }\n  return warnings;\n}\n",
      "type": "registry:component",
      "target": "components/file-tree/lib/validation.ts"
    },
    {
      "path": "src/registry/components/navigation/file-tree/parts/file-tree-row.tsx",
      "content": "\"use client\";\n\nimport type { CSSProperties, MouseEvent, ReactNode, KeyboardEvent } from \"react\";\nimport { ChevronRight, Loader2 } from \"lucide-react\";\nimport { cn } from \"@/lib/utils\";\nimport type {\n  FileTreeDropPosition,\n  FileTreeLabels,\n  FileTreeRow,\n  FileTreeValidateRenameArgs,\n  FsNode,\n} from \"../types\";\nimport { defaultFileIcon, defaultFolderIcon } from \"../lib/icons\";\nimport { FileTreeRenameInput } from \"./file-tree-rename-input\";\n\ninterface FileTreeRowProps {\n  row: FileTreeRow;\n  indent: number;\n  indentGuides: boolean;\n  rowHeight: number;\n  iconForNode?: (args: { node: FsNode }) => ReactNode;\n  isRenaming: boolean;\n  validateRename?: (args: FileTreeValidateRenameArgs) => string | null;\n  enableInternalDrag: boolean;\n  /** Drop-target visual state for this row — set only when this row is the current hover target. */\n  dropPosition?: FileTreeDropPosition | null;\n  dropInvalid?: boolean;\n  /** Lazy-load error message for this folder — when set, an error sub-row replaces the children area. */\n  loadError?: string;\n  onRetryLoad?: () => void;\n  labels: FileTreeLabels;\n  // Callbacks from orchestrator\n  onClick: (e: MouseEvent, row: FileTreeRow) => void;\n  onChevronClick: (e: MouseEvent, row: FileTreeRow) => void;\n  onDoubleClick: (e: MouseEvent, row: FileTreeRow) => void;\n  onContextMenu: (e: MouseEvent, row: FileTreeRow) => void;\n  onRenameCommit: (id: string, nextName: string) => void;\n  onRenameCancel: () => void;\n  onDragStart?: (e: React.DragEvent, row: FileTreeRow) => void;\n  onDragOver?: (e: React.DragEvent, row: FileTreeRow) => void;\n  onDragLeave?: (e: React.DragEvent, row: FileTreeRow) => void;\n  onDrop?: (e: React.DragEvent, row: FileTreeRow) => void;\n  onDragEnd?: (e: React.DragEvent, row: FileTreeRow) => void;\n  onRowKeyDown?: (e: KeyboardEvent<HTMLDivElement>, row: FileTreeRow) => void;\n  /** Used as `style` on the outer div; lets the row-list place rows absolutely in virtual mode. */\n  outerStyle?: CSSProperties;\n}\n\nconst MAX_INDENT_PX = 200;\n\nexport function FileTreeRowComponent(props: FileTreeRowProps) {\n  const {\n    row,\n    indent,\n    indentGuides,\n    rowHeight,\n    iconForNode,\n    isRenaming,\n    validateRename,\n    enableInternalDrag,\n    dropPosition,\n    dropInvalid,\n    loadError,\n    onRetryLoad,\n    labels,\n    onClick,\n    onChevronClick,\n    onDoubleClick,\n    onContextMenu,\n    onRenameCommit,\n    onRenameCancel,\n    onDragStart,\n    onDragOver,\n    onDragLeave,\n    onDrop,\n    onDragEnd,\n    onRowKeyDown,\n    outerStyle,\n  } = props;\n\n  const { node, depth, expanded, selected, focused, hasChildren, loadingChildren } =\n    row;\n  const isFolder = node.type === \"folder\";\n\n  const computedIndent = Math.min(depth * indent, MAX_INDENT_PX);\n  const dropClass = dropInvalid ? \"ring-2 ring-destructive\" : \"\";\n  const insideDrop = dropPosition === \"inside\";\n\n  const icon = node.icon\n    ? node.icon\n    : iconForNode\n      ? iconForNode({ node })\n      : isFolder\n        ? defaultFolderIcon(expanded)\n        : defaultFileIcon(node);\n\n  return (\n    <div role=\"presentation\" className=\"relative\" style={outerStyle}>\n      {/* before / after drop indicator (line) */}\n      {dropPosition === \"before\" && !dropInvalid ? (\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute left-0 right-0 top-0 z-10 h-0.5 bg-primary\"\n        />\n      ) : null}\n      {dropPosition === \"after\" && !dropInvalid ? (\n        <div\n          aria-hidden=\"true\"\n          className=\"pointer-events-none absolute bottom-0 left-0 right-0 z-10 h-0.5 bg-primary\"\n        />\n      ) : null}\n\n      <div\n        role=\"treeitem\"\n        data-row-id={node.id}\n        aria-level={depth + 1}\n        aria-expanded={isFolder ? expanded : undefined}\n        aria-selected={selected}\n        aria-setsize={row.siblingCount}\n        aria-posinset={row.siblingIndex}\n        tabIndex={focused ? 0 : -1}\n        draggable={enableInternalDrag && !isRenaming}\n        onClick={(e) => onClick(e, row)}\n        onDoubleClick={(e) => onDoubleClick(e, row)}\n        onContextMenu={(e) => onContextMenu(e, row)}\n        onDragStart={(e) =>\n          enableInternalDrag && onDragStart && onDragStart(e, row)\n        }\n        onDragOver={(e) => onDragOver?.(e, row)}\n        onDragLeave={(e) => onDragLeave?.(e, row)}\n        onDrop={(e) => onDrop?.(e, row)}\n        onDragEnd={(e) => onDragEnd?.(e, row)}\n        onKeyDown={(e) => onRowKeyDown?.(e, row)}\n        className={cn(\n          \"group relative flex cursor-pointer select-none items-center gap-1.5 px-2 text-sm\",\n          \"rounded-sm\",\n          \"outline-none transition-colors\",\n          selected\n            ? \"bg-secondary text-secondary-foreground\"\n            : \"hover:bg-muted/60\",\n          focused && \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\",\n          insideDrop && !dropInvalid && \"ring-2 ring-primary\",\n          dropClass,\n        )}\n        style={{ height: `${rowHeight}px` }}\n      >\n        {/* indent guides */}\n        {indentGuides\n          ? Array.from({ length: depth }).map((_, i) => (\n              <span\n                key={i}\n                aria-hidden=\"true\"\n                className=\"pointer-events-none absolute top-0 h-full w-px bg-border/50\"\n                style={{\n                  left: `${Math.min((i + 1) * indent, MAX_INDENT_PX) - 1}px`,\n                }}\n              />\n            ))\n          : null}\n\n        {/* indent spacer */}\n        <span\n          aria-hidden=\"true\"\n          className=\"shrink-0\"\n          style={{ width: `${computedIndent}px` }}\n        />\n\n        {/* chevron OR spacer */}\n        {isFolder && hasChildren ? (\n          <button\n            type=\"button\"\n            tabIndex={-1}\n            aria-label={expanded ? \"Collapse\" : \"Expand\"}\n            onClick={(e) => {\n              e.stopPropagation();\n              onChevronClick(e, row);\n            }}\n            className=\"flex size-4 shrink-0 items-center justify-center text-muted-foreground hover:text-foreground\"\n          >\n            {loadingChildren ? (\n              <Loader2 className=\"size-3.5 animate-spin\" aria-hidden=\"true\" />\n            ) : (\n              <ChevronRight\n                aria-hidden=\"true\"\n                className={cn(\n                  \"size-3.5 transition-transform\",\n                  expanded && \"rotate-90\",\n                )}\n              />\n            )}\n          </button>\n        ) : (\n          <span\n            aria-hidden=\"true\"\n            className=\"size-4 shrink-0\"\n          />\n        )}\n\n        {/* icon */}\n        <span\n          aria-hidden=\"true\"\n          className={cn(\n            \"shrink-0\",\n            isFolder\n              ? \"text-amber-600 dark:text-amber-400\"\n              : \"text-muted-foreground\",\n            selected && \"text-secondary-foreground\",\n          )}\n        >\n          {icon}\n        </span>\n\n        {/* label OR rename input */}\n        {isRenaming ? (\n          <FileTreeRenameInput\n            node={node}\n            validateRename={validateRename}\n            onCommit={(next) => onRenameCommit(node.id, next)}\n            onCancel={onRenameCancel}\n          />\n        ) : (\n          <span\n            className=\"min-w-0 flex-1 truncate\"\n            title={node.name}\n          >\n            {node.name}\n          </span>\n        )}\n      </div>\n\n      {/* lazy-load error sub-row */}\n      {loadError ? (\n        <div\n          role=\"alert\"\n          className=\"flex items-center gap-2 px-2 py-1 text-xs text-destructive\"\n          style={{\n            paddingLeft: `${Math.min((depth + 1) * indent + 8, MAX_INDENT_PX) + 8}px`,\n          }}\n        >\n          <span>{labels.loadError}: {loadError}</span>\n          {onRetryLoad ? (\n            <button\n              type=\"button\"\n              onClick={(e) => {\n                e.stopPropagation();\n                onRetryLoad();\n              }}\n              className=\"rounded-sm border border-destructive px-1.5 py-0.5 text-xs hover:bg-destructive/10\"\n            >\n              {labels.retry}\n            </button>\n          ) : null}\n        </div>\n      ) : null}\n\n      {/* (empty) placeholder for known-empty folders that are expanded */}\n      {isFolder &&\n      expanded &&\n      node.children !== undefined &&\n      node.children.length === 0 &&\n      !loadError ? (\n        <div\n          aria-hidden=\"true\"\n          className=\"px-2 py-1 text-xs italic text-muted-foreground\"\n          style={{\n            paddingLeft: `${Math.min((depth + 1) * indent + 8, MAX_INDENT_PX) + 16}px`,\n          }}\n        >\n          (empty)\n        </div>\n      ) : null}\n    </div>\n  );\n}\n\nFileTreeRowComponent.displayName = \"FileTreeRow\";\n\nexport { FileTreeRowComponent as FileTreeRowView };\n",
      "type": "registry:component",
      "target": "components/file-tree/parts/file-tree-row.tsx"
    },
    {
      "path": "src/registry/components/navigation/file-tree/parts/file-tree-row-list.tsx",
      "content": "\"use client\";\n\nimport type {\n  KeyboardEvent,\n  MouseEvent,\n  ReactNode,\n  Ref,\n} from \"react\";\nimport type { Virtualizer } from \"@tanstack/react-virtual\";\nimport type {\n  FileTreeDropPosition,\n  FileTreeLabels,\n  FileTreeRow,\n  FileTreeRowRenderArgs,\n  FileTreeValidateRenameArgs,\n  FsNode,\n} from \"../types\";\nimport { FileTreeRowView } from \"./file-tree-row\";\n\nexport interface DragOverState {\n  rowId: string;\n  position: FileTreeDropPosition | null;\n  valid: boolean;\n}\n\ninterface FileTreeRowListProps {\n  rows: FileTreeRow[];\n  rowHeight: number;\n  indent: number;\n  indentGuides: boolean;\n  iconForNode?: (args: { node: FsNode }) => ReactNode;\n  renamingId: string | null;\n  validateRename?: (args: FileTreeValidateRenameArgs) => string | null;\n  enableInternalDrag: boolean;\n  dragOver: DragOverState | null;\n  loadErrors: ReadonlyMap<string, string>;\n  onRetryLoad?: (nodeId: string) => void;\n  labels: FileTreeLabels;\n  /** Active virtualizer (if virtualization is on; else undefined). */\n  virtualizer?: Virtualizer<HTMLDivElement, HTMLDivElement>;\n  scrollRef: Ref<HTMLDivElement>;\n  /** Render-row slot — when provided, replaces the default row rendering. */\n  renderRow?: (args: FileTreeRowRenderArgs) => ReactNode;\n  // Shared callbacks\n  onRowClick: (e: MouseEvent, row: FileTreeRow) => void;\n  onChevronClick: (e: MouseEvent, row: FileTreeRow) => void;\n  onRowDoubleClick: (e: MouseEvent, row: FileTreeRow) => void;\n  onRowContextMenu: (e: MouseEvent, row: FileTreeRow) => void;\n  onRenameCommit: (id: string, nextName: string) => void;\n  onRenameCancel: () => void;\n  onRowDragStart?: (e: React.DragEvent, row: FileTreeRow) => void;\n  onRowDragOver?: (e: React.DragEvent, row: FileTreeRow) => void;\n  onRowDragLeave?: (e: React.DragEvent, row: FileTreeRow) => void;\n  onRowDrop?: (e: React.DragEvent, row: FileTreeRow) => void;\n  onRowDragEnd?: (e: React.DragEvent, row: FileTreeRow) => void;\n  onRowKeyDown?: (e: KeyboardEvent<HTMLDivElement>, row: FileTreeRow) => void;\n  /** Container-level drop fallback (when drop hits the empty whitespace). */\n  onContainerDragOver?: (e: React.DragEvent) => void;\n  onContainerDrop?: (e: React.DragEvent) => void;\n  onContainerContextMenu?: (e: MouseEvent) => void;\n}\n\nexport function FileTreeRowList(props: FileTreeRowListProps) {\n  const {\n    rows,\n    rowHeight,\n    indent,\n    indentGuides,\n    iconForNode,\n    renamingId,\n    validateRename,\n    enableInternalDrag,\n    dragOver,\n    loadErrors,\n    onRetryLoad,\n    labels,\n    virtualizer,\n    scrollRef,\n    renderRow,\n    onRowClick,\n    onChevronClick,\n    onRowDoubleClick,\n    onRowContextMenu,\n    onRenameCommit,\n    onRenameCancel,\n    onRowDragStart,\n    onRowDragOver,\n    onRowDragLeave,\n    onRowDrop,\n    onRowDragEnd,\n    onRowKeyDown,\n    onContainerDragOver,\n    onContainerDrop,\n    onContainerContextMenu,\n  } = props;\n\n  const buildRow = (\n    row: FileTreeRow,\n    outerStyle?: React.CSSProperties,\n  ): ReactNode => {\n    const dropPosition =\n      dragOver?.rowId === row.node.id ? dragOver.position : null;\n    const dropInvalid =\n      dragOver?.rowId === row.node.id ? !dragOver.valid : false;\n    const error = loadErrors.get(row.node.id);\n\n    if (renderRow) {\n      // When a slot replaces the row contents, the outer wrapper carries the\n      // virtualized positioning. The default row passed in does NOT.\n      const defaultRow = (\n        <FileTreeRowView\n          row={row}\n          rowHeight={rowHeight}\n          indent={indent}\n          indentGuides={indentGuides}\n          iconForNode={iconForNode}\n          isRenaming={renamingId === row.node.id}\n          validateRename={validateRename}\n          enableInternalDrag={enableInternalDrag}\n          dropPosition={dropPosition}\n          dropInvalid={dropInvalid}\n          loadError={error}\n          onRetryLoad={\n            onRetryLoad ? () => onRetryLoad(row.node.id) : undefined\n          }\n          labels={labels}\n          onClick={onRowClick}\n          onChevronClick={onChevronClick}\n          onDoubleClick={onRowDoubleClick}\n          onContextMenu={onRowContextMenu}\n          onRenameCommit={onRenameCommit}\n          onRenameCancel={onRenameCancel}\n          onDragStart={onRowDragStart}\n          onDragOver={onRowDragOver}\n          onDragLeave={onRowDragLeave}\n          onDrop={onRowDrop}\n          onDragEnd={onRowDragEnd}\n          onRowKeyDown={onRowKeyDown}\n        />\n      );\n      return (\n        <div key={row.node.id} role=\"presentation\" style={outerStyle}>\n          {renderRow({ row, defaultRow })}\n        </div>\n      );\n    }\n\n    // Default path: no extra wrapper — the row view IS the outer element.\n    return (\n      <FileTreeRowView\n        key={row.node.id}\n        row={row}\n        rowHeight={rowHeight}\n        indent={indent}\n        indentGuides={indentGuides}\n        iconForNode={iconForNode}\n        isRenaming={renamingId === row.node.id}\n        validateRename={validateRename}\n        enableInternalDrag={enableInternalDrag}\n        dropPosition={dropPosition}\n        dropInvalid={dropInvalid}\n        loadError={error}\n        onRetryLoad={\n          onRetryLoad ? () => onRetryLoad(row.node.id) : undefined\n        }\n        labels={labels}\n        onClick={onRowClick}\n        onChevronClick={onChevronClick}\n        onDoubleClick={onRowDoubleClick}\n        onContextMenu={onRowContextMenu}\n        onRenameCommit={onRenameCommit}\n        onRenameCancel={onRenameCancel}\n        onDragStart={onRowDragStart}\n        onDragOver={onRowDragOver}\n        onDragLeave={onRowDragLeave}\n        onDrop={onRowDrop}\n        onDragEnd={onRowDragEnd}\n        onRowKeyDown={onRowKeyDown}\n        outerStyle={outerStyle}\n      />\n    );\n  };\n\n  // Virtualized mode\n  if (virtualizer && virtualizer.options.enabled) {\n    const totalHeight = virtualizer.getTotalSize();\n    const items = virtualizer.getVirtualItems();\n    return (\n      <div\n        ref={scrollRef}\n        data-file-tree-scroller\n        role=\"presentation\"\n        className=\"relative h-full overflow-y-auto overflow-x-hidden\"\n        onDragOver={onContainerDragOver}\n        onDrop={onContainerDrop}\n        onContextMenu={onContainerContextMenu}\n      >\n        <div\n          role=\"presentation\"\n          style={{\n            height: `${totalHeight}px`,\n            width: \"100%\",\n            position: \"relative\",\n          }}\n        >\n          {items.map((vi) => {\n            const row = rows[vi.index];\n            if (!row) return null;\n            const outerStyle: React.CSSProperties = {\n              position: \"absolute\",\n              top: 0,\n              left: 0,\n              width: \"100%\",\n              transform: `translateY(${vi.start}px)`,\n            };\n            return buildRow(row, outerStyle);\n          })}\n        </div>\n      </div>\n    );\n  }\n\n  // Naive mode\n  return (\n    <div\n      ref={scrollRef}\n      role=\"presentation\"\n      className=\"relative h-full overflow-y-auto overflow-x-hidden\"\n      onDragOver={onContainerDragOver}\n      onDrop={onContainerDrop}\n      onContextMenu={onContainerContextMenu}\n    >\n      <div role=\"presentation\" className=\"py-1\">\n        {rows.map((row) => buildRow(row))}\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/file-tree/parts/file-tree-row-list.tsx"
    },
    {
      "path": "src/registry/components/navigation/file-tree/parts/file-tree-rename-input.tsx",
      "content": "\"use client\";\n\nimport { useEffect, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\nimport type { FsNode, FileTreeValidateRenameArgs } from \"../types\";\n\ninterface FileTreeRenameInputProps {\n  node: FsNode;\n  onCommit: (nextName: string) => void;\n  onCancel: () => void;\n  validateRename?: (args: FileTreeValidateRenameArgs) => string | null;\n}\n\nexport function FileTreeRenameInput(props: FileTreeRenameInputProps) {\n  const { node, onCommit, onCancel, validateRename } = props;\n  const [value, setValue] = useState(node.name);\n  const [error, setError] = useState<string | null>(null);\n  const inputRef = useRef<HTMLInputElement>(null);\n\n  useEffect(() => {\n    const input = inputRef.current;\n    if (!input) return;\n    input.focus();\n    // Select the basename (everything before the final dot for files; whole\n    // name for folders).\n    if (node.type === \"folder\") {\n      input.select();\n    } else {\n      const dot = node.name.lastIndexOf(\".\");\n      if (dot > 0) {\n        input.setSelectionRange(0, dot);\n      } else {\n        input.select();\n      }\n    }\n  }, [node.type, node.name]);\n\n  const tryCommit = () => {\n    const trimmed = value.trim();\n    if (trimmed === \"\") {\n      setError(\"Name cannot be empty\");\n      return;\n    }\n    if (trimmed === node.name) {\n      onCancel();\n      return;\n    }\n    if (validateRename) {\n      const message = validateRename({ node, nextName: trimmed });\n      if (message) {\n        setError(message);\n        return;\n      }\n    }\n    onCommit(trimmed);\n  };\n\n  return (\n    <div className=\"relative flex flex-1 min-w-0 items-center\">\n      <input\n        ref={inputRef}\n        type=\"text\"\n        value={value}\n        onChange={(e) => {\n          setValue(e.target.value);\n          if (error) setError(null);\n        }}\n        onKeyDown={(e) => {\n          if (e.key === \"Enter\") {\n            e.preventDefault();\n            e.stopPropagation();\n            tryCommit();\n          } else if (e.key === \"Escape\") {\n            e.preventDefault();\n            e.stopPropagation();\n            onCancel();\n          } else {\n            // Stop arrow keys from bubbling to the tree's keyboard handler\n            e.stopPropagation();\n          }\n        }}\n        onBlur={() => tryCommit()}\n        onClick={(e) => e.stopPropagation()}\n        onDoubleClick={(e) => e.stopPropagation()}\n        aria-label={`Rename ${node.name}`}\n        aria-invalid={error ? true : undefined}\n        className={cn(\n          \"h-6 min-w-0 flex-1 rounded-sm border bg-background px-1 text-sm outline-none\",\n          \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-1 focus-visible:ring-offset-background\",\n          error\n            ? \"border-destructive ring-1 ring-destructive\"\n            : \"border-border\",\n        )}\n      />\n      {error ? (\n        <span\n          role=\"alert\"\n          className=\"pointer-events-none absolute left-0 top-full z-10 mt-0.5 rounded-sm bg-destructive px-1.5 py-0.5 text-xs text-destructive-foreground shadow-sm\"\n        >\n          {error}\n        </span>\n      ) : null}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/file-tree/parts/file-tree-rename-input.tsx"
    },
    {
      "path": "src/registry/components/navigation/file-tree/parts/file-tree-context-menu.tsx",
      "content": "\"use client\";\n\nimport { useMemo, useState, type MouseEvent, type ReactNode } from \"react\";\nimport {\n  FilePlus,\n  FolderPlus,\n  Pencil,\n  RefreshCw,\n  Trash2,\n} from \"lucide-react\";\nimport {\n  ContextMenu,\n  ContextMenuContent,\n  ContextMenuItem,\n  ContextMenuSeparator,\n  ContextMenuTrigger,\n} from \"@/components/ui/context-menu\";\nimport type {\n  FileTreeActions,\n  FileTreeContextMenuContext,\n  FileTreeContextMenuItem,\n  FileTreeLabels,\n  FileTreeState,\n  FsNode,\n} from \"../types\";\n\ninterface FileTreeContextMenuProps {\n  state: FileTreeState;\n  actions: FileTreeActions;\n  enabled: boolean;\n  labels: FileTreeLabels;\n  /** Per-action gates from `contextMenuActions` prop. */\n  gates: {\n    open: boolean;\n    newFile: boolean;\n    newFolder: boolean;\n    rename: boolean;\n    delete: boolean;\n    refresh: boolean;\n  };\n  /** Whether the consumer has wired the corresponding callback (we hide actions whose op nobody listens to). */\n  wired: {\n    onOpen: boolean;\n    onCreate: boolean;\n    onRename: boolean;\n    onDelete: boolean;\n    onRefresh: boolean;\n  };\n  /** Index of nodes (id → node) for resolving target on right-click. */\n  nodeIndex: ReadonlyMap<string, FsNode>;\n  /** Slot override. Receives the same context the default would render. */\n  renderContextMenu?: (ctx: FileTreeContextMenuContext) => ReactNode;\n  /** Children — the area wrapped by the context-menu trigger. */\n  children: ReactNode;\n}\n\nexport function FileTreeContextMenu(props: FileTreeContextMenuProps) {\n  const {\n    state,\n    actions,\n    enabled,\n    labels,\n    gates,\n    wired,\n    nodeIndex,\n    renderContextMenu,\n    children,\n  } = props;\n  const [target, setTarget] = useState<{\n    node: FsNode | null;\n    position: { x: number; y: number };\n  } | null>(null);\n\n  const captureTarget = (e: MouseEvent) => {\n    const el = e.target as HTMLElement | null;\n    const rowEl = el?.closest(\"[data-row-id]\") as HTMLElement | null;\n    const id = rowEl?.getAttribute(\"data-row-id\") ?? null;\n    const node = id ? (nodeIndex.get(id) ?? null) : null;\n    setTarget({ node, position: { x: e.clientX, y: e.clientY } });\n  };\n\n  const defaultActions = useMemo<FileTreeContextMenuItem[]>(() => {\n    if (!target) return [];\n    const node = target.node;\n    const isFolder = node?.type === \"folder\";\n    const list: FileTreeContextMenuItem[] = [];\n\n    if (gates.open && wired.onOpen && node && node.type === \"file\") {\n      list.push({\n        id: \"open\",\n        label: labels.contextOpen,\n        icon: <FilePlus className=\"size-3.5\" />,\n        onSelect: () => actions.focusNode(node.id), // open is a consumer hook; no-op default safety\n      });\n    }\n\n    if (gates.newFile && wired.onCreate) {\n      list.push({\n        id: \"new-file\",\n        label: labels.contextNewFile,\n        icon: <FilePlus className=\"size-3.5\" />,\n        onSelect: () => {\n          const parentId =\n            isFolder && node ? node.id : (node?.parentId ?? null);\n          actions.triggerCreate(parentId, \"file\");\n        },\n      });\n    }\n    if (gates.newFolder && wired.onCreate) {\n      list.push({\n        id: \"new-folder\",\n        label: labels.contextNewFolder,\n        icon: <FolderPlus className=\"size-3.5\" />,\n        onSelect: () => {\n          const parentId =\n            isFolder && node ? node.id : (node?.parentId ?? null);\n          actions.triggerCreate(parentId, \"folder\");\n        },\n      });\n    }\n    if (gates.rename && wired.onRename && node) {\n      list.push({\n        id: \"rename\",\n        label: labels.contextRename,\n        icon: <Pencil className=\"size-3.5\" />,\n        onSelect: () => actions.startRename(node.id),\n      });\n    }\n    if (gates.refresh && wired.onRefresh && isFolder && node) {\n      list.push({\n        id: \"refresh\",\n        label: labels.contextRefresh,\n        icon: <RefreshCw className=\"size-3.5\" />,\n        onSelect: () => actions.refresh(node.id),\n      });\n    }\n    if (gates.delete && wired.onDelete && node) {\n      list.push({\n        id: \"delete\",\n        label: labels.contextDelete,\n        icon: <Trash2 className=\"size-3.5\" />,\n        destructive: true,\n        onSelect: () => {\n          const ids = state.selectedIds.has(node.id)\n            ? Array.from(state.selectedIds)\n            : [node.id];\n          actions.triggerDelete(ids);\n        },\n      });\n    }\n    return list;\n  }, [target, gates, wired, labels, actions, state.selectedIds]);\n\n  if (!enabled) {\n    return <>{children}</>;\n  }\n\n  // Rebuild the slot context on each open\n  const ctx: FileTreeContextMenuContext = {\n    state,\n    actions,\n    node: target?.node ?? null,\n    defaultActions,\n    position: target?.position ?? { x: 0, y: 0 },\n    labels,\n  };\n\n  return (\n    <ContextMenu>\n      {/* F-cross-13: no `asChild` — Base UI triggers reject it. `display: contents`\n          keeps the trigger element out of layout (the wrapped div already used it);\n          right-clicks bubble up from children. */}\n      <ContextMenuTrigger\n        role=\"presentation\"\n        className=\"contents\"\n        onContextMenu={captureTarget}\n      >\n        {children}\n      </ContextMenuTrigger>\n      <ContextMenuContent className=\"min-w-[180px]\">\n        {renderContextMenu ? (\n          renderContextMenu(ctx)\n        ) : defaultActions.length === 0 ? (\n          <ContextMenuItem disabled>{labels.emptyTitle}</ContextMenuItem>\n        ) : (\n          <DefaultContextMenuItems items={defaultActions} />\n        )}\n      </ContextMenuContent>\n    </ContextMenu>\n  );\n}\n\nfunction DefaultContextMenuItems({\n  items,\n}: {\n  items: FileTreeContextMenuItem[];\n}) {\n  // Insert a separator before the destructive (delete) action\n  const out: ReactNode[] = [];\n  items.forEach((item, i) => {\n    if (item.destructive && i > 0) {\n      out.push(<ContextMenuSeparator key={`sep-${i}`} />);\n    }\n    out.push(\n      <ContextMenuItem\n        key={item.id}\n        disabled={item.disabled}\n        variant={item.destructive ? \"destructive\" : \"default\"}\n        onSelect={() => item.onSelect()}\n      >\n        {item.icon}\n        {item.label}\n      </ContextMenuItem>,\n    );\n  });\n  return <>{out}</>;\n}\n",
      "type": "registry:component",
      "target": "components/file-tree/parts/file-tree-context-menu.tsx"
    },
    {
      "path": "src/registry/components/navigation/file-tree/parts/file-tree-header.tsx",
      "content": "\"use client\";\n\nimport { cn } from \"@/lib/utils\";\nimport { useFileTree } from \"../hooks/use-file-tree-context\";\nimport { FileTreeNewFileButton } from \"./file-tree-new-file-button\";\nimport { FileTreeNewFolderButton } from \"./file-tree-new-folder-button\";\nimport { FileTreeRefreshButton } from \"./file-tree-refresh-button\";\nimport { FileTreeCollapseAllButton } from \"./file-tree-collapse-all-button\";\n\nexport interface FileTreeHeaderProps {\n  className?: string;\n}\n\nexport function FileTreeHeader(props: FileTreeHeaderProps) {\n  const { className } = props;\n  const { title, labels, totalCount } = useFileTree();\n  const headerTitle = title ?? labels.title;\n\n  return (\n    <div\n      className={cn(\n        \"flex h-9 shrink-0 items-center gap-1 border-b border-border/60 bg-card/40 pl-3 pr-1\",\n        className,\n      )}\n    >\n      <h2 className=\"flex min-w-0 items-baseline gap-1.5 text-xs font-semibold uppercase tracking-wide text-muted-foreground\">\n        <span className=\"truncate text-foreground/90\">{headerTitle}</span>\n        <span className=\"font-mono text-[10px] text-muted-foreground/70\">\n          {totalCount}\n        </span>\n      </h2>\n      <div className=\"ml-auto flex items-center gap-0.5\">\n        <FileTreeNewFileButton className=\"size-7\" />\n        <FileTreeNewFolderButton className=\"size-7\" />\n        <FileTreeRefreshButton className=\"size-7\" />\n        <FileTreeCollapseAllButton className=\"size-7\" />\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/file-tree/parts/file-tree-header.tsx"
    },
    {
      "path": "src/registry/components/navigation/file-tree/parts/file-tree-new-file-button.tsx",
      "content": "\"use client\";\n\nimport { FilePlus } from \"lucide-react\";\nimport { buttonVariants } from \"@/components/ui/button\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\nimport { useFileTree } from \"../hooks/use-file-tree-context\";\n\nexport interface FileTreeNewFileButtonProps {\n  /** Optional explicit parent id (defaults to focused folder, then root). */\n  parentId?: string | null;\n  className?: string;\n}\n\nexport function FileTreeNewFileButton(props: FileTreeNewFileButtonProps) {\n  const { parentId, className } = props;\n  const { actions, showNewFile, state, labels, rows } = useFileTree();\n  if (!showNewFile) return null;\n\n  const resolveParent = (): string | null => {\n    if (parentId !== undefined) return parentId;\n    // Prefer focused folder; otherwise focused file's parent; otherwise root.\n    if (state.focusedId) {\n      const focused = rows.find((r) => r.node.id === state.focusedId);\n      if (focused) {\n        return focused.node.type === \"folder\"\n          ? focused.node.id\n          : (focused.node.parentId ?? null);\n      }\n    }\n    return null;\n  };\n\n  // F-cross-13: no `asChild` — Base UI triggers reject it; the trigger IS the button, styled via buttonVariants.\n  return (\n    <Tooltip>\n      <TooltipTrigger\n        type=\"button\"\n        className={cn(buttonVariants({ variant: \"ghost\", size: \"icon\" }), className)}\n        onClick={() => actions.triggerCreate(resolveParent(), \"file\")}\n        aria-label={labels.newFile}\n      >\n        <FilePlus className=\"size-4\" aria-hidden=\"true\" />\n      </TooltipTrigger>\n      <TooltipContent>{labels.newFile}</TooltipContent>\n    </Tooltip>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/file-tree/parts/file-tree-new-file-button.tsx"
    },
    {
      "path": "src/registry/components/navigation/file-tree/parts/file-tree-new-folder-button.tsx",
      "content": "\"use client\";\n\nimport { FolderPlus } from \"lucide-react\";\nimport { buttonVariants } from \"@/components/ui/button\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\nimport { useFileTree } from \"../hooks/use-file-tree-context\";\n\nexport interface FileTreeNewFolderButtonProps {\n  /** Optional explicit parent id (defaults to focused folder, then root). */\n  parentId?: string | null;\n  className?: string;\n}\n\nexport function FileTreeNewFolderButton(props: FileTreeNewFolderButtonProps) {\n  const { parentId, className } = props;\n  const { actions, showNewFolder, state, labels, rows } = useFileTree();\n  if (!showNewFolder) return null;\n\n  const resolveParent = (): string | null => {\n    if (parentId !== undefined) return parentId;\n    if (state.focusedId) {\n      const focused = rows.find((r) => r.node.id === state.focusedId);\n      if (focused) {\n        return focused.node.type === \"folder\"\n          ? focused.node.id\n          : (focused.node.parentId ?? null);\n      }\n    }\n    return null;\n  };\n\n  // F-cross-13: no `asChild` — Base UI triggers reject it; the trigger IS the button, styled via buttonVariants.\n  return (\n    <Tooltip>\n      <TooltipTrigger\n        type=\"button\"\n        className={cn(buttonVariants({ variant: \"ghost\", size: \"icon\" }), className)}\n        onClick={() => actions.triggerCreate(resolveParent(), \"folder\")}\n        aria-label={labels.newFolder}\n      >\n        <FolderPlus className=\"size-4\" aria-hidden=\"true\" />\n      </TooltipTrigger>\n      <TooltipContent>{labels.newFolder}</TooltipContent>\n    </Tooltip>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/file-tree/parts/file-tree-new-folder-button.tsx"
    },
    {
      "path": "src/registry/components/navigation/file-tree/parts/file-tree-refresh-button.tsx",
      "content": "\"use client\";\n\nimport { RefreshCw } from \"lucide-react\";\nimport { buttonVariants } from \"@/components/ui/button\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\nimport { useFileTree } from \"../hooks/use-file-tree-context\";\n\nexport interface FileTreeRefreshButtonProps {\n  /** Refresh scope. `null` = whole tree (default); pass an id to refresh a single folder. */\n  nodeId?: string | null;\n  className?: string;\n}\n\nexport function FileTreeRefreshButton(props: FileTreeRefreshButtonProps) {\n  const { nodeId = null, className } = props;\n  const { actions, showRefresh, labels } = useFileTree();\n  if (!showRefresh) return null;\n\n  // F-cross-13: no `asChild` — Base UI triggers reject it; the trigger IS the button, styled via buttonVariants.\n  return (\n    <Tooltip>\n      <TooltipTrigger\n        type=\"button\"\n        className={cn(buttonVariants({ variant: \"ghost\", size: \"icon\" }), className)}\n        onClick={() => actions.refresh(nodeId)}\n        aria-label={labels.refresh}\n      >\n        <RefreshCw className=\"size-4\" aria-hidden=\"true\" />\n      </TooltipTrigger>\n      <TooltipContent>{labels.refresh}</TooltipContent>\n    </Tooltip>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/file-tree/parts/file-tree-refresh-button.tsx"
    },
    {
      "path": "src/registry/components/navigation/file-tree/parts/file-tree-collapse-all-button.tsx",
      "content": "\"use client\";\n\nimport { ChevronsDownUp } from \"lucide-react\";\nimport { buttonVariants } from \"@/components/ui/button\";\nimport {\n  Tooltip,\n  TooltipContent,\n  TooltipTrigger,\n} from \"@/components/ui/tooltip\";\nimport { cn } from \"@/lib/utils\";\nimport { useFileTree } from \"../hooks/use-file-tree-context\";\n\nexport interface FileTreeCollapseAllButtonProps {\n  className?: string;\n}\n\nexport function FileTreeCollapseAllButton(\n  props: FileTreeCollapseAllButtonProps,\n) {\n  const { className } = props;\n  const { actions, showCollapseAll, labels } = useFileTree();\n  if (!showCollapseAll) return null;\n\n  // F-cross-13: no `asChild` — Base UI triggers reject it; the trigger IS the button, styled via buttonVariants.\n  return (\n    <Tooltip>\n      <TooltipTrigger\n        type=\"button\"\n        className={cn(buttonVariants({ variant: \"ghost\", size: \"icon\" }), className)}\n        onClick={() => actions.collapseAll()}\n        aria-label={labels.collapseAll}\n      >\n        <ChevronsDownUp className=\"size-4\" aria-hidden=\"true\" />\n      </TooltipTrigger>\n      <TooltipContent>{labels.collapseAll}</TooltipContent>\n    </Tooltip>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/file-tree/parts/file-tree-collapse-all-button.tsx"
    },
    {
      "path": "src/registry/components/navigation/file-tree/parts/file-tree-empty.tsx",
      "content": "\"use client\";\n\nimport { FilePlus, FolderPlus, Inbox } from \"lucide-react\";\nimport { Button } from \"@/components/ui/button\";\nimport { useFileTree } from \"../hooks/use-file-tree-context\";\n\nexport function FileTreeEmpty() {\n  const { actions, showNewFile, showNewFolder, labels } = useFileTree();\n  return (\n    <div\n      className=\"flex flex-col items-center justify-center gap-3 px-4 py-10 text-center text-sm text-muted-foreground\"\n      role=\"status\"\n    >\n      <Inbox aria-hidden=\"true\" className=\"size-8 opacity-60\" />\n      <p>{labels.emptyTitle}</p>\n      {(showNewFile || showNewFolder) && (\n        <div className=\"flex gap-2\">\n          {showNewFile ? (\n            <Button\n              type=\"button\"\n              variant=\"outline\"\n              size=\"sm\"\n              onClick={() => actions.triggerCreate(null, \"file\")}\n            >\n              <FilePlus className=\"size-3.5\" aria-hidden=\"true\" />\n              {labels.newFile}\n            </Button>\n          ) : null}\n          {showNewFolder ? (\n            <Button\n              type=\"button\"\n              variant=\"outline\"\n              size=\"sm\"\n              onClick={() => actions.triggerCreate(null, \"folder\")}\n            >\n              <FolderPlus className=\"size-3.5\" aria-hidden=\"true\" />\n              {labels.newFolder}\n            </Button>\n          ) : null}\n        </div>\n      )}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/file-tree/parts/file-tree-empty.tsx"
    },
    {
      "path": "src/registry/components/navigation/file-tree/parts/file-tree-loading.tsx",
      "content": "\"use client\";\n\nimport { useFileTree } from \"../hooks/use-file-tree-context\";\n\nexport function FileTreeLoading() {\n  const { labels } = useFileTree();\n  return (\n    <div\n      className=\"flex flex-col gap-1 px-2 py-2\"\n      role=\"status\"\n      aria-label={labels.loading}\n    >\n      {[0, 1, 2, 3].map((i) => (\n        <div\n          key={i}\n          className=\"flex items-center gap-2 px-1 py-1\"\n          style={{ paddingLeft: `${(i % 3) * 12 + 8}px` }}\n        >\n          <div className=\"size-3.5 animate-pulse rounded-sm bg-muted\" />\n          <div\n            className=\"h-3 animate-pulse rounded-sm bg-muted\"\n            style={{ width: `${60 + (i * 7) % 30}%` }}\n          />\n        </div>\n      ))}\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/file-tree/parts/file-tree-loading.tsx"
    },
    {
      "path": "src/registry/components/navigation/file-tree/parts/file-tree-drag-overlay.tsx",
      "content": "\"use client\";\n\nimport { FileUp } from \"lucide-react\";\nimport type { FileTreeLabels } from \"../types\";\n\ninterface FileTreeDragOverlayProps {\n  visible: boolean;\n  labels: FileTreeLabels;\n}\n\nexport function FileTreeDragOverlay(props: FileTreeDragOverlayProps) {\n  const { visible, labels } = props;\n  if (!visible) return null;\n  return (\n    <div\n      className=\"pointer-events-none absolute inset-0 z-20 flex items-center justify-center rounded-md border-2 border-dashed border-primary/70 bg-background/85 backdrop-blur-[2px]\"\n      role=\"presentation\"\n    >\n      <div className=\"flex flex-col items-center gap-2 text-sm font-medium text-foreground\">\n        <FileUp className=\"size-6 text-primary\" aria-hidden=\"true\" />\n        <span>{labels.externalDropOverlay}</span>\n      </div>\n    </div>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/file-tree/parts/file-tree-drag-overlay.tsx"
    },
    {
      "path": "src/registry/components/navigation/file-tree/parts/file-tree-delete-confirm.tsx",
      "content": "\"use client\";\n\nimport {\n  AlertDialog,\n  AlertDialogAction,\n  AlertDialogCancel,\n  AlertDialogContent,\n  AlertDialogDescription,\n  AlertDialogFooter,\n  AlertDialogHeader,\n  AlertDialogTitle,\n} from \"@/components/ui/alert-dialog\";\nimport type { FsNode, FileTreeLabels } from \"../types\";\n\ninterface FileTreeDeleteConfirmProps {\n  open: boolean;\n  ids: string[];\n  nodes: FsNode[];\n  labels: FileTreeLabels;\n  onConfirm: () => void;\n  onCancel: () => void;\n}\n\nexport function FileTreeDeleteConfirm(props: FileTreeDeleteConfirmProps) {\n  const { open, ids, nodes, labels, onConfirm, onCancel } = props;\n  const count = ids.length;\n  const first = nodes[0]?.name ?? \"\";\n  const title =\n    count === 1\n      ? `Delete \"${first}\"?`\n      : labels.deleteConfirmTitle.replace(\"items\", `${count} items`);\n  const description =\n    count === 1\n      ? labels.deleteConfirmDescription\n      : `Delete \"${first}\" and ${count - 1} other${count - 1 > 1 ? \"s\" : \"\"}? ${labels.deleteConfirmDescription}`;\n\n  return (\n    <AlertDialog\n      open={open}\n      onOpenChange={(next) => {\n        if (!next) onCancel();\n      }}\n    >\n      <AlertDialogContent>\n        <AlertDialogHeader>\n          <AlertDialogTitle>{title}</AlertDialogTitle>\n          <AlertDialogDescription>{description}</AlertDialogDescription>\n        </AlertDialogHeader>\n        <AlertDialogFooter>\n          <AlertDialogCancel onClick={onCancel}>\n            {labels.deleteConfirmCancel}\n          </AlertDialogCancel>\n          <AlertDialogAction\n            onClick={onConfirm}\n            className=\"bg-destructive text-destructive-foreground hover:bg-destructive/90 focus-visible:ring-destructive/40\"\n          >\n            {labels.deleteConfirmAction}\n          </AlertDialogAction>\n        </AlertDialogFooter>\n      </AlertDialogContent>\n    </AlertDialog>\n  );\n}\n",
      "type": "registry:component",
      "target": "components/file-tree/parts/file-tree-delete-confirm.tsx"
    }
  ],
  "categories": [
    "navigation",
    "tree",
    "filesystem"
  ],
  "type": "registry:block"
}