"use client"; import { GitBranchIcon, GitMergeIcon, TagIcon } from "lucide-react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { type KeyboardEvent, useEffect, useMemo, useRef, useState } from "react"; import { cn } from "@/lib/utils"; export interface CommitRef { name: string; type: "branch" | "tag"; current?: boolean; } export interface Commit { sha: string; message: string; body?: string; author: { name: string; initials?: string; className?: string }; date: string; parents: string[]; refs?: CommitRef[]; stats?: { files: number; additions: number; deletions: number }; } export interface BranchGraphProps { commits: Commit[]; title?: string; onSelect?: (commit: Commit) => void; className?: string; } const ROW = 36; const LANE = 16; const PAD = 14; const R = 4; const LANES = [ { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, { 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" }, ]; const laneTone = (i: number) => LANES[i % LANES.length]; interface Edge { from: number; to: number; fromLane: number; toLane: number; route: number; } function layout(commits: Commit[]) { const row = new Map(commits.map((c, i) => [c.sha, i])); const lanes: (string | null)[] = []; const laneOf: number[] = []; const pending: { from: number; parent: string; route: number }[] = []; const free = (skip = -1) => { const i = lanes.findIndex((l, k) => l === null && k !== skip); return i === -1 ? lanes.length : i; }; commits.forEach((c, i) => { let lane = lanes.indexOf(c.sha); if (lane === -1) lane = free(); lanes.forEach((l, k) => { if (l === c.sha && k !== lane) lanes[k] = null; }); laneOf[i] = lane; lanes[lane] = c.parents[0] ?? null; if (c.parents[0]) pending.push({ from: i, parent: c.parents[0], route: lane }); for (const p of c.parents.slice(1)) { let j = lanes.indexOf(p); if (j === -1) { j = free(lane); lanes[j] = p; } pending.push({ from: i, parent: p, route: j }); } while (lanes.length && lanes[lanes.length - 1] === null) lanes.pop(); }); const edges: Edge[] = pending.map((e) => { const to = row.get(e.parent); return to === undefined ? { from: e.from, to: commits.length, fromLane: laneOf[e.from], toLane: e.route, route: e.route } : { from: e.from, to, fromLane: laneOf[e.from], toLane: laneOf[to], route: e.route }; }); const width = Math.max(...laneOf, ...edges.map((e) => e.route)) + 1; return { laneOf, edges, width }; } const x = (lane: number) => PAD + lane * LANE; const y = (r: number) => r * ROW + ROW / 2; const curve = (x1: number, y1: number, x2: number, y2: number) => { const m = (y2 - y1) / 2; return `C ${x1} ${y1 + m} ${x2} ${y2 - m} ${x2} ${y2}`; }; function edgePath(e: Edge) { const x1 = x(e.fromLane); const y1 = y(e.from); const xr = x(e.route); const x2 = x(e.toLane); const y2 = y(e.to); if (e.to - e.from === 1) return `M ${x1} ${y1} ${x1 === x2 ? `L ${x2} ${y2}` : curve(x1, y1, x2, y2)}`; let d = `M ${x1} ${y1} `; let cy = y1; if (xr !== x1) { d += `${curve(x1, y1, xr, y1 + ROW)} `; cy = y1 + ROW; } if (xr !== x2) { if (y2 - ROW > cy) d += `L ${xr} ${y2 - ROW} `; d += curve(xr, y2 - ROW, x2, y2); } else d += `L ${x2} ${y2}`; return d; } const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; const pad2 = (n: number) => String(n).padStart(2, "0"); const shortDate = (iso: string) => { const d = new Date(iso); return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}`; }; const longDate = (iso: string) => { const d = new Date(iso); return `${MONTHS[d.getUTCMonth()]} ${d.getUTCDate()}, ${d.getUTCFullYear()} · ${pad2(d.getUTCHours())}:${pad2(d.getUTCMinutes())} UTC`; }; const initialsOf = (name: string) => name .split(/\s+/) .map((p) => p[0]) .join("") .slice(0, 2) .toUpperCase(); function Avatar({ author, size = "sm" }: { author: Commit["author"]; size?: "sm" | "md" }) { return ( {author.initials ?? initialsOf(author.name)} ); } export function BranchGraph({ commits, title = "History", onSelect, className }: BranchGraphProps) { const reduce = useReducedMotion(); const { laneOf, edges, width } = useMemo(() => layout(commits), [commits]); const graphW = PAD * 2 + (width - 1) * LANE; const [hover, setHover] = useState(null); const [focusRow, setFocusRow] = useState(0); const [warm, setWarm] = useState(false); const [shown, setShown] = useState(null); const delay = useRef | null>(null); const cool = useRef | null>(null); const rowRefs = useRef<(HTMLDivElement | null)[]>([]); useEffect(() => { if (delay.current) clearTimeout(delay.current); if (hover === null) { cool.current = setTimeout(() => { setShown(null); setWarm(false); }, 120); return; } if (cool.current) clearTimeout(cool.current); if (warm) setShown(hover); else delay.current = setTimeout(() => { setShown(hover); setWarm(true); }, 380); }, [hover, warm]); useEffect( () => () => { if (delay.current) clearTimeout(delay.current); if (cool.current) clearTimeout(cool.current); }, [], ); const onKey = (e: KeyboardEvent, i: number) => { const next = e.key === "ArrowDown" ? i + 1 : e.key === "ArrowUp" ? i - 1 : e.key === "Home" ? 0 : e.key === "End" ? commits.length - 1 : -1; if (next >= 0 && next < commits.length) { e.preventDefault(); setFocusRow(next); rowRefs.current[next]?.focus(); } else if (e.key === "Enter") onSelect?.(commits[i]); else if (e.key === "Escape") setHover(null); }; const card = shown !== null ? commits[shown] : null; const cardBelow = shown !== null && shown < commits.length - 3; const branches = new Set(commits.flatMap((c) => (c.refs ?? []).filter((r) => r.type === "branch").map((r) => r.name))); const merges = commits.filter((c) => c.parents.length > 1).length; return (

{title}

{branches.size} branches {merges} merges
setHover(null)}> {edges.map((e, i) => ( ))} {commits.map((c, i) => { const tone = laneTone(laneOf[i]); const active = hover === i || shown === i; const merge = c.parents.length > 1; return ( ); })}
{commits.map((c, i) => { const tone = laneTone(laneOf[i]); return (
setHover(null)} onClick={() => onSelect?.(c)} onFocus={() => { setFocusRow(i); setHover(i); }} onKeyDown={(e) => onKey(e, i)} onMouseEnter={() => setHover(i)} ref={(el) => { rowRefs.current[i] = el; }} role="listitem" style={{ height: ROW, paddingInlineStart: graphW + 4 }} tabIndex={i === focusRow ? 0 : -1} > {(c.refs ?? []).map((ref) => ( {ref.type === "tag" ? : } {ref.name} ))} 1 && "text-muted-foreground")}>{c.message} {shortDate(c.date)} {c.sha.slice(0, 7)}
); })}
{card && shown !== null && (

{card.message}

{card.author.name} · {longDate(card.date)}

{card.body &&

{card.body}

}
{card.sha.slice(0, 10)} {card.parents.length > 1 ? ( merges {card.parents.map((p) => p.slice(0, 7)).join(" + ")} ) : ( card.stats && ( +{card.stats.additions} −{card.stats.deletions} · {card.stats.files} file{card.stats.files === 1 ? "" : "s"} ) )}
)}
); }