{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-tree",
  "type": "registry:component",
  "title": "File Tree",
  "description": "A project tree that feels like your editor: arrow keys to open folders, git changes marked in colour, F2 to rename and drag to tidy things up.",
  "categories": [
    "component",
    "Workspace"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://coss.com/ui/r/kbd.json"
  ],
  "files": [
    {
      "path": "registry/devl/file-tree.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport {\n  ChevronRightIcon,\n  FileBracesIcon,\n  FileCodeIcon,\n  FileCogIcon,\n  FileIcon,\n  FileImageIcon,\n  FileLockIcon,\n  FileTerminalIcon,\n  FileTextIcon,\n  FileTypeIcon,\n  FolderIcon,\n  FolderOpenIcon,\n  GitBranchIcon,\n  type LucideIcon,\n} from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport {\n  type DragEvent,\n  type KeyboardEvent,\n  useCallback,\n  useEffect,\n  useMemo,\n  useRef,\n  useState,\n} from \"react\";\nimport { Kbd } from \"@/components/ui/kbd\";\nimport { cn } from \"@/lib/utils\";\n\nexport type GitStatus = \"modified\" | \"added\" | \"untracked\" | \"renamed\" | \"deleted\";\n\nexport interface FileNode {\n  id: string;\n  name: string;\n  children?: FileNode[];\n  status?: GitStatus;\n}\n\nexport interface FileTreeProps {\n  data: FileNode[];\n  repo?: string;\n  branch?: string;\n  defaultExpanded?: string[];\n  defaultSelected?: string;\n  onOpen?: (node: FileNode) => void;\n  onRename?: (node: FileNode, name: string) => void;\n  onMove?: (node: FileNode, folderId: string | null) => void;\n  onChange?: (data: FileNode[]) => void;\n  className?: string;\n}\n\nconst statuses: Record<GitStatus, { label: string; letter: string; dot: string; text: string }> = {\n  modified: { label: \"Modified\", letter: \"M\", dot: \"bg-warning\", text: \"text-warning-foreground\" },\n  added: { label: \"Added\", letter: \"A\", dot: \"bg-success\", text: \"text-success-foreground\" },\n  untracked: { label: \"Untracked\", letter: \"U\", dot: \"bg-info\", text: \"text-info-foreground\" },\n  renamed: { label: \"Renamed\", letter: \"R\", dot: \"bg-muted-foreground\", text: \"text-muted-foreground\" },\n  deleted: { label: \"Deleted\", letter: \"D\", dot: \"bg-destructive\", text: \"text-destructive-foreground\" },\n};\n\nconst statusRank: GitStatus[] = [\"deleted\", \"modified\", \"renamed\", \"added\", \"untracked\"];\n\nfunction fileIcon(name: string): { Icon: LucideIcon; tone: string } {\n  const lower = name.toLowerCase();\n  const ext = lower.includes(\".\") ? lower.slice(lower.lastIndexOf(\".\") + 1) : \"\";\n  if (lower.endsWith(\".lock\") || lower === \"pnpm-lock.yaml\") return { Icon: FileLockIcon, tone: \"text-muted-foreground\" };\n  if (lower.startsWith(\".env\") || lower.includes(\"config\") || lower.startsWith(\".\")) {\n    return { Icon: FileCogIcon, tone: \"text-muted-foreground\" };\n  }\n  if ([\"ts\", \"tsx\", \"js\", \"jsx\", \"mjs\"].includes(ext)) return { Icon: FileCodeIcon, tone: \"text-info\" };\n  if ([\"json\", \"yaml\", \"yml\", \"toml\"].includes(ext)) return { Icon: FileBracesIcon, tone: \"text-warning\" };\n  if ([\"md\", \"mdx\", \"txt\"].includes(ext)) return { Icon: FileTextIcon, tone: \"text-muted-foreground\" };\n  if ([\"png\", \"jpg\", \"jpeg\", \"svg\", \"webp\", \"gif\"].includes(ext)) return { Icon: FileImageIcon, tone: \"text-success\" };\n  if ([\"css\", \"scss\", \"woff2\", \"ttf\"].includes(ext)) return { Icon: FileTypeIcon, tone: \"text-violet-600 dark:text-violet-400\" };\n  if ([\"sh\", \"zsh\", \"bash\"].includes(ext)) return { Icon: FileTerminalIcon, tone: \"text-muted-foreground\" };\n  return { Icon: FileIcon, tone: \"text-muted-foreground\" };\n}\n\nconst isFolder = (n: FileNode) => Array.isArray(n.children);\n\nfunction sortNodes(nodes: FileNode[]): FileNode[] {\n  return [...nodes]\n    .map((n) => (n.children ? { ...n, children: sortNodes(n.children) } : n))\n    .sort((a, b) => Number(isFolder(b)) - Number(isFolder(a)) || a.name.localeCompare(b.name));\n}\n\ninterface Row {\n  node: FileNode;\n  depth: number;\n  parentId: string | null;\n  posinset: number;\n  setsize: number;\n}\n\nfunction flatten(nodes: FileNode[], expanded: Set<string>, depth = 0, parentId: string | null = null): Row[] {\n  return nodes.flatMap((node, i) => {\n    const row: Row = { node, depth, parentId, posinset: i + 1, setsize: nodes.length };\n    return node.children && expanded.has(node.id)\n      ? [row, ...flatten(node.children, expanded, depth + 1, node.id)]\n      : [row];\n  });\n}\n\nfunction findNode(nodes: FileNode[], id: string): FileNode | undefined {\n  for (const n of nodes) {\n    if (n.id === id) return n;\n    const hit = n.children && findNode(n.children, id);\n    if (hit) return hit;\n  }\n}\n\nfunction findParentId(nodes: FileNode[], id: string, parent: string | null = null): string | null | undefined {\n  for (const n of nodes) {\n    if (n.id === id) return parent;\n    if (n.children) {\n      const hit = findParentId(n.children, id, n.id);\n      if (hit !== undefined) return hit;\n    }\n  }\n}\n\nfunction removeNode(nodes: FileNode[], id: string): FileNode[] {\n  return nodes\n    .filter((n) => n.id !== id)\n    .map((n) => (n.children ? { ...n, children: removeNode(n.children, id) } : n));\n}\n\nfunction insertNode(nodes: FileNode[], folderId: string | null, node: FileNode): FileNode[] {\n  if (folderId === null) return sortNodes([...nodes, node]);\n  return nodes.map((n) =>\n    n.id === folderId\n      ? { ...n, children: sortNodes([...(n.children ?? []), node]) }\n      : n.children\n        ? { ...n, children: insertNode(n.children, folderId, node) }\n        : n,\n  );\n}\n\nfunction updateNode(nodes: FileNode[], id: string, patch: Partial<FileNode>): FileNode[] {\n  return sortNodes(\n    nodes.map((n) =>\n      n.id === id ? { ...n, ...patch } : n.children ? { ...n, children: updateNode(n.children, id, patch) } : n,\n    ),\n  );\n}\n\nfunction contains(node: FileNode, id: string): boolean {\n  return node.id === id || Boolean(node.children?.some((c) => contains(c, id)));\n}\n\nfunction folderStatus(node: FileNode): GitStatus | undefined {\n  const found = new Set<GitStatus>();\n  const walk = (n: FileNode) => {\n    if (n.status) found.add(n.status);\n    n.children?.forEach(walk);\n  };\n  node.children?.forEach(walk);\n  return statusRank.find((s) => found.has(s));\n}\n\nfunction countChanges(nodes: FileNode[]): number {\n  return nodes.reduce((sum, n) => sum + (n.status && !n.children ? 1 : 0) + (n.children ? countChanges(n.children) : 0), 0);\n}\n\nconst ROW = 28;\nconst INDENT = 14;\n\nexport function FileTree({\n  data,\n  repo = \"workspace\",\n  branch,\n  defaultExpanded = [],\n  defaultSelected,\n  onOpen,\n  onRename,\n  onMove,\n  onChange,\n  className,\n}: FileTreeProps) {\n  const reduce = useReducedMotion();\n  const [tree, setTree] = useState(() => sortNodes(data));\n  const [expanded, setExpanded] = useState(() => new Set(defaultExpanded));\n  const [selected, setSelected] = useState<string | null>(defaultSelected ?? null);\n  const [renaming, setRenaming] = useState<string | null>(null);\n  const [draft, setDraft] = useState(\"\");\n  const [renameError, setRenameError] = useState(false);\n  const [dragId, setDragId] = useState<string | null>(null);\n  const [dropTarget, setDropTarget] = useState<string | \"root\" | null>(null);\n  const rowRefs = useRef(new Map<string, HTMLLIElement>());\n  const hoverTimer = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const focusAfter = useRef<string | null>(null);\n\n  const rows = useMemo(() => flatten(tree, expanded), [tree, expanded]);\n  const changes = useMemo(() => countChanges(tree), [tree]);\n  const activeId = selected && rows.some((r) => r.node.id === selected) ? selected : (rows[0]?.node.id ?? null);\n\n  const commit = useCallback(\n    (next: FileNode[]) => {\n      setTree(next);\n      onChange?.(next);\n    },\n    [onChange],\n  );\n\n  useEffect(() => () => {\n    if (hoverTimer.current) clearTimeout(hoverTimer.current);\n  }, []);\n\n  useEffect(() => {\n    if (focusAfter.current) {\n      rowRefs.current.get(focusAfter.current)?.focus();\n      focusAfter.current = null;\n    }\n  });\n\n  const focusRow = (id: string) => {\n    setSelected(id);\n    focusAfter.current = id;\n  };\n\n  const toggle = (id: string, open?: boolean) =>\n    setExpanded((prev) => {\n      const next = new Set(prev);\n      const shouldOpen = open ?? !next.has(id);\n      if (shouldOpen) next.add(id);\n      else next.delete(id);\n      return next;\n    });\n\n  const startRename = (node: FileNode) => {\n    setSelected(node.id);\n    setRenaming(node.id);\n    setDraft(node.name);\n    setRenameError(false);\n  };\n\n  const finishRename = (save: boolean) => {\n    const id = renaming;\n    if (!id) return;\n    const node = findNode(tree, id);\n    const name = draft.trim();\n    if (save && node && name && name !== node.name) {\n      const parentId = findParentId(tree, id);\n      const siblings = parentId ? (findNode(tree, parentId)?.children ?? []) : tree;\n      if (siblings.some((s) => s.id !== id && s.name.toLowerCase() === name.toLowerCase())) {\n        setRenameError(true);\n        return;\n      }\n      const status: GitStatus | undefined =\n        isFolder(node) || node.status === \"added\" || node.status === \"untracked\" ? node.status : \"renamed\";\n      commit(updateNode(tree, id, { name, status }));\n      onRename?.(node, name);\n    }\n    setRenaming(null);\n    focusRow(id);\n  };\n\n  const move = (id: string, folderId: string | null) => {\n    const node = findNode(tree, id);\n    if (!node) return;\n    if (folderId && contains(node, folderId)) return;\n    if (findParentId(tree, id) === folderId) return;\n    const siblings = folderId ? (findNode(tree, folderId)?.children ?? []) : tree;\n    const clash = siblings.some((s) => s.name.toLowerCase() === node.name.toLowerCase());\n    if (clash) return;\n    const moved: FileNode =\n      isFolder(node) || node.status === \"added\" || node.status === \"untracked\" ? node : { ...node, status: \"renamed\" };\n    commit(insertNode(removeNode(tree, id), folderId, moved));\n    if (folderId) toggle(folderId, true);\n    onMove?.(node, folderId);\n    focusRow(id);\n  };\n\n  const onKeyDown = (e: KeyboardEvent<HTMLUListElement>) => {\n    if (renaming || !activeId) return;\n    const index = rows.findIndex((r) => r.node.id === activeId);\n    const row = rows[index];\n    if (!row) return;\n    const folder = isFolder(row.node);\n    const open = expanded.has(row.node.id);\n    const go = (i: number) => {\n      const target = rows[Math.max(0, Math.min(rows.length - 1, i))];\n      if (target) focusRow(target.node.id);\n    };\n    switch (e.key) {\n      case \"ArrowDown\":\n        go(index + 1);\n        break;\n      case \"ArrowUp\":\n        go(index - 1);\n        break;\n      case \"Home\":\n        go(0);\n        break;\n      case \"End\":\n        go(rows.length - 1);\n        break;\n      case \"ArrowRight\":\n        if (folder && !open) toggle(row.node.id, true);\n        else if (folder && row.node.children?.length) go(index + 1);\n        break;\n      case \"ArrowLeft\":\n        if (folder && open) toggle(row.node.id, false);\n        else if (row.parentId) focusRow(row.parentId);\n        break;\n      case \"Enter\":\n      case \" \":\n        if (folder) toggle(row.node.id);\n        else onOpen?.(row.node);\n        break;\n      case \"F2\":\n        startRename(row.node);\n        break;\n      default:\n        return;\n    }\n    e.preventDefault();\n  };\n\n  const targetFor = (row: Row): string | \"root\" => (isFolder(row.node) ? row.node.id : (row.parentId ?? \"root\"));\n\n  const onDragOverRow = (e: DragEvent, row: Row) => {\n    if (!dragId) return;\n    const target = targetFor(row);\n    const dragged = findNode(tree, dragId);\n    if (dragged && target !== \"root\" && contains(dragged, target)) {\n      setDropTarget(null);\n      return;\n    }\n    e.preventDefault();\n    e.stopPropagation();\n    e.dataTransfer.dropEffect = \"move\";\n    if (dropTarget !== target) {\n      setDropTarget(target);\n      if (hoverTimer.current) clearTimeout(hoverTimer.current);\n      if (target !== \"root\" && !expanded.has(target)) {\n        hoverTimer.current = setTimeout(() => toggle(target, true), 650);\n      }\n    }\n  };\n\n  const endDrag = () => {\n    if (hoverTimer.current) clearTimeout(hoverTimer.current);\n    setDragId(null);\n    setDropTarget(null);\n  };\n\n  const onDrop = (e: DragEvent) => {\n    e.preventDefault();\n    e.stopPropagation();\n    const id = dragId ?? e.dataTransfer.getData(\"text/plain\");\n    if (id && dropTarget) move(id, dropTarget === \"root\" ? null : dropTarget);\n    endDrag();\n  };\n\n  const inTarget = (row: Row) => {\n    if (!dropTarget || dropTarget === \"root\") return false;\n    let parent: string | null = row.node.id;\n    while (parent) {\n      if (parent === dropTarget) return true;\n      parent = findParentId(tree, parent) ?? null;\n    }\n    return false;\n  };\n\n  const rowTransition = reduce ? { duration: 0 } : { type: \"spring\" as const, stiffness: 520, damping: 42, mass: 0.7 };\n\n  return (\n    <div\n      className={cn(\n        \"flex w-full max-w-[340px] flex-col overflow-hidden rounded-2xl border bg-card text-card-foreground shadow-xs/5\",\n        className,\n      )}\n    >\n      <div className=\"flex items-center gap-2 border-b px-3.5 py-2.5\">\n        <div className=\"min-w-0 flex-1\">\n          <p className=\"truncate font-medium text-sm\">{repo}</p>\n          {branch && (\n            <p className=\"flex items-center gap-1 text-muted-foreground text-xs\">\n              <GitBranchIcon aria-hidden className=\"size-3\" />\n              <span className=\"truncate font-mono\">{branch}</span>\n            </p>\n          )}\n        </div>\n        <span className=\"rounded-full bg-muted px-2 py-0.5 text-muted-foreground text-xs tabular-nums\">\n          <AnimatedCount value={changes} /> {changes === 1 ? \"change\" : \"changes\"}\n        </span>\n      </div>\n\n      <div\n        className={cn(\n          \"relative p-1.5 transition-colors\",\n          dropTarget === \"root\" && \"bg-primary/4 shadow-[inset_0_0_0_1px_var(--color-ring)]\",\n        )}\n        onDragLeave={(e) => {\n          if (!e.currentTarget.contains(e.relatedTarget as Node)) setDropTarget(null);\n        }}\n        onDragOver={(e) => {\n          if (!dragId) return;\n          e.preventDefault();\n          if (dropTarget !== \"root\") setDropTarget(\"root\");\n        }}\n        onDrop={onDrop}\n      >\n        <ul aria-label={`${repo} files`} className=\"outline-none\" onKeyDown={onKeyDown} role=\"tree\">\n          <AnimatePresence initial={false}>\n            {rows.map((row) => {\n              const { node, depth } = row;\n              const folder = isFolder(node);\n              const open = expanded.has(node.id);\n              const isActive = node.id === activeId;\n              const isRenaming = renaming === node.id;\n              const status = folder ? folderStatus(node) : node.status;\n              const { Icon, tone } = folder\n                ? { Icon: open ? FolderOpenIcon : FolderIcon, tone: \"text-muted-foreground\" }\n                : fileIcon(node.name);\n              const highlighted = inTarget(row);\n              return (\n                <motion.li\n                  animate={{ opacity: 1, height: ROW }}\n                  aria-expanded={folder ? open : undefined}\n                  aria-label={`${node.name}${status && !folder ? `, ${statuses[status].label}` : \"\"}`}\n                  aria-level={depth + 1}\n                  aria-posinset={row.posinset}\n                  aria-selected={isActive}\n                  aria-setsize={row.setsize}\n                  className={cn(\n                    \"group/row relative cursor-default select-none overflow-hidden rounded-lg outline-none\",\n                    \"focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset\",\n                  )}\n                  exit={{ opacity: 0, height: 0 }}\n                  initial={{ opacity: 0, height: 0 }}\n                  key={node.id}\n                  layout=\"position\"\n                  onClick={() => {\n                    setSelected(node.id);\n                    if (folder) toggle(node.id);\n                  }}\n                  onDoubleClick={() => (folder ? undefined : startRename(node))}\n                  onDragOver={(e) => onDragOverRow(e, row)}\n                  onDrop={onDrop}\n                  ref={(el) => {\n                    if (el) rowRefs.current.set(node.id, el);\n                    else rowRefs.current.delete(node.id);\n                  }}\n                  role=\"treeitem\"\n                  tabIndex={isActive && !isRenaming ? 0 : -1}\n                  transition={rowTransition}\n                >\n                <div\n                  className={cn(\"relative flex h-7 items-center\", dragId === node.id && \"opacity-48\")}\n                  draggable={!isRenaming}\n                  onDragEnd={endDrag}\n                  onDragStart={(e) => {\n                    e.dataTransfer.effectAllowed = \"move\";\n                    e.dataTransfer.setData(\"text/plain\", node.id);\n                    setDragId(node.id);\n                    setSelected(node.id);\n                  }}\n                  style={{ paddingLeft: 6 + depth * INDENT }}\n                >\n                  {highlighted && <span aria-hidden className=\"absolute inset-0 rounded-lg bg-primary/6\" />}\n                  {isActive && <span aria-hidden className=\"absolute inset-0 rounded-lg bg-accent\" />}\n                  {!isActive && <span aria-hidden className=\"absolute inset-0 rounded-lg group-hover/row:bg-accent/50\" />}\n                  {depth > 0 &&\n                    Array.from({ length: depth }, (_, i) => (\n                      <span\n                        aria-hidden\n                        className=\"absolute inset-y-0 w-px bg-border\"\n                        key={i}\n                        style={{ left: 6 + i * INDENT + 7 }}\n                      />\n                    ))}\n                  <span className=\"relative flex w-3.5 shrink-0 items-center justify-center\">\n                    {folder && (\n                      <ChevronRightIcon\n                        aria-hidden\n                        className={cn(\n                          \"size-3.5 text-muted-foreground transition-transform duration-200 motion-reduce:transition-none\",\n                          open && \"rotate-90\",\n                        )}\n                      />\n                    )}\n                  </span>\n                  <Icon aria-hidden className={cn(\"relative ms-1 size-4 shrink-0\", tone)} />\n                  {isRenaming ? (\n                    <input\n                      aria-invalid={renameError || undefined}\n                      aria-label={`Rename ${node.name}`}\n                      autoFocus\n                      className={cn(\n                        \"relative ms-1.5 me-1 h-6 min-w-0 flex-1 rounded-md border border-ring bg-background px-1.5 font-mono text-xs outline-none ring-2 ring-ring/24\",\n                        renameError && \"border-destructive ring-destructive/16\",\n                      )}\n                      onBlur={() => finishRename(true)}\n                      onChange={(e) => {\n                        setDraft(e.target.value);\n                        setRenameError(false);\n                      }}\n                      onClick={(e) => e.stopPropagation()}\n                      onFocus={(e) => {\n                        const dot = e.target.value.lastIndexOf(\".\");\n                        e.target.setSelectionRange(0, dot > 0 && !folder ? dot : e.target.value.length);\n                      }}\n                      onKeyDown={(e) => {\n                        e.stopPropagation();\n                        if (e.key === \"Enter\") finishRename(true);\n                        if (e.key === \"Escape\") finishRename(false);\n                      }}\n                      spellCheck={false}\n                      value={draft}\n                    />\n                  ) : (\n                    <span\n                      className={cn(\n                        \"relative ms-1.5 min-w-0 flex-1 truncate text-sm\",\n                        node.status === \"deleted\" && \"text-muted-foreground line-through\",\n                        isActive ? \"text-foreground\" : \"text-foreground/88\",\n                      )}\n                    >\n                      {node.name}\n                    </span>\n                  )}\n                  {status && !isRenaming && (\n                    <span className=\"relative me-1.5 flex shrink-0 items-center gap-1.5\">\n                      {!folder && (\n                        <span\n                          aria-hidden\n                          className={cn(\n                            \"font-medium font-mono text-[10px] opacity-0 transition-opacity group-hover/row:opacity-100 group-focus-visible/row:opacity-100\",\n                            statuses[status].text,\n                            isActive && \"opacity-100\",\n                          )}\n                        >\n                          {statuses[status].letter}\n                        </span>\n                      )}\n                      <motion.span\n                        animate={{ scale: 1, opacity: folder ? 0.7 : 1 }}\n                        aria-hidden\n                        className={cn(\"size-1.5 rounded-full\", statuses[status].dot, folder && \"size-1\")}\n                        initial={reduce ? false : { scale: 0.5, opacity: 0 }}\n                        key={status}\n                        title={statuses[status].label}\n                        transition={reduce ? { duration: 0 } : { duration: 0.2, ease: [0.23, 1, 0.32, 1] }}\n                      />\n                    </span>\n                  )}\n                </div>\n                </motion.li>\n              );\n            })}\n          </AnimatePresence>\n        </ul>\n      </div>\n\n      <div className=\"flex items-center justify-between gap-3 border-t px-3.5 py-2 text-muted-foreground text-xs\">\n        <div className=\"flex items-center gap-2.5\">\n          {([\"modified\", \"added\", \"renamed\"] as const).map((s) => (\n            <span className=\"flex items-center gap-1\" key={s}>\n              <span aria-hidden className={cn(\"size-1.5 rounded-full\", statuses[s].dot)} />\n              {statuses[s].label}\n            </span>\n          ))}\n        </div>\n        <span className=\"hidden items-center gap-1 sm:flex\">\n          <Kbd>F2</Kbd> rename\n        </span>\n      </div>\n    </div>\n  );\n}\n\nfunction AnimatedCount({ value }: { value: number }) {\n  const reduce = useReducedMotion();\n  return (\n    <span className=\"relative inline-flex overflow-hidden align-bottom\">\n      <AnimatePresence initial={false} mode=\"popLayout\">\n        <motion.span\n          animate={{ y: 0, opacity: 1 }}\n          exit={reduce ? { opacity: 0 } : { y: -10, opacity: 0 }}\n          initial={reduce ? { opacity: 0 } : { y: 10, opacity: 0 }}\n          key={value}\n          transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 500, damping: 34 }}\n        >\n          {value}\n        </motion.span>\n      </AnimatePresence>\n    </span>\n  );\n}\n"
    }
  ]
}