shadimage
neon-ui
componentbranch-diffsource: ok · refreshed

BranchDiff

Schema and row-level comparison between two branches with change counts, search, kind filters, and expandable DDL and word-level value diffs.

npx shadcn add "https://ui.neon.com/r/branch-diff.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · light
@hugeicons/react@hugeicons/core-free-iconsdiff

Registry dependencies

utilshttps://ui.neon.com/r/tabs.jsonhttps://ui.neon.com/r/neon-tokens.json

Files2 · source cached 10/8/2026, 6:17:37 AM

src/components/branch-diff/branch-diff.tsx
"use client";

import {
  Alert02Icon,
  ArrowDown01Icon,
  ArrowRight01Icon,
  GitBranchIcon,
  Search01Icon,
  Table01Icon,
} from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { diffWordsWithSpace } from "diff";
import { useMemo, useState } from "react";

import {
  Tabs,
  TabsContent,
  TabsIndicator,
  TabsList,
  TabsTrigger,
} from "@/components/ui/tabs";
import { cn } from "@/lib/utils";

type ChangeKind = "added" | "removed" | "modified";
type DiffTab = "schema" | "data";
type DiffValue = boolean | number | string | null;

interface BranchDiffBranch {
  id: string;
  name: string;
}

interface SchemaChange {
  id: string;
  kind: ChangeKind;
  objectType: "table" | "column" | "index" | "constraint";
  path: string[];
  before?: string;
  after?: string;
  ddl?: string;
  breaking?: boolean;
}

interface RowChange {
  id: string;
  kind: ChangeKind;
  primaryKey: Record<string, DiffValue>;
  before?: Record<string, DiffValue>;
  after?: Record<string, DiffValue>;
}

interface TableDataDiff {
  id: string;
  schema: string;
  table: string;
  primaryKey: string[];
  rows: RowChange[];
  addedCount: number;
  removedCount: number;
  modifiedCount: number;
  unchangedCount?: number;
  hasMore?: boolean;
}

interface BranchDiffProps {
  from: BranchDiffBranch;
  to: BranchDiffBranch;
  schemaChanges: SchemaChange[];
  dataDiffs: TableDataDiff[];
  tab?: DiffTab;
  defaultTab?: DiffTab;
  onTabChange?: (tab: DiffTab) => void;
  onLoadMore?: (table: TableDataDiff) => void | Promise<void>;
  loadingTableId?: string | null;
  isLoading?: boolean;
  error?: Error | string | null;
  className?: string;
}

const KIND = {
  added: {
    className: "text-[var(--status-active)]",
    label: "Added",
    surface: "bg-[var(--status-active)]/8",
    symbol: "+",
  },
  modified: {
    className: "text-[var(--status-scaling)]",
    label: "Modified",
    surface: "bg-[var(--status-scaling)]/8",
    symbol: "~",
  },
  removed: {
    className: "text-destructive",
    label: "Removed",
    surface: "bg-destructive/7",
    symbol: "−",
  },
} satisfies Record<ChangeKind, Record<string, string>>;

const countSchemaKinds = (changes: SchemaChange[]) => ({
  added: changes.filter((change) => change.kind === "added").length,
  modified: changes.filter((change) => change.kind === "modified").length,
  removed: changes.filter((change) => change.kind === "removed").length,
});

const countDataKinds = (tables: TableDataDiff[]) => {
  let added = 0;
  let modified = 0;
  let removed = 0;
  for (const table of tables) {
    added += table.addedCount;
    modified += table.modifiedCount;
    removed += table.removedCount;
  }
  return { added, modified, removed };
};

const matchesSearch = (values: string[], query: string) =>
  values.join(" ").toLocaleLowerCase().includes(query.toLocaleLowerCase());

const formatValue = (value: DiffValue | undefined) => {
  if (value === undefined) {
    return "—";
  }
  if (value === null) {
    return "NULL";
  }
  return String(value);
};

const WordDiff = ({
  after,
  before,
  side,
}: {
  after: string;
  before: string;
  side: "before" | "after";
}) => {
  const parts = useMemo(
    () => diffWordsWithSpace(before, after),
    [after, before]
  );
  const highlight =
    side === "before"
      ? "bg-destructive/20 text-destructive"
      : "bg-[var(--status-active)]/20 text-[var(--status-active)]";

  return (
    <span className="break-all">
      {parts.map((part, index) => {
        if (side === "before" ? part.added : part.removed) {
          return null;
        }
        const changed = side === "before" ? part.removed : part.added;
        return (
          <span
            className={changed ? cn("rounded-xs px-0.5", highlight) : undefined}
            key={`${index}-${part.value}`}
          >
            {part.value}
          </span>
        );
      })}
    </span>
  );
};

