crates/gunnar-ui-web/src/pages/graph.rs
//! The commit graph — lanes, splines, and clickable history.
//!
//! The layout is computed by [`crate::graph`], which is pure and host-tested;
//! this page only fetches, merges and renders. The SVG gutter draws the
//! topology, an HTML list beside it carries the words, and the two share one
//! scroll container and one row pitch so they cannot drift apart.
//!
//! Windowing is honest by construction: gunnar serves at most 1000 commits per
//! `Log` call and has no cursor (`docs/coverage.md`), so this page continues by
//! refetching from `start=<frontier oid>` and merging by oid — the overlap is
//! the cost of a cursorless server and the footer says so. Edges that leave
//! the window are stubs: click one and exactly that lane is fetched.
use std::collections::HashMap;
use leptos::prelude::*;
use leptos_router::hooks::{use_navigate, use_params_map, use_query_map};
use serde::Deserialize;
use crate::api::{segment, Fail, Page};
use crate::fmt;
use crate::graph::{
self, log_url, GraphCommit, GraphLayout, HistoryView, GUTTER_PAD, LANE_PITCH, NODE_R, ROW_PITCH,
};
use crate::pages::repo::RefTarget;
use crate::ui::{Badge, FailBox, Hint, Loading, PageHeader, Panel, Tone};
/// The initial window, and each "Load more" extension.
const WINDOW: usize = 500;
/// What a clicked stub fetches — one lane's worth.
const STUB_WINDOW: usize = 250;
/// The client-side cap. DOM sanity, said on screen when reached.
const MAX_ROWS: usize = 4000;
/// Columns beyond this render as a `+k` marker, not lanes.
const MAX_LANES: usize = 24;
/// The slice of a commit this page needs. serde ignores the rest.
#[derive(Debug, Clone, Deserialize, PartialEq)]
struct WireCommit {
oid: String,
parent_oids: Vec<String>,
author: Option<WireSignature>,
committer: Option<WireSignature>,
subject: String,
}
#[derive(Debug, Clone, Deserialize, PartialEq)]
struct WireSignature {
name: String,
time_unix_seconds: i64,
}
#[component]
pub fn CommitGraph() -> impl IntoView {
let params = use_params_map();
let query = use_query_map();
let namespace = Memo::new(move |_| params.read().get("namespace").unwrap_or_default());
let name = Memo::new(move |_| params.read().get("name").unwrap_or_default());
let start = Memo::new(move |_| query.read().get("start").unwrap_or_default());
// Which refs the wall is drawn from: the default branch, the picker's
// ref, or every branch and tag tip. gunnar walks them; this only names it.
let history = Memo::new(move |_| {
let q = query.read();
HistoryView::from_query(q.get("ref"), q.get("all"))
});
let path = Memo::new(move |_| format!("{}/{}", namespace.get(), name.get()));
// The window: every commit fetched so far, deduped by oid, in arrival
// order. The layout re-sorts topologically, so arrival order carries no
// meaning and the merge can be a plain append-if-new.
let commits: RwSignal<Vec<WireCommit>> = RwSignal::new(Vec::new());
let fail: RwSignal<Option<Fail>> = RwSignal::new(None);
let busy: RwSignal<bool> = RwSignal::new(false);
let hot: RwSignal<Option<usize>> = RwSignal::new(None);
let fetch = move |from: String, limit: usize| {
if busy.get_untracked() {
return;
}
busy.set(true);
fail.set(None);
let url = log_url(
&segment(&namespace.get_untracked()),
&segment(&name.get_untracked()),
limit,
&from,
&history.get_untracked(),
);
leptos::task::spawn_local(async move {
match crate::api::get::<Page<WireCommit>>(&url).await {
Ok(page) => commits.update(|held| {
let known: std::collections::HashSet<String> =
held.iter().map(|c| c.oid.clone()).collect();
for commit in page.items {
if held.len() >= MAX_ROWS {
break;
}
if !known.contains(&commit.oid) {
held.push(commit);
}
}
}),
Err(refusal) => fail.set(Some(refusal)),
}
busy.set(false);
});
};
// (Re)load when the repository, the starting revision or the picked view
// changes.
Effect::new(move |_| {
let _ = namespace.get();
let _ = name.get();
let _ = history.get();
let from = start.get();
commits.set(Vec::new());
fetch(from, WINDOW);
});
// Ref chips and the default branch, each a single-shot read.
// Every reference, page after page (GUN-FIELD-TEST, 2026-10-31: the chips
// were drawn from the first page of `/refs` only).
let refs = LocalResource::new(move || {
let (ns, repo) = (segment(&namespace.get()), segment(&name.get()));
async move { crate::pages::repo::every_ref(&ns, &repo).await }
});
let repo = LocalResource::new(move || {
let url = format!(
"/api/repos/{}/{}",
segment(&namespace.get()),
segment(&name.get())
);
async move { crate::api::get::<super::repos::Repo>(&url).await }
});
// Where the repository came from is drawn for a session only: its read
// is not on the anonymous allow-list (see `pages::repo`).
let session = crate::app::session();
let navigate = use_navigate();
let repo_href = move || {
format!(
"/{}/{}",
segment(&namespace.get()),
segment(&name.get())
)
};
let picker = history_picker(refs, history, {
let navigate = navigate.clone();
move |picked: HistoryView| {
let to = format!(
"/{}/{}/commits{}",
segment(&namespace.get_untracked()),
segment(&name.get_untracked()),
picked.page_query()
);
navigate(&to, Default::default());
}
});
view! {
<PageHeader
title=move || format!("{} — graph", path.get())
>
{picker}
<a class="btn" href=repo_href>"Repository"</a>
</PageHeader>
<crate::pages::repo::RepoTabs/>
<Panel title="Commit graph">
{move || {
if let Some(refusal) = fail.get() {
let retry = Callback::new(move |_| fetch(start.get_untracked(), WINDOW));
return view! {
<div class="panel-body"><FailBox fail=refusal retry=retry/></div>
}
.into_any();
}
let held = commits.get();
if held.is_empty() {
return view! { <Loading rows=6/> }.into_any();
}
// ── the pure half ───────────────────────────────────────────
let input: Vec<GraphCommit> = held
.iter()
.map(|c| GraphCommit {
oid: c.oid.clone(),
parent_oids: c.parent_oids.clone(),
time_unix_seconds: c
.committer
.as_ref()
.or(c.author.as_ref())
.map(|s| s.time_unix_seconds)
.unwrap_or(0),
})
.collect();
let layout = graph::layout(&input);
let paths = graph::svg_paths(&layout);
let shown_lanes = layout.lane_count.min(MAX_LANES);
let clipped_lanes = layout.lane_count.saturating_sub(MAX_LANES);
let width = GUTTER_PAD * 2 + shown_lanes.max(1) * LANE_PITCH;
let height = layout.rows.len() * ROW_PITCH;
// Chips: every ref whose target (peeled or direct) is a shown
// commit, on that commit's row.
let default_branch = repo
.get()
.and_then(|r| r.ok())
.map(|r| r.default_branch)
.unwrap_or_default();
let mut chips: HashMap<String, Vec<(String, Tone)>> = HashMap::new();
if let Some(Ok(page)) = refs.get() {
for target in page.items {
let at = target
.peeled_oid
.clone()
.unwrap_or_else(|| target.oid.clone());
let tone = if target.ref_name == default_branch {
Tone::Live
} else if target.ref_name.starts_with("refs/tags/") {
Tone::Warn
} else {
Tone::Plain
};
chips
.entry(at)
.or_default()
.push((fmt::short_ref(&target.ref_name).to_owned(), tone));
}
}
let now = super::now_ms();
let ns_seg = segment(&namespace.get_untracked());
let name_seg = segment(&name.get_untracked());
// The frontier for "Load more": the lowest stub on the wall.
let frontier = layout
.rows
.iter()
.rev()
.flat_map(|r| &r.stubs)
.map(|s| s.parent_oid.clone())
.next();
let capped = held.len() >= MAX_ROWS;
let gutter = render_gutter(
&layout,
&paths,
&held,
width,
height,
shown_lanes,
hot,
now,
{
let navigate = navigate.clone();
let ns = ns_seg.clone();
let nm = name_seg.clone();
move |commit: &WireCommit| {
navigate(&commit_href(&ns, &nm, commit), Default::default());
}
},
move |stub_oid: String| fetch(stub_oid, STUB_WINDOW),
);
let rows = layout
.rows
.iter()
.enumerate()
.map(|(r, row)| {
let commit = &held[row.commit];
let subject = commit.subject.clone();
let author = commit
.author
.as_ref()
.map(|a| a.name.clone())
.unwrap_or_default();
let when = commit
.committer
.as_ref()
.or(commit.author.as_ref())
.map(|s| fmt::since(s.time_unix_seconds * 1000, now))
.unwrap_or_default();
// At most CHIP_CAP chips and "+N more" (GUN-FIELD-TEST, 2026-11-01).
let ranked: Vec<(String, u8, Tone)> = chips
.remove(&commit.oid)
.unwrap_or_default()
.into_iter()
.map(|(label, tone)| {
let rank = match tone {
Tone::Live => 0,
Tone::Warn => 1,
_ => 2,
};
(label, rank, tone)
})
.collect();
let (row_chips, more) = crate::graph::capped_chips(&ranked);
let href = commit_href(&ns_seg, &name_seg, commit);
let navigate = navigate.clone();
let oid_short = fmt::short_oid(&commit.oid);
view! {
<li
class:hot=move || hot.get() == Some(r)
on:mouseenter=move |_| hot.set(Some(r))
on:mouseleave=move |_| hot.set(None)
on:click=move |_| navigate(&href, Default::default())
>
<span class="oid">{oid_short}</span>
{row_chips
.into_iter()
.map(|(label, _, tone)| view! { <Badge tone=tone label=label/> })
.collect_view()}
{(more > 0).then(|| view! {
<span class="chip-more" title="more references on this commit: see References below">{format!("+{more} more")}</span>
})}
<span class="graph-subject">{subject}</span>
<span class="graph-author">{author}</span>
<span class="graph-when">{when}</span>
</li>
}
})
.collect_view();
view! {
<div class="graph-scroll">
{gutter}
<ol class="graph-rows">{rows}</ol>
</div>
<div class="panel-body graph-foot">
{frontier.clone().map(|from| {
let label = if busy.get() { "Loading …" } else { "Load more" };
view! {
<button
class="btn"
disabled=move || busy.get() || capped
on:click=move |_| fetch(from.clone(), WINDOW)
>
{label}
</button>
}
})}
<Hint
line=format!(
"Showing {} commits{}.{}",
layout.rows.len(),
if clipped_lanes > 0 {
format!(" · {clipped_lanes} lanes beyond the 24 shown")
} else {
String::new()
},
if capped {
format!(" The client caps the wall at {MAX_ROWS} rows.")
} else {
String::new()
},
)
more="History continues wherever a lane fades out; Load more refetches from the \
frontier because Gunnar has no log cursor, so some overlap is refetched. \
Order is a topological sort with committer-time tiebreak, which can differ \
from git log --date-order."
/>
</div>
}
.into_any()
}}
</Panel>
// The references and where the repository came from live here, on
// History, since 2026-10-04 (owner: the Readme tab is the README and
// the clone line). `refs` is the read the chips above already made.
<crate::pages::repo::References refs=refs/>
<Show when=move || session.get().is_some_and(|s| s.authenticated || s.open)>
<crate::pages::mirrors::MirrorPanel namespace=namespace name=name/>
</Show>
}
}
/// **The History picker** (owner order 2026-10-18): the default branch, all
/// branches at once, or any one branch or tag. Picking navigates, so the view
/// is in the URL and survives a reload or a shared link.
fn history_picker(
refs: LocalResource<Result<Page<RefTarget>, Fail>>,
history: Memo<HistoryView>,
pick: impl Fn(HistoryView) + Clone + 'static,
) -> impl IntoView {
let chosen = move || history.get().pick_value();
view! {
<label class="vh" for="history-ref">"Branch"</label>
<select
id="history-ref"
on:change=move |ev| pick(HistoryView::from_pick(&event_target_value(&ev)))
>
<option value="" selected=move || chosen().is_empty()>"Default branch"</option>
<option value=graph::PICK_ALL selected=move || chosen() == graph::PICK_ALL>
"All branches"
</option>
{move || {
refs.get()
.and_then(|r| r.ok())
.map(|page| {
let mut names: Vec<String> = page
.items
.into_iter()
.map(|t| t.ref_name)
.filter(|n| n.starts_with("refs/heads/") || n.starts_with("refs/tags/"))
.collect();
// Branches first, then tags, each by name.
names.sort_by_key(|n| (n.starts_with("refs/tags/"), n.clone()));
names
.into_iter()
.map(|n| {
let label = fmt::short_ref(&n).to_owned();
let label = if n.starts_with("refs/tags/") {
format!("tag {label}")
} else {
label
};
let value = n.clone();
view! {
<option value=value selected=move || chosen() == n>{label}</option>
}
})
.collect_view()
})
}}
</select>
}
}
/// Where clicking a commit goes: its diff against its first parent, or the
/// file view for a root commit, whose diff cannot exist.
fn commit_href(ns: &str, name: &str, commit: &WireCommit) -> String {
match commit.parent_oids.first() {
Some(parent) => format!("/{ns}/{name}/diff?from={parent}&to={}", commit.oid),
None => format!("/{ns}/{name}/tree?rev={}", commit.oid),
}
}
/// The SVG half: coalesced lane paths, nodes with native tooltips, and
/// clickable fading stubs where history leaves the window.
#[allow(clippy::too_many_arguments)]
fn render_gutter(
layout: &GraphLayout,
paths: &[graph::PathSpec],
held: &[WireCommit],
width: usize,
height: usize,
shown_lanes: usize,
hot: RwSignal<Option<usize>>,
now: i64,
go: impl Fn(&WireCommit) + Clone + 'static,
expand: impl Fn(String) + Clone + 'static,
) -> AnyView {
let lanes = paths
.iter()
.filter(|p| !p.d.is_empty())
.map(|p| {
view! { <path class=format!("lane-{}", p.color % 8) d=p.d.clone()></path> }
})
.collect_view();
let nodes = layout
.rows
.iter()
.enumerate()
.filter(|(_, row)| row.column < shown_lanes)
.map(|(r, row)| {
let commit = &held[row.commit];
let cx = graph::lane_x(row.column);
let cy = graph::row_y(r);
let class = format!(
"node lane-{}{}",
row.color % 8,
if row.merge { " hollow" } else { "" }
);
let title = format!(
"{} · {} · {} · {}",
fmt::short_oid(&commit.oid),
commit.subject,
commit
.author
.as_ref()
.map(|a| a.name.as_str())
.unwrap_or(""),
commit
.committer
.as_ref()
.or(commit.author.as_ref())
.map(|s| fmt::since(s.time_unix_seconds * 1000, now))
.unwrap_or_default(),
);
let go = go.clone();
let commit = commit.clone();
view! {
<circle
class=class
class:hot=move || hot.get() == Some(r)
cx=cx
cy=cy
r=NODE_R
on:mouseenter=move |_| hot.set(Some(r))
on:mouseleave=move |_| hot.set(None)
on:click=move |_| go(&commit)
>
<title>{title}</title>
</circle>
}
})
.collect_view();
let stubs = layout
.rows
.iter()
.enumerate()
.flat_map(|(r, row)| row.stubs.iter().map(move |s| (r, s.clone())))
.filter(|(_, stub)| stub.col < shown_lanes)
.map(|(r, stub)| {
let x = graph::lane_x(stub.col);
let y1 = graph::row_y(r) + NODE_R;
let y2 = y1 + 14;
let title = format!(
"history continues at {} — click to fetch it",
stub.parent_oid
);
let expand = expand.clone();
let parent = stub.parent_oid.clone();
view! {
<path
class=format!("lane-{} stub", stub.color % 8)
d=format!("M {x} {y1} V {y2}")
on:click=move |_| expand(parent.clone())
>
<title>{title}</title>
</path>
}
})
.collect_view();
view! {
<svg
class="graph-gutter"
width=width
height=height
viewBox=format!("0 0 {width} {height}")
aria-label="Commit topology"
role="img"
>
{lanes}
{stubs}
{nodes}
</svg>
}
.into_any()
}