Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/gunnar-ui

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()
}