{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "branch-graph",
  "type": "registry:component",
  "title": "Branch Graph",
  "description": "A git log drawn as a graph: each branch gets its own coloured lane, merges sweep in on smooth curves, refs and tags sit on their commits, and hovering a row slides a detail card along with you.",
  "categories": [
    "component",
    "Dev Tools"
  ],
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/devl/branch-graph.tsx",
      "type": "registry:component",
      "content": "\"use client\";\n\nimport { GitBranchIcon, GitMergeIcon, TagIcon } from \"lucide-react\";\nimport { AnimatePresence, motion, useReducedMotion } from \"motion/react\";\nimport { type KeyboardEvent, useEffect, useMemo, useRef, useState } from \"react\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface CommitRef {\n  name: string;\n  type: \"branch\" | \"tag\";\n  current?: boolean;\n}\n\nexport interface Commit {\n  sha: string;\n  message: string;\n  body?: string;\n  author: { name: string; initials?: string; className?: string };\n  date: string;\n  parents: string[];\n  refs?: CommitRef[];\n  stats?: { files: number; additions: number; deletions: number };\n}\n\nexport interface BranchGraphProps {\n  commits: Commit[];\n  title?: string;\n  onSelect?: (commit: Commit) => void;\n  className?: string;\n}\n\nconst ROW = 36;\nconst LANE = 16;\nconst PAD = 14;\nconst R = 4;\n\nconst LANES = [\n  { stroke: \"stroke-sky-500\", fill: \"fill-sky-500\", dot: \"bg-sky-500\", chip: \"bg-sky-500/12 text-sky-700 dark:text-sky-300\", ring: \"stroke-sky-500/30\" },\n  { stroke: \"stroke-violet-500\", fill: \"fill-violet-500\", dot: \"bg-violet-500\", chip: \"bg-violet-500/12 text-violet-700 dark:text-violet-300\", ring: \"stroke-violet-500/30\" },\n  { stroke: \"stroke-emerald-500\", fill: \"fill-emerald-500\", dot: \"bg-emerald-500\", chip: \"bg-emerald-500/12 text-emerald-700 dark:text-emerald-300\", ring: \"stroke-emerald-500/30\" },\n  { stroke: \"stroke-amber-500\", fill: \"fill-amber-500\", dot: \"bg-amber-500\", chip: \"bg-amber-500/14 text-amber-800 dark:text-amber-300\", ring: \"stroke-amber-500/30\" },\n  { stroke: \"stroke-rose-500\", fill: \"fill-rose-500\", dot: \"bg-rose-500\", chip: \"bg-rose-500/12 text-rose-700 dark:text-rose-300\", ring: \"stroke-rose-500/30\" },\n  { stroke: \"stroke-cyan-500\", fill: \"fill-cyan-500\", dot: \"bg-cyan-500\", chip: \"bg-cyan-500/12 text-cyan-700 dark:text-cyan-300\", ring: \"stroke-cyan-500/30\" },\n];\nconst laneTone = (i: number) => LANES[i % LANES.length];\n\ninterface Edge {\n  from: number;\n  to: number;\n  fromLane: number;\n  toLane: number;\n  route: number;\n}\n\nfunction layout(commits: Commit[]) {\n  const row = new Map(commits.map((c, i) => [c.sha, i]));\n  const lanes: (string | null)[] = [];\n  const laneOf: number[] = [];\n  const pending: { from: number; parent: string; route: number }[] = [];\n  const free = (skip = -1) => {\n    const i = lanes.findIndex((l, k) => l === null && k !== skip);\n    return i === -1 ? lanes.length : i;\n  };\n  commits.forEach((c, i) => {\n    let lane = lanes.indexOf(c.sha);\n    if (lane === -1) lane = free();\n    lanes.forEach((l, k) => {\n      if (l === c.sha && k !== lane) lanes[k] = null;\n    });\n    laneOf[i] = lane;\n    lanes[lane] = c.parents[0] ?? null;\n    if (c.parents[0]) pending.push({ from: i, parent: c.parents[0], route: lane });\n    for (const p of c.parents.slice(1)) {\n      let j = lanes.indexOf(p);\n      if (j === -1) {\n        j = free(lane);\n        lanes[j] = p;\n      }\n      pending.push({ from: i, parent: p, route: j });\n    }\n    while (lanes.length && lanes[lanes.length - 1] === null) lanes.pop();\n  });\n  const edges: Edge[] = pending.map((e) => {\n    const to = row.get(e.parent);\n    return to === undefined\n      ? { from: e.from, to: commits.length, fromLane: laneOf[e.from], toLane: e.route, route: e.route }\n      : { from: e.from, to, fromLane: laneOf[e.from], toLane: laneOf[to], route: e.route };\n  });\n  const width = Math.max(...laneOf, ...edges.map((e) => e.route)) + 1;\n  return { laneOf, edges, width };\n}\n\nconst x = (lane: number) => PAD + lane * LANE;\nconst y = (r: number) => r * ROW + ROW / 2;\nconst curve = (x1: number, y1: number, x2: number, y2: number) => {\n  const m = (y2 - y1) / 2;\n  return `C ${x1} ${y1 + m} ${x2} ${y2 - m} ${x2} ${y2}`;\n};\n\nfunction edgePath(e: Edge) {\n  const x1 = x(e.fromLane);\n  const y1 = y(e.from);\n  const xr = x(e.route);\n  const x2 = x(e.toLane);\n  const y2 = y(e.to);\n  if (e.to - e.from === 1) return `M ${x1} ${y1} ${x1 === x2 ? `L ${x2} ${y2}` : curve(x1, y1, x2, y2)}`;\n  let d = `M ${x1} ${y1} `;\n  let cy = y1;\n  if (xr !== x1) {\n    d += `${curve(x1, y1, xr, y1 + ROW)} `;\n    cy = y1 + ROW;\n  }\n  if (xr !== x2) {\n    if (y2 - ROW > cy) d += `L ${xr} ${y2 - ROW} `;\n    d += curve(xr, y2 - ROW, x2, y2);\n  } else d += `L ${x2} ${y2}`;\n  return d;\n}\n\nconst MONTHS = [\"Jan\", \"Feb\", \"Mar\", \"Apr\", \"May\", \"Jun\", \"Jul\", \"Aug\", \"Sep\", \"Oct\", \"Nov\", \"Dec\"];\nconst pad2 = (n: number) => String(n).padStart(2, \"0\");\nconst shortDate = (iso: string) => {\n  const d = new Date(iso);\n  return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`;\n};\nconst longDate = (iso: string) => {\n  const d = new Date(iso);\n  return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()} · ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())} UTC`;\n};\nconst initialsOf = (name: string) =>\n  name\n    .split(/\\s+/)\n    .map((p) => p[0])\n    .join(\"\")\n    .slice(0, 2)\n    .toUpperCase();\n\nfunction Avatar({ author, size = \"sm\" }: { author: Commit[\"author\"]; size?: \"sm\" | \"md\" }) {\n  return (\n    <span\n      aria-hidden\n      className={cn(\n        \"grid shrink-0 place-items-center rounded-full font-medium\",\n        size === \"sm\" ? \"size-5 text-[9px]\" : \"size-6 text-[10px]\",\n        author.className ?? \"bg-muted text-muted-foreground\",\n      )}\n    >\n      {author.initials ?? initialsOf(author.name)}\n    </span>\n  );\n}\n\nexport function BranchGraph({ commits, title = \"History\", onSelect, className }: BranchGraphProps) {\n  const reduce = useReducedMotion();\n  const { laneOf, edges, width } = useMemo(() => layout(commits), [commits]);\n  const graphW = PAD * 2 + (width - 1) * LANE;\n  const [hover, setHover] = useState<number | null>(null);\n  const [focusRow, setFocusRow] = useState(0);\n  const [warm, setWarm] = useState(false);\n  const [shown, setShown] = useState<number | null>(null);\n  const delay = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const cool = useRef<ReturnType<typeof setTimeout> | null>(null);\n  const rowRefs = useRef<(HTMLDivElement | null)[]>([]);\n\n  useEffect(() => {\n    if (delay.current) clearTimeout(delay.current);\n    if (hover === null) {\n      cool.current = setTimeout(() => {\n        setShown(null);\n        setWarm(false);\n      }, 120);\n      return;\n    }\n    if (cool.current) clearTimeout(cool.current);\n    if (warm) setShown(hover);\n    else\n      delay.current = setTimeout(() => {\n        setShown(hover);\n        setWarm(true);\n      }, 380);\n  }, [hover, warm]);\n\n  useEffect(\n    () => () => {\n      if (delay.current) clearTimeout(delay.current);\n      if (cool.current) clearTimeout(cool.current);\n    },\n    [],\n  );\n\n  const onKey = (e: KeyboardEvent, i: number) => {\n    const next = e.key === \"ArrowDown\" ? i + 1 : e.key === \"ArrowUp\" ? i - 1 : e.key === \"Home\" ? 0 : e.key === \"End\" ? commits.length - 1 : -1;\n    if (next >= 0 && next < commits.length) {\n      e.preventDefault();\n      setFocusRow(next);\n      rowRefs.current[next]?.focus();\n    } else if (e.key === \"Enter\") onSelect?.(commits[i]);\n    else if (e.key === \"Escape\") setHover(null);\n  };\n\n  const card = shown !== null ? commits[shown] : null;\n  const cardBelow = shown !== null && shown < commits.length - 3;\n  const branches = new Set(commits.flatMap((c) => (c.refs ?? []).filter((r) => r.type === \"branch\").map((r) => r.name)));\n  const merges = commits.filter((c) => c.parents.length > 1).length;\n\n  return (\n    <div className={cn(\"w-full min-w-0 max-w-[600px] rounded-2xl border bg-card text-sm shadow-xs/5\", className)}>\n      <div className=\"flex items-center gap-3 border-b px-4 py-3\">\n        <h3 className=\"flex-1 font-medium\">{title}</h3>\n        <span className=\"flex items-center gap-1 text-muted-foreground text-xs tabular-nums\">\n          <GitBranchIcon className=\"size-3.5\" />\n          {branches.size} branches\n        </span>\n        <span className=\"flex items-center gap-1 text-muted-foreground text-xs tabular-nums\">\n          <GitMergeIcon className=\"size-3.5\" />\n          {merges} merges\n        </span>\n      </div>\n\n      <div className=\"relative py-1.5\" onMouseLeave={() => setHover(null)}>\n        <svg\n          aria-hidden\n          className=\"pointer-events-none absolute top-1.5 left-0\"\n          fill=\"none\"\n          height={commits.length * ROW}\n          width={graphW}\n        >\n          {edges.map((e, i) => (\n            <motion.path\n              animate={{ pathLength: 1, opacity: 1 }}\n              className={laneTone(e.route).stroke}\n              d={edgePath(e)}\n              initial={{ pathLength: 0, opacity: 0 }}\n              key={i}\n              strokeLinecap=\"round\"\n              strokeWidth={2}\n              transition={reduce ? { duration: 0 } : { duration: 0.5, delay: e.from * 0.035, ease: [0.23, 1, 0.32, 1] }}\n            />\n          ))}\n          {commits.map((c, i) => {\n            const tone = laneTone(laneOf[i]);\n            const active = hover === i || shown === i;\n            const merge = c.parents.length > 1;\n            return (\n              <g key={c.sha} transform={`translate(${x(laneOf[i])} ${y(i)})`}>\n                <motion.circle\n                  animate={{ scale: active ? 1 : 0.4, opacity: active ? 1 : 0 }}\n                  className={tone.ring}\n                  fill=\"none\"\n                  initial={false}\n                  r={9}\n                  strokeWidth={3}\n                  transition={{ duration: 0.18, ease: [0.23, 1, 0.32, 1] }}\n                />\n                <motion.circle\n                  animate={{ scale: active ? 1.35 : 1, opacity: 1 }}\n                  className={cn(merge ? \"fill-card\" : tone.fill, tone.stroke)}\n                  initial={{ scale: 0.6, opacity: 0 }}\n                  r={merge ? R + 0.5 : R}\n                  strokeWidth={merge ? 2.5 : 0}\n                  transition={reduce ? { duration: 0 } : { type: \"spring\", stiffness: 520, damping: 26, delay: active ? 0 : i * 0.035 }}\n                />\n              </g>\n            );\n          })}\n        </svg>\n\n        <div aria-label=\"Commits\" role=\"list\">\n          {commits.map((c, i) => {\n            const tone = laneTone(laneOf[i]);\n            return (\n              <div\n                aria-label={`${c.message}, ${c.author.name}, ${shortDate(c.date)}, ${c.sha.slice(0, 7)}`}\n                className={cn(\n                  \"relative flex cursor-default items-center gap-2 pe-4 outline-none transition-colors duration-100\",\n                  hover === i ? \"bg-accent/60\" : \"\",\n                  \"focus-visible:bg-accent/60 focus-visible:shadow-[inset_2px_0_0_var(--color-ring)]\",\n                )}\n                key={c.sha}\n                onBlur={() => setHover(null)}\n                onClick={() => onSelect?.(c)}\n                onFocus={() => {\n                  setFocusRow(i);\n                  setHover(i);\n                }}\n                onKeyDown={(e) => onKey(e, i)}\n                onMouseEnter={() => setHover(i)}\n                ref={(el) => {\n                  rowRefs.current[i] = el;\n                }}\n                role=\"listitem\"\n                style={{ height: ROW, paddingInlineStart: graphW + 4 }}\n                tabIndex={i === focusRow ? 0 : -1}\n              >\n                {(c.refs ?? []).map((ref) => (\n                  <span\n                    className={cn(\n                      \"inline-flex h-5 shrink-0 items-center gap-1 rounded-md px-1.5 font-medium font-mono text-[10.5px] max-sm:hidden\",\n                      ref.type === \"tag\" ? \"border bg-background text-muted-foreground\" : tone.chip,\n                      ref.current && \"ring-1 ring-current/40\",\n                    )}\n                    key={ref.name}\n                  >\n                    {ref.type === \"tag\" ? <TagIcon className=\"size-2.5\" /> : <GitBranchIcon className=\"size-2.5\" />}\n                    {ref.name}\n                  </span>\n                ))}\n                <span className={cn(\"min-w-0 flex-1 truncate\", c.parents.length > 1 && \"text-muted-foreground\")}>{c.message}</span>\n                <span className=\"shrink-0 max-sm:hidden\">\n                  <Avatar author={c.author} />\n                </span>\n                <span className=\"w-12 shrink-0 text-end text-muted-foreground text-xs tabular-nums max-sm:hidden\">{shortDate(c.date)}</span>\n                <code className=\"w-[52px] shrink-0 text-end font-mono text-muted-foreground text-xs\">{c.sha.slice(0, 7)}</code>\n              </div>\n            );\n          })}\n        </div>\n\n        <AnimatePresence>\n          {card && shown !== null && (\n            <motion.div\n              animate={{ opacity: 1, scale: 1, y: cardBelow ? y(shown) + ROW / 2 + 6 : y(shown) - ROW / 2 - 6 }}\n              aria-hidden\n              className=\"pointer-events-none absolute top-1.5 z-20 w-[300px]\"\n              exit={{ opacity: 0, scale: 0.97, transition: { duration: 0.12 } }}\n              initial={{ opacity: 0, scale: 0.97, y: cardBelow ? y(shown) + ROW / 2 + 6 : y(shown) - ROW / 2 - 6 }}\n              style={{ left: graphW + 2, maxWidth: `calc(100% - ${graphW + 14}px)`, transformOrigin: \"top left\" }}\n              transition={{\n                y: reduce ? { duration: 0 } : { type: \"spring\", stiffness: 600, damping: 44 },\n                opacity: { duration: 0.14 },\n                scale: { duration: 0.16, ease: [0.23, 1, 0.32, 1] },\n              }}\n            >\n              <div\n                className={cn(\n                  \"rounded-xl border bg-popover p-3 text-popover-foreground shadow-lg/5\",\n                  !cardBelow && \"-translate-y-full\",\n                )}\n              >\n                <div className=\"flex items-start gap-2.5\">\n                  <Avatar author={card.author} size=\"md\" />\n                  <div className=\"min-w-0 flex-1\">\n                    <p className=\"font-medium text-sm leading-5 text-pretty\">{card.message}</p>\n                    <p className=\"mt-0.5 text-muted-foreground text-xs\">\n                      {card.author.name} · {longDate(card.date)}\n                    </p>\n                  </div>\n                </div>\n                {card.body && <p className=\"mt-2 text-muted-foreground text-xs leading-5 text-pretty\">{card.body}</p>}\n                <div className=\"mt-2.5 flex items-center gap-2 border-t pt-2.5 font-mono text-xs\">\n                  <span className={cn(\"size-2 rounded-full\", laneTone(laneOf[shown]).dot)} />\n                  <span className=\"text-foreground\">{card.sha.slice(0, 10)}</span>\n                  {card.parents.length > 1 ? (\n                    <span className=\"truncate text-muted-foreground\">\n                      merges {card.parents.map((p) => p.slice(0, 7)).join(\" + \")}\n                    </span>\n                  ) : (\n                    card.stats && (\n                      <span className=\"ms-auto flex items-center gap-1.5 tabular-nums\">\n                        <span className=\"text-success-foreground\">+{card.stats.additions}</span>\n                        <span className=\"text-destructive-foreground\">−{card.stats.deletions}</span>\n                        <span className=\"font-sans text-muted-foreground\">\n                          · {card.stats.files} file{card.stats.files === 1 ? \"\" : \"s\"}\n                        </span>\n                      </span>\n                    )\n                  )}\n                </div>\n              </div>\n            </motion.div>\n          )}\n        </AnimatePresence>\n      </div>\n    </div>\n  );\n}\n"
    }
  ]
}