181534a9

Archive
Tree [181534a9] [>]
commit
181534a995deadc6448bdc921d1a8064434d7a47
parent
author
Christopher K. Schmitt <me@shmish.dev>
date
2026-07-08
committer
Christopher K. Schmitt <me@shmish.dev>
date
2026-07-08
changes
5
insertions
847
deletions
10
Add commit log with SVG ancestry graph and detail view
Mstatic/base.css
~@layer reset {~  *, *::before, *::after { box-sizing: border-box; }~  html { overflow-x: clip; }+  * { scrollbar-width: thin; scrollbar-color: var(--highlight) transparent; }+  *::-webkit-scrollbar { width: 6px; height: 6px; }+  *::-webkit-scrollbar-track { background: none; }+  *::-webkit-scrollbar-thumb { background: var(--highlight); border-radius: 3px; }~}~~@layer layout {
~~  .commit-row:hover { background: var(--highlight); }~  .commit-sha { color: var(--secondary); grid-row: 1; }+  .commit-parent, .commit-ref-row { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }~~  .commit-message {~    color: var(--primary);
~  .commit-graph-layout {~    display: flex;~    align-items: flex-start;-    overflow-x: auto;~  }~~  .commit-graph-svg {~    flex-shrink: 0;-    max-width: 12rem;+    max-width: 10rem;+    padding-left: 1rem;~    overflow-x: auto;~  }~
~    display: flex;~    align-items: center;~    gap: 1rem;-    padding-right: 1rem;-    min-width: max-content;+    padding: 0 1rem 0 .75rem;~    text-decoration: none;~    color: inherit;~    font-family: var(--font-mono);
~  .commit-date {~    flex-shrink: 0;~    color: var(--secondary);+  }++  .commit-ref-branch { color: var(--blue); }+  .commit-ref-tag { color: var(--violet); }+  .tag-info .commit-ref-branch { color: var(--blue); }+  .tag-info .commit-ref-tag { color: var(--violet); }++  /* Diff stat */+  .diff-stat-type {+    flex-shrink: 0;+    width: 1rem;+    text-align: center;+  }++  .diff-stat-path {+    flex: 1;+    min-width: 0;+    overflow: hidden;+    text-overflow: ellipsis;+    white-space: nowrap;+    color: var(--primary);+  }++  .diff-stat-A { color: var(--green); }+  .diff-stat-D { color: var(--red); }+  .diff-stat-M { color: var(--blue); }+  .diff-stat-R { color: var(--violet); }++  /* Inline diff */+  .diff-files {+    margin: 0 0 1rem;+  }++  .diff-line { display: flex; gap: 1rem; }+  .diff-line > span { white-space: pre; }++  .diff-line-marker {+    flex-shrink: 0;+    width: 1rem;+    text-align: center;+  }++  .diff-hunks { padding: 0; margin: 0; }++  .diff-hunk {+    font-family: var(--font-mono);+    line-height: 1.45;+    margin: 0;+    padding: .75rem 1rem;+    overflow-x: auto;+    overflow-y: hidden;+    tab-size: 4;+    border-bottom: 1px solid var(--highlight);~  }~+  .diff-line-ctx { color: var(--secondary); }+  .diff-line-\+ { color: var(--green); }+  .diff-line-- { color: var(--red); }+~  /* Pagination */~  .pagination { display: flex; justify-content: space-between; padding: .75rem 1rem; font-size: 1rem; font-family: var(--font-mono); }~  .pagination a, .pagination span { flex: 1; text-align: center; }~  .pagination a { color: var(--secondary); text-decoration: none; }~  .pagination a:hover span:not(.pagination-sym) { text-decoration: underline; text-underline-offset: 3px; }++  @media (max-width: 640px) {+    .commit-graph-row .commit-sha,+    .commit-graph-row .commit-date {+      display: none;+    }+  }~~  /* Details / summary */~  details { border-top: 1px solid var(--highlight); }
Mstatic/markdown.css
~~article code {~  font-family: var(--font-mono);-  font-size: .875em;~  padding: .2em .4em;~  background-color: var(--highlight);~  border-radius: 4px;
~~article pre {~  font-family: var(--font-mono);-  font-size: .875em;~  line-height: 1.45;~  padding: 1rem;~  margin: 0 0 1rem;
Mtemplates/commit.html
+{% extends "base.html" %}~+{% block title %}{{ sha[..8] }} - {{ repo }}{% endblock %}++{% block breadcrumbs %}+<ul>+  <li><a href="/">{{ crate::hostname() }}</a></li>+  <li><a href="/{{ repo }}">{{ repo }}</a></li>+  <li><a href="/{{ repo }}/commits">commits</a></li>+  <li>{{ sha[..8] }}</li>+</ul>+{% endblock %}++{% block content %}+<h1>{{ sha[..8] }}</h1>++<details>+  <summary><svg class="summary-icon"><use href="#icon-archive"/></svg>Archive</summary>+  <ul>+    <li><a href="/{{ repo }}/archive/tar.gz/{{ sha }}" download="{{ repo }}-{{ sha[..8] }}.tar.gz"><span class="archive-link">{{ repo }}-{{ sha[..8] }}.tar.gz</span><span>tar.gz</span></a></li>+    <li><a href="/{{ repo }}/archive/tar.xz/{{ sha }}" download="{{ repo }}-{{ sha[..8] }}.tar.xz"><span class="archive-link">{{ repo }}-{{ sha[..8] }}.tar.xz</span><span>tar.xz</span></a></li>+    <li><a href="/{{ repo }}/archive/zip/{{ sha }}" download="{{ repo }}-{{ sha[..8] }}.zip"><span class="archive-link">{{ repo }}-{{ sha[..8] }}.zip</span><span>zip</span></a></li>+  </ul>+</details>++<div class="repo-nav">+  <a href="/{{ repo }}/commits/{{ sha }}/tree" class="repo-nav-item">+    <svg class="summary-icon"><use href="#icon-tree"/></svg>+    <span>Tree</span>+    <span class="repo-nav-item-meta">[{{ sha[..8] }}]</span>+    <span class="repo-nav-item-arrow">[&gt;]</span>+  </a>+</div>++{% if !refs.is_empty() %}+<dl class="tag-info">+  <dt>refs</dt>+  <dd>{% for r in refs %}<div class="commit-ref-row"><a href="/{{ repo }}/{% if r.kind == "branch" %}branches{% else %}tags{% endif %}/{{ r.name }}" class="commit-ref commit-ref-{{ r.kind }}">{{ r.name }}</a></div>{% endfor %}</dd>+</dl>+{% endif %}++<dl class="tag-info">+  <dt>commit</dt><dd><a class="commit-sha" href="/{{ repo }}/commits/{{ sha }}">{{ sha }}</a></dd>+  <dt>parent{% if parents.len() != 1 %}s{% endif %}</dt>+  <dd>{% for (parent_sha, _) in parents %}<div class="commit-parent"><a class="commit-sha" href="/{{ repo }}/commits/{{ parent_sha }}">{{ parent_sha }}</a></div>{% endfor %}</dd>+  <dt>author</dt><dd>{{ author }}</dd>+  <dt>date</dt><dd>{{ date }}</dd>+</dl>++<dl class="tag-info">+  <dt>committer</dt><dd>{{ committer }}</dd>+  <dt>date</dt><dd>{{ committer_date }}</dd>+</dl>++{% if files_changed > 0 || lines_added > 0 || lines_removed > 0 %}+<dl class="tag-info">+  <dt>changes</dt><dd>{{ files_changed }}</dd>+  <dt>insertions</dt><dd>{{ lines_added }}</dd>+  <dt>deletions</dt><dd>{{ lines_removed }}</dd>+</dl>+{% endif %}++{% if !message.is_empty() %}+<pre class="message">{{ message }}</pre>+{% endif %}++{% if files_changed > 0 || lines_added > 0 || lines_removed > 0 %}+<div class="diff-files">+{% for fc in file_changes %}+  <details class="diff-file">+    <summary><span class="diff-stat-type diff-stat-{{ fc.change_type }}">{{ fc.change_type }}</span><span class="diff-stat-path">{{ fc.path }}</span></summary>+    {% if !fc.hunks.is_empty() %}+    <div class="diff-hunks">{% for hunk in fc.hunks %}<pre class="diff-hunk">{% for line in hunk %}<span class="diff-line"><span class="diff-line-marker diff-line-{{ line.css_class }}">{{ line.marker }}</span><span>{{ line.content }}</span></span>{% endfor %}</pre>{% endfor %}</div>+    {% endif %}+  </details>+{% endfor %}+</div>+{% endif %}+{% endblock %}
Mtemplates/commit_list.html
+{% extends "base.html" %}~+{% block title %}commits - {{ repo }}{% endblock %}++{% block breadcrumbs %}+<ul>+  <li><a href="/">{{ crate::hostname() }}</a></li>+  <li><a href="/{{ repo }}">{{ repo }}</a></li>+  <li>commits</li>+</ul>+{% endblock %}++{% block content %}+<h1>commits</h1>++{% if commits.is_empty() %}+<p>No commits.</p>+{% else %}+<div class="commit-graph-layout">+  <div class="commit-graph-svg">{{ graph_svg|safe }}</div>+  <div class="commit-graph-rows">+    {% for commit in commits %}+    <a href="/{{ repo }}/commits/{{ commit.sha }}" class="commit-graph-row" style="border-left: 3px solid {{ commit.color }}">+      <span class="commit-message">{% for r in commit.refs %}<span class="commit-ref commit-ref-{{ r.kind }}">{{ r.name }}</span> {% endfor %}{{ commit.message }}</span>+      <span class="commit-sha">{{ commit.sha[..8] }}</span>+      <span class="commit-date">{{ commit.date }}</span>+    </a>+    {% endfor %}+  </div>+</div>+<nav class="pagination">+  {% if has_prev %}<a href="?page={{ page - 1 }}"><span class="pagination-sym">&lt;</span> <span>newer</span></a>{% else %}<span></span>{% endif %}+  {% if has_next %}<a href="?page={{ page + 1 }}"><span>older</span> <span class="pagination-sym">&gt;</span></a>{% else %}<span></span>{% endif %}+</nav>+{% endif %}+{% endblock %}
Msrc/routes/commit.rs
~//! `detail` handler renders a single commit view with full metadata, a diff~//! stat, and per-file inline diffs via `gix::diff::blob::UnifiedDiff`.~+use std::collections::HashMap;+use std::ops::ControlFlow;++use askama::Template;+use askama_web::WebTemplate;+use axum::extract::{Path, Query, State};~use axum::response::IntoResponse;+use gix::bstr::ByteSlice;+use serde::Deserialize;++use crate::error::{Error, Result};+use crate::routes::{AppState, RepoName, commit_date, committer_date};++use gix::diff::blob::UnifiedDiff;+use gix::diff::blob::platform::prepare_diff::Operation;+use gix::diff::blob::unified_diff::{ConsumeHunk, ContextSize, DiffLineKind, HunkHeader};++/// Number of commits per page in the log view.+const PAGE_SIZE: usize = 50;++// SVG graph layout constants.+// `ROW_H` is the per-row height, `LANE_W` is the per-lane width (spacing+// between parallel tracks), `NODE_R` is the dot radius, and `BEND_R` is+// the corner radius used when an edge changes lanes.+const ROW_H: f64 = 40.0;+const LANE_W: f64 = 20.0;+const NODE_R: f64 = 5.0;+const OUTER_R: f64 = ROW_H / 2.0;+const STROKE: f64 = 2.0;+const Y_MID: f64 = ROW_H / 2.0;+const BEND_R: f64 = 6.0;++/// Color palette used for SVG graph lanes.+/// Each lane gets one color from this cycle.+const COLORS: &[&str] = &[+    "var(--blue)",+    "var(--red)",+    "var(--green)",+    "var(--yellow)",+    "var(--violet)",+    "var(--magenta)",+    "var(--cyan)",+    "var(--orange)",+];++/// A single commit entry in the commit log.+struct CommitEntry {+    sha: String,+    message: String,+    date: String,+    parents: Vec<String>,+}++/// Loads up to `limit` commits from `HEAD`, walking ancestors.+fn load_commits(git_repo: &gix::Repository, limit: usize) -> Result<Vec<CommitEntry>> {+    let head = git_repo+        .head_commit()+        .map_err(|e| Error::GitCorrupt(e.to_string()))?;++    let entries = head+        .id()+        .ancestors()+        .all()+        .map_err(|e| Error::GitCorrupt(e.to_string()))?+        .filter_map(|info| {+            let info = info.ok()?;+            let commit = info.id().object().ok()?.try_into_commit().ok()?;++            Some(CommitEntry {+                sha: info.id().to_string(),+                message: commit.message().ok()?.summary().to_string(),+                date: commit_date(&commit).unwrap_or_default(),+                parents: commit.parent_ids().map(|id| id.to_string()).collect(),+            })+        })+        .take(limit)+        .collect();++    Ok(entries)+}++/// (row, column) positions for every commit in the current page, plus a+/// snapshot of active lanes entering the page from above so the SVG renderer+/// can draw continuation edges.+struct Layout {+    positions: HashMap<String, (usize, usize)>,+    active_at_page_start: Vec<Option<String>>,+}++/// Assigns each commit a (row, column) position for the SVG graph.+///+/// Uses a column-stealing algorithm: a commit claims the column its first+/// parent occupies, freeing the previous column for other branches. Lane+/// re-use keeps the graph narrow.+fn compute_layout(commits: &[CommitEntry], page_start: usize) -> Layout {+    let mut active: Vec<Option<&str>> = vec![];+    let mut positions = HashMap::new();+    let mut active_at_page_start = vec![];++    for (row, commit) in commits.iter().enumerate() {+        if row == page_start {+            active_at_page_start = active+                .iter()+                .map(|opt| opt.map(|s| s.to_string()))+                .collect();+        }++        let sha = commit.sha.as_str();+        let col = match active.iter().position(|s| *s == Some(sha)) {+            Some(i) => i,+            None => match active.iter().position(|s| s.is_none()) {+                Some(i) => {+                    active[i] = Some(sha);+                    i+                }+                None => {+                    active.push(Some(sha));+                    active.len() - 1+                }+            },+        };++        positions.insert(commit.sha.clone(), (row, col));++        if commit.parents.is_empty() {+            active[col] = None;+            while matches!(active.last(), Some(None)) {+                active.pop();+            }+        } else {+            let first = commit.parents[0].as_str();+            if active.iter().position(|s| *s == Some(first)) != Some(col) {+                active[col] = None;+                while matches!(active.last(), Some(None)) {+                    active.pop();+                }+                if active.iter().all(|s| *s != Some(first)) {+                    match active.iter().position(|s| s.is_none()) {+                        Some(i) => active[i] = Some(first),+                        None => active.push(Some(first)),+                    }+                }+            }++            for parent in commit.parents.iter().skip(1) {+                if active.iter().any(|s| *s == Some(parent.as_str())) {+                    continue;+                }+                match active.iter().position(|s| s.is_none()) {+                    Some(i) => active[i] = Some(parent),+                    None => active.push(Some(parent)),+                }+            }+        }+    }++    Layout {+        positions,+        active_at_page_start,+    }+}++/// Renders the SVG ancestry graph for commits[page_start..page_end].+///+/// Three passes:+/// 1. Continuation edges entering from above the viewport.+/// 2. Edges from each visible commit to its parents (straight lines for+///    same-column, L-shaped bends for lane changes, S-shaped curves for+///    merge arrows).+/// 3. Node circles and lane-highlight backgrounds on top.+fn render_page_svg(+    commits: &[CommitEntry],+    layout: &Layout,+    page_start: usize,+    page_end: usize,+) -> String {+    if page_start >= page_end {+        return String::new();+    }++    let mut all_cols: Vec<usize> = Vec::new();+    for c in &commits[page_start..page_end] {+        if let Some(&(_, col)) = layout.positions.get(&c.sha) {+            all_cols.push(col);+        }+    }++    let min_col = all_cols.iter().copied().min().unwrap_or(0);+    let page_max_col = all_cols.iter().copied().max().unwrap_or(0);+    let clamp_col = |c: usize| c.max(min_col).min(page_max_col);++    let svg_h = (page_end - page_start) as f64 * ROW_H;+    let svg_w = (page_max_col - min_col + 1) as f64 * LANE_W + OUTER_R;++    let lx = |col: usize| -> f64 { (col - min_col) as f64 * LANE_W + OUTER_R };+    let ry =+        |abs_row: usize| -> f64 { (abs_row.saturating_sub(page_start)) as f64 * ROW_H + Y_MID };++    let mut body = String::new();++    // Pass 1: continuation edges entering from above.+    for (col, opt_sha) in layout.active_at_page_start.iter().enumerate() {+        let Some(sha) = opt_sha else { continue };+        let Some(&(abs_row, _)) = layout.positions.get(sha.as_str()) else {+            continue;+        };+        let x = lx(clamp_col(col));+        let c = COLORS[col % COLORS.len()];+        let y_to = if abs_row < page_start {+            0.0+        } else if abs_row < page_end {+            ry(abs_row)+        } else {+            svg_h+        };++        body.push_str(&format!(+            r#"<line x1="{x:.1}" y1="0.0" x2="{x:.1}" y2="{y_to:.1}" stroke="{c}" stroke-width="{STROKE}"/>"#+        ));+    }++    // Pass 2: edges originating from commits on this page.+    for commit in &commits[page_start..page_end] {+        let &(abs_row, col) = layout.positions.get(&commit.sha).unwrap();+        let x1 = lx(col);+        let y1 = ry(abs_row);+        let c = COLORS[col % COLORS.len()];++        for (idx, parent_sha) in commit.parents.iter().enumerate() {+            let (x2, y2, p_col) = match layout.positions.get(parent_sha.as_str()) {+                Some(&(p_row, p_col)) if p_row >= page_start && p_row < page_end => {+                    (lx(p_col), ry(p_row), p_col)+                }+                Some(&(p_row, p_col)) => (+                    lx(clamp_col(p_col)),+                    if p_row < page_start { 0.0 } else { svg_h },+                    p_col,+                ),+                None => {+                    body.push_str(&format!(+                        r#"<line x1="{x1:.1}" y1="{y1:.1}" x2="{x1:.1}" y2="{svg_h:.1}" stroke="{c}" stroke-width="{STROKE}"/>"#+                    ));+                    continue;+                }+            };++            if col == p_col {+                body.push_str(&format!(+                    r#"<line x1="{x1:.1}" y1="{y1:.1}" x2="{x2:.1}" y2="{y2:.1}" stroke="{c}" stroke-width="{STROKE}"/>"#+                ));+            } else if idx == 0 {+                let r = BEND_R.min((x2 - x1).abs());+                let (arc_x, sweep) = if x2 < x1 { (x1 - r, 1) } else { (x1 + r, 0) };++                body.push_str(&format!(+                    r#"<path d="M {x1:.1} {y1:.1} L {x1:.1} {:.1} A {r:.1} {r:.1} 0 0 {sweep} {arc_x:.1} {y2:.1} L {x2:.1} {y2:.1}" stroke="{c}" stroke-width="{STROKE}" fill="none"/>"#,+                    y2 - r,+                ));+            } else {+                let pc = COLORS[p_col % COLORS.len()];+                let r = BEND_R.min((x2 - x1).abs());+                let (arc_x, sweep) = if x2 > x1 { (x2 - r, 1) } else { (x2 + r, 0) };++                body.push_str(&format!(+                    r#"<path d="M {x1:.1} {y1:.1} L {arc_x:.1} {y1:.1} A {r:.1} {r:.1} 0 0 {sweep} {x2:.1} {:.1} L {x2:.1} {y2:.1}" stroke="{pc}" stroke-width="{STROKE}" fill="none"/>"#,+                    y1 + r,+                ));+            }+        }+    }++    // Pass 3: circles on top.+    for commit in &commits[page_start..page_end] {+        let &(abs_row, col) = layout.positions.get(&commit.sha).unwrap();+        let cx = lx(col);+        let cy = ry(abs_row);+        let c = COLORS[col % COLORS.len()];+        let row_top = cy - Y_MID;+        let rect_x = cx - OUTER_R;++        body.push_str(&format!(+            r#"<path d="M {rect_x:.1} {top:.1} A {inner:.1} {inner:.1} 0 0 1 {right:.1} {row_top:.1} L {svg_w:.1} {row_top:.1} L {svg_w:.1} {bot:.1} L {right:.1} {bot:.1} A {inner:.1} {inner:.1} 0 0 1 {rect_x:.1} {bot_sub:.1} Z" fill="{c}" opacity="0.10"/>"#,+            top = row_top + OUTER_R,+            inner = OUTER_R,+            right = rect_x + OUTER_R,+            bot = row_top + ROW_H,+            bot_sub = row_top + ROW_H - OUTER_R,+        ));++        body.push_str(&format!(+            r#"<circle cx="{cx:.1}" cy="{cy:.1}" r="{NODE_R}" fill="{c}"/>"#+        ));+    }++    format!(+        r#"<svg width="{svg_w:.1}" height="{svg_h:.1}" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">{body}</svg>"#+    )+}++/// Query parameters for the commit log page.+#[derive(Deserialize, Default)]+pub struct PageQuery {+    /// Zero-based page index.+    #[serde(default)]+    page: usize,+}++/// A branch or tag ref pointing at a commit.+struct RefLabel {+    name: String,+    kind: String,+}++/// A single row of the commit log, ready for the template.+struct CommitRow {+    sha: String,+    message: String,+    date: String,+    color: String,+    refs: Vec<RefLabel>,+}++/// Template data for the paginated commit log page.+#[derive(Template, WebTemplate)]+#[template(path = "commit_list.html")]+struct CommitList {+    repo: String,+    graph_svg: String,+    commits: Vec<CommitRow>,+    page: usize,+    has_prev: bool,+    has_next: bool,+}~-pub async fn detail() -> impl IntoResponse {-    "Commit detail"+/// Renders a paginated commit log with an SVG ancestry graph.+pub async fn list(+    State(state): State<AppState>,+    RepoName(repo): RepoName,+    Query(q): Query<PageQuery>,+) -> Result<impl IntoResponse> {+    let git_repo =+        gix::open(state.root.join(&repo)).map_err(|_| Error::RepoNotFound(repo.clone()))?;++    let page_start = q.page * PAGE_SIZE;+    let page_end = page_start + PAGE_SIZE;++    let entries = load_commits(&git_repo, page_end + 1)?;+    let has_next = entries.len() > page_end;+    let visible_end = page_end.min(entries.len());++    let layout = compute_layout(&entries, page_start);+    let graph_svg = render_page_svg(&entries, &layout, page_start, visible_end);++    let mut refs_by_sha: HashMap<String, Vec<RefLabel>> = HashMap::new();+    if let Ok(refs) = git_repo.references() {+        for mut branch in refs.local_branches().into_iter().flatten().flatten() {+            if let Ok(commit) = branch.peel_to_commit() {+                refs_by_sha+                    .entry(commit.id().to_string())+                    .or_default()+                    .push(RefLabel {+                        name: branch.name().shorten().to_string(),+                        kind: "branch".into(),+                    });+            }+        }++        for mut tag in refs.tags().into_iter().flatten().flatten() {+            if let Ok(commit) = tag.peel_to_commit() {+                refs_by_sha+                    .entry(commit.id().to_string())+                    .or_default()+                    .push(RefLabel {+                        name: tag.name().shorten().to_string(),+                        kind: "tag".into(),+                    });+            }+        }+    }++    let commits = entries[page_start..visible_end]+        .iter()+        .map(|e| CommitRow {+            color: layout+                .positions+                .get(&e.sha)+                .map(|&(_, col)| COLORS[col % COLORS.len()])+                .unwrap_or("")+                .to_string(),+            sha: e.sha.clone(),+            message: e.message.clone(),+            date: e.date.clone(),+            refs: refs_by_sha.remove(e.sha.as_str()).unwrap_or_default(),+        })+        .collect();++    Ok(CommitList {+        repo,+        graph_svg,+        commits,+        page: q.page,+        has_prev: q.page > 0,+        has_next,+    })+}++/// Path parameter for the commit detail page.+#[derive(Deserialize)]+pub(super) struct CommitPath {+    sha: String,+}++/// A single line inside a diff hunk, ready for the template.+///+/// `marker` is the visible prefix character (`+`, `-`, or `~`).+/// `css_class` is the CSS class suffix used to color the marker.+/// `content` is the rest of the line.+struct DiffLine {+    marker: String,+    css_class: String,+    content: String,+}++/// Collects hunks produced by `UnifiedDiff` into a `Vec<Vec<DiffLine>>`,+/// counting added and removed lines at the same time.+struct DiffHunkCollector<'a> {+    hunks: &'a mut Vec<Vec<DiffLine>>,+    file_added: &'a mut u64,+    file_removed: &'a mut u64,+}++impl ConsumeHunk for DiffHunkCollector<'_> {+    type Out = ();++    fn consume_hunk(+        &mut self,+        _header: HunkHeader,+        lines: &[(DiffLineKind, &[u8])],+    ) -> std::io::Result<()> {+        let mut hunk = Vec::new();+        for (kind, content) in lines {+            let content_str = std::str::from_utf8(content)+                .unwrap_or("<binary>")+                .to_string();++            let (marker, css_class) = match kind {+                DiffLineKind::Context => ("~".into(), "ctx".into()),+                DiffLineKind::Add => ("+".into(), "+".into()),+                DiffLineKind::Remove => ("-".into(), "-".into()),+            };++            if matches!(kind, DiffLineKind::Add) {+                *self.file_added += 1;+            } else if matches!(kind, DiffLineKind::Remove) {+                *self.file_removed += 1;+            }++            hunk.push(DiffLine {+                marker,+                css_class,+                content: content_str,+            });+        }++        self.hunks.push(hunk);+        Ok(())+    }++    fn finish(self) -> Self::Out {}+}++/// A single file touched by a commit, with its inline diff hunks.+struct FileChange {+    path: String,+    change_type: String,+    hunks: Vec<Vec<DiffLine>>,+}++/// Template data for the commit detail page.+#[derive(Template, WebTemplate)]+#[template(path = "commit.html")]+struct CommitDetail {+    repo: String,+    sha: String,+    author: String,+    committer: String,+    date: String,+    committer_date: String,+    message: String,+    parents: Vec<(String, String)>,+    refs: Vec<RefLabel>,+    files_changed: u64,+    lines_added: u64,+    lines_removed: u64,+    file_changes: Vec<FileChange>,~}++/// Displays a single commit: metadata, diff stat, and per-file inline diffs.+pub async fn detail(+    State(state): State<AppState>,+    RepoName(repo): RepoName,+    Path(params): Path<CommitPath>,+) -> Result<impl IntoResponse> {+    let sha = params.sha;++    let git_repo =+        gix::open(state.root.join(&repo)).map_err(|_| Error::RepoNotFound(repo.clone()))?;++    let oid = gix::ObjectId::from_hex(sha.as_bytes())+        .map_err(|_| Error::BadRequest(format!("invalid sha: {sha}")))?;++    let commit = git_repo+        .find_object(oid)+        .map_err(|_| Error::NotFound(format!("commit {sha}")))?+        .try_into_commit()+        .map_err(|_| Error::BadRequest(format!("{sha} is not a commit")))?;++    let sha = commit.id().to_string();++    let author = commit+        .author()+        .map(|a| format!("{} <{}>", a.name, a.email))+        .unwrap_or_default();++    let committer = commit+        .committer()+        .map(|c| format!("{} <{}>", c.name, c.email))+        .unwrap_or_default();++    let date = commit_date(&commit).unwrap_or_default();+    let committer_date = committer_date(&commit).unwrap_or_default();+    let message = commit.message_raw_sloppy().to_str_lossy().into_owned();++    let parents: Vec<(String, String)> = commit+        .parent_ids()+        .map(|id| {+            let s = id.to_string();+            let short = s[..8].to_string();+            (s, short)+        })+        .collect();++    // Collect refs (branches + tags) pointing to this commit.+    let mut refs: Vec<RefLabel> = Vec::new();+    if let Ok(rs) = git_repo.references() {+        for mut branch in rs.local_branches().into_iter().flatten().flatten() {+            if let Ok(c) = branch.peel_to_commit()+                && c.id().to_string() == sha+            {+                refs.push(RefLabel {+                    name: branch.name().shorten().to_string(),+                    kind: "branch".into(),+                });+            }+        }++        for mut tag in rs.tags().into_iter().flatten().flatten() {+            if let Ok(c) = tag.peel_to_commit()+                && c.id().to_string() == sha+            {+                refs.push(RefLabel {+                    name: tag.name().shorten().to_string(),+                    kind: "tag".into(),+                });+            }+        }+    }++    // Compute diff stat against the first parent (or empty tree for root commits).+    let commit_tree = commit+        .tree()+        .map_err(|e| Error::GitCorrupt(e.to_string()))?;++    let parent_tree = commit+        .parent_ids()+        .next()+        .and_then(|pid| pid.object().ok())+        .and_then(|o| o.try_into_commit().ok())+        .and_then(|c| c.tree().ok());++    let mut resource_cache = git_repo+        .diff_resource_cache_for_tree_diff()+        .map_err(|e| Error::GitCorrupt(e.to_string()))?;++    let mut files_changed = 0u64;+    let mut lines_added = 0u64;+    let mut lines_removed = 0u64;+    let mut file_changes: Vec<FileChange> = Vec::new();++    let source_tree = match parent_tree {+        Some(ref t) => t,+        None => &git_repo.empty_tree(),+    };++    source_tree+        .changes()+        .map_err(|e| Error::GitCorrupt(e.to_string()))?+        .for_each_to_obtain_tree(&commit_tree, |change| {+            // Skip directory entries - only show leaf-level file changes.+            if change.entry_mode().is_tree() {+                return Ok::<_, std::convert::Infallible>(ControlFlow::Continue(()));+            }++            let path = change.location().to_str_lossy().into_owned();+            let change_type = match change {+                gix::object::tree::diff::Change::Addition { .. } => "A",+                gix::object::tree::diff::Change::Deletion { .. } => "D",+                gix::object::tree::diff::Change::Modification { .. } => "M",+                gix::object::tree::diff::Change::Rewrite { .. } => "R",+            };++            files_changed += 1;++            let mut hunks = Vec::new();+            let mut added = 0u64;+            let mut removed = 0u64;++            if let Ok(platform) = change.diff(&mut resource_cache) {+                platform+                    .resource_cache+                    .options+                    .skip_internal_diff_if_external_is_configured = false;+                if let Ok(prep) = platform.resource_cache.prepare_diff() {+                    if let Operation::InternalDiff { algorithm } = prep.operation {+                        let input = prep.interned_input();+                        let collector = DiffHunkCollector {+                            hunks: &mut hunks,+                            file_added: &mut added,+                            file_removed: &mut removed,+                        };+                        let sink = UnifiedDiff::new(&input, collector, ContextSize::symmetrical(3));+                        let _ = gix::diff::blob::diff(algorithm, &input, sink);+                    }+                }+            }++            resource_cache.clear_resource_cache_keep_allocation();++            lines_added += added;+            lines_removed += removed;++            file_changes.push(FileChange {+                path,+                change_type: change_type.to_string(),+                hunks,+            });++            Ok::<_, std::convert::Infallible>(ControlFlow::Continue(()))+        })+        .map_err(|e| Error::GitCorrupt(e.to_string()))?;~-pub async fn list() -> impl IntoResponse {-    "Commit list"+    Ok(CommitDetail {+        repo,+        sha,+        author,+        committer,+        date,+        committer_date,+        message,+        parents,+        refs,+        files_changed,+        lines_added,+        lines_removed,+        file_changes,+    })~}