const TabCount = ({ value }: { value: number }) => (
  <span className="font-normal text-[11px] text-muted-foreground tabular-nums">
    {value}
  </span>
);

const ChangeCount = ({ count, kind }: { count: number; kind: ChangeKind }) => {
  const meta = KIND[kind];
  return (
    <span
      className={cn(
        "inline-flex items-center gap-1 tabular-nums",
        meta.className
      )}
    >
      <span aria-hidden="true" className="font-mono font-semibold">
        {meta.symbol}
      </span>
      {count}
      <span className="sr-only">{meta.label}</span>
    </span>
  );
};

const BranchDirection = ({
  from,
  to,
}: {
  from: BranchDiffBranch;
  to: BranchDiffBranch;
}) => (
  <div className="flex min-w-0 items-center gap-2 font-mono text-xs">
    <HugeiconsIcon
      aria-hidden="true"
      className="size-3.5 shrink-0 text-muted-foreground"
      icon={GitBranchIcon}
      strokeWidth={1.75}
    />
    <span className="min-w-0 truncate text-muted-foreground">{from.name}</span>
    <HugeiconsIcon
      aria-hidden="true"
      className="size-3 shrink-0 text-muted-foreground/60"
      icon={ArrowRight01Icon}
      strokeWidth={2}
    />
    <span className="min-w-0 truncate font-medium text-foreground">
      {to.name}
    </span>
  </div>
);

const SearchField = ({
  label,
  placeholder,
  value,
  onChange,
}: {
  label: string;
  placeholder: string;
  value: string;
  onChange: (value: string) => void;
}) => (
  <label className="relative block min-w-0 flex-1">
    <span className="sr-only">{label}</span>
    <HugeiconsIcon
      aria-hidden="true"
      className="absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground"
      icon={Search01Icon}
      strokeWidth={1.75}
    />
    <input
      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"
      onChange={(event) => onChange(event.target.value)}
      placeholder={placeholder}
      type="search"
      value={value}
    />
  </label>
);

const KindFilter = ({
  value,
  onChange,
}: {
  value: "all" | ChangeKind;
  onChange: (value: "all" | ChangeKind) => void;
}) => (
  <fieldset className="flex shrink-0 items-center self-start rounded-md border border-input p-0.5 sm:self-auto">
    <legend className="sr-only">Filter changes</legend>
    {(["all", "added", "modified", "removed"] as const).map((kind) => (
      <button
        aria-pressed={value === kind}
        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"
        key={kind}
        onClick={() => onChange(kind)}
        type="button"
      >
        {kind}
      </button>
    ))}
  </fieldset>
);

const SchemaValuePanel = ({
  change,
  side,
}: {
  change: SchemaChange;
  side: "before" | "after";
}) => {
  const isBefore = side === "before";
  const value = isBefore ? change.before : change.after;
  const fallback = isBefore ? "Not present" : "Removed";
  const hasBoth = Boolean(change.before && change.after);

  return (
    <div
      className={cn(
        "min-w-0 rounded-sm px-2 py-1.5 font-mono text-muted-foreground",
        isBefore ? "bg-destructive/5" : "bg-[var(--status-active)]/5"
      )}
    >
      <span
        className={cn(
          "mb-1 block font-sans font-medium",
          isBefore ? "text-destructive/80" : "text-[var(--status-active)]"
        )}
      >
        {isBefore ? "Before" : "After"}
      </span>
      {hasBoth && change.before && change.after ? (
        <WordDiff after={change.after} before={change.before} side={side} />
      ) : (
        <span className="break-all">{value ?? fallback}</span>
      )}
    </div>
  );
};

const SchemaChangeDetails = ({ change }: { change: SchemaChange }) => (
  <div className="border-border/60 border-t bg-muted/20 px-6 py-2.5">
    {change.before || change.after ? (
      <div className="grid gap-1.5 text-[10px] sm:grid-cols-2">
        <SchemaValuePanel change={change} side="before" />
        <SchemaValuePanel change={change} side="after" />
      </div>
    ) : null}
    {change.ddl ? (
      <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">
        <code>{change.ddl}</code>
      </pre>
    ) : null}
  </div>
);

