From 024a9e2e0b5396b538ca60ede4e211ada5a69234 Mon Sep 17 00:00:00 2001 From: hu13-test Date: Tue, 27 Oct 2026 16:24:47 +0000 Subject: [PATCH] diffs: per-file cards, dual line numbers, visual diffstat (#74) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit diff_html now parses chunks into one
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. --- src/build.rs | 35 +++++++- src/commits.rs | 56 ++++++++++-- src/layout.rs | 187 ++++++++++++++++++++++++++++++++-------- static/style.css | 24 +++++- tests/theme_contrast.rs | 2 + 5 files changed, 257 insertions(+), 47 deletions(-) diff --git a/src/build.rs b/src/build.rs index 4c00c85..a813a50 100644 --- a/src/build.rs +++ b/src/build.rs @@ -522,7 +522,12 @@ pub fn now_unix() -> i64 { #[must_use] pub fn parse_ts_secs(s: &str) -> Option { let s = s.trim(); - let (date, rest) = s.split_once('T')?; + // Accept both `2026-10-27T14:08:48Z` (RFC3339) and git's + // `2026-10-27 14:08:48 +0000` / `+00:00` forms. + let (date, rest) = match s.split_once('T') { + Some(v) => v, + None => s.split_once(' ')?, + }; let mut d = date.split('-'); let y: i64 = d.next()?.parse().ok()?; let mo: i64 = d.next()?.parse().ok()?; @@ -532,7 +537,10 @@ pub fn parse_ts_secs(s: &str) -> Option { } let (hms, offset) = if let Some(t) = rest.strip_suffix('Z') { (t, 0i64) - } else if rest.len() >= 6 && matches!(rest.as_bytes()[rest.len() - 6], b'+' | b'-') { + } else if rest.len() >= 6 + && matches!(rest.as_bytes()[rest.len() - 6], b'+' | b'-') + && rest.as_bytes()[rest.len() - 3] == b':' + { let sign = if rest.as_bytes()[rest.len() - 6] == b'-' { -1 } else { @@ -541,6 +549,15 @@ pub fn parse_ts_secs(s: &str) -> Option { let oh: i64 = rest[rest.len() - 5..rest.len() - 3].parse().ok()?; let om: i64 = rest[rest.len() - 2..].parse().ok()?; (&rest[..rest.len() - 6], sign * (oh * 3600 + om * 60)) + } else if rest.len() >= 5 && matches!(rest.as_bytes()[rest.len() - 5], b'+' | b'-') { + let sign = if rest.as_bytes()[rest.len() - 5] == b'-' { + -1 + } else { + 1 + }; + let oh: i64 = rest[rest.len() - 4..rest.len() - 2].parse().ok()?; + let om: i64 = rest[rest.len() - 2..].parse().ok()?; + (&rest[..rest.len() - 5], sign * (oh * 3600 + om * 60)) } else { (rest, 0) }; @@ -553,6 +570,7 @@ pub fn parse_ts_secs(s: &str) -> Option { .split('.') .next() .unwrap_or("0") + .trim() .parse() .ok()?; if !(0..=23).contains(&h) || !(0..=59).contains(&mi) || !(0..=60).contains(&sec) { @@ -2189,6 +2207,19 @@ mod tests { assert!(got.ends_with("…")); } + #[test] + fn parse_git_and_rfc3339_dates() { + assert_eq!( + parse_ts_secs("2026-09-10T07:43:55Z"), + parse_ts_secs("2026-09-10 07:43:55 +0000") + ); + let utc = parse_ts_secs("2026-09-10T07:43:55Z").unwrap(); + let plus1 = parse_ts_secs("2026-09-10 08:43:55 +0100").unwrap(); + assert_eq!(utc, plus1, "offset is applied"); + assert!(parse_ts_secs("not a date").is_none()); + assert_eq!(ts_title("2026-09-10T07:43:55Z"), "2026-09-10 07:43 UTC"); + } + #[test] fn relative_age_fixed_clock() { // f0rge #33 rubric: fixed-clock fixture, build-time relative ages. diff --git a/src/commits.rs b/src/commits.rs index 67872bd..9cf0b82 100644 --- a/src/commits.rs +++ b/src/commits.rs @@ -8,9 +8,42 @@ use std::path::Path; +use crate::build::{now_unix, parse_ts_secs, relative_age, ts_title}; use crate::gitx; use crate::layout::{diff_html, esc, page_site_with_provenance}; +/// Deterministic 1-letter author monogram (same rule as forge pages). +fn monogram(handle: &str) -> String { + let h = handle.trim(); + let initial: String = h + .chars() + .next() + .map(|c| c.to_uppercase().to_string()) + .unwrap_or_default(); + let mut hash: u32 = 0; + for b in h.bytes() { + hash = hash.wrapping_mul(31).wrapping_add(u32::from(b)); + } + format!( + "{}", + hash % 6, + esc(&initial) + ) +} + +/// Relative commit time with the exact value in `title` (f0rge #72/#74). +fn time_ago(date: &str, now: i64) -> String { + match parse_ts_secs(date) { + Some(ts) => format!( + "", + esc(date), + esc(&ts_title(date)), + esc(&relative_age(now, ts)) + ), + None => format!("", esc(date)), + } +} + /// Short display form of a full sha (first 7 hex chars, git-style). #[must_use] pub fn short_sha(sha: &str) -> &str { @@ -40,19 +73,21 @@ pub fn render_loaded( if log.is_empty() { body.push_str("

No commits yet.

\n"); } else { + let now = now_unix(); body.push_str("
\n\n\n"); for c in log { body.push_str(&format!( - "\n", + "\n", esc(&c.subject), esc(&c.subject), esc(&c.sha), esc(&c.sha), esc(short_sha(&c.sha)), esc(&c.author), + monogram(&c.author), esc(&c.author), esc(&c.date), - esc(&c.date), + time_ago(&c.date, now), )); } body.push_str("\n
subjectshaauthordate
{}{}{}{}
{}{}{}{}{}
\n"); @@ -75,14 +110,17 @@ pub fn render_loaded( for c in log { let message = gitx::message(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?; let diff = gitx::diff(repo, &c.sha).map_err(|e| format!("hu13: {e}"))?; + let now = now_unix(); let detail = format!( - "

commit {}

\n

{}

\n

Author: {}
Date: {}

\n

all commits

\n

Message

\n
{}
\n

Diff

\n
{}
\n", - esc(short_sha(&c.sha)), - esc(&c.sha), - esc(&c.author), - esc(&c.date), - esc(&message), - diff_html(&diff), + "

{subject}

\n
{short}{mono}{author}{when}
\n

all commits

\n

Message

\n
{message}
\n

Diff

\n{diff}\n", + subject = esc(&c.subject), + full = esc(&c.sha), + short = esc(short_sha(&c.sha)), + mono = monogram(&c.author), + author = esc(&c.author), + when = time_ago(&c.date, now), + message = esc(&message), + diff = diff_html(&diff), ); let html = page_site_with_provenance( &format!("commit {} — {site_name}", short_sha(&c.sha)), diff --git a/src/layout.rs b/src/layout.rs index d985a04..20480b7 100644 --- a/src/layout.rs +++ b/src/layout.rs @@ -24,26 +24,149 @@ pub fn esc(input: &str) -> String { out } -/// Render unified diff / patch text as escaped HTML with line-level color -/// spans (m6 diff-colors, f0rge issue #22; Tokyo Night vars in -/// `static/style.css`, dark + light). +/// Render unified diff text as one card per file (f0rge #74). /// -/// Line kinds (line-level only, no word-diff): `@@` hunk headers go in -/// ``, `+++`/`---` file lines in -/// `` (bold fg), `+` additions in -/// ``, `-` removals in ``. -/// Everything else (context, `diff --git` / `index` headers, commit -/// metadata, `Binary files ... differ` notes, `[hu13: ...]` truncation / -/// binary markers) is escaped as-is with no span so truncation and binary -/// behavior stay intact. Every line is escaped with [`esc`] before wrapping, -/// so hostile `