component — display
Commit Graph
A git history list with the branch/merge lane graph drawn down its left edge — the `git log --graph` surface every git client has. One row per commit: the graph column, the short sha in a Snippet so it copies, the subject line, ref badges that tell a branch from a tag from a remote from HEAD, the author, and the date through FormattedDate (never a bare toLocaleString, because the site is prerendered). The lane assignment is a pure exported function, layoutCommitGraph(commits), so merges, branch points, reused lanes, octopus merges, root commits and parents outside the window are unit-testable without rendering anything. It renders the commits it is given and never fetches or re-orders them — git log is already topologically ordered and already paginated, so re-sorting here would scramble the one thing the layout depends on; the next page is asked for through onLoadMore, whose affordance sits under the list because this component is what draws the 'there is more' signal, the edges running off the bottom of the last row. Omit onLoadMore and no pagination chrome is rendered at all. The graph column's appearance is four independent options — lineWeight, nodeSize, nodeShape and laneWidth — so it can sit quiet behind a dense log or be the subject of the screen; a merge is always drawn as the inverse of an ordinary marker and a hair larger, which is the one thing they cannot switch off, because it is what tells a merge apart without relying on colour.
- git
- commits
- history
- graph
- lanes
- list
- selection
- interactive
13 examples
Linear history
One branch, one lane. The oldest commit has no parents, so its line stops at the dot.
9f1c0a47c4e2b94a9d6f11b7e3c5A merge and a branch point
'feature/search' forks off the trunk and is merged back. The merge commit is a hollow dot, so the graph never depends on colour alone.
c0ffee1a17e5d29d3b7f1b00c1a55e8a2c7Ref badges
HEAD, a local branch, a remote branch and a tag each get their own intent and glyph.
c0ffee1a17e5d29d3b7f1b00c1a55e8a2c7Selection
Controlled through selectedSha / onSelectCommit. The callback always gets the full sha, not the short one the row displays.
c0ffee1a17e5d29d3b7f1b00c1a55e8a2c7Selected: a17e5d2c9b4f6081e3a25b670f8fe6191ea9b3ff
Pagination
onLoadMore reports the request out and the caller appends the next page; the control lives under the list because the list is what draws the signal it acts on. The oldest row's parent is outside this window, so its lane runs off the bottom — load the next page and the edge terminates on a root dot instead, and the control goes away with it, because a caller says "pagination is mine" by passing no onLoadMore at all.
3f6b1d88b1f4d62a5c9e7d4e8a1cOctopus merge
Three parents on one commit: three lanes arrive at the same dot.
0c70bd566ff77aaabbccd11aa22bRelative dates
relativeDates switches the column to '3 days ago'. FormattedDate computes it after mount and renders the absolute date until then, so a prerender and its hydration never disagree.
9f1c0a47c4e2b94a9d6f11b7e3c5Empty
A filtered history with no matches.
Line weight
How much of the screen the graph is allowed to be. hairline keeps it background behind a dense history; bold makes it the subject.
lineWeight="hairline"
c0ffee1a17e5d29d3b7f1b00c1a55e8a2c7lineWeight="regular"
c0ffee1a17e5d29d3b7f1b00c1a55e8a2c7lineWeight="bold"
c0ffee1a17e5d29d3b7f1b00c1a55e8a2c7Node size
The commit markers, from sm to lg. The lines are unchanged.
nodeSize="sm"
c0ffee1a17e5d29d3b7f1b00c1a55e8a2c7nodeSize="md"
c0ffee1a17e5d29d3b7f1b00c1a55e8a2c7nodeSize="lg"
c0ffee1a17e5d29d3b7f1b00c1a55e8a2c7Node shape
What a commit is drawn as. Whichever you pick, a merge is drawn as its inverse and a hair larger — the top row of every graph here is the merge — so a merge is never told apart by colour alone. That part is not an option.
nodeShape="dot"
c0ffee1a17e5d29d3b7f1b00c1a55e8a2c7nodeShape="ring"
c0ffee1a17e5d29d3b7f1b00c1a55e8a2c7nodeShape="square"
c0ffee1a17e5d29d3b7f1b00c1a55e8a2c7nodeShape="diamond"
c0ffee1a17e5d29d3b7f1b00c1a55e8a2c7Lane width
How much room the lanes get. Shown on the octopus merge, where three lanes are live at once and the difference is the width of the whole column.
laneWidth="tight"
0c70bd566ff77aaabbccd11aa22blaneWidth="regular"
0c70bd566ff77aaabbccd11aa22blaneWidth="wide"
0c70bd566ff77aaabbccd11aa22bComposed
The options are independent, so a dense log can go quiet (hairline, small, tight) or a small history can be the subject of the screen (bold, large, wide).
lineWeight="hairline" nodeSize="sm" laneWidth="tight"
c0ffee1a17e5d29d3b7f1b00c1a55e8a2c7lineWeight="bold" nodeSize="lg" nodeShape="ring" laneWidth="wide"
c0ffee1a17e5d29d3b7f1b00c1a55e8a2c7source
commit-graph.tsxCopy this into your project. Resolve its dependencies from the registryDependencies in the component's API entry.
"use client"
import { Cloud, GitBranch, GitCommitHorizontal, Tag } from "lucide-react"
import { useId } from "react"
import { Avatar } from "@/components/avatar"
import { Badge } from "@/components/badge"
import { Button } from "@/components/button"
import { FormattedDate } from "@/components/formatted-date"
import { GridList, GridListItem } from "@/components/grid-list"
import { Snippet } from "@/components/snippet"
import { cn } from "@/lib/utils"
/**
* CommitGraph — quebi design system
*
* A `git log --graph` surface: one row per commit, with the branch/merge graph
* drawn down the left edge.
*
* ## Who owns the query
*
* The same split as `DataTable` vs `ServerTable`: this component renders the
* commits it is handed and never fetches, filters or re-orders them. `git log`
* is already paginated and already topologically ordered, so sorting `commits`
* here would reorder one page of an answer the caller asked for — and worse, it
* would reorder the one thing the lane layout depends on. Pagination is reported
* out through `onLoadMore`; the affordance lives here rather than beside the
* component because *this* component is what draws the "there is more" signal —
* the edges that run off the bottom of the last row — so the control that acts
* on it belongs under them. Omit `onLoadMore` and nothing is rendered.
*
* ## Dates
*
* The date column goes through `FormattedDate`, never `toLocaleString()`: the
* site is prerendered, so an implicit locale is a hydration bug. `relativeDates`
* switches the column to "3 days ago", which `FormattedDate` computes after
* mount (rendering the absolute date until then) for the same reason.
*
* ## Lane colours
*
* Lane colour is a pure function of the lane index (`laneColor`), so a lane
* keeps its colour for as long as it is alive and a reused lane picks up the
* colour of its index rather than of its history. The colours are the ink
* ramp — quebi's text tokens, re-declared per theme — so the graph reads in
* light and dark without a second palette and without a hue: which branch is
* which is carried by position and the curves, the tone only helps.
*/
/** A ref pointing at a commit. `kind` drives the badge's weight and glyph. */
export interface CommitGraphRef {
name: string
kind: "branch" | "tag" | "head" | "remote"
}
/** One commit, in the shape `git log` hands it over. */
export interface CommitGraphCommit {
sha: string
/** 0 = root, 1 = normal, 2+ = merge. Parents outside the window are fine. */
parents: string[]
/** The subject line. The body is the caller's business. */
message: string
author: { name: string; avatarUrl?: string }
date: Date | string
refs?: CommitGraphRef[]
}
/**
* One line drawn inside a single row's band.
*
* A row's band runs from the top of the row to the bottom, with the commit's
* dot at the vertical middle. An edge therefore has two endpoints, each of which
* is either a lane on the band's boundary or the dot itself:
*
* - `top` → `bottom`: a lane passing through this row.
* - `top` → `node`: a child's line arriving at this commit (the commit's own
* lane, or a second lane merging into it).
* - `node` → `bottom`: a line leaving for one of this commit's parents. When
* that parent is outside the window the line simply keeps going, row after
* row, and runs off the bottom of the last one.
*
* ## Why a lane change is two half-edges and not one
*
* A line that changes lane is connecting two *dots*, and those dots are in
* different rows — so the transition between them belongs to the rule between
* the rows, not to either row's half. Drawn inside one half-band it is both
* asymmetric (all of the bend above the rule, nothing below) and too tight (the
* full sideways step crammed into half a row), and it arrives at the destination
* lane at the rule rather than at the dot it is actually going to.
*
* So one S-curve, spanning dot to dot and *centred on the rule*, is cut in half
* and each row draws its own side: the row above emits `bend: "bottom"` and the
* row below `bend: "top"`, both naming the same `fromLane` → `toLane` pair. The
* two halves are the exact halves of one cubic, so they meet at the rule with
* the same position and the same tangent, and each row still draws only within
* its own band.
*/
export interface CommitGraphEdge {
/**
* Lane the whole transition starts on. Equal to `toLane` for a straight line.
* Note this is the lane of the *transition*, not necessarily of this half: a
* `bend: "top"` half enters the band midway between the two lanes.
*/
fromLane: number
/** Lane the whole transition ends on. */
toLane: number
/** Where it starts: the top of the band, or this row's dot. */
from: "top" | "node"
/** Where it ends: the bottom of the band, or the dot line. */
to: "bottom" | "node"
/**
* Which half of the band carries the lane change, if any. `"bottom"` is the
* first half of the S (leaving), `"top"` the second (arriving), `"none"` a
* straight line.
*/
bend: "none" | "top" | "bottom"
}
/** One laid-out commit. */
export interface CommitGraphRow {
commit: CommitGraphCommit
/** Lane index the commit's dot sits on. */
lane: number
edges: CommitGraphEdge[]
/** More than one parent. */
isMerge: boolean
/** No parents at all — the start of history. */
isRoot: boolean
}
export interface CommitGraphLayout {
rows: CommitGraphRow[]
/** How many lanes the widest row uses — the graph column's width, in lanes. */
lanes: number
}
/** The half of a lane change the row below still owes. */
interface PendingBend {
fromLane: number
toLane: number
/** The destination lane already carried a line, so this arrives beside it. */
isJoin: boolean
}
/**
* Assign lanes and edges to an already topologically ordered list of commits.
*
* Exported and pure on purpose: this is the whole difficulty of the component
* and the drawing is easy once it is right, so it is unit-testable without
* rendering anything (`tests/commit-graph-layout.test.ts`).
*
* The algorithm is one pass, newest commit first, over a list of *active lanes*.
* A lane holds the sha it is still waiting for — the next commit that will
* appear on it. For each commit:
*
* 1. Every lane waiting for this sha ends here. The leftmost of them is the
* commit's own lane; the rest are branches merging in and are freed. A commit
* no lane was waiting for is a head, and claims the leftmost free lane.
* 2. The first parent continues on the commit's own lane, so a linear history
* draws one straight line.
* 3. Every other parent either joins a lane that is already waiting for it —
* which is what a branch point looks like from below — or claims a lane of
* its own. Freed lanes are reused from the left, so a lane that a branch
* finished with is picked up by the next unrelated branch.
*
* Nothing is ever re-indexed: a lane keeps its index for its whole life, which
* is what makes a pass-through edge a straight vertical line and lets a row be
* drawn from its own band alone.
*
* One invariant falls out of step 3 and is worth naming, because it decides what
* the drawing has to handle: since a lane is never opened for a parent another
* lane already awaits, **no two lanes ever hold the same sha**. So exactly one
* line can arrive at a commit from above, and two histories converge by one of
* them joining the other's lane from below — never by two lines meeting at the
* dot from above. `tests/commit-graph-layout.test.ts` pins that.
*/
export function layoutCommitGraph(commits: CommitGraphCommit[]): CommitGraphLayout {
// active[i] === the sha lane i is waiting for, or null when the lane is free.
const active: (string | null)[] = []
const rows: CommitGraphRow[] = []
let lanes = 0
/** Second halves owed to the next row, queued as this row emits the first. */
let pending: PendingBend[] = []
/** The leftmost free lane, growing the list only when there is none. */
const claim = () => {
const free = active.indexOf(null)
if (free !== -1) return free
active.push(null)
return active.length - 1
}
for (const commit of commits) {
const incoming = active.slice()
// At most one lane can be waiting: a lane is never opened for a parent that
// another lane already awaits, so no two lanes ever hold the same sha.
const waiting = incoming.indexOf(commit.sha)
const lane = waiting !== -1 ? waiting : claim()
// The lane waiting for this commit terminates at it — the first parent
// re-claims it below. Freeing it before the parents are placed is what lets
// a lane be reused by one of this commit's own parents.
if (waiting !== -1) active[waiting] = null
// Halves the row above left unfinished. A spawn lands *on* the lane's own
// line and is folded into it; a join arrives beside a line that is already
// there, so it stays a second edge and the two meet on the dot line.
const landing = new Map<number, number>()
const joining: CommitGraphEdge[] = []
for (const half of pending) {
if (half.isJoin) {
joining.push({
fromLane: half.fromLane,
toLane: half.toLane,
from: "top",
to: "node",
bend: "top",
})
} else {
landing.set(half.toLane, half.fromLane)
}
}
pending = []
const edges: CommitGraphEdge[] = []
for (let i = 0; i < incoming.length; i++) {
const expected = incoming[i]
if (expected === null) continue
// A lane is never opened for a parent another lane already awaits, so no
// two lanes ever hold the same sha and `waiting` is at most one long —
// `i` here is the commit's own lane whenever it is waiting for it. Lines
// converge by joining a lane from below, never by arriving side by side
// from above, which is why there is no case for that here.
const landed = landing.get(i)
edges.push({
fromLane: landed ?? i,
toLane: i,
from: "top",
to: expected === commit.sha ? "node" : "bottom",
bend: landed === undefined ? "none" : "top",
})
}
edges.push(...joining)
// A commit listing the same parent twice is malformed, but it costs one Set
// to draw it once rather than to draw two edges on top of each other.
const placed = new Set<string>()
for (let k = 0; k < commit.parents.length; k++) {
const parent = commit.parents[k]
if (placed.has(parent)) continue
placed.add(parent)
const existing = active.indexOf(parent)
let target: number
let isJoin: boolean
if (existing !== -1) {
// Another branch already leads to this parent: join it. This is the
// branch-point case, seen from the younger of the two children.
target = existing
isJoin = true
} else if (k === 0) {
target = lane
active[lane] = parent
isJoin = false
} else {
target = claim()
active[target] = parent
isJoin = false
}
if (target === lane) {
edges.push({ fromLane: lane, toLane: lane, from: "node", to: "bottom", bend: "none" })
} else {
// Only the first half here; the row below draws the rest, so the bend is
// centred on the rule and the line ends on the parent's dot.
edges.push({ fromLane: lane, toLane: target, from: "node", to: "bottom", bend: "bottom" })
pending.push({ fromLane: lane, toLane: target, isJoin })
}
}
let width = lane + 1
for (const edge of edges) {
width = Math.max(width, edge.fromLane + 1, edge.toLane + 1)
}
lanes = Math.max(lanes, width)
rows.push({
commit,
lane,
edges,
isMerge: commit.parents.length > 1,
isRoot: commit.parents.length === 0,
})
}
return { rows, lanes }
}
/** How thick the lane lines are drawn. */
export type CommitGraphLineWeight = "hairline" | "regular" | "bold"
/** How big a commit dot is. */
export type CommitGraphNodeSize = "sm" | "md" | "lg"
/** What a commit is drawn as. */
export type CommitGraphNodeShape = "dot" | "ring" | "square" | "diamond"
/** How far apart the lanes sit. */
export type CommitGraphLaneWidth = "tight" | "regular" | "wide"
const LINE_WEIGHTS: Record<CommitGraphLineWeight, number> = {
hairline: 1,
regular: 1.5,
bold: 2.5,
}
const NODE_RADII: Record<CommitGraphNodeSize, number> = { sm: 3, md: 4, lg: 5.5 }
const LANE_WIDTHS: Record<CommitGraphLaneWidth, number> = { tight: 12, regular: 16, wide: 22 }
/** Width of one lane, in SVG units — also the row band's horizontal step. */
const LANE_WIDTH = LANE_WIDTHS.regular
/** Height of one row's band. Pinned to the row's `h-14` so the lines join up. */
const ROW_HEIGHT = 56
/**
* Everything about the graph column's appearance, resolved from the props once
* so the drawing never reads a prop and a constant in the same breath.
*
* Row height is deliberately not in here. It is pinned to the row's `h-14` —
* the band is drawn at exactly `ROW_HEIGHT` and stretched to the row's content
* box, so the two have to move together or the lines stop meeting across rows.
* A density option is a real thing to want, but it is a change to that coupling
* rather than another entry in this object, so it is not being smuggled in here.
*/
interface GraphMetrics {
laneWidth: number
lineWidth: number
nodeRadius: number
nodeStroke: number
shape: CommitGraphNodeShape
}
/**
* Straight run kept between a dot and the start of a bend.
*
* A curve that begins on the dot reads as though the line is being swallowed by
* it: there is no moment where the line is simply *on* its lane, so the dot
* looks like a junction rather than a point the lane passes through. A short
* stem either side fixes that, and it tightens the bend for free — the same
* sideways step now happens over a shorter run, which is the smaller radius the
* shape wants.
*/
const NODE_STEM = 12
/** Half the bend's vertical span: what is left of a half-band after the stem. */
const BEND_SPAN = ROW_HEIGHT / 2 - NODE_STEM
/**
* The lane palette, written as whole `var(…)` literals on the theme's *runtime*
* variables.
*
* Both of those are deliberate, and neither is a style preference.
*
* **Why `--q-*` and not the `--color-quebi-*` alias.** quebi declares the
* aliases inside `@theme inline`, which makes emitting them Tailwind's job, and
* Tailwind only emits a theme variable it can see used — by scanning source text
* for the name. A colour that reaches the DOM through an SVG `stroke` attribute
* is not a utility class, so the only thing that can keep the variable alive is
* the literal name appearing in a file Tailwind scans. The `--q-*` variables are
* declared in ordinary `:root` / `.light` rules, so they exist no matter what a
* scanner concludes.
*
* **Why whole literals rather than a built string.** An assembled
* `var(--color-${name})` never appears in the source, so Tailwind never emits
* it, and the lane falls back to SVG's defaults — black dots and, because SVG's
* default stroke is `none`, no line at all. Keep these as literals.
*
* The palette is the ink ramp — `fg`, `fg-subtle`, `fg-muted` — ordered so the
* darkest and the lightest alternate. A lane line is a graphical object, so
* WCAG 1.4.11 asks 3:1 of it; every step of the text ramp clears 4.5:1 on the
* page in both themes. Three steps and not more: past that the greys stop
* being told apart, and a lane colour that cannot be told apart is noise.
*
* `tests/commit-graph-contrast.test.ts` checks both halves against
* `src/quebi-theme.css`: that every variable named here is really declared by
* both themes, and that each clears 3:1 against its own background.
*/
export const LANE_COLORS = [
"var(--q-fg)",
"var(--q-fg-subtle)",
"var(--q-fg-muted)",
] as const
/** The colour a lane index draws in. Stable, and defined for every integer. */
export function laneColor(lane: number): string {
const count = LANE_COLORS.length
return LANE_COLORS[((lane % count) + count) % count]
}
/** Horizontal centre of a lane within the band. */
function laneX(lane: number, laneWidth: number = LANE_WIDTH) {
return lane * laneWidth + laneWidth / 2
}
/**
* The `d` of one edge.
*
* A straight line stays in its lane. A lane change is a stem, then half of a
* single cubic: the line runs straight for `NODE_STEM` out of the dot, bends
* across `BEND_SPAN` either side of the rule, and runs straight again into the
* next dot. Splitting that cubic at `t = 0.5` (de Casteljau) gives the two
* halves below — which is why the control points look asymmetric: they are the
* halves of a symmetric curve, not two curves that happen to meet.
*
* The two halves are a 180° rotation of each other about the rule, so they pass
* through `((x1 + x2) / 2, rule)` with the same tangent and the join is
* invisible even though different rows draw them into different SVGs.
*/
export function edgePath(edge: CommitGraphEdge, laneWidth: number = LANE_WIDTH): string {
const middle = ROW_HEIGHT / 2
const x1 = laneX(edge.fromLane, laneWidth)
const x2 = laneX(edge.toLane, laneWidth)
const crossing = (x1 + x2) / 2
if (edge.bend === "none" || x1 === x2) {
const y1 = edge.from === "top" ? 0 : middle
const y2 = edge.to === "bottom" ? ROW_HEIGHT : middle
return `M ${x1} ${y1} L ${x1} ${y2}`
}
if (edge.bend === "top") {
// Second half: in at the rule, back on its lane a stem short of the dot
// line, then straight — through the dot, or on down the band.
const settled = edge.to === "bottom" ? ROW_HEIGHT : middle
return (
`M ${crossing} 0` +
` C ${(crossing + x2) / 2} ${BEND_SPAN / 4} ${x2} ${BEND_SPAN / 2} ${x2} ${BEND_SPAN}` +
` L ${x2} ${settled}`
)
}
// First half: straight out of the dot for a stem, then away to the rule,
// halfway across to its destination.
const start = edge.from === "top" ? 0 : middle
return (
`M ${x1} ${start} L ${x1} ${ROW_HEIGHT - BEND_SPAN}` +
` C ${x1} ${ROW_HEIGHT - BEND_SPAN / 2} ${(x1 + crossing) / 2} ${ROW_HEIGHT - BEND_SPAN / 4} ${crossing} ${ROW_HEIGHT}`
)
}
/** True when the edge spans two lanes and so needs a colour ramp. */
const isBlended = (edge: CommitGraphEdge) => edge.bend !== "none" && edge.fromLane !== edge.toLane
/**
* The gradient vector for a blended edge: the *whole* transition, including the
* half drawn by the other row, so the ramp continues across the rule instead of
* restarting. The half outside this band is simply clipped, and `pad` holds the
* end colours along the straight runs beyond it.
*/
function gradientVector(edge: CommitGraphEdge, laneWidth: number = LANE_WIDTH) {
const rule = edge.bend === "bottom" ? ROW_HEIGHT : 0
return {
x1: laneX(edge.fromLane, laneWidth),
x2: laneX(edge.toLane, laneWidth),
y1: rule - BEND_SPAN,
y2: rule + BEND_SPAN,
}
}
/** Unique within the document, which is what an SVG `url(#…)` reference needs. */
const edgeKey = (edge: CommitGraphEdge) =>
`${edge.from}:${edge.fromLane}-${edge.to}:${edge.toLane}-${edge.bend}`
/**
* One commit's marker.
*
* Shape is the caller's choice; whether it is filled is not. A merge is always
* drawn as the inverse of an ordinary commit — hollow where they are solid, and
* solid where they are hollow — plus a hair larger, so "this commit joined two
* histories" survives being read in greyscale, at a glance, or by someone who
* cannot tell the lane colours apart. That is the one thing a shape option is
* not allowed to switch off, so it is derived here rather than exposed.
*/
function CommitNode({
lane,
isMerge,
metrics,
}: {
lane: number
isMerge: boolean
metrics: GraphMetrics
}) {
const color = laneColor(lane)
const cx = laneX(lane, metrics.laneWidth)
const cy = ROW_HEIGHT / 2
const r = isMerge ? metrics.nodeRadius + 1 : metrics.nodeRadius
const solid = metrics.shape === "ring" ? isMerge : !isMerge
const paint = {
fill: solid ? color : "var(--q-bg)",
stroke: color,
strokeWidth: metrics.nodeStroke,
}
if (metrics.shape === "square") {
return <rect x={cx - r} y={cy - r} width={r * 2} height={r * 2} rx={1} {...paint} />
}
if (metrics.shape === "diamond") {
const d = `M ${cx} ${cy - r} L ${cx + r} ${cy} L ${cx} ${cy + r} L ${cx - r} ${cy} Z`
return <path d={d} {...paint} />
}
return <circle cx={cx} cy={cy} r={r} {...paint} />
}
/** A row's slice of the graph. Decorative — the row's text carries the meaning. */
function CommitGraphLanes({
row,
lanes,
namespace,
metrics,
}: {
row: CommitGraphRow
lanes: number
namespace: string
metrics: GraphMetrics
}) {
const width = Math.max(lanes, 1) * metrics.laneWidth
const blended = row.edges.filter(isBlended)
return (
<svg
data-slot="commit-graph-lanes"
aria-hidden="true"
focusable="false"
width={width}
height={ROW_HEIGHT}
viewBox={`0 0 ${width} ${ROW_HEIGHT}`}
className="shrink-0 self-stretch"
>
{blended.length > 0 && (
<defs>
{blended.map((edge) => (
<linearGradient
key={edgeKey(edge)}
id={`${namespace}-${edgeKey(edge)}`}
gradientUnits="userSpaceOnUse"
{...gradientVector(edge, metrics.laneWidth)}
>
<stop offset="0%" stopColor={laneColor(edge.fromLane)} />
<stop offset="100%" stopColor={laneColor(edge.toLane)} />
</linearGradient>
))}
</defs>
)}
{row.edges.map((edge) => (
<path
key={edgeKey(edge)}
d={edgePath(edge, metrics.laneWidth)}
fill="none"
stroke={isBlended(edge) ? `url(#${namespace}-${edgeKey(edge)})` : laneColor(edge.toLane)}
strokeWidth={metrics.lineWidth}
strokeLinecap="round"
/>
))}
<CommitNode lane={row.lane} isMerge={row.isMerge} metrics={metrics} />
</svg>
)
}
// No hues: HEAD is the filled tag, everything else an outline; the glyph says
// which kind of ref it is.
const refIntents = {
head: "neutral",
branch: "outline",
remote: "outline",
tag: "outline",
} as const
const refIcons = {
head: GitCommitHorizontal,
branch: GitBranch,
remote: Cloud,
tag: Tag,
}
function RefBadge({ commitRef }: { commitRef: CommitGraphRef }) {
const Icon = refIcons[commitRef.kind]
return (
<Badge intent={refIntents[commitRef.kind]} className="max-w-44 shrink-0">
<Icon aria-hidden="true" className="size-3 shrink-0" />
<span className="truncate">{commitRef.name}</span>
</Badge>
)
}
/** First and last initial, which is what an avatar falls back to. */
function initialsOf(name: string) {
const parts = name.trim().split(/\s+/).filter(Boolean)
if (parts.length === 0) return "?"
if (parts.length === 1) return parts[0].slice(0, 2).toUpperCase()
return `${parts[0][0]}${parts[parts.length - 1][0]}`.toUpperCase()
}
export interface CommitGraphProps extends Omit<React.ComponentProps<"div">, "onSelect"> {
/**
* The commits to draw, already topologically ordered (newest first), exactly
* as `git log` hands them over. Never re-ordered here.
*/
commits: CommitGraphCommit[]
/** Controlled selection. `null` selects nothing; omit it to leave the list uncontrolled. */
selectedSha?: string | null
/** Called with the full sha of the row the user selected. */
onSelectCommit?: (sha: string) => void
/**
* Fetch the next page. Omit it and no pagination affordance is rendered —
* the caller is then free to put its own `ShowMore` around the component.
*/
onLoadMore?: () => void
/** Disables the load-more control and swaps its label while a page is in flight. */
isLoadingMore?: boolean
/** Label for the load-more control. */
loadMoreLabel?: string
/** Number of sha characters shown (and copied) per row. */
shortShaLength?: number
/**
* Render the date column as "3 days ago". Computed after mount — the absolute
* date is rendered until then, so a prerender and its hydration agree.
*/
relativeDates?: boolean
/** Shown in place of the list when `commits` is empty. */
emptyState?: React.ReactNode
/**
* Stroke weight of the lane lines. `hairline` suits a dense history where the
* graph should stay background; `bold` suits a graph that is the point of the
* screen.
*/
lineWeight?: CommitGraphLineWeight
/** Size of a commit marker. */
nodeSize?: CommitGraphNodeSize
/**
* Shape of a commit marker. A merge is always drawn as the inverse of this and
* a hair larger — that is not switchable, because it is the only thing telling
* a merge apart that does not rely on colour.
*/
nodeShape?: CommitGraphNodeShape
/**
* Horizontal distance between lanes. `tight` keeps a wide history narrow;
* `wide` gives the curves more room to read.
*/
laneWidth?: CommitGraphLaneWidth
"aria-label"?: string
}
/**
* A commit history with the lane graph drawn beside it.
*/
export function CommitGraph({
commits,
selectedSha,
onSelectCommit,
onLoadMore,
isLoadingMore = false,
loadMoreLabel = "Load older commits",
shortShaLength = 7,
relativeDates = false,
emptyState = "No commits to show.",
lineWeight = "regular",
nodeSize = "md",
nodeShape = "dot",
laneWidth = "regular",
className,
"aria-label": ariaLabel = "Commit history",
...props
}: CommitGraphProps) {
const { rows, lanes } = layoutCommitGraph(commits)
// SVG ids are document-global, and a page can hold several of these. The
// colons React puts in a useId are not safe inside a url(#…) reference.
const namespace = `commit-graph-${useId().replace(/:/g, "")}`
const lineWidth = LINE_WEIGHTS[lineWeight]
const metrics: GraphMetrics = {
laneWidth: LANE_WIDTHS[laneWidth],
lineWidth,
nodeRadius: NODE_RADII[nodeSize],
// The marker's outline tracks the lines it sits on, so a hairline graph does
// not get a dot ringed twice as heavily as its own branch.
nodeStroke: lineWidth + 0.5,
shape: nodeShape,
}
const isSelectable = onSelectCommit !== undefined || selectedSha !== undefined
return (
<div
data-slot="commit-graph"
className={cn(
"w-full overflow-hidden rounded-(--q-radius-surface) border border-quebi-hairline bg-quebi-bg",
className,
)}
{...props}
>
<GridList
aria-label={ariaLabel}
className="border-t-0"
selectionMode={isSelectable ? "single" : "none"}
selectionBehavior="replace"
{...(selectedSha === undefined
? {}
: { selectedKeys: selectedSha === null ? [] : [selectedSha] })}
onSelectionChange={(keys) => {
if (keys === "all") return
const [first] = keys
if (first !== undefined) onSelectCommit?.(String(first))
}}
renderEmptyState={() => (
<div className="px-4 py-10 text-center text-quebi-body-s text-quebi-fg-muted">
{emptyState}
</div>
)}
>
{rows.map((row) => {
const { commit } = row
const shortSha = commit.sha.slice(0, shortShaLength)
return (
<GridListItem
key={commit.sha}
id={commit.sha}
textValue={`${shortSha} ${commit.message}`}
// `py-0` is load-bearing: the band is drawn at exactly ROW_HEIGHT
// and stretched to the row's content box, so vertical padding
// would squash it below the row's own pitch and the lines would
// stop meeting across rows. `px-3` is the gutter the graph column
// needs inside the bordered box. GridListItem's own hairline
// separates the rows; the box's border closes the last one.
className="h-14 gap-3 px-3 py-0 last:border-b-0 sm:gap-3"
>
<CommitGraphLanes
row={row}
lanes={lanes}
namespace={`${namespace}-${commit.sha}`}
metrics={metrics}
/>
<Snippet
text={shortSha}
symbol=""
className="w-auto shrink-0 gap-1.5 border-0 bg-transparent px-0 py-0"
/>
<span className="min-w-0 flex-1 truncate text-quebi-body-s text-quebi-fg">
{commit.message}
</span>
{commit.refs?.length ? (
<span className="hidden shrink-0 items-center gap-1.5 lg:flex">
{commit.refs.map((commitRef) => (
<RefBadge key={`${commitRef.kind}:${commitRef.name}`} commitRef={commitRef} />
))}
</span>
) : null}
<span className="hidden shrink-0 items-center gap-2 sm:flex">
<Avatar
size="xs"
src={commit.author.avatarUrl}
initials={initialsOf(commit.author.name)}
alt={commit.author.name}
/>
<span className="max-w-32 truncate text-quebi-fg-muted text-xs">
{commit.author.name}
</span>
</span>
<FormattedDate
date={commit.date}
dateStyle="medium"
relative={relativeDates}
className="shrink-0 font-mono text-quebi-fg-subtle text-xs tabular-nums"
/>
</GridListItem>
)
})}
</GridList>
{onLoadMore ? (
<div className="flex justify-center border-quebi-hairline border-t px-4 py-3">
<Button
intent="outline"
size="sm"
isDisabled={isLoadingMore}
onPress={() => onLoadMore()}
>
{isLoadingMore ? "Loading…" : loadMoreLabel}
</Button>
</div>
) : null}
</div>
)
}