const SchemaChangeRow = ({ change }: { change: SchemaChange }) => {
  const [open, setOpen] = useState(false);
  const meta = KIND[change.kind];
  const detailsId = `schema-change-${change.id}`;
  const hasDetails = Boolean(change.before || change.after || change.ddl);

  return (
    <li
      className="border-border/60 border-b last:border-b-0"
      data-kind={change.kind}
    >
      <button
        aria-controls={hasDetails ? detailsId : undefined}
        aria-expanded={hasDetails ? open : undefined}
        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"
        disabled={!hasDetails}
        onClick={() => setOpen((current) => !current)}
        type="button"
      >
        <span
          aria-hidden="true"
          className={cn(
            "mt-px w-3 shrink-0 font-mono text-xs font-semibold",
            meta.className
          )}
        >
          {meta.symbol}
        </span>
        <span className="min-w-0 flex-1">
          <span className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5">
            <span className="truncate font-mono text-xs text-foreground">
              {change.path.join(".")}
            </span>
            <span className="text-[10px] capitalize text-muted-foreground">
              {change.objectType}
            </span>
            {change.breaking ? (
              <span className="rounded-sm bg-destructive/8 px-1 py-0.5 text-[9px] font-medium text-destructive uppercase tracking-wide">
                breaking
              </span>
            ) : null}
          </span>
          {change.before || change.after ? (
            <span className="mt-0.5 block truncate font-mono text-[10px] text-muted-foreground">
              {change.before ?? "not present"} → {change.after ?? "removed"}
            </span>
          ) : null}
        </span>
        {hasDetails ? (
          <HugeiconsIcon
            aria-hidden="true"
            className={cn(
              "mt-0.5 size-3.5 shrink-0 text-muted-foreground transition-transform duration-150 motion-reduce:transition-none",
              open && "rotate-180"
            )}
            icon={ArrowDown01Icon}
            strokeWidth={1.75}
          />
        ) : null}
      </button>
      <div
        className={cn(
          "grid transition-[grid-template-rows,opacity] duration-150 motion-reduce:transition-none",
          open ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
        )}
        id={detailsId}
      >
        <div className="overflow-hidden">
          <SchemaChangeDetails change={change} />
        </div>
      </div>
    </li>
  );
};

const ValueDiff = ({
  before,
  after,
}: {
  before?: Record<string, DiffValue>;
  after?: Record<string, DiffValue>;
}) => {
  const keys = [
    ...new Set([...Object.keys(before ?? {}), ...Object.keys(after ?? {})]),
  ];
  return (
    <div className="neon-scroll-fade overflow-x-auto">
      <table className="w-full min-w-[440px] border-collapse text-left text-[10px]">
        <thead className="text-muted-foreground">
          <tr className="border-border/60 border-b">
            <th className="w-32 px-3 py-1.5 font-medium">Column</th>
            <th className="px-3 py-1.5 font-medium">Before</th>
            <th className="px-3 py-1.5 font-medium">After</th>
          </tr>
        </thead>
        <tbody className="font-mono">
          {keys.map((key) => {
            const previous = before?.[key];
            const next = after?.[key];
            const changed = previous !== next;
            return (
              <tr
                className="border-border/40 border-b last:border-b-0"
                key={key}
              >
                <th className="px-3 py-1.5 font-medium text-muted-foreground">
                  {key}
                </th>
                <td
                  className={cn(
                    "px-3 py-1.5",
                    changed && "bg-destructive/5 text-destructive"
                  )}
                >
                  {changed && previous !== undefined && next !== undefined ? (
                    <WordDiff
                      after={formatValue(next)}
                      before={formatValue(previous)}
                      side="before"
                    />
                  ) : (
                    formatValue(previous)
                  )}
                </td>
                <td
                  className={cn(
                    "px-3 py-1.5",
                    changed &&
                      "bg-[var(--status-active)]/5 text-[var(--status-active)]"
                  )}
                >
                  {changed && previous !== undefined && next !== undefined ? (
                    <WordDiff
                      after={formatValue(next)}
                      before={formatValue(previous)}
                      side="after"
                    />
                  ) : (
                    formatValue(next)
                  )}
                </td>
              </tr>
            );
          })}
        </tbody>
      </table>
    </div>
  );
};

