diffs: per-file cards, dual line numbers, visual diffstat (#74)
Message
diffs: per-file cards, dual line numbers, visual diffstat (#74) diff_html now parses chunks into one <details> card per file: path, +n/−m counts, proportional green/red bar, and every hunk line in a two-column (old/new) gutter. /index/mode/Binary lines stay visible as dim metadata; --- /+++ use --diff-file (now purple, distinct from body text); add/del rows carry the existing background colors. Format-patch mbox preambles (From/Date/Subject/ stat) are dropped because they duplicate the PR title. Non-unified text still falls back to the old single-block coloring. Commit pages drop 'Author:/Date:' for a chip + monogram + relative time (parse_ts_secs now accepts git's 'YYYY-MM-DD HH:MM:SS ±HHMM'), and the commits table shows relative dates with exact values in title.
Diff
src/build.rs+33 −2
--- 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
--- 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
--- 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
--- 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 −0
--- 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);