.forge/prs/a5d3c785-f008-4cca-9bac-89d2135f5be7/patch.diff 19.8 KiB 480 lines Raw
1 From 024a9e2e0b5396b538ca60ede4e211ada5a69234 Mon Sep 17 00:00:00 20012 From: hu13-test <[email protected]>3 Date: Tue, 27 Oct 2026 16:24:47 +00004 Subject: [PATCH] diffs: per-file cards, dual line numbers, visual diffstat5  (#74)6 MIME-Version: 1.07 Content-Type: text/plain; charset=UTF-88 Content-Transfer-Encoding: 8bit9 10 diff_html now parses  chunks into one <details> card per11 file: path, +n/−m counts, proportional green/red bar, and every hunk12 line in a two-column (old/new) gutter. /index/mode/Binary13 lines stay visible as dim metadata; --- /+++ use --diff-file (now14 purple, distinct from body text); add/del rows carry the existing15 background colors. Format-patch mbox preambles (From/Date/Subject/16 stat) are dropped because they duplicate the PR title. Non-unified17 text still falls back to the old single-block coloring.18 19 Commit pages drop 'Author:/Date:' for a chip + monogram + relative20 time (parse_ts_secs now accepts git's 'YYYY-MM-DD HH:MM:SS ±HHMM'),21 and the commits table shows relative dates with exact values in22 title.23 ---24  src/build.rs            |  35 +++++++-25  src/commits.rs          |  56 ++++++++++--26  src/layout.rs           | 187 ++++++++++++++++++++++++++++++++--------27  static/style.css        |  24 +++++-28  tests/theme_contrast.rs |   2 +29  5 files changed, 257 insertions(+), 47 deletions(-)30 31 diff --git a/src/build.rs b/src/build.rs32 index 4c00c85..a813a50 10064433 --- a/src/build.rs34 +++ b/src/build.rs35 @@ -522,7 +522,12 @@ pub fn now_unix() -> i64 {36  #[must_use]37  pub fn parse_ts_secs(s: &str) -> Option<i64> {38      let s = s.trim();39 -    let (date, rest) = s.split_once('T')?;40 +    // Accept both `2026-10-27T14:08:48Z` (RFC3339) and git's41 +    // `2026-10-27 14:08:48 +0000` / `+00:00` forms.42 +    let (date, rest) = match s.split_once('T') {43 +        Some(v) => v,44 +        None => s.split_once(' ')?,45 +    };46      let mut d = date.split('-');47      let y: i64 = d.next()?.parse().ok()?;48      let mo: i64 = d.next()?.parse().ok()?;49 @@ -532,7 +537,10 @@ pub fn parse_ts_secs(s: &str) -> Option<i64> {50      }51      let (hms, offset) = if let Some(t) = rest.strip_suffix('Z') {52          (t, 0i64)53 -    } else if rest.len() >= 6 && matches!(rest.as_bytes()[rest.len() - 6], b'+' | b'-') {54 +    } else if rest.len() >= 655 +        && matches!(rest.as_bytes()[rest.len() - 6], b'+' | b'-')56 +        && rest.as_bytes()[rest.len() - 3] == b':'57 +    {58          let sign = if rest.as_bytes()[rest.len() - 6] == b'-' {59              -160          } else {61 @@ -541,6 +549,15 @@ pub fn parse_ts_secs(s: &str) -> Option<i64> {62          let oh: i64 = rest[rest.len() - 5..rest.len() - 3].parse().ok()?;63          let om: i64 = rest[rest.len() - 2..].parse().ok()?;64          (&rest[..rest.len() - 6], sign * (oh * 3600 + om * 60))65 +    } else if rest.len() >= 5 && matches!(rest.as_bytes()[rest.len() - 5], b'+' | b'-') {66 +        let sign = if rest.as_bytes()[rest.len() - 5] == b'-' {67 +            -168 +        } else {69 +            170 +        };71 +        let oh: i64 = rest[rest.len() - 4..rest.len() - 2].parse().ok()?;72 +        let om: i64 = rest[rest.len() - 2..].parse().ok()?;73 +        (&rest[..rest.len() - 5], sign * (oh * 3600 + om * 60))74      } else {75          (rest, 0)76      };77 @@ -553,6 +570,7 @@ pub fn parse_ts_secs(s: &str) -> Option<i64> {78          .split('.')79          .next()80          .unwrap_or("0")81 +        .trim()82          .parse()83          .ok()?;84      if !(0..=23).contains(&h) || !(0..=59).contains(&mi) || !(0..=60).contains(&sec) {85 @@ -2189,6 +2207,19 @@ mod tests {86          assert!(got.ends_with("…"));87      }88  89 +    #[test]90 +    fn parse_git_and_rfc3339_dates() {91 +        assert_eq!(92 +            parse_ts_secs("2026-09-10T07:43:55Z"),93 +            parse_ts_secs("2026-09-10 07:43:55 +0000")94 +        );95 +        let utc = parse_ts_secs("2026-09-10T07:43:55Z").unwrap();96 +        let plus1 = parse_ts_secs("2026-09-10 08:43:55 +0100").unwrap();97 +        assert_eq!(utc, plus1, "offset is applied");98 +        assert!(parse_ts_secs("not a date").is_none());99 +        assert_eq!(ts_title("2026-09-10T07:43:55Z"), "2026-09-10 07:43 UTC");100 +    }101 +102      #[test]103      fn relative_age_fixed_clock() {104          // f0rge #33 rubric: fixed-clock fixture, build-time relative ages.105 diff --git a/src/commits.rs b/src/commits.rs106 index 67872bd..9cf0b82 100644107 --- a/src/commits.rs108 +++ b/src/commits.rs109 @@ -8,9 +8,42 @@110  111  use std::path::Path;112  113 +use crate::build::{now_unix, parse_ts_secs, relative_age, ts_title};114  use crate::gitx;115  use crate::layout::{diff_html, esc, page_site_with_provenance};116  117 +/// Deterministic 1-letter author monogram (same rule as forge pages).118 +fn monogram(handle: &str) -> String {119 +    let h = handle.trim();120 +    let initial: String = h121 +        .chars()122 +        .next()123 +        .map(|c| c.to_uppercase().to_string())124 +        .unwrap_or_default();125 +    let mut hash: u32 = 0;126 +    for b in h.bytes() {127 +        hash = hash.wrapping_mul(31).wrapping_add(u32::from(b));128 +    }129 +    format!(130 +        "<span class=\"mono m{}\" aria-hidden=\"true\">{}</span>",131 +        hash % 6,132 +        esc(&initial)133 +    )134 +}135 +136 +/// Relative commit time with the exact value in `title` (f0rge #72/#74).137 +fn time_ago(date: &str, now: i64) -> String {138 +    match parse_ts_secs(date) {139 +        Some(ts) => format!(140 +            "<time datetime=\"{}\" title=\"{}\">{}</time>",141 +            esc(date),142 +            esc(&ts_title(date)),143 +            esc(&relative_age(now, ts))144 +        ),145 +        None => format!("<time>{}</time>", esc(date)),146 +    }147 +}148 +149  /// Short display form of a full sha (first 7 hex chars, git-style).150  #[must_use]151  pub fn short_sha(sha: &str) -> &str {152 @@ -40,19 +73,21 @@ pub fn render_loaded(153      if log.is_empty() {154          body.push_str("<p class=\"muted\">No commits yet.</p>\n");155      } else {156 +        let now = now_unix();157          body.push_str("<div class=\"table-scroll\"><table>\n<thead><tr><th>subject</th><th>sha</th><th>author</th><th>date</th></tr></thead>\n<tbody>\n");158          for c in log {159              body.push_str(&format!(160 -                "<tr><td class=\"title trunc trunc-subject\" data-label=\"subject\" title=\"{}\">{}</td><td class=\"sha trunc trunc-sha\" data-label=\"sha\" title=\"{}\"><a href=\"/commit/{}.html\"><code>{}</code></a></td><td class=\"trunc trunc-author\" data-label=\"author\" title=\"{}\">{}</td><td class=\"trunc\" data-label=\"date\" title=\"{}\">{}</td></tr>\n",161 +                "<tr><td class=\"title trunc trunc-subject\" data-label=\"subject\" title=\"{}\">{}</td><td class=\"sha trunc trunc-sha\" data-label=\"sha\" title=\"{}\"><a href=\"/commit/{}.html\"><code>{}</code></a></td><td class=\"trunc trunc-author\" data-label=\"author\" title=\"{}\">{}{}</td><td class=\"trunc\" data-label=\"date\" title=\"{}\">{}</td></tr>\n",162                  esc(&c.subject),163                  esc(&c.subject),164                  esc(&c.sha),165                  esc(&c.sha),166                  esc(short_sha(&c.sha)),167                  esc(&c.author),168 +                monogram(&c.author),169                  esc(&c.author),170                  esc(&c.date),171 -                esc(&c.date),172 +                time_ago(&c.date, now),173              ));174          }175          body.push_str("</tbody>\n</table></div>\n");176 @@ -75,14 +110,17 @@ pub fn render_loaded(177      for c in log {178          let message = gitx::message(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?;179          let diff = gitx::diff(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?;180 +        let now = now_unix();181          let detail = format!(182 -            "<h1>commit {}</h1>\n<p class=\"repo-meta\"><code>{}</code></p>\n<p>Author: {}<br>Date: {}</p>\n<p><a href=\"/commits.html\">all commits</a></p>\n<h2>Message</h2>\n<div class=\"pre-scroll\"><pre>{}</pre></div>\n<h2>Diff</h2>\n<div class=\"pre-scroll\"><pre class=\"diff\">{}</pre></div>\n",183 -            esc(short_sha(&c.sha)),184 -            esc(&c.sha),185 -            esc(&c.author),186 -            esc(&c.date),187 -            esc(&message),188 -            diff_html(&diff),189 +            "<h1>{subject}</h1>\n<div class=\"meta\"><span class=\"chip\"><a href=\"/commit/{full}.html\"><code>{short}</code></a></span>{mono}<span class=\"who\">{author}</span><span class=\"meta-when\">{when}</span></div>\n<p><a href=\"/commits.html\">all commits</a></p>\n<h2>Message</h2>\n<div class=\"pre-scroll\"><pre>{message}</pre></div>\n<h2>Diff</h2>\n{diff}\n",190 +            subject = esc(&c.subject),191 +            full = esc(&c.sha),192 +            short = esc(short_sha(&c.sha)),193 +            mono = monogram(&c.author),194 +            author = esc(&c.author),195 +            when = time_ago(&c.date, now),196 +            message = esc(&message),197 +            diff = diff_html(&diff),198          );199          let html = page_site_with_provenance(200              &format!("commit {} — {site_name}", short_sha(&c.sha)),201 diff --git a/src/layout.rs b/src/layout.rs202 index d985a04..20480b7 100644203 --- a/src/layout.rs204 +++ b/src/layout.rs205 @@ -24,26 +24,149 @@ pub fn esc(input: &str) -> String {206      out207  }208  209 -/// Render unified diff / patch text as escaped HTML with line-level color210 -/// spans (m6 diff-colors, f0rge issue #22; Tokyo Night vars in211 -/// `static/style.css`, dark + light).212 +/// Render unified diff text as one card per file (f0rge #74).213  ///214 -/// Line kinds (line-level only, no word-diff): `@@` hunk headers go in215 -/// `<span class="diff-hunk">`, `+++`/`---` file lines in216 -/// `<span class="diff-file">` (bold fg), `+` additions in217 -/// `<span class="diff-add">`, `-` removals in `<span class="diff-del">`.218 -/// Everything else (context, `diff --git` / `index` headers, commit219 -/// metadata, `Binary files ... differ` notes, `[hu13: ...]` truncation /220 -/// binary markers) is escaped as-is with no span so truncation and binary221 -/// behavior stay intact. Every line is escaped with [`esc`] before wrapping,222 -/// so hostile `<script>` inside an added line renders as text. Output joins223 -/// lines with `\n` (preserving a trailing newline) for use inside an224 -/// already-escaped `<pre class="diff">`.225 +/// Each card carries the path, `+n −m` counts and a proportional bar;226 +/// hunk lines show old/new line numbers in a two-column gutter. `diff227 +/// --git`, `index`, mode and `Binary files` lines stay visible as dim228 +/// metadata; the format-patch mbox preamble (From/Date/Subject/stat) is229 +/// dropped because it duplicates the PR title. Every byte is escaped230 +/// before wrapping, so a hostile `<script>` in an added line is text.231  #[must_use]232  pub fn diff_html(raw: &str) -> String {233      if raw.is_empty() {234          return String::new();235      }236 +    if !raw.lines().any(|l| l.starts_with("diff --git ")) {237 +        return plain_diff_html(raw);238 +    }239 +    let all: Vec<&str> = raw.lines().collect();240 +    let mut starts: Vec<usize> = all241 +        .iter()242 +        .enumerate()243 +        .filter(|(_, l)| l.starts_with("diff --git "))244 +        .map(|(i, _)| i)245 +        .collect();246 +    if starts.is_empty() {247 +        return plain_diff_html(raw);248 +    }249 +    starts.push(all.len());250 +    let mut out = String::with_capacity(raw.len() + raw.len() / 3);251 +    for w in starts.windows(2) {252 +        let chunk = &all[w[0]..w[1]];253 +        let path = diff_chunk_path(chunk);254 +        let mut added = 0usize;255 +        let mut removed = 0usize;256 +        for line in chunk {257 +            if line.starts_with('+') && !line.starts_with("+++") {258 +                added += 1;259 +            } else if line.starts_with('-') && !line.starts_with("---") {260 +                removed += 1;261 +            }262 +        }263 +        let total = added + removed;264 +        let pct = (added * 100).checked_div(total).unwrap_or(0);265 +        out.push_str("<details open class=\"difffile\"><summary><code class=\"df-path\">");266 +        out.push_str(&esc(&path));267 +        out.push_str("</code><span class=\"df-count\"><span class=\"df-add\">+");268 +        out.push_str(&added.to_string());269 +        out.push_str("</span> <span class=\"df-del\">−");270 +        out.push_str(&removed.to_string());271 +        out.push_str("</span></span>");272 +        if total > 0 {273 +            out.push_str("<span class=\"df-bar\" aria-hidden=\"true\"><span class=\"df-bar-add\" style=\"width:");274 +            out.push_str(&pct.to_string());275 +            out.push_str("%\"></span></span>");276 +        }277 +        out.push_str("</summary>\n<div class=\"pre-scroll\"><pre class=\"diff\">");278 +279 +        let mut old_ln = 0i64;280 +        let mut new_ln = 0i64;281 +        let mut first = true;282 +        for line in chunk {283 +            if !first {284 +                out.push('\n');285 +            }286 +            first = false;287 +            if line.starts_with("@@") {288 +                let mut parts = line.split_whitespace();289 +                let _ = parts.next();290 +                if let Some(o) = parts.next().and_then(|v| v.strip_prefix('-')) {291 +                    old_ln = o292 +                        .split(',')293 +                        .next()294 +                        .and_then(|v| v.parse().ok())295 +                        .unwrap_or(0);296 +                }297 +                if let Some(n) = parts.next().and_then(|v| v.strip_prefix('+')) {298 +                    new_ln = n299 +                        .split(',')300 +                        .next()301 +                        .and_then(|v| v.parse().ok())302 +                        .unwrap_or(0);303 +                }304 +                out.push_str(&format!("<span class=\"diff-hunk\">{}</span>", esc(line)));305 +            } else if line.starts_with('+') && !line.starts_with("+++") {306 +                out.push_str(&diff_numbered("", &new_ln.to_string(), "diff-add", line));307 +                new_ln += 1;308 +            } else if line.starts_with('-') && !line.starts_with("---") {309 +                out.push_str(&diff_numbered(&old_ln.to_string(), "", "diff-del", line));310 +                old_ln += 1;311 +            } else if line.starts_with(' ') {312 +                out.push_str(&diff_numbered(313 +                    &old_ln.to_string(),314 +                    &new_ln.to_string(),315 +                    "dctx",316 +                    line,317 +                ));318 +                old_ln += 1;319 +                new_ln += 1;320 +            } else if line.starts_with("+++") || line.starts_with("---") {321 +                out.push_str(&format!("<span class=\"diff-file\">{}</span>", esc(line)));322 +            } else {323 +                out.push_str(&format!("<span class=\"df-meta\">{}</span>", esc(line)));324 +            }325 +        }326 +        out.push_str("</pre></div></details>\n");327 +    }328 +    out329 +}330 +331 +fn diff_numbered(old: &str, new: &str, class: &str, line: &str) -> String {332 +    format!(333 +        "<span class=\"df-line {class}-row\"><span class=\"dln\">{old}</span><span class=\"dln\">{new}</span><span class=\"{class}\">{}</span></span>",334 +        esc(line)335 +    )336 +}337 +338 +/// Path for one chunk: `+++ b/<p>` first, then `--- a/<p>`, then the339 +/// `diff --git` header. `/dev/null` falls through to the other side.340 +fn diff_chunk_path(lines: &[&str]) -> String {341 +    for line in lines {342 +        if let Some(rest) = line.strip_prefix("+++ ") {343 +            if rest != "/dev/null" {344 +                return rest.strip_prefix("b/").unwrap_or(rest).to_string();345 +            }346 +        }347 +    }348 +    for line in lines {349 +        if let Some(rest) = line.strip_prefix("--- ") {350 +            if rest != "/dev/null" {351 +                return rest.strip_prefix("a/").unwrap_or(rest).to_string();352 +            }353 +        }354 +    }355 +    lines356 +        .first()357 +        .and_then(|l| l.strip_prefix("diff --git a/"))358 +        .and_then(|rest| rest.split(" b/").next())359 +        .unwrap_or("diff")360 +        .to_string()361 +}362 +363 +/// Fallback for diff text without `diff --git` headers: one escaped block364 +/// with line-level coloring (markers stay span-free, old behavior).365 +fn plain_diff_html(raw: &str) -> String {366      let mut out = String::with_capacity(raw.len() + raw.len() / 4);367      let mut first = true;368      for line in raw.lines() {369 @@ -51,26 +174,7 @@ pub fn diff_html(raw: &str) -> String {370              out.push('\n');371          }372          first = false;373 -        let e = esc(line);374 -        if line.starts_with("@@") {375 -            out.push_str("<span class=\"diff-hunk\">");376 -            out.push_str(&e);377 -            out.push_str("</span>");378 -        } else if line.starts_with("+++") || line.starts_with("---") {379 -            out.push_str("<span class=\"diff-file\">");380 -            out.push_str(&e);381 -            out.push_str("</span>");382 -        } else if line.starts_with('+') {383 -            out.push_str("<span class=\"diff-add\">");384 -            out.push_str(&e);385 -            out.push_str("</span>");386 -        } else if line.starts_with('-') {387 -            out.push_str("<span class=\"diff-del\">");388 -            out.push_str(&e);389 -            out.push_str("</span>");390 -        } else {391 -            out.push_str(&e);392 -        }393 +        out.push_str(&diff_line_span(line));394      }395      if raw.ends_with('\n') {396          out.push('\n');397 @@ -78,6 +182,21 @@ pub fn diff_html(raw: &str) -> String {398      out399  }400  401 +fn diff_line_span(line: &str) -> String {402 +    let e = esc(line);403 +    if line.starts_with("@@") {404 +        format!("<span class=\"diff-hunk\">{e}</span>")405 +    } else if line.starts_with("+++") || line.starts_with("---") {406 +        format!("<span class=\"diff-file\">{e}</span>")407 +    } else if line.starts_with('+') {408 +        format!("<span class=\"diff-add\">{e}</span>")409 +    } else if line.starts_with('-') {410 +        format!("<span class=\"diff-del\">{e}</span>")411 +    } else {412 +        e413 +    }414 +}415 +416  /// Render a full HTML page. `title` is escaped; `nav` and `body` are417  /// inlined as-is (caller must escape untrusted text with [`esc`]).418  #[allow(dead_code)]419 diff --git a/static/style.css b/static/style.css420 index 53ab719..ff5d619 100644421 --- a/static/style.css422 +++ b/static/style.css423 @@ -48,7 +48,7 @@424    --diff-del:#f7768e;425    --diff-del-bg:#38232e;426    --diff-hunk:#7dcfff;427 -  --diff-file:#c0caf5;428 +  --diff-file:#bb9af7;429  }430  @media (prefers-color-scheme:light){431    :root{432 @@ -82,7 +82,7 @@433      --diff-del:#8c4351;434      --diff-del-bg:#d9c2c7;435      --diff-hunk:#0f4b6e;436 -    --diff-file:#343b58;437 +    --diff-file:#5a4a78;438    }439  }440  html{background:var(--bg);-webkit-text-size-adjust:100%}441 @@ -347,3 +347,23 @@ details.clonebox:not([open]){padding-bottom:.25rem}442  .comment-head{display:flex;align-items:center;color:var(--fg-dim);font-size:var(--fs-sm);margin:0 0 var(--s2)}443  .comment-head .who{color:var(--fg);font-weight:600;margin-right:.35em}444  time{font-variant-numeric:tabular-nums}445 +446 +/* Diff cards (f0rge #74): one card per file, dual line numbers. */447 +.difffile{border:1px solid var(--border);background:var(--surface);margin:var(--s3) 0}448 +.difffile>summary{display:flex;flex-wrap:wrap;align-items:center;gap:var(--s2) var(--s3);padding:var(--s2) var(--s3);cursor:pointer;background:var(--bg-alt)}449 +.difffile>summary::marker{color:var(--fg-dim)}450 +.df-path{color:var(--fg);background:none;padding:0;overflow-wrap:anywhere}451 +.df-count{font-size:var(--fs-sm);font-variant-numeric:tabular-nums;margin-left:auto}452 +.df-add{color:var(--green)}453 +.df-del{color:var(--red)}454 +.df-bar{display:inline-block;width:6rem;height:.5rem;background:var(--diff-del-bg);border:1px solid var(--border)}455 +.df-bar-add{display:block;height:100%;background:var(--green)}456 +.difffile .pre-scroll{margin:0}457 +pre.diff{margin:0;border:0;background:var(--surface)}458 +.df-line{display:block}459 +.dln{display:inline-block;width:2.8em;text-align:right;padding-right:.5em;color:var(--fg-dim);user-select:none;font-variant-numeric:tabular-nums}460 +.dln+.dln{border-right:1px solid var(--border);margin-right:.5em}461 +.diff-add-row{background:var(--diff-add-bg)}462 +.diff-del-row{background:var(--diff-del-bg)}463 +.diff-file{color:var(--diff-file);font-weight:600}464 +.df-meta{color:var(--fg-dim)}465 diff --git a/tests/theme_contrast.rs b/tests/theme_contrast.rs466 index 9ce311f..0c0fea3 100644467 --- a/tests/theme_contrast.rs468 +++ b/tests/theme_contrast.rs469 @@ -117,6 +117,8 @@ fn both_themes_pass_aa_for_text_colors() {470          ("--tok-com", "--surface-2"),471          ("--tok-fn", "--surface"),472          ("--tok-type", "--surface"),473 +        ("--diff-file", "--surface"),474 +        ("--diff-hunk", "--surface"),475      ];476      check("dark", dark, &pairs);477      check("light", light, &pairs);478 -- 479 2.56.0480