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