{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "ink/file-change",
  "title": "File Change",
  "description": "Interactive file-change review list with diff expansion, per-file accept/reject actions, and accept-all shortcut",
  "dependencies": [
    "ink"
  ],
  "registryDependencies": [
    "https://termcn.dev/r/ink/diff-view.json",
    "https://termcn.dev/r/ink/interaction.json",
    "https://termcn.dev/r/ink/terminal-symbols.json",
    "https://termcn.dev/r/ink/use-theme.json",
    "https://termcn.dev/r/ink/use-unicode.json"
  ],
  "files": [
    {
      "path": "registry/ui/file-change.tsx",
      "content": "import { Box, Text } from \"ink\";\nimport React, { useEffect, useState } from \"react\";\n\nimport { useInteraction } from \"@/hooks/use-interaction\";\nimport type { InteractionProps } from \"@/hooks/use-interaction\";\nimport { useTheme } from \"@/hooks/use-theme\";\nimport { useUnicode } from \"@/hooks/use-unicode\";\nimport {\n  resolveStatusSymbol,\n  resolveTerminalSymbol,\n} from \"@/lib/terminal-symbols\";\n\nimport { DiffView } from \"./diff-view\";\n\nexport type FileChangeType = \"modify\" | \"create\" | \"delete\";\n\nexport interface FileChangeItem {\n  path: string;\n  type: FileChangeType;\n  diff?: string;\n  content?: string;\n}\n\nexport interface FileChangeProps extends InteractionProps {\n  changes: FileChangeItem[];\n  onAccept?: (path: string) => void;\n  onReject?: (path: string) => void;\n  onAcceptAll?: () => void;\n  \"aria-label\"?: string;\n}\n\nconst TYPE_ICON: Record<FileChangeType, string> = {\n  create: \"A\",\n  delete: \"D\",\n  modify: \"M\",\n};\n\n/**\n * Parse a unified diff string into old and new text arrays for DiffView.\n * Handles simple unified diff format.\n */\nconst parseDiff = (\n  diff: string\n): {\n  oldText: string;\n  newText: string;\n} => {\n  const lines = diff.split(\"\\n\");\n  const oldLines: string[] = [];\n  const newLines: string[] = [];\n\n  for (const line of lines) {\n    if (\n      line.startsWith(\"---\") ||\n      line.startsWith(\"+++\") ||\n      line.startsWith(\"@@\")\n    ) {\n      continue;\n    }\n    if (line.startsWith(\"-\")) {\n      oldLines.push(line.slice(1));\n    } else if (line.startsWith(\"+\")) {\n      newLines.push(line.slice(1));\n    } else if (line.startsWith(\" \")) {\n      const content = line.slice(1);\n      oldLines.push(content);\n      newLines.push(content);\n    } else {\n      oldLines.push(line);\n      newLines.push(line);\n    }\n  }\n\n  return { newText: newLines.join(\"\\n\"), oldText: oldLines.join(\"\\n\") };\n};\n\nexport const FileChange = ({\n  changes,\n  onAccept,\n  onReject,\n  onAcceptAll,\n  id,\n  autoFocus,\n  isActive,\n  disabled,\n  \"aria-label\": ariaLabel = \"File changes\",\n}: FileChangeProps) => {\n  const theme = useTheme();\n  const unicode = useUnicode();\n  const cursor = resolveTerminalSymbol(unicode, \"›\", \">\");\n  const [activeIndex, setActiveIndex] = useState(0);\n  const [expandedPaths, setExpandedPaths] = useState<Set<string>>(new Set());\n  const [acceptedPaths, setAcceptedPaths] = useState<Set<string>>(new Set());\n  const [rejectedPaths, setRejectedPaths] = useState<Set<string>>(new Set());\n\n  const { isFocused } = useInteraction(\n    (input, key) => {\n      if (key.upArrow) {\n        setActiveIndex((i) => Math.max(0, i - 1));\n      } else if (key.downArrow) {\n        setActiveIndex((i) => Math.min(changes.length - 1, i + 1));\n      } else if (key.home) {\n        setActiveIndex(0);\n      } else if (key.end) {\n        setActiveIndex(Math.max(0, changes.length - 1));\n      } else if (key.return || input === \" \") {\n        const item = changes[activeIndex];\n        if (!item) {\n          return;\n        }\n        setExpandedPaths((prev) => {\n          const next = new Set(prev);\n          if (next.has(item.path)) {\n            next.delete(item.path);\n          } else {\n            next.add(item.path);\n          }\n          return next;\n        });\n      } else if (input === \"y\" || input === \"Y\") {\n        const item = changes[activeIndex];\n        if (!item) {\n          return;\n        }\n        setAcceptedPaths((prev) => new Set([...prev, item.path]));\n        setRejectedPaths((prev) => {\n          const next = new Set(prev);\n          next.delete(item.path);\n          return next;\n        });\n        onAccept?.(item.path);\n      } else if (input === \"n\" || input === \"N\") {\n        const item = changes[activeIndex];\n        if (!item) {\n          return;\n        }\n        setRejectedPaths((prev) => new Set([...prev, item.path]));\n        setAcceptedPaths((prev) => {\n          const next = new Set(prev);\n          next.delete(item.path);\n          return next;\n        });\n        onReject?.(item.path);\n      } else if (input === \"a\" || input === \"A\") {\n        onAcceptAll?.();\n      }\n    },\n    { autoFocus, disabled, id, isActive }\n  );\n\n  useEffect(() => {\n    setActiveIndex((current) =>\n      Math.min(current, Math.max(0, changes.length - 1))\n    );\n  }, [changes.length]);\n\n  const typeColor = (type: FileChangeType): string => {\n    switch (type) {\n      case \"create\": {\n        return theme.colors.success ?? \"green\";\n      }\n      case \"delete\": {\n        return theme.colors.error ?? \"red\";\n      }\n      case \"modify\": {\n        return theme.colors.warning ?? \"yellow\";\n      }\n      default: {\n        return theme.colors.mutedForeground;\n      }\n    }\n  };\n\n  return (\n    <Box\n      flexDirection=\"column\"\n      aria-role=\"list\"\n      aria-state={{ disabled: disabled || undefined }}\n    >\n      <Text aria-label={`${ariaLabel}. ${changes.length} files.`}>{\"\"}</Text>\n      <Box gap={2} marginBottom={1}>\n        <Text bold color={theme.colors.foreground}>\n          File Changes ({changes.length})\n        </Text>\n        <Text aria-hidden dimColor color={theme.colors.mutedForeground}>\n          {unicode\n            ? \"[↑↓] navigate [Enter/Space] expand [y] accept [n] reject [a] accept all\"\n            : \"[up/down] navigate [Enter/Space] expand [y] accept [n] reject [a] accept all\"}\n        </Text>\n      </Box>\n\n      {changes.map((item, idx) => {\n        const isActive = idx === activeIndex;\n        const isExpanded = expandedPaths.has(item.path);\n        const isAccepted = acceptedPaths.has(item.path);\n        const isRejected = rejectedPaths.has(item.path);\n\n        const diffParts = item.diff ? parseDiff(item.diff) : null;\n\n        return (\n          <Box\n            key={item.path}\n            flexDirection=\"column\"\n            aria-role=\"listitem\"\n            aria-state={{\n              expanded: isExpanded,\n              selected: isActive && isFocused,\n            }}\n          >\n            <Text\n              aria-label={`${item.type} file ${item.path}${isAccepted ? \", accepted\" : isRejected ? \", rejected\" : \", pending\"}${isExpanded ? \", expanded\" : \", collapsed\"}${isActive && isFocused ? \", current\" : \"\"}`}\n            >\n              {\"\"}\n            </Text>\n            <Box gap={1}>\n              <Text color={isActive ? theme.colors.primary : undefined}>\n                {isActive ? `[${cursor}]` : \" \"}\n              </Text>\n              <Text bold color={typeColor(item.type)}>\n                {TYPE_ICON[item.type]}\n              </Text>\n              <Text\n                color={\n                  isActive ? theme.colors.primary : theme.colors.foreground\n                }\n                bold={isActive}\n              >\n                {item.path}\n              </Text>\n              {isAccepted && (\n                <Text color={theme.colors.success ?? \"green\"}>\n                  {resolveStatusSymbol(unicode, \"success\")} accepted\n                </Text>\n              )}\n              {isRejected && (\n                <Text color={theme.colors.error ?? \"red\"}>\n                  {resolveStatusSymbol(unicode, \"error\")} rejected\n                </Text>\n              )}\n              {(item.diff || item.content) && (\n                <Text dimColor color={theme.colors.mutedForeground}>\n                  {isExpanded\n                    ? resolveTerminalSymbol(unicode, \"▼\", \"v\")\n                    : resolveTerminalSymbol(unicode, \"▶\", \">\")}\n                </Text>\n              )}\n            </Box>\n\n            {isExpanded && (\n              <Box paddingLeft={2} marginTop={1}>\n                {item.diff && diffParts && (\n                  <DiffView\n                    oldText={diffParts.oldText}\n                    newText={diffParts.newText}\n                    filename={item.path}\n                    showLineNumbers\n                  />\n                )}\n                {!(item.diff && diffParts) && item.content && (\n                  <Box flexDirection=\"column\">\n                    {item.content.split(\"\\n\").map((line, li) => (\n                      <Text key={li} color={theme.colors.success ?? \"green\"}>\n                        +{line}\n                      </Text>\n                    ))}\n                  </Box>\n                )}\n              </Box>\n            )}\n          </Box>\n        );\n      })}\n    </Box>\n  );\n};\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "ai"
  ],
  "type": "registry:ui"
}