{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "git-graph",
  "title": "Git Graph",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "A commit history with lane-routed branch and merge lines, branch and tag labels, authors and dates, selection, and listbox keyboard navigation.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/i18n.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/git-graph.tsx",
      "content": "// Ballmac UI: Git Graph. https://ui.ballmac.com/components/git-graph\n\"use client\"\n\nimport * as React from \"react\"\nimport { GitBranch, GitMerge, Tag } from \"lucide-react\"\n\nimport { cn } from \"@/lib/utils\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\n\ntype GitCommit = {\n  /** Full commit hash (any unique string works). */\n  hash: string\n  /** Parent hashes. The first is the line this commit continues; more than one makes it a merge. */\n  parents: string[]\n  /** First line of the message. */\n  message: string\n  /** Author name. */\n  author: string\n  /** Commit date as an ISO string. Shown in UTC. */\n  date: string\n  /** Branch names pointing here. */\n  branches?: string[]\n  /** Tag names pointing here. */\n  tags?: string[]\n}\n\nconst LANE_COLORS = [\"var(--chart-1)\", \"var(--chart-2)\", \"var(--chart-3)\", \"var(--chart-4)\", \"var(--chart-5)\"]\nconst TONES = [\"bg-chart-1\", \"bg-chart-2\", \"bg-chart-3\", \"bg-chart-4\", \"bg-chart-5\"]\nconst MONTHS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"]\n\ntype Edge = { from: number; to: number; color: number }\ntype Row = {\n  commit: GitCommit\n  col: number\n  color: number\n  /** Lanes drawn straight through this row. */\n  through: { lane: number; color: number }[]\n  /** Lanes that arrive at this commit from above. */\n  incoming: Edge[]\n  /** Lines that leave this commit downward. */\n  outgoing: Edge[]\n}\n\n/** Assigns each commit a lane. Commits must be newest first, parents after children. */\nfunction layout(commits: GitCommit[]) {\n  let lanes: (string | null)[] = []\n  const laneColor: number[] = []\n  let nextColor = 0\n  const rows: Row[] = []\n  let width = 1\n\n  for (const commit of commits) {\n    const before = lanes.slice()\n    let col = before.indexOf(commit.hash)\n    if (col === -1) {\n      col = before.indexOf(null)\n      if (col === -1) col = before.length\n      laneColor[col] = nextColor++ % LANE_COLORS.length\n    }\n    const ending = before.map((h, l) => (h === commit.hash ? l : -1)).filter((l) => l >= 0)\n    const incoming: Edge[] = ending.map((l) => ({ from: l, to: col, color: laneColor[l] ?? 0 }))\n\n    const after = before.slice()\n    while (after.length <= col) after.push(null)\n    for (const l of ending) after[l] = null\n\n    const outgoing: Edge[] = []\n    commit.parents.forEach((parent, index) => {\n      let target = after.indexOf(parent)\n      if (target === -1) {\n        if (index === 0) target = col\n        else {\n          target = after.indexOf(null)\n          if (target === -1) target = after.length\n          laneColor[target] = nextColor++ % LANE_COLORS.length\n        }\n        while (after.length <= target) after.push(null)\n        after[target] = parent\n        if (index === 0) laneColor[target] = laneColor[col] ?? 0\n      }\n      outgoing.push({ from: col, to: target, color: laneColor[target] ?? 0 })\n    })\n\n    const through = before\n      .map((h, l) => (h !== null && h !== commit.hash && after[l] === h ? { lane: l, color: laneColor[l] ?? 0 } : null))\n      .filter((x): x is { lane: number; color: number } => x !== null)\n\n    while (after.length && after[after.length - 1] === null) after.pop()\n    lanes = after\n    width = Math.max(width, before.length, after.length, col + 1)\n    rows.push({ commit, col, color: laneColor[col] ?? 0, through, incoming, outgoing })\n  }\n  return { rows, width }\n}\n\nconst LANE = 18\nconst ROW = 56\n\nfunction curve(x1: number, y1: number, x2: number, y2: number) {\n  if (x1 === x2) return `M${x1} ${y1} L${x2} ${y2}`\n  const mid = (y1 + y2) / 2\n  return `M${x1} ${y1} C${x1} ${mid} ${x2} ${mid} ${x2} ${y2}`\n}\n\nfunction shortDate(iso: string) {\n  const d = new Date(iso)\n  if (Number.isNaN(d.getTime())) return iso\n  return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`\n}\n\nfunction hue(text: string) {\n  let h = 0\n  for (let i = 0; i < text.length; i++) h = (h * 31 + text.charCodeAt(i)) >>> 0\n  return h % TONES.length\n}\n\ntype GitGraphProps = Omit<React.ComponentProps<\"div\">, \"onSelect\" | \"children\"> & {\n  /** Commits, newest first, parents after children. */\n  commits: GitCommit[]\n  /** Selected commit hash (controlled). */\n  selected?: string\n  /** Initially selected hash when uncontrolled. */\n  defaultSelected?: string\n  /** Called with the hash of the chosen commit. */\n  onSelect?: (hash: string, commit: GitCommit) => void\n  /** Accessible name of the list. */\n  label?: string\n  /** Hide the author column. */\n  hideAuthors?: boolean\n}\n\nfunction GitGraph({\n  commits,\n  selected: selectedProp,\n  defaultSelected,\n  onSelect,\n  label,\n  hideAuthors = false,\n  className,\n  ...props\n}: GitGraphProps) {\n  const msg = useMessages()\n  label ??= msg(\"git-graph.label\", \"Commit history\")\n  const uid = React.useId()\n  const { rows, width } = React.useMemo(() => layout(commits), [commits])\n  const [internal, setInternal] = React.useState(defaultSelected)\n  const selected = selectedProp ?? internal\n  const [active, setActive] = React.useState(0)\n  const selectedIndex = Math.max(0, rows.findIndex((r) => r.commit.hash === selected))\n  const optionId = (i: number) => `${uid}-${i}`\n  const gutter = width * LANE + 12\n\n  function choose(index: number) {\n    const row = rows[index]\n    if (!row) return\n    setActive(index)\n    if (selectedProp === undefined) setInternal(row.commit.hash)\n    onSelect?.(row.commit.hash, row.commit)\n  }\n\n  function onKeyDown(event: React.KeyboardEvent) {\n    const last = rows.length - 1\n    const map: Record<string, number> = {\n      ArrowDown: Math.min(last, active + 1),\n      ArrowUp: Math.max(0, active - 1),\n      Home: 0,\n      End: last,\n    }\n    if (event.key in map) {\n      event.preventDefault()\n      setActive(map[event.key]!)\n      document.getElementById(optionId(map[event.key]!))?.scrollIntoView({ block: \"nearest\" })\n    } else if (event.key === \"Enter\" || event.key === \" \") {\n      event.preventDefault()\n      choose(active)\n    }\n  }\n\n  return (\n    <div\n      data-slot=\"git-graph\"\n      className={cn(\"@container w-full overflow-hidden rounded-xl border bg-card text-card-foreground\", className)}\n      {...props}\n    >\n      <div\n        role=\"listbox\"\n        aria-label={label}\n        tabIndex={0}\n        aria-activedescendant={optionId(active)}\n        onKeyDown={onKeyDown}\n        onFocus={() => setActive((a) => (a === 0 && selectedIndex ? selectedIndex : a))}\n        className=\"max-h-[32rem] overflow-auto outline-none focus-visible:ring-[3px] focus-visible:ring-inset focus-visible:ring-ring/50\"\n      >\n        {rows.map((row, i) => {\n          const c = row.commit\n          const isSelected = c.hash === selected\n          const merge = c.parents.length > 1\n          const x = (lane: number) => lane * LANE + LANE / 2 + 6\n          const mid = ROW / 2\n          const refs = [...(c.branches ?? []), ...(c.tags ?? [])]\n          const refChips = refs.map((r) => {\n            const tag = c.tags?.includes(r) && !c.branches?.includes(r)\n            return (\n              <span key={r} className=\"inline-flex shrink-0 items-center gap-1 rounded-full border bg-background px-1.5 py-px font-mono text-[11px] text-foreground\">\n                {tag ? <Tag aria-hidden=\"true\" className=\"size-3\" /> : <GitBranch aria-hidden=\"true\" className=\"size-3\" />}\n                {r}\n              </span>\n            )\n          })\n          const desc = `${c.message}, by ${c.author}, ${shortDate(c.date)}, ${c.hash.slice(0, 7)}${merge ? \", merge commit\" : \"\"}${c.branches?.length ? `, branch ${c.branches.join(\", \")}` : \"\"}${c.tags?.length ? `, tag ${c.tags.join(\", \")}` : \"\"}`\n          return (\n            <div\n              key={c.hash}\n              id={optionId(i)}\n              role=\"option\"\n              aria-selected={isSelected}\n              aria-label={desc}\n              data-active={active === i || undefined}\n              onClick={() => choose(i)}\n              className={cn(\n                \"relative flex cursor-pointer items-center gap-3 pe-4 transition-colors duration-100 select-none hover:bg-accent/50 motion-reduce:transition-none\",\n                isSelected && \"bg-accent\",\n                active === i && \"outline-2 -outline-offset-2 outline-ring/60 [&:not(:focus-within)]:outline-0 group-focus-within:outline-2\"\n              )}\n              style={{ height: ROW }}\n            >\n              <svg aria-hidden=\"true\" width={gutter} height={ROW} className=\"shrink-0 overflow-visible\">\n                {row.through.map((t) => (\n                  <path key={`t${t.lane}`} d={`M${x(t.lane)} 0 L${x(t.lane)} ${ROW}`} stroke={LANE_COLORS[t.color]} strokeWidth=\"2\" fill=\"none\" />\n                ))}\n                {row.incoming.map((e, k) => (\n                  <path key={`i${k}`} d={curve(x(e.from), 0, x(e.to), mid)} stroke={LANE_COLORS[e.color]} strokeWidth=\"2\" fill=\"none\" />\n                ))}\n                {row.outgoing.map((e, k) => (\n                  <path key={`o${k}`} d={curve(x(e.from), mid, x(e.to), ROW)} stroke={LANE_COLORS[e.color]} strokeWidth=\"2\" fill=\"none\" />\n                ))}\n                <circle cx={x(row.col)} cy={mid} r={merge ? 6 : 5} className=\"fill-card\" stroke={LANE_COLORS[row.color]} strokeWidth=\"2.5\" />\n                {merge && <circle cx={x(row.col)} cy={mid} r=\"2\" fill={LANE_COLORS[row.color]} />}\n              </svg>\n              <div className=\"min-w-0 flex-1 py-2\">\n                <div className=\"flex min-w-0 items-center gap-2\">\n                  <p className=\"truncate text-sm font-medium text-foreground\">{c.message}</p>\n                  <span className=\"hidden items-center gap-1.5 @md:flex\">{refChips}</span>\n                </div>\n                <p className=\"mt-0.5 flex items-center gap-2 overflow-hidden text-xs whitespace-nowrap text-muted-foreground\">\n                  <code className=\"font-mono text-foreground/80\">{c.hash.slice(0, 7)}</code>\n                  {merge && (\n                    <span className=\"inline-flex items-center gap-0.5\">\n                      <GitMerge aria-hidden=\"true\" className=\"size-3\" />\n                      {msg(\"git-graph.merge\", \"Merge\")}\n                    </span>\n                  )}\n                  <span className=\"flex items-center gap-1.5 @md:hidden\">{refChips}</span>\n                </p>\n              </div>\n              {!hideAuthors && (\n                <span className=\"hidden shrink-0 items-center gap-2 text-xs text-muted-foreground @md:flex\">\n                  <span aria-hidden=\"true\" className={cn(\"flex size-5 items-center justify-center rounded-full text-[10px] font-semibold text-background uppercase\", TONES[hue(c.author)])}>\n                    {c.author.charAt(0)}\n                  </span>\n                  <span className=\"max-w-24 truncate\">{c.author}</span>\n                </span>\n              )}\n              <time dateTime={c.date} className=\"w-12 shrink-0 text-end text-xs text-muted-foreground tabular-nums\">\n                {shortDate(c.date)}\n              </time>\n            </div>\n          )\n        })}\n      </div>\n    </div>\n  )\n}\n\nexport { GitGraph, layout as layoutCommits, type GitGraphProps, type GitCommit }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/git-graph.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "git",
      "commits",
      "history",
      "branches",
      "graph"
    ],
    "version": "1.0.0",
    "updated": "2026-10-01",
    "ai": {
      "summary": "commits is newest first: [{ hash, parents, message, author, date, branches?, tags? }]. Lanes are computed for you (layoutCommits is exported). selected/onSelect for a detail view. Rows are listbox options.",
      "whenToUse": [
        "Repository or release history views",
        "Explaining a branching strategy in docs"
      ],
      "whenNotToUse": [
        "Diffs (diff-viewer)",
        "Huge histories without paging"
      ],
      "composesWith": [
        "diff-viewer",
        "code-block",
        "badge"
      ],
      "a11y": [
        {
          "keys": "ArrowUp / ArrowDown",
          "action": "Moves the active commit"
        },
        {
          "keys": "Home / End",
          "action": "First or last commit"
        },
        {
          "keys": "Enter / Space",
          "action": "Selects the active commit"
        },
        {
          "keys": "Screen readers",
          "action": "Each option reads message, author, date, short hash, merge and refs"
        }
      ],
      "customization": [
        "selected / onSelect",
        "hideAuthors",
        "label"
      ]
    },
    "examples": [
      "git-graph-demo",
      "git-graph-linear"
    ],
    "url": "https://ui.ballmac.com/components/git-graph"
  },
  "categories": [
    "developer"
  ],
  "type": "registry:ui"
}