fabrica

hanna/fabrica

feat(web): commit view with highlighted unified and split diffs

4b0bedd · hanna committed on 2026-07-25

Add the commit page over the git diff layer. Each file's pre- and
post-image are highlighted as whole documents (never per-hunk) and the
resulting lines are zipped with the diff hunks, so highlight spans live
inside the added/removed rows.

- Commit metadata: subject, signature badge, short sha, author/date, body.
- A changed-files summary (N files, +adds/−dels) with anchors, and each
  file in an independently collapsible <details> with its own counts.
- Unified and split (side-by-side, del/add paired) views, toggled and
  persisted in a cookie; split forced to scroll on narrow screens.
- Between-hunk "expand N hidden lines" rows that hx-get the context
  endpoint (…/-/commit/{sha}/context?file=&from=&to=) and swap in
  highlighted context via htmx.

Verified end-to-end against a live server. diff.rs holds the pure
rendering; repo.rs loads commits on the blocking pool.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
4 files changed · +683 −6UnifiedSplit
assets/base.css +122 −0
@@ -445,6 +445,128 @@ table.blob tr:target {
445445 background: var(--fb-diff-add-bg);
446446 }
447447
448+/* ---- Commit view & diffs ---- */
449+.commit-meta {
450+ margin-bottom: 1rem;
451+}
452+.commit-body {
453+ margin: 0.5rem 0;
454+ padding: 0.5rem 0.75rem;
455+ background: var(--fb-bg-inset);
456+ border-radius: var(--fb-radius);
457+ white-space: pre-wrap;
458+ font-family: var(--fb-font-mono);
459+ font-size: 0.85rem;
460+}
461+.diff-toolbar {
462+ display: flex;
463+ justify-content: space-between;
464+ align-items: center;
465+ flex-wrap: wrap;
466+ gap: 0.5rem;
467+ margin-bottom: 0.75rem;
468+}
469+.diff-toggle .btn {
470+ padding: 0.2rem 0.6rem;
471+}
472+.diff-toggle .btn.active {
473+ border-color: var(--fb-accent);
474+ color: var(--fb-accent);
475+}
476+.changed-files {
477+ display: flex;
478+ flex-direction: column;
479+ gap: 0.15rem;
480+ padding: 0.5rem 0.75rem;
481+ margin-bottom: 1rem;
482+ background: var(--fb-bg-raised);
483+ border: 1px solid var(--fb-border);
484+ border-radius: var(--fb-radius);
485+ font-family: var(--fb-font-mono);
486+ font-size: 0.85rem;
487+}
488+.diff-file {
489+ margin-bottom: 1rem;
490+ border: 1px solid var(--fb-border);
491+ border-radius: var(--fb-radius);
492+ overflow: hidden;
493+}
494+.diff-file > summary {
495+ padding: 0.5rem 0.75rem;
496+ background: var(--fb-bg-raised);
497+ cursor: pointer;
498+}
499+
500+table.diff {
501+ width: 100%;
502+ border-collapse: collapse;
503+ font-family: var(--fb-font-mono);
504+ font-size: 0.82rem;
505+ background: var(--fb-bg-inset);
506+}
507+table.diff td {
508+ padding: 0 0.5rem;
509+ white-space: pre-wrap;
510+ word-break: break-word;
511+ vertical-align: top;
512+}
513+table.diff td.lineno {
514+ width: 1%;
515+ min-width: 2.5rem;
516+ text-align: right;
517+ color: var(--fb-fg-muted);
518+ user-select: none;
519+ white-space: nowrap;
520+}
521+table.diff td.diff-marker {
522+ width: 1ch;
523+ text-align: center;
524+ user-select: none;
525+}
526+tr.diff-add,
527+td.diff-add {
528+ background: var(--fb-diff-add-bg);
529+}
530+tr.diff-add td.diff-marker,
531+tr.diff-add td.code-line {
532+ color: var(--fb-diff-add-fg);
533+}
534+tr.diff-del,
535+td.diff-del {
536+ background: var(--fb-diff-del-bg);
537+}
538+tr.diff-del td.diff-marker,
539+tr.diff-del td.code-line {
540+ color: var(--fb-diff-del-fg);
541+}
542+tr.hunk-header td {
543+ background: var(--fb-bg-raised);
544+ color: var(--fb-fg-muted);
545+}
546+.diff-expander td {
547+ background: var(--fb-bg-raised);
548+ text-align: center;
549+ padding: 0.15rem;
550+}
551+.expand-btn {
552+ background: none;
553+ border: none;
554+ color: var(--fb-accent);
555+ cursor: pointer;
556+ font: inherit;
557+}
558+td.diff-empty {
559+ background: var(--fb-bg-raised);
560+}
561+
562+@media (max-width: 48rem) {
563+ /* Force unified on narrow screens (§9.6). */
564+ table.diff-split {
565+ display: block;
566+ overflow-x: auto;
567+ }
568+}
569+
448570 /* ---- Footer ---- */
449571 .footer {
450572 max-width: 76rem;
crates/web/src/diff.rs +296 −0
@@ -0,0 +1,296 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! Diff rendering: highlighted unified and split views.
6+//!
7+//! Each file's pre- and post-image are highlighted as **whole documents** (§8) —
8+//! never per-hunk, which would feed a context-dependent grammar garbage — and the
9+//! resulting lines are indexed by line number and zipped with the diff hunks.
10+//! Added/removed backgrounds are applied to the row; the highlight spans live
11+//! inside it.
12+
13+use highlight::Line;
14+use maud::{Markup, html};
15+
16+/// One file's diff plus the highlighted lines of each side, ready to zip.
17+pub struct RenderedFile {
18+ /// The structural diff (paths, status, hunks).
19+ pub diff: git::FileDiff,
20+ /// Highlighted pre-image lines (old side), indexed from 1.
21+ pub old_lines: Vec<Line>,
22+ /// Highlighted post-image lines (new side), indexed from 1.
23+ pub new_lines: Vec<Line>,
24+}
25+
26+impl RenderedFile {
27+ /// The file's display path (new path, or old path for a deletion).
28+ #[must_use]
29+ pub fn path(&self) -> &str {
30+ self.diff
31+ .new_path
32+ .as_deref()
33+ .or(self.diff.old_path.as_deref())
34+ .unwrap_or("(unknown)")
35+ }
36+
37+ /// Added / removed line counts, for the summary.
38+ #[must_use]
39+ pub fn stats(&self) -> (usize, usize) {
40+ let mut adds = 0;
41+ let mut dels = 0;
42+ for hunk in &self.diff.hunks {
43+ for line in &hunk.lines {
44+ match line.origin {
45+ git::LineOrigin::Addition => adds += 1,
46+ git::LineOrigin::Deletion => dels += 1,
47+ git::LineOrigin::Context => {}
48+ }
49+ }
50+ }
51+ (adds, dels)
52+ }
53+}
54+
55+/// Render the highlighted spans of line `n` (1-based) from `lines`, or the raw
56+/// `fallback` text if the index is out of range.
57+fn line_markup(lines: &[Line], n: u32, fallback: &str) -> Markup {
58+ let idx = (n as usize).saturating_sub(1);
59+ if let Some(line) = lines.get(idx) {
60+ html! {
61+ @for span in &line.spans {
62+ @match span.class {
63+ Some(class) => { span class=(class) { (span.text) } }
64+ None => { (span.text) }
65+ }
66+ }
67+ }
68+ } else {
69+ html! { (fallback.trim_end_matches(['\n', '\r'])) }
70+ }
71+}
72+
73+/// Render one file's diff in the unified view.
74+///
75+/// `hx_base`, when given, is the context-endpoint URL prefix
76+/// (`…/-/commit/{sha}/context?file={path}`); it enables the "expand hidden lines"
77+/// rows between hunks.
78+#[must_use]
79+pub fn unified(rf: &RenderedFile, hx_base: Option<&str>) -> Markup {
80+ html! {
81+ table class="diff diff-unified" {
82+ tbody {
83+ @for (i, hunk) in rf.diff.hunks.iter().enumerate() {
84+ @if let Some(base) = hx_base {
85+ @if let Some((from, to)) = gap_before(rf, i) {
86+ (expander(base, from, to))
87+ }
88+ }
89+ tr class="hunk-header" {
90+ td colspan="3" class="lineno" {}
91+ td class="code-line" { (hunk.header) }
92+ }
93+ @for line in &hunk.lines {
94+ @let (cls, marker) = row_kind(line.origin);
95+ tr class=(cls) {
96+ td class="lineno" { (opt_num(line.old_lineno)) }
97+ td class="lineno" { (opt_num(line.new_lineno)) }
98+ td class="diff-marker" { (marker) }
99+ td class="code-line" { (unified_line(rf, line)) }
100+ }
101+ }
102+ }
103+ }
104+ }
105+ }
106+}
107+
108+/// The new-side line range hidden before hunk `i`, or `None` when there is no gap.
109+fn gap_before(rf: &RenderedFile, i: usize) -> Option<(u32, u32)> {
110+ let first_new = rf
111+ .diff
112+ .hunks
113+ .get(i)?
114+ .lines
115+ .iter()
116+ .find_map(|l| l.new_lineno)?;
117+ let prev_last = if i == 0 {
118+ 0
119+ } else {
120+ rf.diff.hunks[i - 1]
121+ .lines
122+ .iter()
123+ .filter_map(|l| l.new_lineno)
124+ .next_back()
125+ .unwrap_or(0)
126+ };
127+ if first_new > prev_last + 1 {
128+ Some((prev_last + 1, first_new - 1))
129+ } else {
130+ None
131+ }
132+}
133+
134+/// An expander row that fetches and swaps in the hidden context lines.
135+fn expander(hx_base: &str, from: u32, to: u32) -> Markup {
136+ let url = format!("{hx_base}&from={from}&to={to}");
137+ html! {
138+ tr class="diff-expander" {
139+ td colspan="4" {
140+ button type="button" class="expand-btn"
141+ hx-get=(url) hx-target="closest tr" hx-swap="outerHTML" {
142+ "Expand " (to - from + 1) " hidden lines"
143+ }
144+ }
145+ }
146+ }
147+}
148+
149+/// The highlighted content for a unified diff line, choosing the correct side.
150+fn unified_line(rf: &RenderedFile, line: &git::DiffLine) -> Markup {
151+ match line.origin {
152+ git::LineOrigin::Deletion => {
153+ line_markup(&rf.old_lines, line.old_lineno.unwrap_or(0), &line.content)
154+ }
155+ // Context and additions both take the post-image line.
156+ _ => line_markup(&rf.new_lines, line.new_lineno.unwrap_or(0), &line.content),
157+ }
158+}
159+
160+/// Render one file's diff in the split (side-by-side) view.
161+#[must_use]
162+pub fn split(rf: &RenderedFile) -> Markup {
163+ html! {
164+ table class="diff diff-split" {
165+ tbody {
166+ @for hunk in &rf.diff.hunks {
167+ tr class="hunk-header" {
168+ td colspan="4" class="code-line" { (hunk.header) }
169+ }
170+ @for row in pair_hunk(hunk) {
171+ tr {
172+ @match &row.left {
173+ Some(l) => {
174+ td class="lineno" { (l.0) }
175+ td class=(format!("code-line {}", side_class(l.1))) {
176+ (line_markup(&rf.old_lines, l.0, &l.2))
177+ }
178+ }
179+ None => { td class="lineno" {} td class="code-line diff-empty" {} }
180+ }
181+ @match &row.right {
182+ Some(r) => {
183+ td class="lineno" { (r.0) }
184+ td class=(format!("code-line {}", side_class(r.1))) {
185+ (line_markup(&rf.new_lines, r.0, &r.2))
186+ }
187+ }
188+ None => { td class="lineno" {} td class="code-line diff-empty" {} }
189+ }
190+ }
191+ }
192+ }
193+ }
194+ }
195+ }
196+}
197+
198+/// A paired split row: an optional left (old) and right (new) cell, each carrying
199+/// `(lineno, origin, content)`.
200+struct SplitRow {
201+ left: Option<(u32, git::LineOrigin, String)>,
202+ right: Option<(u32, git::LineOrigin, String)>,
203+}
204+
205+/// Pair a hunk's lines into side-by-side rows: context aligns on both sides, and a
206+/// run of deletions is paired with the following run of additions.
207+fn pair_hunk(hunk: &git::Hunk) -> Vec<SplitRow> {
208+ let mut rows = Vec::new();
209+ let mut dels: Vec<(u32, String)> = Vec::new();
210+ let mut adds: Vec<(u32, String)> = Vec::new();
211+
212+ let flush =
213+ |rows: &mut Vec<SplitRow>, dels: &mut Vec<(u32, String)>, adds: &mut Vec<(u32, String)>| {
214+ let n = dels.len().max(adds.len());
215+ for i in 0..n {
216+ rows.push(SplitRow {
217+ left: dels
218+ .get(i)
219+ .map(|(ln, c)| (*ln, git::LineOrigin::Deletion, c.clone())),
220+ right: adds
221+ .get(i)
222+ .map(|(ln, c)| (*ln, git::LineOrigin::Addition, c.clone())),
223+ });
224+ }
225+ dels.clear();
226+ adds.clear();
227+ };
228+
229+ for line in &hunk.lines {
230+ match line.origin {
231+ git::LineOrigin::Deletion => {
232+ dels.push((line.old_lineno.unwrap_or(0), line.content.clone()));
233+ }
234+ git::LineOrigin::Addition => {
235+ adds.push((line.new_lineno.unwrap_or(0), line.content.clone()));
236+ }
237+ git::LineOrigin::Context => {
238+ flush(&mut rows, &mut dels, &mut adds);
239+ rows.push(SplitRow {
240+ left: Some((
241+ line.old_lineno.unwrap_or(0),
242+ git::LineOrigin::Context,
243+ line.content.clone(),
244+ )),
245+ right: Some((
246+ line.new_lineno.unwrap_or(0),
247+ git::LineOrigin::Context,
248+ line.content.clone(),
249+ )),
250+ });
251+ }
252+ }
253+ }
254+ flush(&mut rows, &mut dels, &mut adds);
255+ rows
256+}
257+
258+/// The CSS class and marker glyph for a unified row.
259+fn row_kind(origin: git::LineOrigin) -> (&'static str, &'static str) {
260+ match origin {
261+ git::LineOrigin::Addition => ("diff-add", "+"),
262+ git::LineOrigin::Deletion => ("diff-del", "-"),
263+ git::LineOrigin::Context => ("diff-ctx", " "),
264+ }
265+}
266+
267+/// The per-cell class for a split row side.
268+fn side_class(origin: git::LineOrigin) -> &'static str {
269+ match origin {
270+ git::LineOrigin::Addition => "diff-add",
271+ git::LineOrigin::Deletion => "diff-del",
272+ git::LineOrigin::Context => "diff-ctx",
273+ }
274+}
275+
276+/// Render an optional line number as a string.
277+fn opt_num(n: Option<u32>) -> String {
278+ n.map_or_else(String::new, |n| n.to_string())
279+}
280+
281+/// Render context lines (for the expand-context partial) as unified rows.
282+#[must_use]
283+pub fn context_rows(lines: &[git::DiffLine], highlighted: &[Line]) -> Markup {
284+ html! {
285+ @for line in lines {
286+ tr class="diff-ctx" {
287+ td class="lineno" { (opt_num(line.old_lineno)) }
288+ td class="lineno" { (opt_num(line.new_lineno)) }
289+ td class="diff-marker" {}
290+ td class="code-line" {
291+ (line_markup(highlighted, line.new_lineno.unwrap_or(0), &line.content))
292+ }
293+ }
294+ }
295+ }
296+}
crates/web/src/lib.rs +1 −0
@@ -11,6 +11,7 @@
1111 //! without JavaScript; htmx only enhances.
1212
1313 mod assets;
14+mod diff;
1415 mod error;
1516 mod layout;
1617 mod markdown;
crates/web/src/repo.rs +264 −6
@@ -13,20 +13,38 @@
1313
1414 use std::path::Path as FsPath;
1515
16-use axum::extract::{Path, State};
16+use axum::extract::{Path, Query, State};
1717 use axum::http::{Uri, header};
18-use axum::response::{IntoResponse, Response};
19-use axum_extra::extract::cookie::CookieJar;
18+use axum::response::{Html, IntoResponse, Response};
19+use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
2020 use maud::{Markup, html};
2121 use model::{Repo, User};
22+use serde::Deserialize;
2223
2324 use crate::AppState;
25+use crate::diff::{self, RenderedFile};
2426 use crate::error::{AppError, AppResult};
2527 use crate::layout::page;
2628 use crate::markdown;
2729 use crate::pages::build_chrome;
2830 use crate::session::MaybeUser;
2931
32+/// The diff-view preference cookie (`unified` | `split`).
33+const DIFF_COOKIE: &str = "fabrica_diffview";
34+
35+/// Query parameters for the commit view and its context-expansion partial.
36+#[derive(Debug, Default, Deserialize)]
37+pub struct DiffQuery {
38+ /// `unified` or `split`.
39+ view: Option<String>,
40+ /// The file whose context to expand.
41+ file: Option<String>,
42+ /// The 1-based first line of the context window.
43+ from: Option<u32>,
44+ /// The 1-based last line of the context window.
45+ to: Option<u32>,
46+}
47+
3048 /// The candidate README filenames, in lookup order.
3149 const README_NAMES: &[&str] = &["README.md", "README", "readme.md", "README.txt"];
3250
@@ -156,13 +174,14 @@ pub async fn dispatch(
156174 MaybeUser(viewer): MaybeUser,
157175 jar: CookieJar,
158176 uri: Uri,
177+ Query(dq): Query<DiffQuery>,
159178 Path((owner_name, rest)): Path<(String, String)>,
160179 ) -> AppResult<Response> {
161180 if let Some((repo_path, view)) = rest.split_once("/-/") {
162181 let ctx = resolve(&state, &owner_name, repo_path, viewer.as_ref())
163182 .await?
164183 .ok_or(AppError::NotFound)?;
165- dispatch_view(&state, viewer, jar, &uri, ctx, view).await
184+ dispatch_view(&state, viewer, jar, &uri, ctx, view, &dq).await
166185 } else {
167186 match resolve(&state, &owner_name, &rest, viewer.as_ref()).await? {
168187 Some(ctx) => repo_home(&state, viewer, jar, &uri, ctx).await,
@@ -179,6 +198,7 @@ async fn dispatch_view(
179198 uri: &Uri,
180199 ctx: RepoCtx,
181200 view: &str,
201+ dq: &DiffQuery,
182202 ) -> AppResult<Response> {
183203 let (kind, rest) = view.split_once('/').unwrap_or((view, ""));
184204 match kind {
@@ -186,10 +206,13 @@ async fn dispatch_view(
186206 "blob" => blob_view(state, viewer, jar, uri, ctx, rest, false).await,
187207 "raw" => raw_view(state, ctx, rest).await,
188208 "commits" => commits_view(state, viewer, jar, uri, ctx, rest).await,
209+ "commit" => match rest.split_once('/') {
210+ Some((sha, "context")) => context_partial(state, ctx, sha, dq).await,
211+ _ => commit_view(state, viewer, jar, uri, ctx, rest, dq).await,
212+ },
189213 "branches" => branches_view(state, viewer, jar, uri, ctx).await,
190214 "tags" => tags_view(state, viewer, jar, uri, ctx).await,
191- // `commit` (single-commit diff) lands with the commit-view phase; the
192- // reserved `runs`/`search`/`settings` slots return 404 until built.
215+ // Reserved `runs`/`search`/`settings` slots return 404 until built.
193216 _ => Err(AppError::NotFound),
194217 }
195218 }
@@ -604,6 +627,241 @@ async fn commits_view(
604627 Ok((jar, page(&chrome, &title, body)).into_response())
605628 }
606629
630+// ---- Commit view ----
631+
632+async fn commit_view(
633+ state: &AppState,
634+ viewer: Option<User>,
635+ jar: CookieJar,
636+ uri: &Uri,
637+ ctx: RepoCtx,
638+ sha: &str,
639+ dq: &DiffQuery,
640+) -> AppResult<Response> {
641+ let keys = signing_keys(state.store.all_keys().await?);
642+ let (detail, files, sig) = load_commit(state, &ctx.repo.id, sha, keys).await?;
643+
644+ // View mode: an explicit `?view=` wins and is remembered; else the cookie;
645+ // else unified.
646+ let chosen = dq.view.clone().filter(|v| v == "split" || v == "unified");
647+ let cookie_mode = jar.get(DIFF_COOKIE).map(|c| c.value().to_string());
648+ let split = chosen.clone().or(cookie_mode).as_deref() == Some("split");
649+
650+ let (adds, dels) = files.iter().fold((0, 0), |(a, d), f| {
651+ let (fa, fd) = f.stats();
652+ (a + fa, d + fd)
653+ });
654+ let repo_base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
655+
656+ let body = html! {
657+ (repo_header(state, &ctx, Tab::Code, &ctx.repo.default_branch))
658+ (commit_meta(&detail, &sig))
659+ div class="diff-toolbar" {
660+ span {
661+ (files.len()) " files changed · "
662+ span style="color:var(--fb-diff-add-fg)" { "+" (adds) }
663+ " "
664+ span style="color:var(--fb-diff-del-fg)" { "−" (dels) }
665+ }
666+ span class="diff-toggle" {
667+ a class=(toggle_class(!split)) href=(format!("{repo_base}/-/commit/{sha}?view=unified")) { "Unified" }
668+ a class=(toggle_class(split)) href=(format!("{repo_base}/-/commit/{sha}?view=split")) { "Split" }
669+ }
670+ }
671+ nav class="changed-files" aria-label="Changed files" {
672+ @for (i, f) in files.iter().enumerate() {
673+ @let (fa, fd) = f.stats();
674+ a href=(format!("#file-{i}")) {
675+ (f.path()) " " span class="muted" { "+" (fa) " −" (fd) }
676+ }
677+ }
678+ }
679+ @for (i, f) in files.iter().enumerate() {
680+ details open id=(format!("file-{i}")) class="diff-file" {
681+ summary {
682+ @let (fa, fd) = f.stats();
683+ span class="mono" { (f.path()) }
684+ " " span class="muted" { "+" (fa) " −" (fd) }
685+ }
686+ @if f.diff.is_binary {
687+ p class="muted" style="padding:0.5rem" { "Binary file not shown." }
688+ } @else if f.diff.hunks.is_empty() {
689+ p class="muted" style="padding:0.5rem" { "No textual changes." }
690+ } @else if split {
691+ (diff::split(f))
692+ } @else {
693+ (diff::unified(f, Some(&format!("{repo_base}/-/commit/{sha}/context?file={}", f.path()))))
694+ }
695+ }
696+ }
697+ };
698+
699+ let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
700+ let jar = match chosen {
701+ Some(mode) => jar.add(diff_cookie(mode, state.config.auth.cookie_secure)),
702+ None => jar,
703+ };
704+ let title = format!(
705+ "{}/{}: {}",
706+ ctx.owner.username,
707+ ctx.repo.path,
708+ detail.oid.short()
709+ );
710+ Ok((jar, page(&chrome, &title, body)).into_response())
711+}
712+
713+/// Load a commit, its diff against the first parent, the highlighted pre/post
714+/// images of each file, and its signature — all on the blocking pool.
715+type LoadedCommit = (git::CommitDetail, Vec<RenderedFile>, git::SignatureState);
716+async fn load_commit(
717+ state: &AppState,
718+ repo_id: &str,
719+ sha: &str,
720+ keys: Vec<git::SigningKey>,
721+) -> AppResult<LoadedCommit> {
722+ let oid = git::Oid::new(sha);
723+ let context_lines = state.config.ui.diff_context_lines;
724+ let sha_owned = sha.to_string();
725+ git_read(state, repo_id, move |repo| {
726+ let detail = repo.commit(&oid)?;
727+ let base = detail.parents.first().cloned();
728+ let diffs = repo.diff(base.as_ref(), &oid, git::DiffOpts { context_lines })?;
729+
730+ let head_rev = git::Resolved {
731+ oid: oid.clone(),
732+ kind: git::RefKind::Commit,
733+ name: sha_owned,
734+ };
735+ let base_rev = base.as_ref().map(|o| git::Resolved {
736+ oid: o.clone(),
737+ kind: git::RefKind::Commit,
738+ name: o.to_string(),
739+ });
740+
741+ let rendered = diffs
742+ .files
743+ .into_iter()
744+ .map(|file| render_one_file(repo, &head_rev, base_rev.as_ref(), file))
745+ .collect();
746+ let sig = repo.signature_state(&oid, &keys);
747+ Ok((detail, rendered, sig))
748+ })
749+ .await
750+}
751+
752+/// Build a [`RenderedFile`] for one diff entry, highlighting each side as a whole
753+/// document.
754+fn render_one_file(
755+ repo: &git::Repo,
756+ head_rev: &git::Resolved,
757+ base_rev: Option<&git::Resolved>,
758+ file: git::FileDiff,
759+) -> RenderedFile {
760+ let text_at = |rev: &git::Resolved, path: &str| {
761+ repo.blob(rev, FsPath::new(path))
762+ .ok()
763+ .filter(|b| !b.is_binary)
764+ .map(|b| String::from_utf8_lossy(&b.content).into_owned())
765+ };
766+ let new_text = file.new_path.as_deref().and_then(|p| text_at(head_rev, p));
767+ let old_text = match (base_rev, file.old_path.as_deref()) {
768+ (Some(rev), Some(p)) => text_at(rev, p),
769+ _ => None,
770+ };
771+ let lang_path = file
772+ .new_path
773+ .clone()
774+ .or_else(|| file.old_path.clone())
775+ .unwrap_or_default();
776+ let probe = new_text.as_deref().or(old_text.as_deref()).unwrap_or("");
777+ let lang = highlight::resolve(FsPath::new(&lang_path), probe.as_bytes(), None);
778+ let highlight_side = |text: Option<&str>| {
779+ text.map(|t| highlight::highlight_document(&lang, t))
780+ .unwrap_or_default()
781+ };
782+ RenderedFile {
783+ old_lines: highlight_side(old_text.as_deref()),
784+ new_lines: highlight_side(new_text.as_deref()),
785+ diff: file,
786+ }
787+}
788+
789+/// The commit metadata block: subject, signature badge, sha, author, and body.
790+fn commit_meta(detail: &git::CommitDetail, sig: &git::SignatureState) -> Markup {
791+ let (subject, body) = detail
792+ .message
793+ .split_once("\n\n")
794+ .map_or((detail.message.as_str(), ""), |(s, b)| (s.trim(), b));
795+ html! {
796+ div class="commit-meta" {
797+ h2 { (subject.lines().next().unwrap_or("")) " " (signature_badge(sig)) }
798+ p class="muted" {
799+ span class="mono" { (detail.oid.short()) }
800+ " · " (detail.author.name)
801+ " committed on " (fmt_date(detail.committer.time_ms))
802+ }
803+ @if !body.trim().is_empty() {
804+ pre class="commit-body" { (body.trim_end()) }
805+ }
806+ }
807+ }
808+}
809+
810+/// `GET …/-/commit/{sha}/context` — the highlighted hidden-context rows, as an
811+/// htmx partial that replaces its expander.
812+async fn context_partial(
813+ state: &AppState,
814+ ctx: RepoCtx,
815+ sha: &str,
816+ dq: &DiffQuery,
817+) -> AppResult<Response> {
818+ let (Some(file), Some(from), Some(to)) = (dq.file.clone(), dq.from, dq.to) else {
819+ return Err(AppError::BadRequest(
820+ "missing context parameters".to_string(),
821+ ));
822+ };
823+ let oid = git::Oid::new(sha);
824+ let sha_owned = sha.to_string();
825+ let (lines, highlighted) = git_read(state, &ctx.repo.id, move |repo| {
826+ let lines = repo.diff_context(&oid, FsPath::new(&file), from, to)?;
827+ let rev = git::Resolved {
828+ oid: oid.clone(),
829+ kind: git::RefKind::Commit,
830+ name: sha_owned,
831+ };
832+ let highlighted = repo
833+ .blob(&rev, FsPath::new(&file))
834+ .ok()
835+ .filter(|b| !b.is_binary)
836+ .map(|b| {
837+ let text = String::from_utf8_lossy(&b.content).into_owned();
838+ let lang = highlight::resolve(FsPath::new(&file), text.as_bytes(), None);
839+ highlight::highlight_document(&lang, &text)
840+ })
841+ .unwrap_or_default();
842+ Ok((lines, highlighted))
843+ })
844+ .await?;
845+
846+ Ok(Html(diff::context_rows(&lines, &highlighted).into_string()).into_response())
847+}
848+
849+/// The active/inactive class for a diff-view toggle button.
850+fn toggle_class(active: bool) -> &'static str {
851+ if active { "btn active" } else { "btn" }
852+}
853+
854+/// Build the diff-view preference cookie.
855+fn diff_cookie(mode: String, secure: bool) -> Cookie<'static> {
856+ Cookie::build((DIFF_COOKIE, mode))
857+ .http_only(false)
858+ .same_site(SameSite::Lax)
859+ .secure(secure)
860+ .path("/")
861+ .max_age(time::Duration::days(365))
862+ .build()
863+}
864+
607865 // ---- Group listing ----
608866
609867 async fn group_listing(