Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +122 −0
| @@ -445,6 +445,128 @@ table.blob tr:target { | |||
| 445 | 445 | background: var(--fb-diff-add-bg); | |
| 446 | 446 | } | |
| 447 | 447 | ||
| 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 | + | ||
| 448 | 570 | /* ---- Footer ---- */ | |
| 449 | 571 | .footer { | |
| 450 | 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 | + | ||
| 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 @@ | |||
| 11 | 11 | //! without JavaScript; htmx only enhances. | |
| 12 | 12 | ||
| 13 | 13 | mod assets; | |
| 14 | + | mod diff; | |
| 14 | 15 | mod error; | |
| 15 | 16 | mod layout; | |
| 16 | 17 | mod markdown; | |
crates/web/src/repo.rs +264 −6
| @@ -13,20 +13,38 @@ | |||
| 13 | 13 | ||
| 14 | 14 | use std::path::Path as FsPath; | |
| 15 | 15 | ||
| 16 | - | use axum::extract::{Path, State}; | |
| 16 | + | use axum::extract::{Path, Query, State}; | |
| 17 | 17 | 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}; | |
| 20 | 20 | use maud::{Markup, html}; | |
| 21 | 21 | use model::{Repo, User}; | |
| 22 | + | use serde::Deserialize; | |
| 22 | 23 | ||
| 23 | 24 | use crate::AppState; | |
| 25 | + | use crate::diff::{self, RenderedFile}; | |
| 24 | 26 | use crate::error::{AppError, AppResult}; | |
| 25 | 27 | use crate::layout::page; | |
| 26 | 28 | use crate::markdown; | |
| 27 | 29 | use crate::pages::build_chrome; | |
| 28 | 30 | use crate::session::MaybeUser; | |
| 29 | 31 | ||
| 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 | + | ||
| 30 | 48 | /// The candidate README filenames, in lookup order. | |
| 31 | 49 | const README_NAMES: &[&str] = &["README.md", "README", "readme.md", "README.txt"]; | |
| 32 | 50 | ||
| @@ -156,13 +174,14 @@ pub async fn dispatch( | |||
| 156 | 174 | MaybeUser(viewer): MaybeUser, | |
| 157 | 175 | jar: CookieJar, | |
| 158 | 176 | uri: Uri, | |
| 177 | + | Query(dq): Query<DiffQuery>, | |
| 159 | 178 | Path((owner_name, rest)): Path<(String, String)>, | |
| 160 | 179 | ) -> AppResult<Response> { | |
| 161 | 180 | if let Some((repo_path, view)) = rest.split_once("/-/") { | |
| 162 | 181 | let ctx = resolve(&state, &owner_name, repo_path, viewer.as_ref()) | |
| 163 | 182 | .await? | |
| 164 | 183 | .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 | |
| 166 | 185 | } else { | |
| 167 | 186 | match resolve(&state, &owner_name, &rest, viewer.as_ref()).await? { | |
| 168 | 187 | Some(ctx) => repo_home(&state, viewer, jar, &uri, ctx).await, | |
| @@ -179,6 +198,7 @@ async fn dispatch_view( | |||
| 179 | 198 | uri: &Uri, | |
| 180 | 199 | ctx: RepoCtx, | |
| 181 | 200 | view: &str, | |
| 201 | + | dq: &DiffQuery, | |
| 182 | 202 | ) -> AppResult<Response> { | |
| 183 | 203 | let (kind, rest) = view.split_once('/').unwrap_or((view, "")); | |
| 184 | 204 | match kind { | |
| @@ -186,10 +206,13 @@ async fn dispatch_view( | |||
| 186 | 206 | "blob" => blob_view(state, viewer, jar, uri, ctx, rest, false).await, | |
| 187 | 207 | "raw" => raw_view(state, ctx, rest).await, | |
| 188 | 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 | 213 | "branches" => branches_view(state, viewer, jar, uri, ctx).await, | |
| 190 | 214 | "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. | |
| 193 | 216 | _ => Err(AppError::NotFound), | |
| 194 | 217 | } | |
| 195 | 218 | } | |
| @@ -604,6 +627,241 @@ async fn commits_view( | |||
| 604 | 627 | Ok((jar, page(&chrome, &title, body)).into_response()) | |
| 605 | 628 | } | |
| 606 | 629 | ||
| 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 | + | ||
| 607 | 865 | // ---- Group listing ---- | |
| 608 | 866 | ||
| 609 | 867 | async fn group_listing( | |