Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
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 | } |
| 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 | /* ---- 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 | |||
| 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 | //! without JavaScript; htmx only enhances. | 11 | //! without JavaScript; htmx only enhances. |
| 12 | 12 | ||
| 13 | mod assets; | 13 | mod assets; |
| 14 | mod diff; | ||
| 14 | mod error; | 15 | mod error; |
| 15 | mod layout; | 16 | mod layout; |
| 16 | mod markdown; | 17 | mod markdown; |
crates/web/src/repo.rs +264 −6
| @@ -13,20 +13,38 @@ | |||
| 13 | 13 | ||
| 14 | use std::path::Path as FsPath; | 14 | use std::path::Path as FsPath; |
| 15 | 15 | ||
| 16 | use axum::extract::{Path, State}; | 16 | use axum::extract::{Path, Query, State}; |
| 17 | use axum::http::{Uri, header}; | 17 | use axum::http::{Uri, header}; |
| 18 | use axum::response::{IntoResponse, Response}; | 18 | use axum::response::{Html, IntoResponse, Response}; |
| 19 | use axum_extra::extract::cookie::CookieJar; | 19 | use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; |
| 20 | use maud::{Markup, html}; | 20 | use maud::{Markup, html}; |
| 21 | use model::{Repo, User}; | 21 | use model::{Repo, User}; |
| 22 | use serde::Deserialize; | ||
| 22 | 23 | ||
| 23 | use crate::AppState; | 24 | use crate::AppState; |
| 25 | use crate::diff::{self, RenderedFile}; | ||
| 24 | use crate::error::{AppError, AppResult}; | 26 | use crate::error::{AppError, AppResult}; |
| 25 | use crate::layout::page; | 27 | use crate::layout::page; |
| 26 | use crate::markdown; | 28 | use crate::markdown; |
| 27 | use crate::pages::build_chrome; | 29 | use crate::pages::build_chrome; |
| 28 | use crate::session::MaybeUser; | 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 | /// The candidate README filenames, in lookup order. | 48 | /// The candidate README filenames, in lookup order. |
| 31 | const README_NAMES: &[&str] = &["README.md", "README", "readme.md", "README.txt"]; | 49 | const README_NAMES: &[&str] = &["README.md", "README", "readme.md", "README.txt"]; |
| 32 | 50 | ||
| @@ -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).await | 184 | 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; the | 215 | // 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 | } |
| 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 | // ---- Group listing ---- | 865 | // ---- Group listing ---- |
| 608 | 866 | ||
| 609 | async fn group_listing( | 867 | async fn group_listing( |