const RowChangeItem = ({ row }: { row: RowChange }) => {
  const [open, setOpen] = useState(false);
  const meta = KIND[row.kind];
  const detailsId = `row-change-${row.id}`;
  const keyText = Object.entries(row.primaryKey)
    .map(([key, value]) => `${key}=${formatValue(value)}`)
    .join(", ");

  return (
    <li className="border-border/50 border-b last:border-b-0">
      <button
        aria-controls={detailsId}
        aria-expanded={open}
        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"
        onClick={() => setOpen((current) => !current)}
        type="button"
      >
        <span
          aria-hidden="true"
          className={cn("w-3 font-mono font-semibold", meta.className)}
        >
          {meta.symbol}
        </span>
        <span className="min-w-0 flex-1 truncate font-mono text-[11px]">
          {keyText}
        </span>
        <span
          className={cn(
            "text-[9px] font-medium uppercase tracking-wide",
            meta.className
          )}
        >
          {meta.label}
        </span>
        <HugeiconsIcon
          aria-hidden="true"
          className={cn(
            "size-3.5 text-muted-foreground transition-transform duration-150 motion-reduce:transition-none",
            open && "rotate-180"
          )}
          icon={ArrowDown01Icon}
          strokeWidth={1.75}
        />
      </button>
      <div
        className={cn(
          "grid transition-[grid-template-rows,opacity] duration-150 motion-reduce:transition-none",
          open ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
        )}
        id={detailsId}
      >
        <div className="overflow-hidden">
          <div className="border-border/50 border-t bg-muted/15">
            <ValueDiff after={row.after} before={row.before} />
          </div>
        </div>
      </div>
    </li>
  );
};

const DataTable = ({
  table,
  onLoadMore,
  loading,
}: {
  table: TableDataDiff;
  onLoadMore?: (table: TableDataDiff) => void | Promise<void>;
  loading: boolean;
}) => {
  const [open, setOpen] = useState(true);
  const detailsId = `table-diff-${table.id}`;
  return (
    <section className="overflow-hidden rounded-md border border-border/70">
      <button
        aria-controls={detailsId}
        aria-expanded={open}
        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"
        onClick={() => setOpen((current) => !current)}
        type="button"
      >
        <HugeiconsIcon
          aria-hidden="true"
          className="size-3.5 text-muted-foreground"
          icon={Table01Icon}
          strokeWidth={1.75}
        />
        <span className="min-w-0 flex-1 truncate font-mono text-xs">
          {table.schema}.{table.table}
        </span>
        <span className="hidden items-center gap-2 text-[10px] sm:flex">
          <ChangeCount count={table.addedCount} kind="added" />
          <ChangeCount count={table.modifiedCount} kind="modified" />
          <ChangeCount count={table.removedCount} kind="removed" />
        </span>
        <HugeiconsIcon
          aria-hidden="true"
          className={cn(
            "size-3.5 text-muted-foreground transition-transform duration-150 motion-reduce:transition-none",
            open && "rotate-180"
          )}
          icon={ArrowDown01Icon}
          strokeWidth={1.75}
        />
      </button>
      <div
        className={cn(
          "grid transition-[grid-template-rows,opacity] duration-150 motion-reduce:transition-none",
          open ? "grid-rows-[1fr] opacity-100" : "grid-rows-[0fr] opacity-0"
        )}
        id={detailsId}
      >
        <div className="overflow-hidden">
          <ul>
            {table.rows.map((row) => (
              <RowChangeItem key={row.id} row={row} />
            ))}
          </ul>
          {table.hasMore && onLoadMore ? (
            <div className="border-border/50 border-t p-2 text-center">
              <button
                aria-busy={loading}
                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"
                disabled={loading}
                onClick={() => onLoadMore(table)}
                type="button"
              >
                {loading ? "Loading changes…" : "Load more changes"}
              </button>
            </div>
          ) : null}
        </div>
      </div>
    </section>
  );
};

const EmptyDiff = ({ query }: { query?: string }) => (
  <div className="flex min-h-48 flex-col items-center justify-center gap-2 p-6 text-center">
    <div className="flex size-8 items-center justify-center rounded-md bg-muted text-muted-foreground">
      <HugeiconsIcon
        aria-hidden="true"
        className="size-4"
        icon={GitBranchIcon}
        strokeWidth={1.75}
      />
    </div>
    <p className="text-sm font-medium">
      {query ? "No matching changes" : "Branches are in sync"}
    </p>
    <p className="max-w-72 text-xs text-muted-foreground">
      {query
        ? "Try a table, column, primary key, or value."
        : "No schema or row changes were found for this comparison."}
    </p>
  </div>
);

