{
  "name": "file-tree",
  "dependencies": [],
  "registryDependencies": [
    "file-icon"
  ],
  "files": [
    {
      "path": "file-tree.tsx",
      "content": "\"use client\";\r\n\r\nimport { css, keyframes, themeVars as theme } from \"@yugnex/core\";\r\nimport { useCallback, useMemo, useRef, useState, type KeyboardEvent } from \"react\";\r\nimport { FileIcon, type FileStatus } from \"./file-icon\";\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Model\r\n * ------------------------------------------------------------------ */\r\n\r\nexport interface FileNode {\r\n  /** Full path, used as the node's identity: \"src/app/page.tsx\". */\r\n  path: string;\r\n  /** Directories may have children; files never do. */\r\n  children?: FileNode[];\r\n  status?: FileStatus;\r\n  /** Overrides the label derived from `path`. */\r\n  name?: string;\r\n}\r\n\r\n/** One row of the flattened, visible tree. */\r\ninterface FlatRow {\r\n  node: FileNode;\r\n  depth: number;\r\n  isDir: boolean;\r\n  expanded: boolean;\r\n  name: string;\r\n}\r\n\r\n/**\r\n * Builds a tree from a flat path list — the shape a generator actually emits\r\n * (\"here are the 40 files I touched\"), rather than the nested shape a tree\r\n * needs. Directory nodes are synthesized from the path segments.\r\n */\r\nexport function buildFileTree(entries: Array<{ path: string; status?: FileStatus }>): FileNode[] {\r\n  const roots: FileNode[] = [];\r\n  const dirs = new Map<string, FileNode>();\r\n\r\n  // Sort so a directory's children arrive together and in a stable order.\r\n  const sorted = [...entries].sort((a, b) => a.path.localeCompare(b.path));\r\n\r\n  for (const entry of sorted) {\r\n    const segments = entry.path.split(\"/\").filter(Boolean);\r\n    if (segments.length === 0) continue;\r\n\r\n    let parentChildren = roots;\r\n    let prefix = \"\";\r\n\r\n    for (let i = 0; i < segments.length - 1; i++) {\r\n      prefix = prefix ? `${prefix}/${segments[i]}` : (segments[i] as string);\r\n      let dir = dirs.get(prefix);\r\n      if (!dir) {\r\n        dir = { path: prefix, name: segments[i] as string, children: [] };\r\n        dirs.set(prefix, dir);\r\n        parentChildren.push(dir);\r\n      }\r\n      // A path segment can collide with a file added earlier; treat the\r\n      // directory as authoritative and give it a children array either way.\r\n      if (!dir.children) dir.children = [];\r\n      parentChildren = dir.children;\r\n    }\r\n\r\n    parentChildren.push({\r\n      path: entry.path,\r\n      name: segments[segments.length - 1] as string,\r\n      status: entry.status,\r\n    });\r\n  }\r\n\r\n  // Directories first, then files, each alphabetical — the ordering every\r\n  // file explorer uses, and the one that makes a deep tree scannable.\r\n  const sortLevel = (nodes: FileNode[]): FileNode[] => {\r\n    nodes.sort((a, b) => {\r\n      const aDir = Boolean(a.children);\r\n      const bDir = Boolean(b.children);\r\n      if (aDir !== bDir) return aDir ? -1 : 1;\r\n      return (a.name ?? a.path).localeCompare(b.name ?? b.path);\r\n    });\r\n    for (const node of nodes) if (node.children) sortLevel(node.children);\r\n    return nodes;\r\n  };\r\n\r\n  return sortLevel(roots);\r\n}\r\n\r\nfunction labelOf(node: FileNode): string {\r\n  return node.name ?? (node.path.split(\"/\").pop() ?? node.path);\r\n}\r\n\r\n/** Walks the tree into the flat, ordered list of rows the DOM actually renders. */\r\nfunction flatten(nodes: FileNode[], expanded: Set<string>, depth = 0, out: FlatRow[] = []): FlatRow[] {\r\n  for (const node of nodes) {\r\n    const isDir = Array.isArray(node.children);\r\n    const isExpanded = isDir && expanded.has(node.path);\r\n    out.push({ node, depth, isDir, expanded: isExpanded, name: labelOf(node) });\r\n    if (isExpanded && node.children) flatten(node.children, expanded, depth + 1, out);\r\n  }\r\n  return out;\r\n}\r\n\r\n/** Every directory path in the tree — the default expanded set. */\r\nfunction allDirPaths(nodes: FileNode[], out: string[] = []): string[] {\r\n  for (const node of nodes) {\r\n    if (node.children) {\r\n      out.push(node.path);\r\n      allDirPaths(node.children, out);\r\n    }\r\n  }\r\n  return out;\r\n}\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Styles\r\n * ------------------------------------------------------------------ */\r\n\r\nconst pulse = keyframes({\r\n  \"0%, 100%\": { opacity: 1 },\r\n  \"50%\": { opacity: 0.35 },\r\n});\r\n\r\nconst rootClass = css({\r\n  fontFamily: theme.fontFamily.sans,\r\n  fontSize: theme.fontSize.sm,\r\n  color: theme.color.foreground,\r\n  userSelect: \"none\",\r\n  overflowY: \"auto\",\r\n});\r\n\r\nconst rowClass = css({\r\n  display: \"flex\",\r\n  alignItems: \"center\",\r\n  gap: theme.space[1.5],\r\n  width: \"100%\",\r\n  padding: `3px ${theme.space[2]}`,\r\n  border: \"none\",\r\n  background: \"transparent\",\r\n  color: \"inherit\",\r\n  font: \"inherit\",\r\n  textAlign: \"left\",\r\n  cursor: \"pointer\",\r\n  borderRadius: theme.radius.sm,\r\n  transitionProperty: \"background-color\",\r\n  transitionDuration: theme.duration.fast,\r\n  \"&:hover\": { backgroundColor: theme.color.muted },\r\n  \"&:focus-visible\": { outline: `2px solid ${theme.color.ring}`, outlineOffset: \"-2px\" },\r\n  '&[aria-selected=\"true\"]': { backgroundColor: theme.color.accent, color: theme.color.accentForeground },\r\n});\r\n\r\nconst chevronClass = css({\r\n  flexShrink: 0,\r\n  transitionProperty: \"transform\",\r\n  transitionDuration: theme.duration.fast,\r\n  transitionTimingFunction: theme.easing.standard,\r\n  '[data-expanded=\"true\"] > &': { transform: \"rotate(90deg)\" },\r\n});\r\n\r\nconst spacerClass = css({ flexShrink: 0, width: \"14px\" });\r\n\r\nconst nameClass = css({\r\n  overflow: \"hidden\",\r\n  textOverflow: \"ellipsis\",\r\n  whiteSpace: \"nowrap\",\r\n  minWidth: 0,\r\n  flex: 1,\r\n});\r\n\r\nconst deletedNameClass = css({ textDecoration: \"line-through\", opacity: 0.6 });\r\n\r\nconst statusDotClass = css({\r\n  flexShrink: 0,\r\n  width: \"6px\",\r\n  height: \"6px\",\r\n  borderRadius: \"9999px\",\r\n});\r\n\r\nconst writingDotClass = css({\r\n  flexShrink: 0,\r\n  width: \"6px\",\r\n  height: \"6px\",\r\n  borderRadius: \"9999px\",\r\n  backgroundColor: theme.color.primary,\r\n  animation: `${pulse} 1s ${theme.easing.standard} infinite`,\r\n});\r\n\r\nconst STATUS_COLOR: Record<Exclude<FileStatus, \"unchanged\">, string> = {\r\n  new: theme.color.success,\r\n  modified: theme.color.warning,\r\n  deleted: theme.color.destructive,\r\n};\r\n\r\nconst STATUS_LABEL: Record<Exclude<FileStatus, \"unchanged\">, string> = {\r\n  new: \"new file\",\r\n  modified: \"modified\",\r\n  deleted: \"deleted\",\r\n};\r\n\r\n/* ------------------------------------------------------------------ *\r\n * Component\r\n * ------------------------------------------------------------------ */\r\n\r\nexport interface FileTreeProps {\r\n  nodes: FileNode[];\r\n  /** Path of the selected file. */\r\n  selected?: string;\r\n  onSelect?: (path: string) => void;\r\n  /**\r\n   * Path currently being written to. Pulses, and its ancestors auto-expand so\r\n   * the active file is never hidden inside a collapsed folder.\r\n   */\r\n  writingPath?: string;\r\n  /** Directory paths open on first render. Defaults to every directory. */\r\n  defaultExpanded?: string[];\r\n  className?: string;\r\n  /** Accessible name for the tree. */\r\n  label?: string;\r\n}\r\n\r\n/**\r\n * A file explorer for generated changesets: per-node status, a live pulse on\r\n * the file being written, and full keyboard navigation.\r\n *\r\n * Rows are flattened before render rather than nested, so arrow-key movement\r\n * is index arithmetic over one list instead of a tree walk — and only visible\r\n * rows cost anything, which is what keeps a wide tree cheap.\r\n */\r\nexport function FileTree({\r\n  nodes,\r\n  selected,\r\n  onSelect,\r\n  writingPath,\r\n  defaultExpanded,\r\n  className,\r\n  label = \"Files\",\r\n}: FileTreeProps) {\r\n  const [expanded, setExpanded] = useState<Set<string>>(\r\n    () => new Set(defaultExpanded ?? allDirPaths(nodes)),\r\n  );\r\n  const [focusIndex, setFocusIndex] = useState(0);\r\n  const rowRefs = useRef<Array<HTMLButtonElement | null>>([]);\r\n\r\n  // The file being written must be reachable, so its ancestors are treated as\r\n  // expanded regardless of what the user collapsed — recollapsing a folder\r\n  // out from under a live write would hide the thing the pulse points at.\r\n  const effectiveExpanded = useMemo(() => {\r\n    if (!writingPath) return expanded;\r\n    const next = new Set(expanded);\r\n    const segments = writingPath.split(\"/\").filter(Boolean);\r\n    let prefix = \"\";\r\n    for (let i = 0; i < segments.length - 1; i++) {\r\n      prefix = prefix ? `${prefix}/${segments[i]}` : (segments[i] as string);\r\n      next.add(prefix);\r\n    }\r\n    return next;\r\n  }, [expanded, writingPath]);\r\n\r\n  const rows = useMemo(() => flatten(nodes, effectiveExpanded), [nodes, effectiveExpanded]);\r\n\r\n  const toggle = useCallback((path: string) => {\r\n    setExpanded((prev) => {\r\n      const next = new Set(prev);\r\n      if (next.has(path)) next.delete(path);\r\n      else next.add(path);\r\n      return next;\r\n    });\r\n  }, []);\r\n\r\n  const focusRow = useCallback((index: number) => {\r\n    setFocusIndex(index);\r\n    rowRefs.current[index]?.focus();\r\n  }, []);\r\n\r\n  const onKeyDown = (event: KeyboardEvent<HTMLDivElement>, index: number) => {\r\n    const row = rows[index];\r\n    if (!row) return;\r\n\r\n    switch (event.key) {\r\n      case \"ArrowDown\":\r\n        event.preventDefault();\r\n        focusRow(Math.min(index + 1, rows.length - 1));\r\n        break;\r\n      case \"ArrowUp\":\r\n        event.preventDefault();\r\n        focusRow(Math.max(index - 1, 0));\r\n        break;\r\n      case \"ArrowRight\":\r\n        event.preventDefault();\r\n        // Collapsed directory opens; already-open one steps into its first child.\r\n        if (row.isDir && !row.expanded) toggle(row.node.path);\r\n        else if (row.isDir) focusRow(Math.min(index + 1, rows.length - 1));\r\n        break;\r\n      case \"ArrowLeft\": {\r\n        event.preventDefault();\r\n        if (row.isDir && row.expanded) {\r\n          toggle(row.node.path);\r\n          break;\r\n        }\r\n        // Otherwise jump to the parent row — the nearest row above at a\r\n        // shallower depth.\r\n        for (let i = index - 1; i >= 0; i--) {\r\n          const candidate = rows[i];\r\n          if (candidate && candidate.depth < row.depth) {\r\n            focusRow(i);\r\n            break;\r\n          }\r\n        }\r\n        break;\r\n      }\r\n      case \"Home\":\r\n        event.preventDefault();\r\n        focusRow(0);\r\n        break;\r\n      case \"End\":\r\n        event.preventDefault();\r\n        focusRow(rows.length - 1);\r\n        break;\r\n      default:\r\n        break;\r\n    }\r\n  };\r\n\r\n  const activate = (row: FlatRow) => {\r\n    if (row.isDir) toggle(row.node.path);\r\n    else onSelect?.(row.node.path);\r\n  };\r\n\r\n  return (\r\n    <div\r\n      role=\"tree\"\r\n      aria-label={label}\r\n      className={className ? `${rootClass} ${className}` : rootClass}\r\n    >\r\n      {rows.map((row, index) => {\r\n        const isWriting = row.node.path === writingPath;\r\n        const status = row.node.status;\r\n        const showStatus = !isWriting && status && status !== \"unchanged\";\r\n\r\n        return (\r\n          <div\r\n            key={row.node.path}\r\n            role=\"treeitem\"\r\n            aria-level={row.depth + 1}\r\n            aria-selected={row.node.path === selected}\r\n            aria-expanded={row.isDir ? row.expanded : undefined}\r\n            onKeyDown={(event) => onKeyDown(event, index)}\r\n          >\r\n            <button\r\n              type=\"button\"\r\n              ref={(el) => {\r\n                rowRefs.current[index] = el;\r\n              }}\r\n              // Roving tabindex: the tree is a single tab stop.\r\n              tabIndex={index === focusIndex ? 0 : -1}\r\n              data-expanded={row.isDir ? row.expanded : undefined}\r\n              aria-selected={row.node.path === selected}\r\n              className={rowClass}\r\n              style={{ paddingLeft: `calc(${theme.space[2]} + ${row.depth} * 0.875rem)` }}\r\n              onClick={() => {\r\n                setFocusIndex(index);\r\n                activate(row);\r\n              }}\r\n              onFocus={() => setFocusIndex(index)}\r\n            >\r\n              {row.isDir ? (\r\n                <svg className={chevronClass} width=\"14\" height=\"14\" viewBox=\"0 0 14 14\" fill=\"none\" aria-hidden=\"true\">\r\n                  <path d=\"M5.5 3.5L9 7l-3.5 3.5\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\r\n                </svg>\r\n              ) : (\r\n                <span className={spacerClass} />\r\n              )}\r\n\r\n              <FileIcon\r\n                filename={row.node.path}\r\n                variant={row.isDir ? (row.expanded ? \"folder-open\" : \"folder\") : \"file\"}\r\n                size={15}\r\n              />\r\n\r\n              <span\r\n                className={\r\n                  status === \"deleted\" ? `${nameClass} ${deletedNameClass}` : nameClass\r\n                }\r\n              >\r\n                {row.name}\r\n              </span>\r\n\r\n              {isWriting ? (\r\n                <>\r\n                  <span className={writingDotClass} aria-hidden=\"true\" />\r\n                  <span style={{ position: \"absolute\", width: 1, height: 1, overflow: \"hidden\", clip: \"rect(0 0 0 0)\" }}>\r\n                    writing\r\n                  </span>\r\n                </>\r\n              ) : null}\r\n\r\n              {showStatus ? (\r\n                <>\r\n                  <span\r\n                    className={statusDotClass}\r\n                    style={{ backgroundColor: STATUS_COLOR[status] }}\r\n                    aria-hidden=\"true\"\r\n                  />\r\n                  <span style={{ position: \"absolute\", width: 1, height: 1, overflow: \"hidden\", clip: \"rect(0 0 0 0)\" }}>\r\n                    {STATUS_LABEL[status]}\r\n                  </span>\r\n                </>\r\n              ) : null}\r\n            </button>\r\n          </div>\r\n        );\r\n      })}\r\n    </div>\r\n  );\r\n}\r\n",
      "type": "registry:component"
    }
  ]
}