{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "git-graph-ascii-lanes",
  "title": "Git Graph ASCII Lanes",
  "description": "A commit-history DAG rendered as `git log --graph` does, where a real lane-allocation pass assigns each branch a column and emits `│ ├─┐ ┘ ┼` connector rows, and hovering a commit lights its full ancestor set through the lanes.",
  "dependencies": [],
  "files": [
    {
      "path": "registry/core/git-graph-ascii-lanes/component.tsx",
      "content": "\"use client\";\n\nimport { useCallback, useMemo, useRef, useState } from \"react\";\n\n// ---------------------------------------------------------------------------\n// GitGraphAsciiLanes — a commit DAG drawn the way `git log --graph` draws one.\n//\n// The mechanic is a real lane allocator, not a pretty picture of one: a single\n// `active: (string|null)[]` array is walked top-to-bottom over the commit list,\n// each commit claiming the slot that already expects it (or the lowest free\n// slot, or a new one), freeing every OTHER slot that also expected it (a branch\n// converging), and opening a slot per extra parent (a merge fanning out). The\n// pairs of lanes that open and close between two commit rows are exactly what\n// the `├─┐` / `├─┘` connector rows draw, and any live lane a horizontal run\n// crosses becomes `┼`.\n//\n// The whole grid is one `useMemo` over (commits, collapsedMerges) that starts\n// from an EMPTY `active` array every time. Nothing is ever patched in place —\n// collapsing a merge's side branch re-derives all of it, which is why the braid\n// visibly straightens instead of leaving a stale column behind.\n// ---------------------------------------------------------------------------\n\nexport interface GitCommit {\n  id: string;\n  subject: string;\n  author: string;\n  date: string;\n  parents: string[];\n  /** Branch name, used only to label a collapsed side branch. */\n  branch?: string;\n}\n\nexport interface GitGraphAsciiLanesProps {\n  /** the commits, newest first */\n  commits?: GitCommit[];\n  /** Commit id HEAD points at. Defaults to the first (newest) commit. */\n  head?: string;\n  /** extra classes merged onto the rendered root element */\n  className?: string;\n}\n\nconst MAX_LANES = 6;\n\nconst DEFAULT_COMMITS: GitCommit[] = [\n  { id: \"4e1b7a2\", subject: \"Bump lockfile after security audit\", author: \"nik\", date: \"2h\", parents: [\"9d2f014\"] },\n  { id: \"9d2f014\", subject: \"Merge branch 'feat/router' into main\", author: \"nik\", date: \"5h\", parents: [\"1f8a4c3\", \"3ad91e0\"] },\n  { id: \"1f8a4c3\", subject: \"Fix range clamp on resize\", author: \"dee\", date: \"1d\", parents: [\"7c02be9\"] },\n  { id: \"7c02be9\", subject: \"Merge branch 'fix/hydration' into main\", author: \"sam\", date: \"2d\", parents: [\"b6e5170\", \"0c9d3f4\"] },\n  { id: \"3ad91e0\", subject: \"Guard against an empty route table\", author: \"rui\", date: \"1d\", branch: \"feat/router\", parents: [\"c81ba07\"] },\n  { id: \"c81ba07\", subject: \"Cache compiled matchers per segment\", author: \"rui\", date: \"2d\", branch: \"feat/router\", parents: [\"5f47d92\"] },\n  { id: \"5f47d92\", subject: \"Move param parsing off the hot path\", author: \"rui\", date: \"3d\", branch: \"feat/router\", parents: [\"e29c1b8\"] },\n  { id: \"e29c1b8\", subject: \"Sketch the segment router interface\", author: \"rui\", date: \"3d\", branch: \"feat/router\", parents: [\"b6e5170\"] },\n  { id: \"0c9d3f4\", subject: \"Drop the client-only theme read\", author: \"dee\", date: \"2d\", branch: \"fix/hydration\", parents: [\"d4a8e6b\"] },\n  { id: \"d4a8e6b\", subject: \"Reproduce the hydration mismatch in a test\", author: \"dee\", date: \"3d\", branch: \"fix/hydration\", parents: [\"f70b25c\"] },\n  { id: \"b6e5170\", subject: \"Split the token map out of the theme provider\", author: \"nik\", date: \"4d\", parents: [\"f70b25c\"] },\n  { id: \"f70b25c\", subject: \"Add lane allocation to the history view\", author: \"sam\", date: \"5d\", parents: [\"8ae3c40\"] },\n  { id: \"8ae3c40\", subject: \"Initial commit\", author: \"nik\", date: \"6d\", parents: [] },\n];\n\ntype Cell = { ch: string; owner: string | null; lane: number };\n\ntype Row =\n  | { kind: \"commit\"; key: string; commit: GitCommit; lane: number; isMerge: boolean; cells: (Cell | null)[] }\n  | { kind: \"connector\"; key: string; cells: (Cell | null)[] }\n  | { kind: \"group\"; key: string; count: number; branch: string; cells: (Cell | null)[] };\n\n/** Every commit reachable from `start` by walking `parents`. */\nfunction reachable(byId: Map<string, GitCommit>, start: string): Set<string> {\n  const seen = new Set<string>();\n  const queue = [start];\n  while (queue.length) {\n    const id = queue.shift() as string;\n    if (seen.has(id)) continue;\n    seen.add(id);\n    const node = byId.get(id);\n    if (node) for (const p of node.parents) if (!seen.has(p)) queue.push(p);\n  }\n  return seen;\n}\n\nfunction buildGrid(commits: GitCommit[], collapsed: ReadonlySet<string>) {\n  const byId = new Map(commits.map((c) => [c.id, c]));\n\n  // --- which rows are hidden behind a collapsed merge -----------------------\n  const hidden = new Set<string>();\n  const groups = new Map<string, { count: number; branch: string }>();\n  for (const mergeId of collapsed) {\n    const m = byId.get(mergeId);\n    if (!m || m.parents.length < 2) continue;\n    const mainline = reachable(byId, m.parents[0]);\n    const side = new Set<string>();\n    for (const p of m.parents.slice(1)) {\n      for (const id of reachable(byId, p)) if (!mainline.has(id)) side.add(id);\n    }\n    if (side.size === 0) continue;\n    // A side commit that some OTHER visible commit still points at must stay\n    // visible: hiding it would leave the lane expecting it open all the way to\n    // the bottom of the graph. Refuse the collapse rather than emit a stray lane.\n    let escapes = false;\n    for (const other of commits) {\n      if (other.id === mergeId || side.has(other.id) || hidden.has(other.id)) continue;\n      if (other.parents.some((p) => side.has(p))) {\n        escapes = true;\n        break;\n      }\n    }\n    if (escapes) continue;\n    for (const id of side) hidden.add(id);\n    groups.set(mergeId, {\n      count: side.size,\n      branch: byId.get(m.parents[1])?.branch ?? \"side branch\",\n    });\n  }\n\n  // --- the lane allocator ---------------------------------------------------\n  const active: (string | null)[] = [];\n  let laneCount = 0;\n  type Pair = { a: number; b: number; open: boolean };\n  const raw: {\n    commit: GitCommit;\n    lane: number;\n    isMerge: boolean;\n    snapshot: (string | null)[];\n    after: (string | null)[];\n    pairs: Pair[];\n  }[] = [];\n\n  for (const c of commits) {\n    if (hidden.has(c.id)) continue;\n    // A collapsed merge is walked as if it had only its first parent, so the\n    // side lane is never opened at all — that is what straightens the braid.\n    const parents = collapsed.has(c.id) && groups.has(c.id) ? c.parents.slice(0, 1) : c.parents;\n\n    let lane = active.indexOf(c.id);\n    if (lane === -1) lane = active.indexOf(null);\n    if (lane === -1) lane = active.push(null) - 1;\n    active[lane] = c.id;\n\n    const snapshot = active.slice();\n    const pairs: Pair[] = [];\n\n    // every OTHER slot expecting this commit is a branch converging here\n    for (let i = 0; i < active.length; i++) {\n      if (i !== lane && active[i] === c.id) {\n        active[i] = null;\n        pairs.push({ a: i, b: lane, open: false });\n      }\n    }\n\n    active[lane] = parents[0] ?? null;\n\n    for (let k = 1; k < parents.length; k++) {\n      const p = parents[k];\n      let pLane = active.indexOf(p);\n      if (pLane === -1) pLane = active.indexOf(null);\n      if (pLane === -1) pLane = active.push(null) - 1;\n      active[pLane] = p;\n      pairs.push({ a: lane, b: pLane, open: true });\n    }\n\n    laneCount = Math.max(laneCount, active.length, snapshot.length);\n    raw.push({ commit: c, lane, isMerge: c.parents.length > 1, snapshot, after: active.slice(), pairs });\n  }\n\n  // --- glyph emission -------------------------------------------------------\n  const overflow = laneCount > MAX_LANES;\n  const colOf = (lane: number) => Math.min(lane, MAX_LANES - 1) * 2;\n  const laneGlyph = (lane: number, ch: string) => (overflow && lane >= MAX_LANES - 1 ? \"⋯\" : ch);\n  const width = Math.min(laneCount, MAX_LANES) * 2 - 1;\n\n  const blank = (): (Cell | null)[] => new Array(Math.max(width, 1)).fill(null);\n  const rows: Row[] = [];\n\n  for (const r of raw) {\n    // commit row: ● (or ◍ for a merge) at its own lane, │ at every other live lane\n    const cells = blank();\n    for (let i = 0; i < r.snapshot.length; i++) {\n      const owner = r.snapshot[i];\n      if (!owner) continue;\n      const ch = i === r.lane ? (r.isMerge ? \"◍\" : \"●\") : \"│\";\n      cells[colOf(i)] = { ch: laneGlyph(i, ch), owner, lane: i };\n    }\n    rows.push({ kind: \"commit\", key: r.commit.id, commit: r.commit, lane: r.lane, isMerge: r.isMerge, cells });\n\n    const group = groups.get(r.commit.id);\n    if (group) {\n      const gcells = blank();\n      for (let i = 0; i < r.after.length; i++) {\n        const owner = r.after[i];\n        if (owner) gcells[colOf(i)] = { ch: laneGlyph(i, \"│\"), owner, lane: i };\n      }\n      rows.push({ kind: \"group\", key: `${r.commit.id}:group`, count: group.count, branch: group.branch, cells: gcells });\n    }\n\n    if (r.pairs.length === 0) continue;\n\n    // connector row: live lanes first, then every open/close pair drawn over them\n    const cc = blank();\n    for (let i = 0; i < r.after.length; i++) {\n      const owner = r.after[i];\n      if (owner) cc[colOf(i)] = { ch: laneGlyph(i, \"│\"), owner, lane: i };\n    }\n    for (const pair of r.pairs) {\n      const lo = Math.min(pair.a, pair.b);\n      const hi = Math.max(pair.a, pair.b);\n      const cLo = colOf(lo);\n      const cHi = colOf(hi);\n      cc[cLo] = { ch: \"├\", owner: r.commit.id, lane: lo };\n      for (let x = cLo + 1; x < cHi; x++) {\n        const held = cc[x];\n        cc[x] = held && held.ch === \"│\" ? { ...held, ch: \"┼\" } : { ch: \"─\", owner: r.commit.id, lane: -1 };\n      }\n      cc[cHi] = { ch: pair.open ? \"┐\" : \"┘\", owner: r.commit.id, lane: hi };\n    }\n    rows.push({ kind: \"connector\", key: `${r.commit.id}:conn`, cells: cc });\n  }\n\n  return { rows, byId };\n}\n\nexport function GitGraphAsciiLanes({\n  commits = DEFAULT_COMMITS,\n  head,\n  className = \"\",\n}: GitGraphAsciiLanesProps) {\n  const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set<string>());\n  const [hoverId, setHoverId] = useState<string | null>(null);\n  const [pinnedId, setPinnedId] = useState<string | null>(null);\n  const [focusIndex, setFocusIndex] = useState(0);\n  const buttonsRef = useRef<(HTMLButtonElement | null)[]>([]);\n\n  const headId = head ?? commits[0]?.id ?? null;\n  const activeId = hoverId ?? pinnedId;\n\n  const { rows, byId } = useMemo(() => buildGrid(commits, collapsed), [commits, collapsed]);\n\n  const ancestors = useMemo(() => {\n    if (!activeId) return null;\n    return reachable(byId, activeId);\n  }, [activeId, byId]);\n\n  const commitRows = rows.filter((r) => r.kind === \"commit\") as Extract<Row, { kind: \"commit\" }>[];\n  // Collapsing a merge removes rows, so a stale focusIndex could point past the\n  // end and leave NO row with tabIndex 0 — Tab would then skip the graph\n  // entirely. Clamp at render instead of trusting the stored index.\n  const rovingIndex = Math.min(focusIndex, Math.max(0, commitRows.length - 1));\n  buttonsRef.current.length = commitRows.length;\n\n  const lit = useCallback(\n    (owner: string | null) => !ancestors || (owner !== null && ancestors.has(owner)),\n    [ancestors],\n  );\n\n  const toneOf = (cell: Cell) => {\n    if (ancestors) {\n      if (!lit(cell.owner)) return \"text-border\";\n      if (cell.owner === headId && (cell.ch === \"●\" || cell.ch === \"◍\")) return \"text-ns-accent\";\n      return \"text-foreground\";\n    }\n    if (cell.owner === headId && (cell.ch === \"●\" || cell.ch === \"◍\")) return \"text-ns-accent\";\n    // At rest the whole braid must be legible — the trunk reads at full ink and\n    // every other lane at --ns-muted. --border is reserved for the DIMMED state\n    // above, so \"not an ancestor\" stays visually distinct from \"off-trunk\".\n    return cell.lane === 0 ? \"text-foreground\" : \"text-ns-muted\";\n  };\n\n  const glyphs = (cells: (Cell | null)[]) => (\n    <span aria-hidden className=\"whitespace-pre\">\n      {cells.map((cell, i) =>\n        cell ? (\n          <span key={i} className={`transition-colors duration-[140ms] motion-reduce:transition-none ${toneOf(cell)}`}>\n            {cell.ch}\n          </span>\n        ) : (\n          <span key={i}> </span>\n        ),\n      )}\n    </span>\n  );\n\n  const moveFocus = (next: number) => {\n    const clamped = Math.max(0, Math.min(commitRows.length - 1, next));\n    setFocusIndex(clamped);\n    buttonsRef.current[clamped]?.focus();\n  };\n\n  const toggleCollapse = (id: string) => {\n    setCollapsed((prev) => {\n      const nx = new Set(prev);\n      if (nx.has(id)) nx.delete(id);\n      else nx.add(id);\n      return nx;\n    });\n  };\n\n  let commitIndex = -1;\n\n  return (\n    <div className={`ns-gga font-mono text-[12px] leading-[20px] ${className}`}>\n      <style>{CSS}</style>\n      <div role=\"group\" aria-label=\"Commit history graph\">\n        {rows.map((row) => {\n          if (row.kind === \"connector\") {\n            return (\n              <div key={row.key} className=\"ns-gga-line select-none\" aria-hidden>\n                {glyphs(row.cells)}\n              </div>\n            );\n          }\n\n          if (row.kind === \"group\") {\n            return (\n              <div key={row.key} data-commit-group className=\"ns-gga-line flex items-center gap-3 select-none\">\n                {glyphs(row.cells)}\n                <span className=\"text-ns-muted\">\n                  ⋯ {row.count} commits from {row.branch}\n                </span>\n              </div>\n            );\n          }\n\n          commitIndex += 1;\n          const idx = commitIndex;\n          const c = row.commit;\n          const dim = ancestors !== null && !ancestors.has(c.id);\n          const isHead = c.id === headId;\n          const shaTone = dim ? \"text-border\" : isHead ? \"text-ns-accent\" : \"text-ns-muted\";\n          const subjectTone = dim ? \"text-border\" : \"text-foreground\";\n          const metaTone = dim ? \"text-border\" : \"text-ns-muted\";\n\n          return (\n            <button\n              key={row.key}\n              type=\"button\"\n              ref={(el) => {\n                buttonsRef.current[idx] = el;\n              }}\n              data-commit={c.id}\n              {...(row.isMerge ? { \"data-commit-merge\": \"\" } : {})}\n              tabIndex={idx === rovingIndex ? 0 : -1}\n              aria-expanded={row.isMerge ? !collapsed.has(c.id) : undefined}\n              aria-label={\n                `${c.subject}. Commit ${c.id} by ${c.author}, ${c.date} ago` +\n                (isHead ? \", HEAD\" : \"\") +\n                (row.isMerge\n                  ? `. Merge commit — press Enter to ${collapsed.has(c.id) ? \"expand\" : \"collapse\"} the merged branch`\n                  : \". Press Enter to hold its ancestor highlight\")\n              }\n              className=\"ns-gga-row ns-gga-line flex w-full items-center gap-3 text-left\"\n              onPointerEnter={() => setHoverId(c.id)}\n              onPointerLeave={() => setHoverId((cur) => (cur === c.id ? null : cur))}\n              onFocus={() => {\n                setFocusIndex(idx);\n                setHoverId(c.id);\n              }}\n              onBlur={() => setHoverId((cur) => (cur === c.id ? null : cur))}\n              onClick={() => {\n                if (row.isMerge) toggleCollapse(c.id);\n                else setPinnedId((cur) => (cur === c.id ? null : c.id));\n              }}\n              onKeyDown={(e) => {\n                if (e.key === \"ArrowDown\") {\n                  e.preventDefault();\n                  moveFocus(idx + 1);\n                } else if (e.key === \"ArrowUp\") {\n                  e.preventDefault();\n                  moveFocus(idx - 1);\n                } else if (e.key === \"Home\") {\n                  e.preventDefault();\n                  moveFocus(0);\n                } else if (e.key === \"End\") {\n                  e.preventDefault();\n                  moveFocus(commitRows.length - 1);\n                } else if (e.key === \"Escape\") {\n                  setHoverId(null);\n                  setPinnedId(null);\n                }\n              }}\n            >\n              {glyphs(row.cells)}\n              <span className={`shrink-0 transition-colors duration-[140ms] motion-reduce:transition-none ${shaTone}`}>\n                {c.id}\n              </span>\n              <span\n                className={`flex-1 truncate transition-colors duration-[140ms] motion-reduce:transition-none ${subjectTone}`}\n              >\n                {c.subject}\n              </span>\n              <span\n                className={`shrink-0 tabular-nums transition-colors duration-[140ms] motion-reduce:transition-none ${metaTone}`}\n              >\n                {c.author} · {c.date}\n              </span>\n            </button>\n          );\n        })}\n      </div>\n    </div>\n  );\n}\n\nconst CSS = `\n.ns-gga-line { min-height: 20px; }\n.ns-gga-row { cursor: pointer; }\n.ns-gga-row:focus-visible { outline: 2px solid var(--ns-accent); outline-offset: -2px; }\n`;\n",
      "type": "registry:ui",
      "target": "components/ui/git-graph-ascii-lanes.tsx"
    }
  ],
  "cssVars": {
    "theme": {
      "color-ns-muted": "var(--ns-muted)",
      "color-ns-accent": "var(--ns-accent)"
    },
    "light": {
      "ns-muted": "#4d4d4d",
      "ns-accent": "#006bff"
    },
    "dark": {
      "ns-muted": "#8f8f8f"
    }
  },
  "meta": {
    "collection": "core",
    "tags": [
      "git",
      "graph",
      "dag",
      "ascii",
      "box-drawing",
      "history",
      "keyboard-navigation",
      "developer-tools"
    ],
    "instruction": "Build <GitGraphAsciiLanes commits? head? className?> where `commits` is `{id, subject, author, date, parents: string[], branch?}[]` in newest-first topological order, and render it exactly the way `git log --graph` renders a history: a monospace lane grid of DOM rows, no canvas and no rAF loop anywhere. THE LANE ALLOCATOR (the actual mechanic, not a router): keep one `active: (string|null)[]` array, initially empty, and walk the commit list top to bottom. For each commit c: `lane = active.indexOf(c.id)`; if -1, `lane = active.indexOf(null)`; if still -1, `lane = active.push(null) - 1`; then `active[lane] = c.id` so the row's snapshot shows the slot occupied. Snapshot `active` for the commit row, then free every OTHER slot still holding c.id — a branch converging — setting it null and recording a CLOSE pair `(thatLane -> lane)`. Set `active[lane] = c.parents[0] ?? null`. For each extra parent p at index >= 1 (i.e. a merge): `pLane = active.indexOf(p)`, else the lowest null slot, else a pushed new slot; `active[pLane] = p`; record an OPEN pair `(lane -> pLane)`. ROW RENDERING: every lane is 2 characters wide (glyph plus a spacer column). The commit row draws `●` at the commit's own lane — `◍` instead when `parents.length > 1`, so a merge is distinguishable by GLYPH and never by hue — `│` at every other non-null lane, and a space at null lanes. The CONNECTOR row is emitted between two commit rows only when that commit produced open or close pairs: live lanes are laid down as `│` first, then for each pair spanning lanes a<b it writes `├` at a, `─` across every intermediate column, and `┐` at b for an open pair or `┘` at b for a close pair; any live lane whose `│` the horizontal run crosses is upgraded to `┼`, and untouched live lanes keep their `│`. Lanes past MAX_LANES = 6 collapse: the last lane column (index MAX_LANES - 1) renders `⋯` and every lane at or beyond it is drawn there. The entire grid is ONE `useMemo` over (commits, collapsedMerges) that rebuilds from an EMPTY `active` array — never patched incrementally — so layout is a pure function of the data. INK is monochrome plus one accent and carries no branch identity: at REST the first-parent trunk lane's glyphs and every commit subject are `--foreground`, every other lane glyph and connector is `--ns-muted`, shas and author/age are `--ns-muted`, and HEAD's dot and sha are `--ns-accent`. `--border` is reserved exclusively for the dimmed non-ancestor state below, so the resting braid is never drawn in the faintest token and is fully legible before any input. ANCESTOR LIGHTING: hovering or keyboard-focusing a row runs a BFS over `parents` from that commit, and every row NOT in the resulting ancestor set — plus every lane column whose current occupant is not in it — drops to `--border` while the ancestor chain and the lanes it occupies hold `--foreground` (`--ns-accent` on HEAD), so the branch you are asking about lights up through the braid. Pointer-out or blur restores full ink across a 140ms color transition with no state left behind. COLLAPSE: Enter or Space on a merge row toggles that merge into a `collapsedMerges` set; the grid pass then walks that merge as if it had only its first parent, hides every commit reachable from its second parent but NOT from its first (an exclusive-ancestor set difference), and emits a single `⋯ N commits from feat/router` row in their place — because the whole lane grid is re-derived, the side lane is never opened and the braid visibly straightens; Enter again expands it. Enter on a non-merge row instead pins its ancestor highlight so it survives pointer-out. KEYBOARD: roving tabindex across the commit rows with ArrowUp/ArrowDown and Home/End, Escape clearing both the hover and pinned highlight, a real `<button>` per commit carrying an `aria-label` naming the subject, sha, author, age, HEAD status and the available action, `aria-expanded` on merge rows, and a `:focus-visible` outline in `--ns-accent` at `-2px` offset on every row. Tokens only (`--background --foreground --ns-muted --border --ns-accent`) applied as Tailwind token classes, never hex, correct in both themes. There is no ambient animation to gate, and every transition is `motion-reduce:transition-none`, so `prefers-reduced-motion` leaves the component fully usable and instantly settled. Props: `commits` (defaults to a 13-commit sample history with two merges and two side branches), `head` (commit id HEAD points at, defaults to the newest commit), `className`. Zero dependencies."
  },
  "type": "registry:ui"
}