#56 Diffs: per-file cards, dual line numbers, visual diffstat (#74)

mergedpassopened
details
branches
master ← issue-74-diff-cards
issues
7fa9434e-8f1f-4e63-8236-c039237b073a
updated
id
a5d3c785-f008-4cca-9bac-89d2135f5be7

Patch

src/build.rs+33 −2
diff --git a/src/build.rs b/src/build.rsindex 4c00c85..a813a50 100644--- a/src/build.rs+++ b/src/build.rs@@ -522,7 +522,12 @@ pub fn now_unix() -> i64 {522522 #[must_use]523523 pub fn parse_ts_secs(s: &str) -> Option<i64> {524524     let s = s.trim();525-    let (date, rest) = s.split_once('T')?;525+    // Accept both `2026-10-27T14:08:48Z` (RFC3339) and git's526+    // `2026-10-27 14:08:48 +0000` / `+00:00` forms.527+    let (date, rest) = match s.split_once('T') {528+        Some(v) => v,529+        None => s.split_once(' ')?,530+    };526531     let mut d = date.split('-');527532     let y: i64 = d.next()?.parse().ok()?;528533     let mo: i64 = d.next()?.parse().ok()?;@@ -532,7 +537,10 @@ pub fn parse_ts_secs(s: &str) -> Option<i64> {532537     }533538     let (hms, offset) = if let Some(t) = rest.strip_suffix('Z') {534539         (t, 0i64)535-    } else if rest.len() >= 6 && matches!(rest.as_bytes()[rest.len() - 6], b'+' | b'-') {540+    } else if rest.len() >= 6541+        && matches!(rest.as_bytes()[rest.len() - 6], b'+' | b'-')542+        && rest.as_bytes()[rest.len() - 3] == b':'543+    {536544         let sign = if rest.as_bytes()[rest.len() - 6] == b'-' {537545             -1538546         } else {@@ -541,6 +549,15 @@ pub fn parse_ts_secs(s: &str) -> Option<i64> {541549         let oh: i64 = rest[rest.len() - 5..rest.len() - 3].parse().ok()?;542550         let om: i64 = rest[rest.len() - 2..].parse().ok()?;543551         (&rest[..rest.len() - 6], sign * (oh * 3600 + om * 60))552+    } else if rest.len() >= 5 && matches!(rest.as_bytes()[rest.len() - 5], b'+' | b'-') {553+        let sign = if rest.as_bytes()[rest.len() - 5] == b'-' {554+            -1555+        } else {556+            1557+        };558+        let oh: i64 = rest[rest.len() - 4..rest.len() - 2].parse().ok()?;559+        let om: i64 = rest[rest.len() - 2..].parse().ok()?;560+        (&rest[..rest.len() - 5], sign * (oh * 3600 + om * 60))544561     } else {545562         (rest, 0)546563     };@@ -553,6 +570,7 @@ pub fn parse_ts_secs(s: &str) -> Option<i64> {553570         .split('.')554571         .next()555572         .unwrap_or("0")573+        .trim()556574         .parse()557575         .ok()?;558576     if !(0..=23).contains(&h) || !(0..=59).contains(&mi) || !(0..=60).contains(&sec) {@@ -2189,6 +2207,19 @@ mod tests {21892207         assert!(got.ends_with("…"));21902208     }21912209 2210+    #[test]2211+    fn parse_git_and_rfc3339_dates() {2212+        assert_eq!(2213+            parse_ts_secs("2026-09-10T07:43:55Z"),2214+            parse_ts_secs("2026-09-10 07:43:55 +0000")2215+        );2216+        let utc = parse_ts_secs("2026-09-10T07:43:55Z").unwrap();2217+        let plus1 = parse_ts_secs("2026-09-10 08:43:55 +0100").unwrap();2218+        assert_eq!(utc, plus1, "offset is applied");2219+        assert!(parse_ts_secs("not a date").is_none());2220+        assert_eq!(ts_title("2026-09-10T07:43:55Z"), "2026-09-10 07:43 UTC");2221+    }2222+21922223     #[test]21932224     fn relative_age_fixed_clock() {21942225         // f0rge #33 rubric: fixed-clock fixture, build-time relative ages.
src/commits.rs+47 −9
diff --git a/src/commits.rs b/src/commits.rsindex 67872bd..9cf0b82 100644--- a/src/commits.rs+++ b/src/commits.rs@@ -8,9 +8,42 @@88 99 use std::path::Path;1010 11+use crate::build::{now_unix, parse_ts_secs, relative_age, ts_title};1112 use crate::gitx;1213 use crate::layout::{diff_html, esc, page_site_with_provenance};1314 15+/// Deterministic 1-letter author monogram (same rule as forge pages).16+fn monogram(handle: &str) -> String {17+    let h = handle.trim();18+    let initial: String = h19+        .chars()20+        .next()21+        .map(|c| c.to_uppercase().to_string())22+        .unwrap_or_default();23+    let mut hash: u32 = 0;24+    for b in h.bytes() {25+        hash = hash.wrapping_mul(31).wrapping_add(u32::from(b));26+    }27+    format!(28+        "<span class=\"mono m{}\" aria-hidden=\"true\">{}</span>",29+        hash % 6,30+        esc(&initial)31+    )32+}33+34+/// Relative commit time with the exact value in `title` (f0rge #72/#74).35+fn time_ago(date: &str, now: i64) -> String {36+    match parse_ts_secs(date) {37+        Some(ts) => format!(38+            "<time datetime=\"{}\" title=\"{}\">{}</time>",39+            esc(date),40+            esc(&ts_title(date)),41+            esc(&relative_age(now, ts))42+        ),43+        None => format!("<time>{}</time>", esc(date)),44+    }45+}46+1447 /// Short display form of a full sha (first 7 hex chars, git-style).1548 #[must_use]1649 pub fn short_sha(sha: &str) -> &str {@@ -40,19 +73,21 @@ pub fn render_loaded(4073     if log.is_empty() {4174         body.push_str("<p class=\"muted\">No commits yet.</p>\n");4275     } else {76+        let now = now_unix();4377         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");4478         for c in log {4579             body.push_str(&format!(46-                "<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",80+                "<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",4781                 esc(&c.subject),4882                 esc(&c.subject),4983                 esc(&c.sha),5084                 esc(&c.sha),5185                 esc(short_sha(&c.sha)),5286                 esc(&c.author),87+                monogram(&c.author),5388                 esc(&c.author),5489                 esc(&c.date),55-                esc(&c.date),90+                time_ago(&c.date, now),5691             ));5792         }5893         body.push_str("</tbody>\n</table></div>\n");@@ -75,14 +110,17 @@ pub fn render_loaded(75110     for c in log {76111         let message = gitx::message(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?;77112         let diff = gitx::diff(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?;113+        let now = now_unix();78114         let detail = format!(79-            "<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",80-            esc(short_sha(&c.sha)),81-            esc(&c.sha),82-            esc(&c.author),83-            esc(&c.date),84-            esc(&message),85-            diff_html(&diff),115+            "<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",116+            subject = esc(&c.subject),117+            full = esc(&c.sha),118+            short = esc(short_sha(&c.sha)),119+            mono = monogram(&c.author),120+            author = esc(&c.author),121+            when = time_ago(&c.date, now),122+            message = esc(&message),123+            diff = diff_html(&diff),86124         );87125         let html = page_site_with_provenance(88126             &format!("commit {} — {site_name}", short_sha(&c.sha)),
src/layout.rs+153 −34
diff --git a/src/layout.rs b/src/layout.rsindex d985a04..20480b7 100644--- a/src/layout.rs+++ b/src/layout.rs@@ -24,26 +24,149 @@ pub fn esc(input: &str) -> String {2424     out2525 }2626 27-/// Render unified diff / patch text as escaped HTML with line-level color28-/// spans (m6 diff-colors, f0rge issue #22; Tokyo Night vars in29-/// `static/style.css`, dark + light).27+/// Render unified diff text as one card per file (f0rge #74).3028 ///31-/// Line kinds (line-level only, no word-diff): `@@` hunk headers go in32-/// `<span class="diff-hunk">`, `+++`/`---` file lines in33-/// `<span class="diff-file">` (bold fg), `+` additions in34-/// `<span class="diff-add">`, `-` removals in `<span class="diff-del">`.35-/// Everything else (context, `diff --git` / `index` headers, commit36-/// metadata, `Binary files ... differ` notes, `[hu13: ...]` truncation /37-/// binary markers) is escaped as-is with no span so truncation and binary38-/// behavior stay intact. Every line is escaped with [`esc`] before wrapping,39-/// so hostile `<script>` inside an added line renders as text. Output joins40-/// lines with `\n` (preserving a trailing newline) for use inside an41-/// already-escaped `<pre class="diff">`.29+/// Each card carries the path, `+n −m` counts and a proportional bar;30+/// hunk lines show old/new line numbers in a two-column gutter. `diff31+/// --git`, `index`, mode and `Binary files` lines stay visible as dim32+/// metadata; the format-patch mbox preamble (From/Date/Subject/stat) is33+/// dropped because it duplicates the PR title. Every byte is escaped34+/// before wrapping, so a hostile `<script>` in an added line is text.4235 #[must_use]4336 pub fn diff_html(raw: &str) -> String {4437     if raw.is_empty() {4538         return String::new();4639     }40+    if !raw.lines().any(|l| l.starts_with("diff --git ")) {41+        return plain_diff_html(raw);42+    }43+    let all: Vec<&str> = raw.lines().collect();44+    let mut starts: Vec<usize> = all45+        .iter()46+        .enumerate()47+        .filter(|(_, l)| l.starts_with("diff --git "))48+        .map(|(i, _)| i)49+        .collect();50+    if starts.is_empty() {51+        return plain_diff_html(raw);52+    }53+    starts.push(all.len());54+    let mut out = String::with_capacity(raw.len() + raw.len() / 3);55+    for w in starts.windows(2) {56+        let chunk = &all[w[0]..w[1]];57+        let path = diff_chunk_path(chunk);58+        let mut added = 0usize;59+        let mut removed = 0usize;60+        for line in chunk {61+            if line.starts_with('+') && !line.starts_with("+++") {62+                added += 1;63+            } else if line.starts_with('-') && !line.starts_with("---") {64+                removed += 1;65+            }66+        }67+        let total = added + removed;68+        let pct = (added * 100).checked_div(total).unwrap_or(0);69+        out.push_str("<details open class=\"difffile\"><summary><code class=\"df-path\">");70+        out.push_str(&esc(&path));71+        out.push_str("</code><span class=\"df-count\"><span class=\"df-add\">+");72+        out.push_str(&added.to_string());73+        out.push_str("</span> <span class=\"df-del\">−");74+        out.push_str(&removed.to_string());75+        out.push_str("</span></span>");76+        if total > 0 {77+            out.push_str("<span class=\"df-bar\" aria-hidden=\"true\"><span class=\"df-bar-add\" style=\"width:");78+            out.push_str(&pct.to_string());79+            out.push_str("%\"></span></span>");80+        }81+        out.push_str("</summary>\n<div class=\"pre-scroll\"><pre class=\"diff\">");82+83+        let mut old_ln = 0i64;84+        let mut new_ln = 0i64;85+        let mut first = true;86+        for line in chunk {87+            if !first {88+                out.push('\n');89+            }90+            first = false;91+            if line.starts_with("@@") {92+                let mut parts = line.split_whitespace();93+                let _ = parts.next();94+                if let Some(o) = parts.next().and_then(|v| v.strip_prefix('-')) {95+                    old_ln = o96+                        .split(',')97+                        .next()98+                        .and_then(|v| v.parse().ok())99+                        .unwrap_or(0);100+                }101+                if let Some(n) = parts.next().and_then(|v| v.strip_prefix('+')) {102+                    new_ln = n103+                        .split(',')104+                        .next()105+                        .and_then(|v| v.parse().ok())106+                        .unwrap_or(0);107+                }108+                out.push_str(&format!("<span class=\"diff-hunk\">{}</span>", esc(line)));109+            } else if line.starts_with('+') && !line.starts_with("+++") {110+                out.push_str(&diff_numbered("", &new_ln.to_string(), "diff-add", line));111+                new_ln += 1;112+            } else if line.starts_with('-') && !line.starts_with("---") {113+                out.push_str(&diff_numbered(&old_ln.to_string(), "", "diff-del", line));114+                old_ln += 1;115+            } else if line.starts_with(' ') {116+                out.push_str(&diff_numbered(117+                    &old_ln.to_string(),118+                    &new_ln.to_string(),119+                    "dctx",120+                    line,121+                ));122+                old_ln += 1;123+                new_ln += 1;124+            } else if line.starts_with("+++") || line.starts_with("---") {125+                out.push_str(&format!("<span class=\"diff-file\">{}</span>", esc(line)));126+            } else {127+                out.push_str(&format!("<span class=\"df-meta\">{}</span>", esc(line)));128+            }129+        }130+        out.push_str("</pre></div></details>\n");131+    }132+    out133+}134+135+fn diff_numbered(old: &str, new: &str, class: &str, line: &str) -> String {136+    format!(137+        "<span class=\"df-line {class}-row\"><span class=\"dln\">{old}</span><span class=\"dln\">{new}</span><span class=\"{class}\">{}</span></span>",138+        esc(line)139+    )140+}141+142+/// Path for one chunk: `+++ b/<p>` first, then `--- a/<p>`, then the143+/// `diff --git` header. `/dev/null` falls through to the other side.144+fn diff_chunk_path(lines: &[&str]) -> String {145+    for line in lines {146+        if let Some(rest) = line.strip_prefix("+++ ") {147+            if rest != "/dev/null" {148+                return rest.strip_prefix("b/").unwrap_or(rest).to_string();149+            }150+        }151+    }152+    for line in lines {153+        if let Some(rest) = line.strip_prefix("--- ") {154+            if rest != "/dev/null" {155+                return rest.strip_prefix("a/").unwrap_or(rest).to_string();156+            }157+        }158+    }159+    lines160+        .first()161+        .and_then(|l| l.strip_prefix("diff --git a/"))162+        .and_then(|rest| rest.split(" b/").next())163+        .unwrap_or("diff")164+        .to_string()165+}166+167+/// Fallback for diff text without `diff --git` headers: one escaped block168+/// with line-level coloring (markers stay span-free, old behavior).169+fn plain_diff_html(raw: &str) -> String {47170     let mut out = String::with_capacity(raw.len() + raw.len() / 4);48171     let mut first = true;49172     for line in raw.lines() {@@ -51,26 +174,7 @@ pub fn diff_html(raw: &str) -> String {51174             out.push('\n');52175         }53176         first = false;54-        let e = esc(line);55-        if line.starts_with("@@") {56-            out.push_str("<span class=\"diff-hunk\">");57-            out.push_str(&e);58-            out.push_str("</span>");59-        } else if line.starts_with("+++") || line.starts_with("---") {60-            out.push_str("<span class=\"diff-file\">");61-            out.push_str(&e);62-            out.push_str("</span>");63-        } else if line.starts_with('+') {64-            out.push_str("<span class=\"diff-add\">");65-            out.push_str(&e);66-            out.push_str("</span>");67-        } else if line.starts_with('-') {68-            out.push_str("<span class=\"diff-del\">");69-            out.push_str(&e);70-            out.push_str("</span>");71-        } else {72-            out.push_str(&e);73-        }177+        out.push_str(&diff_line_span(line));74178     }75179     if raw.ends_with('\n') {76180         out.push('\n');@@ -78,6 +182,21 @@ pub fn diff_html(raw: &str) -> String {78182     out79183 }80184 185+fn diff_line_span(line: &str) -> String {186+    let e = esc(line);187+    if line.starts_with("@@") {188+        format!("<span class=\"diff-hunk\">{e}</span>")189+    } else if line.starts_with("+++") || line.starts_with("---") {190+        format!("<span class=\"diff-file\">{e}</span>")191+    } else if line.starts_with('+') {192+        format!("<span class=\"diff-add\">{e}</span>")193+    } else if line.starts_with('-') {194+        format!("<span class=\"diff-del\">{e}</span>")195+    } else {196+        e197+    }198+}199+81200 /// Render a full HTML page. `title` is escaped; `nav` and `body` are82201 /// inlined as-is (caller must escape untrusted text with [`esc`]).83202 #[allow(dead_code)]
static/style.css+22 −2
diff --git a/static/style.css b/static/style.cssindex 53ab719..ff5d619 100644--- a/static/style.css+++ b/static/style.css@@ -48,7 +48,7 @@4848   --diff-del:#f7768e;4949   --diff-del-bg:#38232e;5050   --diff-hunk:#7dcfff;51-  --diff-file:#c0caf5;51+  --diff-file:#bb9af7;5252 }5353 @media (prefers-color-scheme:light){5454   :root{@@ -82,7 +82,7 @@8282     --diff-del:#8c4351;8383     --diff-del-bg:#d9c2c7;8484     --diff-hunk:#0f4b6e;85-    --diff-file:#343b58;85+    --diff-file:#5a4a78;8686   }8787 }8888 html{background:var(--bg);-webkit-text-size-adjust:100%}@@ -347,3 +347,23 @@ details.clonebox:not([open]){padding-bottom:.25rem}347347 .comment-head{display:flex;align-items:center;color:var(--fg-dim);font-size:var(--fs-sm);margin:0 0 var(--s2)}348348 .comment-head .who{color:var(--fg);font-weight:600;margin-right:.35em}349349 time{font-variant-numeric:tabular-nums}350+351+/* Diff cards (f0rge #74): one card per file, dual line numbers. */352+.difffile{border:1px solid var(--border);background:var(--surface);margin:var(--s3) 0}353+.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)}354+.difffile>summary::marker{color:var(--fg-dim)}355+.df-path{color:var(--fg);background:none;padding:0;overflow-wrap:anywhere}356+.df-count{font-size:var(--fs-sm);font-variant-numeric:tabular-nums;margin-left:auto}357+.df-add{color:var(--green)}358+.df-del{color:var(--red)}359+.df-bar{display:inline-block;width:6rem;height:.5rem;background:var(--diff-del-bg);border:1px solid var(--border)}360+.df-bar-add{display:block;height:100%;background:var(--green)}361+.difffile .pre-scroll{margin:0}362+pre.diff{margin:0;border:0;background:var(--surface)}363+.df-line{display:block}364+.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}365+.dln+.dln{border-right:1px solid var(--border);margin-right:.5em}366+.diff-add-row{background:var(--diff-add-bg)}367+.diff-del-row{background:var(--diff-del-bg)}368+.diff-file{color:var(--diff-file);font-weight:600}369+.df-meta{color:var(--fg-dim)}
tests/theme_contrast.rs+2 −1
diff --git a/tests/theme_contrast.rs b/tests/theme_contrast.rsindex 9ce311f..0c0fea3 100644--- a/tests/theme_contrast.rs+++ b/tests/theme_contrast.rs@@ -117,6 +117,8 @@ fn both_themes_pass_aa_for_text_colors() {117117         ("--tok-com", "--surface-2"),118118         ("--tok-fn", "--surface"),119119         ("--tok-type", "--surface"),120+        ("--diff-file", "--surface"),121+        ("--diff-hunk", "--surface"),120122     ];121123     check("dark", dark, &pairs);122124     check("light", light, &pairs);123-- 2.56.0

Comments (0)

No comments yet.

Reviews (0)

No reviews yet.

all pulls