{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "file-changes",
  "type": "registry:ui",
  "title": "File Changes",
  "description": "The files an agent touched: a compact folder tree or flat list with A/M/D/R status, +/− counts, live “editing” markers and selection.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "https://kuro.abstergo.fyi/r/kura-theme.json",
    "https://kuro.abstergo.fyi/r/icon-button.json",
    "https://kuro.abstergo.fyi/r/shimmer-text.json",
    "https://kuro.abstergo.fyi/r/use-controllable-state.json",
    "utils"
  ],
  "files": [
    {
      "path": "registry/kura/file-changes/file-changes.tsx",
      "type": "registry:ui",
      "target": "components/kura/file-changes/file-changes.tsx",
      "content": "\"use client\";\n\nimport { ChevronRight, FileDiff, List, ListTree } from \"lucide-react\";\nimport * as React from \"react\";\nimport { useControllableState } from \"@/hooks/kura/use-controllable-state\";\nimport { cn } from \"@/lib/utils\";\nimport { IconButton } from \"@/components/kura/icon-button\";\nimport { ShimmerText } from \"@/components/kura/shimmer-text\";\n\nexport type FileChangeStatus = \"A\" | \"M\" | \"D\" | \"R\";\n\nexport interface FileChange {\n  /** Path relative to the repo root, e.g. \"src/auth/client.ts\". */\n  path: string;\n  /** A = added, M = modified, D = deleted, R = renamed. */\n  status: FileChangeStatus;\n  /** Previous path for renames. */\n  oldPath?: string;\n  additions?: number;\n  deletions?: number;\n  /** The agent is touching this file right now. */\n  state?: \"reading\" | \"editing\";\n}\n\nexport interface FileChangesProps extends Omit<React.ComponentProps<\"div\">, \"title\" | \"onSelect\"> {\n  /** The files the agent touched. */\n  files: readonly FileChange[];\n  /** Header title. Default \"Changes\". */\n  title?: React.ReactNode;\n  /** \"tree\" groups by folder (single-child folders compacted, like VS Code); \"list\" is flat. Controlled. */\n  view?: \"tree\" | \"list\";\n  /** Initial view when uncontrolled. Default \"tree\". */\n  defaultView?: \"tree\" | \"list\";\n  onViewChange?: (view: \"tree\" | \"list\") => void;\n  /** Show the tree/list toggle in the header. Default true. */\n  viewToggle?: boolean;\n  /** The selected file (controlled). */\n  selectedPath?: string | null;\n  /** Initially selected file when uncontrolled. */\n  defaultSelectedPath?: string | null;\n  /** Called when a file is selected (click, Enter or Space). */\n  onSelect?: (path: string) => void;\n  /** Folder paths collapsed initially, e.g. [\"src/auth/__tests__\"]. */\n  defaultCollapsed?: readonly string[];\n  /** Scroll the rows inside this height (px). */\n  maxHeight?: number;\n  /** Extra header controls. */\n  actions?: React.ReactNode;\n  /** Rendered when `files` is empty. Default \"No changes yet\". */\n  empty?: React.ReactNode;\n}\n\nconst STATUS_CLASS: Record<FileChangeStatus, string> = {\n  A: \"text-kura-success-text\",\n  M: \"text-kura-warning-text\",\n  D: \"text-kura-danger-text\",\n  R: \"text-kura-fg-muted\",\n};\nconst STATUS_WORD: Record<FileChangeStatus, string> = {\n  A: \"added\",\n  M: \"modified\",\n  D: \"deleted\",\n  R: \"renamed\",\n};\n\ninterface FolderNode {\n  kind: \"folder\";\n  /** Full path, e.g. \"src/auth\". */\n  path: string;\n  /** Display name; compacted chains read \"src/auth\". */\n  name: string;\n  children: TreeNode[];\n}\ninterface FileNode {\n  kind: \"file\";\n  path: string;\n  name: string;\n  file: FileChange;\n}\ntype TreeNode = FolderNode | FileNode;\n\nfunction buildTree(files: readonly FileChange[]): TreeNode[] {\n  const root: FolderNode = { kind: \"folder\", path: \"\", name: \"\", children: [] };\n  for (const file of files) {\n    const parts = file.path.split(\"/\").filter(Boolean);\n    let node = root;\n    parts.slice(0, -1).forEach((part) => {\n      const path = node.path ? `${node.path}/${part}` : part;\n      let next = node.children.find((c): c is FolderNode => c.kind === \"folder\" && c.path === path);\n      if (!next) {\n        next = { kind: \"folder\", path, name: part, children: [] };\n        node.children.push(next);\n      }\n      node = next;\n    });\n    node.children.push({\n      kind: \"file\",\n      path: file.path,\n      name: parts[parts.length - 1] ?? file.path,\n      file,\n    });\n  }\n  const compact = (n: TreeNode): TreeNode => {\n    if (n.kind === \"file\") return n;\n    let folder = n;\n    while (folder.children.length === 1 && folder.children[0]!.kind === \"folder\") {\n      const only = folder.children[0] as FolderNode;\n      folder = { ...only, name: `${folder.name}/${only.name}` };\n    }\n    const children = folder.children\n      .map(compact)\n      .sort((a, b) =>\n        a.kind !== b.kind ? (a.kind === \"folder\" ? -1 : 1) : a.name.localeCompare(b.name),\n      );\n    return { ...folder, children };\n  };\n  return root.children\n    .map(compact)\n    .sort((a, b) =>\n      a.kind !== b.kind ? (a.kind === \"folder\" ? -1 : 1) : a.name.localeCompare(b.name),\n    );\n}\n\ninterface Row {\n  node: TreeNode;\n  depth: number;\n  posinset: number;\n  setsize: number;\n}\n\nfunction flatten(nodes: TreeNode[], collapsed: Set<string>, depth = 0, out: Row[] = []): Row[] {\n  nodes.forEach((node, i) => {\n    out.push({ node, depth, posinset: i + 1, setsize: nodes.length });\n    if (node.kind === \"folder\" && !collapsed.has(node.path))\n      flatten(node.children, collapsed, depth + 1, out);\n  });\n  return out;\n}\n\nfunction folderStats(node: FolderNode): { files: number; live: boolean } {\n  let files = 0;\n  let live = false;\n  const walk = (n: TreeNode) => {\n    if (n.kind === \"file\") {\n      files++;\n      if (n.file.state === \"editing\") live = true;\n    } else n.children.forEach(walk);\n  };\n  walk(node);\n  return { files, live };\n}\n\nfunction Counts({ file }: { file: FileChange }) {\n  if (file.state === \"editing\")\n    return (\n      <span className=\"inline-flex items-center gap-1.5 text-kura-accent-text\">\n        <span\n          aria-hidden\n          className=\"size-1.5 rounded-full bg-kura-accent animate-kura-pulse motion-reduce:animate-none\"\n        />\n        editing\n      </span>\n    );\n  if (file.state === \"reading\") return <span className=\"text-kura-fg-subtle\">reading</span>;\n  if (file.additions == null && file.deletions == null) return null;\n  return (\n    <>\n      {file.additions != null && file.additions > 0 && (\n        <span className=\"text-kura-success-text\">+{file.additions}</span>\n      )}\n      {file.deletions != null && file.deletions > 0 && (\n        <span className=\"text-kura-danger-text\">−{file.deletions}</span>\n      )}\n    </>\n  );\n}\n\n/**\n * What the agent touched: a compact tree (or flat list) of changed files with status letters\n * (A/M/D/R in semantic colour), +/− counts, a live \"editing\" marker on files being written, a\n * summary header, single selection and full tree keyboard navigation.\n */\nexport function FileChanges({\n  files,\n  title = \"Changes\",\n  view: viewProp,\n  defaultView = \"tree\",\n  onViewChange,\n  viewToggle = true,\n  selectedPath: selectedProp,\n  defaultSelectedPath = null,\n  onSelect,\n  defaultCollapsed,\n  maxHeight,\n  actions,\n  empty,\n  className,\n  ...props\n}: FileChangesProps) {\n  const [view, setView] = useControllableState({\n    value: viewProp,\n    defaultValue: defaultView,\n    onChange: onViewChange,\n  });\n  const [selected, setSelected] = useControllableState<string | null>({\n    value: selectedProp,\n    defaultValue: defaultSelectedPath,\n  });\n  const [collapsed, setCollapsed] = React.useState(() => new Set(defaultCollapsed ?? []));\n\n  const tree = React.useMemo(() => buildTree(files), [files]);\n  const rows: Row[] = React.useMemo(\n    () =>\n      view === \"tree\"\n        ? flatten(tree, collapsed)\n        : [...files]\n            .sort((a, b) => a.path.localeCompare(b.path))\n            .map((file, i, all) => ({\n              node: {\n                kind: \"file\" as const,\n                path: file.path,\n                name: file.path.slice(file.path.lastIndexOf(\"/\") + 1),\n                file,\n              },\n              depth: 0,\n              posinset: i + 1,\n              setsize: all.length,\n            })),\n    [view, tree, collapsed, files],\n  );\n\n  const totals = React.useMemo(\n    () =>\n      files.reduce(\n        (acc, f) => ({\n          additions: acc.additions + (f.additions ?? 0),\n          deletions: acc.deletions + (f.deletions ?? 0),\n        }),\n        { additions: 0, deletions: 0 },\n      ),\n    [files],\n  );\n\n  // Roving focus.\n  const [focusKey, setFocusKey] = React.useState<string | null>(null);\n  const rowKey = (r: Row) => `${r.node.kind}:${r.node.path}`;\n  const tabStop =\n    rows.find((r) => rowKey(r) === focusKey) ??\n    rows.find((r) => r.node.path === selected && r.node.kind === \"file\") ??\n    rows[0];\n  const rowEls = React.useRef(new Map<string, HTMLElement>());\n  const focusRow = (r: Row | undefined) => {\n    if (!r) return;\n    const key = rowKey(r);\n    setFocusKey(key);\n    rowEls.current.get(key)?.focus();\n  };\n\n  const toggleFolder = (path: string, open?: boolean) =>\n    setCollapsed((prev) => {\n      const next = new Set(prev);\n      const isOpen = !next.has(path);\n      if ((open ?? !isOpen) === isOpen) return prev;\n      if (isOpen) next.add(path);\n      else next.delete(path);\n      return next;\n    });\n\n  const select = (path: string) => {\n    setSelected(path);\n    onSelect?.(path);\n  };\n\n  const onKeyDown = (event: React.KeyboardEvent, row: Row, index: number) => {\n    const { node } = row;\n    switch (event.key) {\n      case \"ArrowDown\":\n        event.preventDefault();\n        focusRow(rows[index + 1]);\n        break;\n      case \"ArrowUp\":\n        event.preventDefault();\n        focusRow(rows[index - 1]);\n        break;\n      case \"Home\":\n        event.preventDefault();\n        focusRow(rows[0]);\n        break;\n      case \"End\":\n        event.preventDefault();\n        focusRow(rows[rows.length - 1]);\n        break;\n      case \"ArrowRight\":\n        if (node.kind !== \"folder\") return;\n        event.preventDefault();\n        if (collapsed.has(node.path)) toggleFolder(node.path, true);\n        else focusRow(rows[index + 1]);\n        break;\n      case \"ArrowLeft\": {\n        event.preventDefault();\n        if (node.kind === \"folder\" && !collapsed.has(node.path)) {\n          toggleFolder(node.path, false);\n          break;\n        }\n        for (let k = index - 1; k >= 0; k--)\n          if (rows[k]!.depth < row.depth) {\n            focusRow(rows[k]);\n            break;\n          }\n        break;\n      }\n      case \"Enter\":\n      case \" \":\n        event.preventDefault();\n        if (node.kind === \"folder\") toggleFolder(node.path);\n        else select(node.path);\n        break;\n    }\n  };\n\n  const isTree = view === \"tree\";\n\n  return (\n    <div\n      data-slot=\"file-changes\"\n      data-view={view}\n      className={cn(\n        \"@container/files flex min-w-0 flex-col overflow-hidden rounded-kura-xl bg-kura-surface font-kura-sans text-[13px] text-kura-fg shadow-kura-sm\",\n        className,\n      )}\n      {...props}\n    >\n      <div\n        data-slot=\"file-changes-header\"\n        className=\"flex h-10 shrink-0 items-center gap-2.5 border-kura-border-subtle border-b pr-1.5 pl-3.5\"\n      >\n        <span className=\"min-w-[3rem] truncate font-medium tracking-[-0.006em]\">{title}</span>\n        <span\n          data-slot=\"file-changes-summary\"\n          className=\"flex shrink-0 items-baseline gap-2 font-kura-mono text-[12px] text-kura-fg-subtle tabular-nums\"\n        >\n          <span className=\"hidden truncate @[20rem]/files:inline\">\n            {files.length} {files.length === 1 ? \"file\" : \"files\"}\n          </span>\n          {(totals.additions > 0 || totals.deletions > 0) && (\n            <span className=\"flex shrink-0 gap-1.5\">\n              <span className=\"text-kura-success-text\">+{totals.additions}</span>\n              <span className=\"text-kura-danger-text\">−{totals.deletions}</span>\n              <span className=\"sr-only\">\n                {totals.additions} additions, {totals.deletions} deletions\n              </span>\n            </span>\n          )}\n        </span>\n        <span className=\"flex-1\" />\n        {actions}\n        {viewToggle && files.length > 0 && (\n          <span className=\"flex shrink-0 items-center gap-0.5\">\n            <IconButton\n              size=\"xs\"\n              label=\"Tree view\"\n              aria-pressed={isTree}\n              icon={<ListTree strokeWidth={1.75} />}\n              className=\"aria-pressed:bg-kura-active aria-pressed:text-kura-fg\"\n              onClick={() => setView(\"tree\")}\n            />\n            <IconButton\n              size=\"xs\"\n              label=\"List view\"\n              aria-pressed={!isTree}\n              icon={<List strokeWidth={1.75} />}\n              className=\"aria-pressed:bg-kura-active aria-pressed:text-kura-fg\"\n              onClick={() => setView(\"list\")}\n            />\n          </span>\n        )}\n      </div>\n\n      {files.length === 0 ? (\n        <div\n          data-slot=\"file-changes-empty\"\n          className=\"flex h-24 flex-col items-center justify-center gap-1.5 text-kura-fg-subtle\"\n        >\n          {empty ?? (\n            <>\n              <FileDiff aria-hidden className=\"size-4\" strokeWidth={1.75} />\n              <span>No changes yet</span>\n            </>\n          )}\n        </div>\n      ) : (\n        // biome-ignore lint/a11y/useAriaPropsSupportedByRole: role is tree or listbox, both take a label\n        <div\n          data-slot=\"file-changes-rows\"\n          role={isTree ? \"tree\" : \"listbox\"}\n          aria-label={typeof title === \"string\" ? title : \"Changed files\"}\n          className={cn(\n            \"py-1\",\n            maxHeight != null && \"overflow-y-auto overscroll-contain [scrollbar-width:thin]\",\n          )}\n          style={maxHeight != null ? { maxHeight } : undefined}\n        >\n          {rows.map((row, index) => {\n            const { node } = row;\n            const key = rowKey(row);\n            const focusable = tabStop && rowKey(tabStop) === key;\n            const indent = 10 + row.depth * 14;\n            const guides = isTree\n              ? Array.from({ length: row.depth }, (_, k) => (\n                  <span\n                    key={k}\n                    aria-hidden\n                    data-slot=\"file-changes-guide\"\n                    className=\"pointer-events-none absolute inset-y-0 w-px bg-kura-border-subtle\"\n                    style={{ left: 10 + k * 14 + 6.5 }}\n                  />\n                ))\n              : null;\n            const ref = (el: HTMLDivElement | null) => {\n              if (el) rowEls.current.set(key, el);\n              else rowEls.current.delete(key);\n            };\n            const rowClass =\n              \"relative flex h-8 cursor-pointer select-none items-center gap-1.5 pr-3 outline-none transition-colors duration-(--kura-duration-fast) ease-kura-out hover:bg-kura-hover focus-visible:shadow-[inset_0_0_0_2px_var(--kura-ring)] motion-reduce:transition-none\";\n\n            if (node.kind === \"folder\") {\n              const open = !collapsed.has(node.path);\n              const stats = folderStats(node);\n              return (\n                <div\n                  key={key}\n                  ref={ref}\n                  tabIndex={focusable ? 0 : -1}\n                  onFocus={() => setFocusKey(key)}\n                  onKeyDown={(e) => onKeyDown(e, row, index)}\n                  style={{ paddingLeft: indent }}\n                  role=\"treeitem\"\n                  aria-level={row.depth + 1}\n                  aria-posinset={row.posinset}\n                  aria-setsize={row.setsize}\n                  aria-expanded={open}\n                  data-slot=\"file-changes-folder\"\n                  data-state={open ? \"open\" : \"closed\"}\n                  onClick={() => {\n                    setFocusKey(key);\n                    toggleFolder(node.path);\n                  }}\n                  className={cn(rowClass, \"text-kura-fg-muted\")}\n                >\n                  {guides}\n                  <ChevronRight\n                    aria-hidden\n                    strokeWidth={1.75}\n                    className={cn(\n                      \"size-3.5 shrink-0 text-kura-fg-subtle transition-transform duration-(--kura-duration-base) ease-kura-out motion-reduce:transition-none\",\n                      open && \"rotate-90\",\n                    )}\n                  />\n                  <span className=\"min-w-0 truncate font-kura-mono text-[12.5px]\">{node.name}</span>\n                  {!open && (\n                    <span className=\"ml-auto flex shrink-0 items-center gap-2 font-kura-mono text-[12px] text-kura-fg-subtle tabular-nums\">\n                      {stats.live && (\n                        <span\n                          aria-label=\"contains a file being edited\"\n                          role=\"img\"\n                          className=\"size-1.5 rounded-full bg-kura-accent animate-kura-pulse motion-reduce:animate-none\"\n                        />\n                      )}\n                      {stats.files}\n                    </span>\n                  )}\n                </div>\n              );\n            }\n\n            const { file } = node;\n            const isSelected = selected === file.path;\n            const dir = file.path.slice(0, file.path.length - node.name.length);\n            const live = file.state === \"editing\";\n            return (\n              // biome-ignore lint/a11y/useAriaPropsSupportedByRole: role is treeitem (tree view) or option (list view); aria-level is only set for treeitem\n              // biome-ignore lint/a11y/noStaticElementInteractions: role is treeitem or option (an expression biome cannot resolve)\n              <div\n                key={key}\n                ref={ref}\n                tabIndex={focusable ? 0 : -1}\n                onFocus={() => setFocusKey(key)}\n                onKeyDown={(e) => onKeyDown(e, row, index)}\n                style={{ paddingLeft: indent }}\n                role={isTree ? \"treeitem\" : \"option\"}\n                aria-level={isTree ? row.depth + 1 : undefined}\n                aria-posinset={row.posinset}\n                aria-setsize={row.setsize}\n                aria-selected={isSelected}\n                data-slot=\"file-changes-file\"\n                data-status={file.status}\n                data-state={file.state}\n                data-selected={isSelected || undefined}\n                onClick={() => {\n                  setFocusKey(key);\n                  select(file.path);\n                }}\n                className={cn(\n                  rowClass,\n                  \"animate-kura-fade-in\",\n                  isSelected && \"bg-kura-active hover:bg-kura-active\",\n                )}\n                title={file.oldPath ? `${file.oldPath} → ${file.path}` : file.path}\n              >\n                {guides}\n                {isSelected && (\n                  <span\n                    aria-hidden\n                    className=\"absolute inset-y-1 left-0 w-0.5 rounded-r-full bg-kura-accent\"\n                  />\n                )}\n                {isTree && <span aria-hidden className=\"w-3.5 shrink-0\" />}\n                <span className=\"flex min-w-0 flex-1 items-baseline gap-2\">\n                  <span\n                    data-slot=\"file-changes-name\"\n                    className={cn(\n                      \"min-w-0 shrink truncate font-kura-mono text-[12.5px]\",\n                      file.status === \"D\"\n                        ? \"text-kura-fg-subtle line-through decoration-kura-fg-subtle/60\"\n                        : \"text-kura-fg\",\n                    )}\n                  >\n                    {live ? <ShimmerText>{node.name}</ShimmerText> : node.name}\n                  </span>\n                  {!isTree && dir && (\n                    <span className=\"min-w-0 flex-1 truncate text-left font-kura-mono text-[12px] text-kura-fg-subtle [direction:rtl]\">\n                      <span className=\"[direction:ltr] [unicode-bidi:isolate]\">\n                        {dir.slice(0, -1)}\n                      </span>\n                    </span>\n                  )}\n                </span>\n                <span\n                  data-slot=\"file-changes-counts\"\n                  className=\"flex shrink-0 items-center gap-1.5 pl-2 font-kura-mono text-[12px] tabular-nums\"\n                >\n                  <Counts file={file} />\n                </span>\n                <span\n                  data-slot=\"file-changes-status\"\n                  className={cn(\n                    \"w-3 shrink-0 text-right font-kura-mono font-medium text-[12px]\",\n                    STATUS_CLASS[file.status],\n                  )}\n                >\n                  <span aria-hidden>{file.status}</span>\n                  <span className=\"sr-only\">\n                    , {STATUS_WORD[file.status]}\n                    {live ? \", being edited\" : file.state === \"reading\" ? \", being read\" : \"\"}\n                  </span>\n                </span>\n              </div>\n            );\n          })}\n        </div>\n      )}\n    </div>\n  );\n}\n"
    },
    {
      "path": "registry/kura/file-changes/index.ts",
      "type": "registry:ui",
      "target": "components/kura/file-changes/index.ts",
      "content": "export {\n  type FileChange,\n  type FileChangeStatus,\n  FileChanges,\n  type FileChangesProps,\n} from \"./file-changes\";\n"
    }
  ],
  "categories": [
    "workspace",
    "files",
    "changes",
    "git",
    "tree"
  ],
  "docs": "Docs: https://kuro.abstergo.fyi/components/file-changes · Markdown for agents: https://kuro.abstergo.fyi/components/file-changes.md",
  "meta": {
    "kura": {
      "status": "experimental",
      "whenToUse": [
        "Show which files a coding agent has changed during or after a run (a sidebar next to the conversation or a diff).",
        "Indicate live activity: which file the agent is reading or editing right now.",
        "A file picker for a review screen: select a file to show its diff in diff-view."
      ],
      "whenNotToUse": [
        "Showing the actual line changes — use diff-view (pair them: FileChanges as navigation, DiffView as content).",
        "A step-by-step log of the agent's actions — use agent-timeline or task-list.",
        "A general file browser or editor tree with create/rename/drag — this is a read-only change list."
      ]
    }
  }
}
