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.
npx shadcn add https://design.helpmarq.com /r/git-graph-ascii-lanes.jsonregistry/core/git-graph-ascii-lanes/component.tsx"use client";
import { useCallback, useMemo, useRef, useState } from "react";
// ---------------------------------------------------------------------------
// GitGraphAsciiLanes — a commit DAG drawn the way `git log --graph` draws one.
//
// The mechanic is a real lane allocator, not a pretty picture of one: a single
// `active: (string|null)[]` array is walked top-to-bottom over the commit list,
// each commit claiming the slot that already expects it (or the lowest free
// slot, or a new one), freeing every OTHER slot that also expected it (a branch
// converging), and opening a slot per extra parent (a merge fanning out). The
// pairs of lanes that open and close between two commit rows are exactly what
// the `├─┐` / `├─┘` connector rows draw, and any live lane a horizontal run
// crosses becomes `┼`.
//
// The whole grid is one `useMemo` over (commits, collapsedMerges) that starts
// from an EMPTY `active` array every time. Nothing is ever patched in place —
// collapsing a merge's side branch re-derives all of it, which is why the braid
// visibly straightens instead of leaving a stale column behind.
// ---------------------------------------------------------------------------
export interface GitCommit {
id: string;
subject: string;
author: string;
date: string;
parents: string[];
/** Branch name, used only to label a collapsed side branch. */
branch?: string;
}
export interface GitGraphAsciiLanesProps {
commits?: GitCommit[];
/** Commit id HEAD points at. Defaults to the first (newest) commit. */
head?: string;
className?: string;
}
const MAX_LANES = 6;
const DEFAULT_COMMITS: GitCommit[] = [
{ id: "4e1b7a2", subject: "Bump lockfile after security audit", author: "nik", date: "2h", parents: ["9d2f014"] },
{ id: "9d2f014", subject: "Merge branch 'feat/router' into main", author: "nik", date: "5h", parents: ["1f8a4c3", "3ad91e0"] },
{ id: "1f8a4c3", subject: "Fix range clamp on resize", author: "dee", date: "1d", parents: ["7c02be9"] },
{ id: "7c02be9", subject: "Merge branch 'fix/hydration' into main", author: "sam", date: "2d", parents: ["b6e5170", "0c9d3f4"] },
{ id: "3ad91e0", subject: "Guard against an empty route table", author: "rui", date: "1d", branch: "feat/router", parents: ["c81ba07"] },
{ id: "c81ba07", subject: "Cache compiled matchers per segment", author: "rui", date: "2d", branch: "feat/router", parents: ["5f47d92"] },
{ id: "5f47d92", subject: "Move param parsing off the hot path", author: "rui", date: "3d", branch: "feat/router", parents: ["e29c1b8"] },
{ id: "e29c1b8", subject: "Sketch the segment router interface", author: "rui", date: "3d", branch: "feat/router", parents: ["b6e5170"] },
{ id: "0c9d3f4", subject: "Drop the client-only theme read", author: "dee", date: "2d", branch: "fix/hydration", parents: ["d4a8e6b"] },
{ id: "d4a8e6b", subject: "Reproduce the hydration mismatch in a test", author: "dee", date: "3d", branch: "fix/hydration", parents: ["f70b25c"] },
{ id: "b6e5170", subject: "Split the token map out of the theme provider", author: "nik", date: "4d", parents: ["f70b25c"] },
{ id: "f70b25c", subject: "Add lane allocation to the history view", author: "sam", date: "5d", parents: ["8ae3c40"] },
{ id: "8ae3c40", subject: "Initial commit", author: "nik", date: "6d", parents: [] },
];
type Cell = { ch: string; owner: string | null; lane: number };
type Row =
| { kind: "commit"; key: string; commit: GitCommit; lane: number; isMerge: boolean; cells: (Cell | null)[] }
| { kind: "connector"; key: string; cells: (Cell | null)[] }
| { kind: "group"; key: string; count: number; branch: string; cells: (Cell | null)[] };
/** Every commit reachable from `start` by walking `parents`. */
function reachable(byId: Map<string, GitCommit>, start: string): Set<string> {
const seen = new Set<string>();
const queue = [start];
while (queue.length) {
const id = queue.shift() as string;
if (seen.has(id)) continue;
seen.add(id);
const node = byId.get(id);
if (node) for (const p of node.parents) if (!seen.has(p)) queue.push(p);
}
return seen;
}
function buildGrid(commits: GitCommit[], collapsed: ReadonlySet<string>) {
const byId = new Map(commits.map((c) => [c.id, c]));
// --- which rows are hidden behind a collapsed merge -----------------------
const hidden = new Set<string>();
const groups = new Map<string, { count: number; branch: string }>();
for (const mergeId of collapsed) {
const m = byId.get(mergeId);
if (!m || m.parents.length < 2) continue;
const mainline = reachable(byId, m.parents[0]);
const side = new Set<string>();
for (const p of m.parents.slice(1)) {
for (const id of reachable(byId, p)) if (!mainline.has(id)) side.add(id);
}
if (side.size === 0) continue;
// A side commit that some OTHER visible commit still points at must stay
// visible: hiding it would leave the lane expecting it open all the way to
// the bottom of the graph. Refuse the collapse rather than emit a stray lane.
let escapes = false;
for (const other of commits) {
if (other.id === mergeId || side.has(other.id) || hidden.has(other.id)) continue;
if (other.parents.some((p) => side.has(p))) {
escapes = true;
break;
}
}
if (escapes) continue;
for (const id of side) hidden.add(id);
groups.set(mergeId, {
count: side.size,
branch: byId.get(m.parents[1])?.branch ?? "side branch",
});
}
// --- the lane allocator ---------------------------------------------------
const active: (string | null)[] = [];
let laneCount = 0;
type Pair = { a: number; b: number; open: boolean };
const raw: {
commit: GitCommit;
lane: number;
isMerge: boolean;
snapshot: (string | null)[];
after: (string | null)[];
pairs: Pair[];
}[] = [];
for (const c of commits) {
if (hidden.has(c.id)) continue;
// A collapsed merge is walked as if it had only its first parent, so the
// side lane is never opened at all — that is what straightens the braid.
const parents = collapsed.has(c.id) && groups.has(c.id) ? c.parents.slice(0, 1) : c.parents;
let lane = active.indexOf(c.id);
if (lane === -1) lane = active.indexOf(null);
if (lane === -1) lane = active.push(null) - 1;
active[lane] = c.id;
const snapshot = active.slice();
const pairs: Pair[] = [];
// every OTHER slot expecting this commit is a branch converging here
for (let i = 0; i < active.length; i++) {
if (i !== lane && active[i] === c.id) {
active[i] = null;
pairs.push({ a: i, b: lane, open: false });
}
}
active[lane] = parents[0] ?? null;
for (let k = 1; k < parents.length; k++) {
const p = parents[k];
let pLane = active.indexOf(p);
if (pLane === -1) pLane = active.indexOf(null);
if (pLane === -1) pLane = active.push(null) - 1;
active[pLane] = p;
pairs.push({ a: lane, b: pLane, open: true });
}
laneCount = Math.max(laneCount, active.length, snapshot.length);
raw.push({ commit: c, lane, isMerge: c.parents.length > 1, snapshot, after: active.slice(), pairs });
}
// --- glyph emission -------------------------------------------------------
const overflow = laneCount > MAX_LANES;
const colOf = (lane: number) => Math.min(lane, MAX_LANES - 1) * 2;
const laneGlyph = (lane: number, ch: string) => (overflow && lane >= MAX_LANES - 1 ? "⋯" : ch);
const width = Math.min(laneCount, MAX_LANES) * 2 - 1;
const blank = (): (Cell | null)[] => new Array(Math.max(width, 1)).fill(null);
const rows: Row[] = [];
for (const r of raw) {
// commit row: ● (or ◍ for a merge) at its own lane, │ at every other live lane
const cells = blank();
for (let i = 0; i < r.snapshot.length; i++) {
const owner = r.snapshot[i];
if (!owner) continue;
const ch = i === r.lane ? (r.isMerge ? "◍" : "●") : "│";
cells[colOf(i)] = { ch: laneGlyph(i, ch), owner, lane: i };
}
rows.push({ kind: "commit", key: r.commit.id, commit: r.commit, lane: r.lane, isMerge: r.isMerge, cells });
const group = groups.get(r.commit.id);
if (group) {
const gcells = blank();
for (let i = 0; i < r.after.length; i++) {
const owner = r.after[i];
if (owner) gcells[colOf(i)] = { ch: laneGlyph(i, "│"), owner, lane: i };
}
rows.push({ kind: "group", key: `${r.commit.id}:group`, count: group.count, branch: group.branch, cells: gcells });
}
if (r.pairs.length === 0) continue;
// connector row: live lanes first, then every open/close pair drawn over them
const cc = blank();
for (let i = 0; i < r.after.length; i++) {
const owner = r.after[i];
if (owner) cc[colOf(i)] = { ch: laneGlyph(i, "│"), owner, lane: i };
}
for (const pair of r.pairs) {
const lo = Math.min(pair.a, pair.b);
const hi = Math.max(pair.a, pair.b);
const cLo = colOf(lo);
const cHi = colOf(hi);
cc[cLo] = { ch: "├", owner: r.commit.id, lane: lo };
for (let x = cLo + 1; x < cHi; x++) {
const held = cc[x];
cc[x] = held && held.ch === "│" ? { ...held, ch: "┼" } : { ch: "─", owner: r.commit.id, lane: -1 };
}
cc[cHi] = { ch: pair.open ? "┐" : "┘", owner: r.commit.id, lane: hi };
}
rows.push({ kind: "connector", key: `${r.commit.id}:conn`, cells: cc });
}
return { rows, byId };
}
export function GitGraphAsciiLanes({
commits = DEFAULT_COMMITS,
head,
className = "",
}: GitGraphAsciiLanesProps) {
const [collapsed, setCollapsed] = useState<ReadonlySet<string>>(() => new Set<string>());
const [hoverId, setHoverId] = useState<string | null>(null);
const [pinnedId, setPinnedId] = useState<string | null>(null);
const [focusIndex, setFocusIndex] = useState(0);
const buttonsRef = useRef<(HTMLButtonElement | null)[]>([]);
const headId = head ?? commits[0]?.id ?? null;
const activeId = hoverId ?? pinnedId;
const { rows, byId } = useMemo(() => buildGrid(commits, collapsed), [commits, collapsed]);
const ancestors = useMemo(() => {
if (!activeId) return null;
return reachable(byId, activeId);
}, [activeId, byId]);
const commitRows = rows.filter((r) => r.kind === "commit") as Extract<Row, { kind: "commit" }>[];
// Collapsing a merge removes rows, so a stale focusIndex could point past the
// end and leave NO row with tabIndex 0 — Tab would then skip the graph
// entirely. Clamp at render instead of trusting the stored index.
const rovingIndex = Math.min(focusIndex, Math.max(0, commitRows.length - 1));
buttonsRef.current.length = commitRows.length;
const lit = useCallback(
(owner: string | null) => !ancestors || (owner !== null && ancestors.has(owner)),
[ancestors],
);
const toneOf = (cell: Cell) => {
if (ancestors) {
if (!lit(cell.owner)) return "text-border";
if (cell.owner === headId && (cell.ch === "●" || cell.ch === "◍")) return "text-accent";
return "text-foreground";
}
if (cell.owner === headId && (cell.ch === "●" || cell.ch === "◍")) return "text-accent";
// At rest the whole braid must be legible — the trunk reads at full ink and
// every other lane at --muted. --border is reserved for the DIMMED state
// above, so "not an ancestor" stays visually distinct from "off-trunk".
return cell.lane === 0 ? "text-foreground" : "text-muted";
};
const glyphs = (cells: (Cell | null)[]) => (
<span aria-hidden className="whitespace-pre">
{cells.map((cell, i) =>
cell ? (
<span key={i} className={`transition-colors duration-[140ms] motion-reduce:transition-none ${toneOf(cell)}`}>
{cell.ch}
</span>
) : (
<span key={i}> </span>
),
)}
</span>
);
const moveFocus = (next: number) => {
const clamped = Math.max(0, Math.min(commitRows.length - 1, next));
setFocusIndex(clamped);
buttonsRef.current[clamped]?.focus();
};
const toggleCollapse = (id: string) => {
setCollapsed((prev) => {
const nx = new Set(prev);
if (nx.has(id)) nx.delete(id);
else nx.add(id);
return nx;
});
};
let commitIndex = -1;
return (
<div className={`ns-gga font-mono text-[12px] leading-[20px] ${className}`}>
<style>{CSS}</style>
<div role="group" aria-label="Commit history graph">
{rows.map((row) => {
if (row.kind === "connector") {
return (
<div key={row.key} className="ns-gga-line select-none" aria-hidden>
{glyphs(row.cells)}
</div>
);
}
if (row.kind === "group") {
return (
<div key={row.key} data-commit-group className="ns-gga-line flex items-center gap-3 select-none">
{glyphs(row.cells)}
<span className="text-muted">
⋯ {row.count} commits from {row.branch}
</span>
</div>
);
}
commitIndex += 1;
const idx = commitIndex;
const c = row.commit;
const dim = ancestors !== null && !ancestors.has(c.id);
const isHead = c.id === headId;
const shaTone = dim ? "text-border" : isHead ? "text-accent" : "text-muted";
const subjectTone = dim ? "text-border" : "text-foreground";
const metaTone = dim ? "text-border" : "text-muted";
return (
<button
key={row.key}
type="button"
ref={(el) => {
buttonsRef.current[idx] = el;
}}
data-commit={c.id}
{...(row.isMerge ? { "data-commit-merge": "" } : {})}
tabIndex={idx === rovingIndex ? 0 : -1}
aria-expanded={row.isMerge ? !collapsed.has(c.id) : undefined}
aria-label={
`${c.subject}. Commit ${c.id} by ${c.author}, ${c.date} ago` +
(isHead ? ", HEAD" : "") +
(row.isMerge
? `. Merge commit — press Enter to ${collapsed.has(c.id) ? "expand" : "collapse"} the merged branch`
: ". Press Enter to hold its ancestor highlight")
}
className="ns-gga-row ns-gga-line flex w-full items-center gap-3 text-left"
onPointerEnter={() => setHoverId(c.id)}
onPointerLeave={() => setHoverId((cur) => (cur === c.id ? null : cur))}
onFocus={() => {
setFocusIndex(idx);
setHoverId(c.id);
}}
onBlur={() => setHoverId((cur) => (cur === c.id ? null : cur))}
onClick={() => {
if (row.isMerge) toggleCollapse(c.id);
else setPinnedId((cur) => (cur === c.id ? null : c.id));
}}
onKeyDown={(e) => {
if (e.key === "ArrowDown") {
e.preventDefault();
moveFocus(idx + 1);
} else if (e.key === "ArrowUp") {
e.preventDefault();
moveFocus(idx - 1);
} else if (e.key === "Home") {
e.preventDefault();
moveFocus(0);
} else if (e.key === "End") {
e.preventDefault();
moveFocus(commitRows.length - 1);
} else if (e.key === "Escape") {
setHoverId(null);
setPinnedId(null);
}
}}
>
{glyphs(row.cells)}
<span className={`shrink-0 transition-colors duration-[140ms] motion-reduce:transition-none ${shaTone}`}>
{c.id}
</span>
<span
className={`flex-1 truncate transition-colors duration-[140ms] motion-reduce:transition-none ${subjectTone}`}
>
{c.subject}
</span>
<span
className={`shrink-0 tabular-nums transition-colors duration-[140ms] motion-reduce:transition-none ${metaTone}`}
>
{c.author} · {c.date}
</span>
</button>
);
})}
</div>
</div>
);
}
const CSS = `
.ns-gga-line { min-height: 20px; }
.ns-gga-row { cursor: pointer; }
.ns-gga-row:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
`;
Pick git-graph-ascii-lanes when the subject is a branching commit history that must read as a terminal git graph — lane columns, `├─┐`/`├─┘` connectors, ancestor lighting on hover, and collapsible merge side-branches. diagram-ascii-flow is the better pick for a free-positioned, user-draggable process graph with an orthogonal router; tree-box-drawing for a strict expand/collapse hierarchy with no lane allocation at all.
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 `--muted`, shas and author/age are `--muted`, and HEAD's dot and sha are `--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` (`--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 `--accent` at `-2px` offset on every row. Tokens only (`--background --foreground --muted --border --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.