const LoadingDiff = () => (
  <output
    aria-label="Loading branch comparison"
    className="block space-y-2 p-3"
  >
    {Array.from({ length: 4 }, (_, index) => (
      <div
        className="h-10 animate-pulse rounded-md bg-muted motion-reduce:animate-none"
        key={index}
      />
    ))}
  </output>
);

const BranchDiff = ({
  from,
  to,
  schemaChanges,
  dataDiffs,
  tab: controlledTab,
  defaultTab = "schema",
  onTabChange,
  onLoadMore,
  loadingTableId,
  isLoading = false,
  error,
  className,
}: BranchDiffProps) => {
  const errorMessage =
    error instanceof Error ? error.message : (error ?? undefined);
  const [internalTab, setInternalTab] = useState<DiffTab>(defaultTab);
  const [query, setQuery] = useState("");
  const [kind, setKind] = useState<"all" | ChangeKind>("all");
  const tab = controlledTab ?? internalTab;
  const schemaCounts = useMemo(
    () => countSchemaKinds(schemaChanges),
    [schemaChanges]
  );
  const dataCounts = useMemo(() => countDataKinds(dataDiffs), [dataDiffs]);
  const counts = {
    added: schemaCounts.added + dataCounts.added,
    modified: schemaCounts.modified + dataCounts.modified,
    removed: schemaCounts.removed + dataCounts.removed,
  };
  const filteredSchema = useMemo(
    () =>
      schemaChanges.filter(
        (change) =>
          (kind === "all" || change.kind === kind) &&
          matchesSearch(
            [
              ...change.path,
              change.before ?? "",
              change.after ?? "",
              change.ddl ?? "",
            ],
            query
          )
      ),
    [kind, query, schemaChanges]
  );
  const filteredData = useMemo(
    () =>
      dataDiffs
        .map((table) => ({
          ...table,
          rows: table.rows.filter(
            (row) =>
              (kind === "all" || row.kind === kind) &&
              matchesSearch(
                [
                  table.schema,
                  table.table,
                  JSON.stringify(row.primaryKey),
                  JSON.stringify(row.before),
                  JSON.stringify(row.after),
                ],
                query
              )
          ),
        }))
        .filter((table) => table.rows.length > 0),
    [dataDiffs, kind, query]
  );
  const total = counts.added + counts.modified + counts.removed;
  const setTab = (next: DiffTab) => {
    if (controlledTab === undefined) {
      setInternalTab(next);
    }
    onTabChange?.(next);
  };

  return (
    <section
      className={cn(
        "w-full min-w-0 overflow-hidden rounded-lg border border-border/70 bg-card text-card-foreground shadow-xs",
        className
      )}
      data-slot="branch-diff"
    >
      <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">
        <div className="min-w-0">
          <h2 className="text-sm font-semibold">Branch diff</h2>
          <BranchDirection from={from} to={to} />
        </div>
        <div
          aria-label={`${total} total changes`}
          className="flex items-center gap-3 text-[10px]"
        >
          <ChangeCount count={counts.added} kind="added" />
          <ChangeCount count={counts.modified} kind="modified" />
          <ChangeCount count={counts.removed} kind="removed" />
        </div>
      </header>
      {errorMessage ? (
        <div
          className="flex min-h-48 items-center justify-center gap-2 p-6 text-sm text-destructive"
          role="alert"
        >
          <HugeiconsIcon
            aria-hidden="true"
            className="size-4"
            icon={Alert02Icon}
            strokeWidth={1.75}
          />
          {errorMessage}
        </div>
      ) : null}
      {isLoading && !errorMessage ? <LoadingDiff /> : null}
      {isLoading || errorMessage ? null : (
        <Tabs onValueChange={(value) => setTab(value as DiffTab)} value={tab}>
          <div className="flex min-w-0 flex-col gap-2 border-border/60 border-b p-3">
            <TabsList className="relative w-fit shrink-0" variant="default">
              <TabsIndicator />
              <TabsTrigger
                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"
                value="schema"
              >
                <span>Schema</span>
                <TabCount value={schemaChanges.length} />
              </TabsTrigger>
              <TabsTrigger
                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"
                value="data"
              >
                <span>Data</span>
                <TabCount
                  value={
                    dataCounts.added + dataCounts.modified + dataCounts.removed
                  }
                />
              </TabsTrigger>
            </TabsList>
            <div className="flex min-w-0 flex-col gap-2 sm:flex-row sm:items-center">
              <SearchField
                label={`Search ${tab} changes`}
                onChange={setQuery}
                placeholder={`Search ${tab} changes…`}
                value={query}
              />
              <KindFilter onChange={setKind} value={kind} />
            </div>
          </div>
          <TabsContent
            className="fade-in-0 slide-in-from-left-1 animate-in duration-200 ease-out motion-reduce:animate-none"
            value="schema"
          >
            {filteredSchema.length > 0 ? (
              <ul className="neon-scroll-fade max-h-[30rem] overflow-y-auto">
                {filteredSchema.map((change) => (
                  <SchemaChangeRow change={change} key={change.id} />
                ))}
              </ul>
            ) : (
              <EmptyDiff
                query={query || kind !== "all" ? query || kind : undefined}
              />
            )}
          </TabsContent>
          <TabsContent
            className="fade-in-0 slide-in-from-right-1 animate-in p-3 duration-200 ease-out motion-reduce:animate-none"
            value="data"
          >
            {filteredData.length > 0 ? (
              <div className="neon-scroll-fade max-h-[34rem] space-y-2 overflow-y-auto">
                {filteredData.map((table) => (
                  <DataTable
                    key={table.id}
                    loading={loadingTableId === table.id}
                    onLoadMore={onLoadMore}
                    table={table}
                  />
                ))}
              </div>
            ) : (
              <EmptyDiff
                query={query || kind !== "all" ? query || kind : undefined}
              />
            )}
          </TabsContent>
        </Tabs>
      )}
    </section>
  );
};

