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 {
445 background: var(--fb-diff-add-bg);445 background: var(--fb-diff-add-bg);
446}446}
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
500table.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}
507table.diff td {
508 padding: 0 0.5rem;
509 white-space: pre-wrap;
510 word-break: break-word;
511 vertical-align: top;
512}
513table.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}
521table.diff td.diff-marker {
522 width: 1ch;
523 text-align: center;
524 user-select: none;
525}
526tr.diff-add,
527td.diff-add {
528 background: var(--fb-diff-add-bg);
529}
530tr.diff-add td.diff-marker,
531tr.diff-add td.code-line {
532 color: var(--fb-diff-add-fg);
533}
534tr.diff-del,
535td.diff-del {
536 background: var(--fb-diff-del-bg);
537}
538tr.diff-del td.diff-marker,
539tr.diff-del td.code-line {
540 color: var(--fb-diff-del-fg);
541}
542tr.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}
558td.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
448/* ---- Footer ---- */570/* ---- Footer ---- */
449.footer {571.footer {
450 max-width: 76rem;572 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
13use highlight::Line;
14use maud::{Markup, html};
15
16/// One file's diff plus the highlighted lines of each side, ready to zip.
17pub 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
26impl 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.
57fn 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]
79pub 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.
109fn 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.
135fn 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.
150fn 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]
162pub 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)`.
200struct 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.
207fn 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.
259fn 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.
268fn 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.
277fn 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]
283pub 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 @@
11//! without JavaScript; htmx only enhances.11//! without JavaScript; htmx only enhances.
1212
13mod assets;13mod assets;
14mod diff;
14mod error;15mod error;
15mod layout;16mod layout;
16mod markdown;17mod markdown;
crates/web/src/repo.rs +264 −6
@@ -13,20 +13,38 @@
1313
14use std::path::Path as FsPath;14use std::path::Path as FsPath;
1515
16use axum::extract::{Path, State};16use axum::extract::{Path, Query, State};
17use axum::http::{Uri, header};17use axum::http::{Uri, header};
18use axum::response::{IntoResponse, Response};18use axum::response::{Html, IntoResponse, Response};
19use axum_extra::extract::cookie::CookieJar;19use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite};
20use maud::{Markup, html};20use maud::{Markup, html};
21use model::{Repo, User};21use model::{Repo, User};
22use serde::Deserialize;
2223
23use crate::AppState;24use crate::AppState;
25use crate::diff::{self, RenderedFile};
24use crate::error::{AppError, AppResult};26use crate::error::{AppError, AppResult};
25use crate::layout::page;27use crate::layout::page;
26use crate::markdown;28use crate::markdown;
27use crate::pages::build_chrome;29use crate::pages::build_chrome;
28use crate::session::MaybeUser;30use crate::session::MaybeUser;
2931
32/// The diff-view preference cookie (`unified` | `split`).
33const DIFF_COOKIE: &str = "fabrica_diffview";
34
35/// Query parameters for the commit view and its context-expansion partial.
36#[derive(Debug, Default, Deserialize)]
37pub 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
30/// The candidate README filenames, in lookup order.48/// The candidate README filenames, in lookup order.
31const README_NAMES: &[&str] = &["README.md", "README", "readme.md", "README.txt"];49const README_NAMES: &[&str] = &["README.md", "README", "readme.md", "README.txt"];
3250
@@ -156,13 +174,14 @@ pub async fn dispatch(
156 MaybeUser(viewer): MaybeUser,174 MaybeUser(viewer): MaybeUser,
157 jar: CookieJar,175 jar: CookieJar,
158 uri: Uri,176 uri: Uri,
177 Query(dq): Query<DiffQuery>,
159 Path((owner_name, rest)): Path<(String, String)>,178 Path((owner_name, rest)): Path<(String, String)>,
160) -> AppResult<Response> {179) -> AppResult<Response> {
161 if let Some((repo_path, view)) = rest.split_once("/-/") {180 if let Some((repo_path, view)) = rest.split_once("/-/") {
162 let ctx = resolve(&state, &owner_name, repo_path, viewer.as_ref())181 let ctx = resolve(&state, &owner_name, repo_path, viewer.as_ref())
163 .await?182 .await?
164 .ok_or(AppError::NotFound)?;183 .ok_or(AppError::NotFound)?;
165 dispatch_view(&state, viewer, jar, &uri, ctx, view).await184 dispatch_view(&state, viewer, jar, &uri, ctx, view, &dq).await
166 } else {185 } else {
167 match resolve(&state, &owner_name, &rest, viewer.as_ref()).await? {186 match resolve(&state, &owner_name, &rest, viewer.as_ref()).await? {
168 Some(ctx) => repo_home(&state, viewer, jar, &uri, ctx).await,187 Some(ctx) => repo_home(&state, viewer, jar, &uri, ctx).await,
@@ -179,6 +198,7 @@ async fn dispatch_view(
179 uri: &Uri,198 uri: &Uri,
180 ctx: RepoCtx,199 ctx: RepoCtx,
181 view: &str,200 view: &str,
201 dq: &DiffQuery,
182) -> AppResult<Response> {202) -> AppResult<Response> {
183 let (kind, rest) = view.split_once('/').unwrap_or((view, ""));203 let (kind, rest) = view.split_once('/').unwrap_or((view, ""));
184 match kind {204 match kind {
@@ -186,10 +206,13 @@ async fn dispatch_view(
186 "blob" => blob_view(state, viewer, jar, uri, ctx, rest, false).await,206 "blob" => blob_view(state, viewer, jar, uri, ctx, rest, false).await,
187 "raw" => raw_view(state, ctx, rest).await,207 "raw" => raw_view(state, ctx, rest).await,
188 "commits" => commits_view(state, viewer, jar, uri, ctx, rest).await,208 "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 },
189 "branches" => branches_view(state, viewer, jar, uri, ctx).await,213 "branches" => branches_view(state, viewer, jar, uri, ctx).await,
190 "tags" => tags_view(state, viewer, jar, uri, ctx).await,214 "tags" => tags_view(state, viewer, jar, uri, ctx).await,
191 // `commit` (single-commit diff) lands with the commit-view phase; the215 // Reserved `runs`/`search`/`settings` slots return 404 until built.
192 // reserved `runs`/`search`/`settings` slots return 404 until built.
193 _ => Err(AppError::NotFound),216 _ => Err(AppError::NotFound),
194 }217 }
195}218}
@@ -604,6 +627,241 @@ async fn commits_view(
604 Ok((jar, page(&chrome, &title, body)).into_response())627 Ok((jar, page(&chrome, &title, body)).into_response())
605}628}
606629
630// ---- Commit view ----
631
632async 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.
715type LoadedCommit = (git::CommitDetail, Vec<RenderedFile>, git::SignatureState);
716async 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.
754fn 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.
790fn 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.
812async 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.
850fn toggle_class(active: bool) -> &'static str {
851 if active { "btn active" } else { "btn" }
852}
853
854/// Build the diff-view preference cookie.
855fn 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
607// ---- Group listing ----865// ---- Group listing ----
608866
609async fn group_listing(867async fn group_listing(