{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "branch-diff",
  "title": "BranchDiff",
  "description": "Schema and row-level comparison between two branches with change counts, search, kind filters, and expandable DDL and word-level value diffs.",
  "dependencies": [
    "@hugeicons/react",
    "@hugeicons/core-free-icons",
    "diff"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.neon.com/r/tabs.json",
    "https://ui.neon.com/r/neon-tokens.json"
  ],
  "files": [
    {
      "path": "src/components/branch-diff/branch-diff.tsx",
      "content": "\"use client\";\n\nimport {\n  Alert02Icon,\n  ArrowDown01Icon,\n  ArrowRight01Icon,\n  GitBranchIcon,\n  Search01Icon,\n  Table01Icon,\n} from \"@hugeicons/core-free-icons\";\nimport { HugeiconsIcon } from \"@hugeicons/react\";\nimport { diffWordsWithSpace } from \"diff\";\nimport { useMemo, useState } from \"react\";\n\nimport {\n  Tabs,\n  TabsContent,\n  TabsIndicator,\n  TabsList,\n  TabsTrigger,\n} from \"@/components/ui/tabs\";\nimport { cn } from \"@/lib/utils\";\n\ntype ChangeKind = \"added\" | \"removed\" | \"modified\";\ntype DiffTab = \"schema\" | \"data\";\ntype DiffValue = boolean | number | string | null;\n\ninterface BranchDiffBranch {\n  id: string;\n  name: string;\n}\n\ninterface SchemaChange {\n  id: string;\n  kind: ChangeKind;\n  objectType: \"table\" | \"column\" | \"index\" | \"constraint\";\n  path: string[];\n  before?: string;\n  after?: string;\n  ddl?: string;\n  breaking?: boolean;\n}\n\ninterface RowChange {\n  id: string;\n  kind: ChangeKind;\n  primaryKey: Record<string, DiffValue>;\n  before?: Record<string, DiffValue>;\n  after?: Record<string, DiffValue>;\n}\n\ninterface TableDataDiff {\n  id: string;\n  schema: string;\n  table: string;\n  primaryKey: string[];\n  rows: RowChange[];\n  addedCount: number;\n  removedCount: number;\n  modifiedCount: number;\n  unchangedCount?: number;\n  hasMore?: boolean;\n}\n\ninterface BranchDiffProps {\n  from: BranchDiffBranch;\n  to: BranchDiffBranch;\n  schemaChanges: SchemaChange[];\n  dataDiffs: TableDataDiff[];\n  tab?: DiffTab;\n  defaultTab?: DiffTab;\n  onTabChange?: (tab: DiffTab) => void;\n  onLoadMore?: (table: TableDataDiff) => void | Promise<void>;\n  loadingTableId?: string | null;\n  isLoading?: boolean;\n  error?: Error | string | null;\n  className?: string;\n}\n\nconst KIND = {\n  added: {\n    className: \"text-[var(--status-active)]\",\n    label: \"Added\",\n    surface: \"bg-[var(--status-active)]/8\",\n    symbol: \"+\",\n  },\n  modified: {\n    className: \"text-[var(--status-scaling)]\",\n    label: \"Modified\",\n    surface: \"bg-[var(--status-scaling)]/8\",\n    symbol: \"~\",\n  },\n  removed: {\n    className: \"text-destructive\",\n    label: \"Removed\",\n    surface: \"bg-destructive/7\",\n    symbol: \"−\",\n  },\n} satisfies Record<ChangeKind, Record<string, string>>;\n\nconst countSchemaKinds = (changes: SchemaChange[]) => ({\n  added: changes.filter((change) => change.kind === \"added\").length,\n  modified: changes.filter((change) => change.kind === \"modified\").length,\n  removed: changes.filter((change) => change.kind === \"removed\").length,\n});\n\nconst countDataKinds = (tables: TableDataDiff[]) => {\n  let added = 0;\n  let modified = 0;\n  let removed = 0;\n  for (const table of tables) {\n    added += table.addedCount;\n    modified += table.modifiedCount;\n    removed += table.removedCount;\n  }\n  return { added, modified, removed };\n};\n\nconst matchesSearch = (values: string[], query: string) =>\n  values.join(\" \").toLocaleLowerCase().includes(query.toLocaleLowerCase());\n\nconst formatValue = (value: DiffValue | undefined) => {\n  if (value === undefined) {\n    return \"—\";\n  }\n  if (value === null) {\n    return \"NULL\";\n  }\n  return String(value);\n};\n\nconst WordDiff = ({\n  after,\n  before,\n  side,\n}: {\n  after: string;\n  before: string;\n  side: \"before\" | \"after\";\n}) => {\n  const parts = useMemo(\n    () => diffWordsWithSpace(before, after),\n    [after, before]\n  );\n  const highlight =\n    side === \"before\"\n      ? \"bg-destructive/20 text-destructive\"\n      : \"bg-[var(--status-active)]/20 text-[var(--status-active)]\";\n\n  return (\n    <span className=\"break-all\">\n      {parts.map((part, index) => {\n        if (side === \"before\" ? part.added : part.removed) {\n          return null;\n        }\n        const changed = side === \"before\" ? part.removed : part.added;\n        return (\n          <span\n            className={changed ? cn(\"rounded-xs px-0.5\", highlight) : undefined}\n            key={`${index}-${part.value}`}\n          >\n            {part.value}\n          </span>\n        );\n      })}\n    </span>\n  );\n};\n\nconst TabCount = ({ value }: { value: number }) => (\n  <span className=\"font-normal text-[11px] text-muted-foreground tabular-nums\">\n    {value}\n  </span>\n);\n\nconst ChangeCount = ({ count, kind }: { count: number; kind: ChangeKind }) => {\n  const meta = KIND[kind];\n  return (\n    <span\n      className={cn(\n        \"inline-flex items-center gap-1 tabular-nums\",\n        meta.className\n      )}\n    >\n      <span aria-hidden=\"true\" className=\"font-mono font-semibold\">\n        {meta.symbol}\n      </span>\n      {count}\n      <span className=\"sr-only\">{meta.label}</span>\n    </span>\n  );\n};\n\nconst BranchDirection = ({\n  from,\n  to,\n}: {\n  from: BranchDiffBranch;\n  to: BranchDiffBranch;\n}) => (\n  <div className=\"flex min-w-0 items-center gap-2 font-mono text-xs\">\n    <HugeiconsIcon\n      aria-hidden=\"true\"\n      className=\"size-3.5 shrink-0 text-muted-foreground\"\n      icon={GitBranchIcon}\n      strokeWidth={1.75}\n    />\n    <span className=\"min-w-0 truncate text-muted-foreground\">{from.name}</span>\n    <HugeiconsIcon\n      aria-hidden=\"true\"\n      className=\"size-3 shrink-0 text-muted-foreground/60\"\n      icon={ArrowRight01Icon}\n      strokeWidth={2}\n    />\n    <span className=\"min-w-0 truncate font-medium text-foreground\">\n      {to.name}\n    </span>\n  </div>\n);\n\nconst SearchField = ({\n  label,\n  placeholder,\n  value,\n  onChange,\n}: {\n  label: string;\n  placeholder: string;\n  value: string;\n  onChange: (value: string) => void;\n}) => (\n  <label className=\"relative block min-w-0 flex-1\">\n    <span className=\"sr-only\">{label}</span>\n    <HugeiconsIcon\n      aria-hidden=\"true\"\n      className=\"absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground\"\n      icon={Search01Icon}\n      strokeWidth={1.75}\n    />\n    <input\n      className=\"h-8 w-full rounded-md border border-input bg-transparent pr-3 pl-8 text-xs outline-none transition-colors placeholder:text-muted-foreground/70 focus:border-ring focus:ring-3 focus:ring-ring/20\"\n      onChange={(event) => onChange(event.target.value)}\n      placeholder={placeholder}\n      type=\"search\"\n      value={value}\n    />\n  </label>\n);\n\nconst KindFilter = ({\n  value,\n  onChange,\n}: {\n  value: \"all\" | ChangeKind;\n  onChange: (value: \"all\" | ChangeKind) => void;\n}) => (\n  <fieldset className=\"flex shrink-0 items-center self-start rounded-md border border-input p-0.5 sm:self-auto\">\n    <legend className=\"sr-only\">Filter changes</legend>\n    {([\"all\", \"added\", \"modified\", \"removed\"] as const).map((kind) => (\n      <button\n        aria-pressed={value === kind}\n        className=\"h-6 rounded-sm px-2 text-[10px] font-medium capitalize text-muted-foreground transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none aria-pressed:bg-muted aria-pressed:text-foreground\"\n        key={kind}\n        onClick={() => onChange(kind)}\n        type=\"button\"\n      >\n        {kind}\n      </button>\n    ))}\n  </fieldset>\n);\n\nconst SchemaValuePanel = ({\n  change,\n  side,\n}: {\n  change: SchemaChange;\n  side: \"before\" | \"after\";\n}) => {\n  const isBefore = side === \"before\";\n  const value = isBefore ? change.before : change.after;\n  const fallback = isBefore ? \"Not present\" : \"Removed\";\n  const hasBoth = Boolean(change.before && change.after);\n\n  return (\n    <div\n      className={cn(\n        \"min-w-0 rounded-sm px-2 py-1.5 font-mono text-muted-foreground\",\n        isBefore ? \"bg-destructive/5\" : \"bg-[var(--status-active)]/5\"\n      )}\n    >\n      <span\n        className={cn(\n          \"mb-1 block font-sans font-medium\",\n          isBefore ? \"text-destructive/80\" : \"text-[var(--status-active)]\"\n        )}\n      >\n        {isBefore ? \"Before\" : \"After\"}\n      </span>\n      {hasBoth && change.before && change.after ? (\n        <WordDiff after={change.after} before={change.before} side={side} />\n      ) : (\n        <span className=\"break-all\">{value ?? fallback}</span>\n      )}\n    </div>\n  );\n};\n\nconst SchemaChangeDetails = ({ change }: { change: SchemaChange }) => (\n  <div className=\"border-border/60 border-t bg-muted/20 px-6 py-2.5\">\n    {change.before || change.after ? (\n      <div className=\"grid gap-1.5 text-[10px] sm:grid-cols-2\">\n        <SchemaValuePanel change={change} side=\"before\" />\n        <SchemaValuePanel change={change} side=\"after\" />\n      </div>\n    ) : null}\n    {change.ddl ? (\n      <pre className=\"neon-scroll-fade !m-0 mt-2 max-h-40 overflow-auto whitespace-pre !rounded-none !border-0 !bg-transparent font-mono text-[10px] text-foreground leading-relaxed !shadow-none\">\n        <code>{change.ddl}</code>\n      </pre>\n    ) : null}\n  </div>\n);\n\nconst SchemaChangeRow = ({ change }: { change: SchemaChange }) => {\n  const [open, setOpen] = useState(false);\n  const meta = KIND[change.kind];\n  const detailsId = `schema-change-${change.id}`;\n  const hasDetails = Boolean(change.before || change.after || change.ddl);\n\n  return (\n    <li\n      className=\"border-border/60 border-b last:border-b-0\"\n      data-kind={change.kind}\n    >\n      <button\n        aria-controls={hasDetails ? detailsId : undefined}\n        aria-expanded={hasDetails ? open : undefined}\n        className=\"flex w-full min-w-0 items-start gap-2 px-3 py-2 text-left transition-colors hover:bg-muted/35 focus-visible:bg-muted/35 focus-visible:outline-none\"\n        disabled={!hasDetails}\n        onClick={() => setOpen((current) => !current)}\n        type=\"button\"\n      >\n        <span\n          aria-hidden=\"true\"\n          className={cn(\n            \"mt-px w-3 shrink-0 font-mono text-xs font-semibold\",\n            meta.className\n          )}\n        >\n          {meta.symbol}\n        </span>\n        <span className=\"min-w-0 flex-1\">\n          <span className=\"flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5\">\n            <span className=\"truncate font-mono text-xs text-foreground\">\n              {change.path.join(\".\")}\n            </span>\n            <span className=\"text-[10px] capitalize text-muted-foreground\">\n              {change.objectType}\n            </span>\n            {change.breaking ? (\n              <span className=\"rounded-sm bg-destructive/8 px-1 py-0.5 text-[9px] font-medium text-destructive uppercase tracking-wide\">\n                breaking\n              </span>\n            ) : null}\n          </span>\n          {change.before || change.after ? (\n            <span className=\"mt-0.5 block truncate font-mono text-[10px] text-muted-foreground\">\n              {change.before ?? \"not present\"} → {change.after ?? \"removed\"}\n            </span>\n          ) : null}\n        </span>\n        {hasDetails ? (\n          <HugeiconsIcon\n            aria-hidden=\"true\"\n            className={cn(\n              \"mt-0.5 size-3.5 shrink-0 text-muted-foreground transition-transform duration-150 motion-reduce:transition-none\",\n              open && \"rotate-180\"\n            )}\n            icon={ArrowDown01Icon}\n            strokeWidth={1.75}\n          />\n        ) : null}\n      </button>\n      <div\n        className={cn(\n          \"grid transition-[grid-template-rows,opacity] duration-150 motion-reduce:transition-none\",\n          open ? \"grid-rows-[1fr] opacity-100\" : \"grid-rows-[0fr] opacity-0\"\n        )}\n        id={detailsId}\n      >\n        <div className=\"overflow-hidden\">\n          <SchemaChangeDetails change={change} />\n        </div>\n      </div>\n    </li>\n  );\n};\n\nconst ValueDiff = ({\n  before,\n  after,\n}: {\n  before?: Record<string, DiffValue>;\n  after?: Record<string, DiffValue>;\n}) => {\n  const keys = [\n    ...new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]),\n  ];\n  return (\n    <div className=\"neon-scroll-fade overflow-x-auto\">\n      <table className=\"w-full min-w-[440px] border-collapse text-left text-[10px]\">\n        <thead className=\"text-muted-foreground\">\n          <tr className=\"border-border/60 border-b\">\n            <th className=\"w-32 px-3 py-1.5 font-medium\">Column</th>\n            <th className=\"px-3 py-1.5 font-medium\">Before</th>\n            <th className=\"px-3 py-1.5 font-medium\">After</th>\n          </tr>\n        </thead>\n        <tbody className=\"font-mono\">\n          {keys.map((key) => {\n            const previous = before?.[key];\n            const next = after?.[key];\n            const changed = previous !== next;\n            return (\n              <tr\n                className=\"border-border/40 border-b last:border-b-0\"\n                key={key}\n              >\n                <th className=\"px-3 py-1.5 font-medium text-muted-foreground\">\n                  {key}\n                </th>\n                <td\n                  className={cn(\n                    \"px-3 py-1.5\",\n                    changed && \"bg-destructive/5 text-destructive\"\n                  )}\n                >\n                  {changed && previous !== undefined && next !== undefined ? (\n                    <WordDiff\n                      after={formatValue(next)}\n                      before={formatValue(previous)}\n                      side=\"before\"\n                    />\n                  ) : (\n                    formatValue(previous)\n                  )}\n                </td>\n                <td\n                  className={cn(\n                    \"px-3 py-1.5\",\n                    changed &&\n                      \"bg-[var(--status-active)]/5 text-[var(--status-active)]\"\n                  )}\n                >\n                  {changed && previous !== undefined && next !== undefined ? (\n                    <WordDiff\n                      after={formatValue(next)}\n                      before={formatValue(previous)}\n                      side=\"after\"\n                    />\n                  ) : (\n                    formatValue(next)\n                  )}\n                </td>\n              </tr>\n            );\n          })}\n        </tbody>\n      </table>\n    </div>\n  );\n};\n\nconst RowChangeItem = ({ row }: { row: RowChange }) => {\n  const [open, setOpen] = useState(false);\n  const meta = KIND[row.kind];\n  const detailsId = `row-change-${row.id}`;\n  const keyText = Object.entries(row.primaryKey)\n    .map(([key, value]) => `${key}=${formatValue(value)}`)\n    .join(\", \");\n\n  return (\n    <li className=\"border-border/50 border-b last:border-b-0\">\n      <button\n        aria-controls={detailsId}\n        aria-expanded={open}\n        className=\"flex w-full min-w-0 items-center gap-2 px-3 py-2 text-left transition-colors hover:bg-muted/30 focus-visible:bg-muted/30 focus-visible:outline-none\"\n        onClick={() => setOpen((current) => !current)}\n        type=\"button\"\n      >\n        <span\n          aria-hidden=\"true\"\n          className={cn(\"w-3 font-mono font-semibold\", meta.className)}\n        >\n          {meta.symbol}\n        </span>\n        <span className=\"min-w-0 flex-1 truncate font-mono text-[11px]\">\n          {keyText}\n        </span>\n        <span\n          className={cn(\n            \"text-[9px] font-medium uppercase tracking-wide\",\n            meta.className\n          )}\n        >\n          {meta.label}\n        </span>\n        <HugeiconsIcon\n          aria-hidden=\"true\"\n          className={cn(\n            \"size-3.5 text-muted-foreground transition-transform duration-150 motion-reduce:transition-none\",\n            open && \"rotate-180\"\n          )}\n          icon={ArrowDown01Icon}\n          strokeWidth={1.75}\n        />\n      </button>\n      <div\n        className={cn(\n          \"grid transition-[grid-template-rows,opacity] duration-150 motion-reduce:transition-none\",\n          open ? \"grid-rows-[1fr] opacity-100\" : \"grid-rows-[0fr] opacity-0\"\n        )}\n        id={detailsId}\n      >\n        <div className=\"overflow-hidden\">\n          <div className=\"border-border/50 border-t bg-muted/15\">\n            <ValueDiff after={row.after} before={row.before} />\n          </div>\n        </div>\n      </div>\n    </li>\n  );\n};\n\nconst DataTable = ({\n  table,\n  onLoadMore,\n  loading,\n}: {\n  table: TableDataDiff;\n  onLoadMore?: (table: TableDataDiff) => void | Promise<void>;\n  loading: boolean;\n}) => {\n  const [open, setOpen] = useState(true);\n  const detailsId = `table-diff-${table.id}`;\n  return (\n    <section className=\"overflow-hidden rounded-md border border-border/70\">\n      <button\n        aria-controls={detailsId}\n        aria-expanded={open}\n        className=\"flex w-full min-w-0 items-center gap-2 bg-muted/20 px-3 py-2 text-left hover:bg-muted/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40\"\n        onClick={() => setOpen((current) => !current)}\n        type=\"button\"\n      >\n        <HugeiconsIcon\n          aria-hidden=\"true\"\n          className=\"size-3.5 text-muted-foreground\"\n          icon={Table01Icon}\n          strokeWidth={1.75}\n        />\n        <span className=\"min-w-0 flex-1 truncate font-mono text-xs\">\n          {table.schema}.{table.table}\n        </span>\n        <span className=\"hidden items-center gap-2 text-[10px] sm:flex\">\n          <ChangeCount count={table.addedCount} kind=\"added\" />\n          <ChangeCount count={table.modifiedCount} kind=\"modified\" />\n          <ChangeCount count={table.removedCount} kind=\"removed\" />\n        </span>\n        <HugeiconsIcon\n          aria-hidden=\"true\"\n          className={cn(\n            \"size-3.5 text-muted-foreground transition-transform duration-150 motion-reduce:transition-none\",\n            open && \"rotate-180\"\n          )}\n          icon={ArrowDown01Icon}\n          strokeWidth={1.75}\n        />\n      </button>\n      <div\n        className={cn(\n          \"grid transition-[grid-template-rows,opacity] duration-150 motion-reduce:transition-none\",\n          open ? \"grid-rows-[1fr] opacity-100\" : \"grid-rows-[0fr] opacity-0\"\n        )}\n        id={detailsId}\n      >\n        <div className=\"overflow-hidden\">\n          <ul>\n            {table.rows.map((row) => (\n              <RowChangeItem key={row.id} row={row} />\n            ))}\n          </ul>\n          {table.hasMore && onLoadMore ? (\n            <div className=\"border-border/50 border-t p-2 text-center\">\n              <button\n                aria-busy={loading}\n                className=\"rounded-sm px-2.5 py-1 text-[10px] font-medium text-muted-foreground hover:bg-muted hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 focus-visible:outline-none disabled:opacity-50\"\n                disabled={loading}\n                onClick={() => onLoadMore(table)}\n                type=\"button\"\n              >\n                {loading ? \"Loading changes…\" : \"Load more changes\"}\n              </button>\n            </div>\n          ) : null}\n        </div>\n      </div>\n    </section>\n  );\n};\n\nconst EmptyDiff = ({ query }: { query?: string }) => (\n  <div className=\"flex min-h-48 flex-col items-center justify-center gap-2 p-6 text-center\">\n    <div className=\"flex size-8 items-center justify-center rounded-md bg-muted text-muted-foreground\">\n      <HugeiconsIcon\n        aria-hidden=\"true\"\n        className=\"size-4\"\n        icon={GitBranchIcon}\n        strokeWidth={1.75}\n      />\n    </div>\n    <p className=\"text-sm font-medium\">\n      {query ? \"No matching changes\" : \"Branches are in sync\"}\n    </p>\n    <p className=\"max-w-72 text-xs text-muted-foreground\">\n      {query\n        ? \"Try a table, column, primary key, or value.\"\n        : \"No schema or row changes were found for this comparison.\"}\n    </p>\n  </div>\n);\n\nconst LoadingDiff = () => (\n  <output\n    aria-label=\"Loading branch comparison\"\n    className=\"block space-y-2 p-3\"\n  >\n    {Array.from({ length: 4 }, (_, index) => (\n      <div\n        className=\"h-10 animate-pulse rounded-md bg-muted motion-reduce:animate-none\"\n        key={index}\n      />\n    ))}\n  </output>\n);\n\nconst BranchDiff = ({\n  from,\n  to,\n  schemaChanges,\n  dataDiffs,\n  tab: controlledTab,\n  defaultTab = \"schema\",\n  onTabChange,\n  onLoadMore,\n  loadingTableId,\n  isLoading = false,\n  error,\n  className,\n}: BranchDiffProps) => {\n  const errorMessage =\n    error instanceof Error ? error.message : (error ?? undefined);\n  const [internalTab, setInternalTab] = useState<DiffTab>(defaultTab);\n  const [query, setQuery] = useState(\"\");\n  const [kind, setKind] = useState<\"all\" | ChangeKind>(\"all\");\n  const tab = controlledTab ?? internalTab;\n  const schemaCounts = useMemo(\n    () => countSchemaKinds(schemaChanges),\n    [schemaChanges]\n  );\n  const dataCounts = useMemo(() => countDataKinds(dataDiffs), [dataDiffs]);\n  const counts = {\n    added: schemaCounts.added + dataCounts.added,\n    modified: schemaCounts.modified + dataCounts.modified,\n    removed: schemaCounts.removed + dataCounts.removed,\n  };\n  const filteredSchema = useMemo(\n    () =>\n      schemaChanges.filter(\n        (change) =>\n          (kind === \"all\" || change.kind === kind) &&\n          matchesSearch(\n            [\n              ...change.path,\n              change.before ?? \"\",\n              change.after ?? \"\",\n              change.ddl ?? \"\",\n            ],\n            query\n          )\n      ),\n    [kind, query, schemaChanges]\n  );\n  const filteredData = useMemo(\n    () =>\n      dataDiffs\n        .map((table) => ({\n          ...table,\n          rows: table.rows.filter(\n            (row) =>\n              (kind === \"all\" || row.kind === kind) &&\n              matchesSearch(\n                [\n                  table.schema,\n                  table.table,\n                  JSON.stringify(row.primaryKey),\n                  JSON.stringify(row.before),\n                  JSON.stringify(row.after),\n                ],\n                query\n              )\n          ),\n        }))\n        .filter((table) => table.rows.length > 0),\n    [dataDiffs, kind, query]\n  );\n  const total = counts.added + counts.modified + counts.removed;\n  const setTab = (next: DiffTab) => {\n    if (controlledTab === undefined) {\n      setInternalTab(next);\n    }\n    onTabChange?.(next);\n  };\n\n  return (\n    <section\n      className={cn(\n        \"w-full min-w-0 overflow-hidden rounded-lg border border-border/70 bg-card text-card-foreground shadow-xs\",\n        className\n      )}\n      data-slot=\"branch-diff\"\n    >\n      <header className=\"flex min-w-0 flex-col gap-2 border-border/60 border-b px-3 py-3 sm:flex-row sm:items-center sm:justify-between\">\n        <div className=\"min-w-0\">\n          <h2 className=\"text-sm font-semibold\">Branch diff</h2>\n          <BranchDirection from={from} to={to} />\n        </div>\n        <div\n          aria-label={`${total} total changes`}\n          className=\"flex items-center gap-3 text-[10px]\"\n        >\n          <ChangeCount count={counts.added} kind=\"added\" />\n          <ChangeCount count={counts.modified} kind=\"modified\" />\n          <ChangeCount count={counts.removed} kind=\"removed\" />\n        </div>\n      </header>\n      {errorMessage ? (\n        <div\n          className=\"flex min-h-48 items-center justify-center gap-2 p-6 text-sm text-destructive\"\n          role=\"alert\"\n        >\n          <HugeiconsIcon\n            aria-hidden=\"true\"\n            className=\"size-4\"\n            icon={Alert02Icon}\n            strokeWidth={1.75}\n          />\n          {errorMessage}\n        </div>\n      ) : null}\n      {isLoading && !errorMessage ? <LoadingDiff /> : null}\n      {isLoading || errorMessage ? null : (\n        <Tabs onValueChange={(value) => setTab(value as DiffTab)} value={tab}>\n          <div className=\"flex min-w-0 flex-col gap-2 border-border/60 border-b p-3\">\n            <TabsList className=\"relative w-fit shrink-0\" variant=\"default\">\n              <TabsIndicator />\n              <TabsTrigger\n                className=\"z-1 flex-none gap-1.5 bg-transparent text-xs shadow-none data-active:bg-transparent data-active:shadow-none dark:data-active:border-transparent dark:data-active:bg-transparent\"\n                value=\"schema\"\n              >\n                <span>Schema</span>\n                <TabCount value={schemaChanges.length} />\n              </TabsTrigger>\n              <TabsTrigger\n                className=\"z-1 flex-none gap-1.5 bg-transparent text-xs shadow-none data-active:bg-transparent data-active:shadow-none dark:data-active:border-transparent dark:data-active:bg-transparent\"\n                value=\"data\"\n              >\n                <span>Data</span>\n                <TabCount\n                  value={\n                    dataCounts.added + dataCounts.modified + dataCounts.removed\n                  }\n                />\n              </TabsTrigger>\n            </TabsList>\n            <div className=\"flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center\">\n              <SearchField\n                label={`Search ${tab} changes`}\n                onChange={setQuery}\n                placeholder={`Search ${tab} changes…`}\n                value={query}\n              />\n              <KindFilter onChange={setKind} value={kind} />\n            </div>\n          </div>\n          <TabsContent\n            className=\"fade-in-0 slide-in-from-left-1 animate-in duration-200 ease-out motion-reduce:animate-none\"\n            value=\"schema\"\n          >\n            {filteredSchema.length > 0 ? (\n              <ul className=\"neon-scroll-fade max-h-[30rem] overflow-y-auto\">\n                {filteredSchema.map((change) => (\n                  <SchemaChangeRow change={change} key={change.id} />\n                ))}\n              </ul>\n            ) : (\n              <EmptyDiff\n                query={query || kind !== \"all\" ? query || kind : undefined}\n              />\n            )}\n          </TabsContent>\n          <TabsContent\n            className=\"fade-in-0 slide-in-from-right-1 animate-in p-3 duration-200 ease-out motion-reduce:animate-none\"\n            value=\"data\"\n          >\n            {filteredData.length > 0 ? (\n              <div className=\"neon-scroll-fade max-h-[34rem] space-y-2 overflow-y-auto\">\n                {filteredData.map((table) => (\n                  <DataTable\n                    key={table.id}\n                    loading={loadingTableId === table.id}\n                    onLoadMore={onLoadMore}\n                    table={table}\n                  />\n                ))}\n              </div>\n            ) : (\n              <EmptyDiff\n                query={query || kind !== \"all\" ? query || kind : undefined}\n              />\n            )}\n          </TabsContent>\n        </Tabs>\n      )}\n    </section>\n  );\n};\n\nexport { BranchDiff };\nexport type {\n  BranchDiffBranch,\n  BranchDiffProps,\n  ChangeKind,\n  DiffTab,\n  DiffValue,\n  RowChange,\n  SchemaChange,\n  TableDataDiff,\n};\n",
      "type": "registry:component"
    },
    {
      "path": "src/components/branch-diff/use-branch-diff.ts",
      "content": "\"use client\";\n\nimport { useCallback, useEffect, useState } from \"react\";\n\nimport type {\n  SchemaChange,\n  TableDataDiff,\n} from \"@/components/branch-diff/branch-diff\";\n\nexport interface BranchDiffPayload {\n  schemaChanges: SchemaChange[];\n  dataDiffs: TableDataDiff[];\n}\n\nexport interface UseBranchDiffOptions {\n  endpoint?: string;\n  fromBranchId: string;\n  toBranchId: string;\n}\n\nexport const useBranchDiff = ({\n  endpoint = \"/api/branch-diff\",\n  fromBranchId,\n  toBranchId,\n}: UseBranchDiffOptions) => {\n  const [diff, setDiff] = useState<BranchDiffPayload>({\n    dataDiffs: [],\n    schemaChanges: [],\n  });\n  const [isLoading, setIsLoading] = useState(true);\n  const [loadError, setLoadError] = useState<Error | null>(null);\n\n  const refresh = useCallback(\n    async (signal?: AbortSignal) => {\n      setIsLoading(true);\n      setLoadError(null);\n\n      const search = new URLSearchParams({\n        from: fromBranchId,\n        to: toBranchId,\n      });\n\n      try {\n        const response = await fetch(`${endpoint}?${search}`, { signal });\n        if (!response.ok) {\n          throw new Error(`Could not load branch diff (${response.status}).`);\n        }\n        setDiff((await response.json()) as BranchDiffPayload);\n      } catch (error) {\n        if (error instanceof DOMException && error.name === \"AbortError\") {\n          return;\n        }\n        setLoadError(\n          error instanceof Error\n            ? error\n            : new Error(\"Could not load branch diff.\")\n        );\n      } finally {\n        setIsLoading(false);\n      }\n    },\n    [endpoint, fromBranchId, toBranchId]\n  );\n\n  useEffect(() => {\n    const controller = new AbortController();\n    let active = true;\n    queueMicrotask(async () => {\n      if (active) {\n        await refresh(controller.signal);\n      }\n    });\n    return () => {\n      active = false;\n      controller.abort();\n    };\n  }, [refresh]);\n\n  return {\n    dataDiffs: diff.dataDiffs,\n    error: loadError,\n    isLoading,\n    refresh,\n    schemaChanges: diff.schemaChanges,\n    setDiff,\n  };\n};\n",
      "type": "registry:hook"
    }
  ],
  "type": "registry:component"
}