export { BranchDiff };
export type {
  BranchDiffBranch,
  BranchDiffProps,
  ChangeKind,
  DiffTab,
  DiffValue,
  RowChange,
  SchemaChange,
  TableDataDiff,
};
src/components/branch-diff/use-branch-diff.ts
"use client";

import { useCallback, useEffect, useState } from "react";

import type {
  SchemaChange,
  TableDataDiff,
} from "@/components/branch-diff/branch-diff";

export interface BranchDiffPayload {
  schemaChanges: SchemaChange[];
  dataDiffs: TableDataDiff[];
}

export interface UseBranchDiffOptions {
  endpoint?: string;
  fromBranchId: string;
  toBranchId: string;
}

export const useBranchDiff = ({
  endpoint = "/api/branch-diff",
  fromBranchId,
  toBranchId,
}: UseBranchDiffOptions) => {
  const [diff, setDiff] = useState<BranchDiffPayload>({
    dataDiffs: [],
    schemaChanges: [],
  });
  const [isLoading, setIsLoading] = useState(true);
  const [loadError, setLoadError] = useState<Error | null>(null);

  const refresh = useCallback(
    async (signal?: AbortSignal) => {
      setIsLoading(true);
      setLoadError(null);

      const search = new URLSearchParams({
        from: fromBranchId,
        to: toBranchId,
      });

      try {
        const response = await fetch(`${endpoint}?${search}`, { signal });
        if (!response.ok) {
          throw new Error(`Could not load branch diff (${response.status}).`);
        }
        setDiff((await response.json()) as BranchDiffPayload);
      } catch (error) {
        if (error instanceof DOMException && error.name === "AbortError") {
          return;
        }
        setLoadError(
          error instanceof Error
            ? error
            : new Error("Could not load branch diff.")
        );
      } finally {
        setIsLoading(false);
      }
    },
    [endpoint, fromBranchId, toBranchId]
  );

  useEffect(() => {
    const controller = new AbortController();
    let active = true;
    queueMicrotask(async () => {
      if (active) {
        await refresh(controller.signal);
      }
    });
    return () => {
      active = false;
      controller.abort();
    };
  }, [refresh]);

  return {
    dataDiffs: diff.dataDiffs,
    error: loadError,
    isLoading,
    refresh,
    schemaChanges: diff.schemaChanges,
    setDiff,
  };
};

More from neon-ui

Browse all components in this library →