1 From 4eabe68a356be4631d544340616cca4166ffa0a6 Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 07:34:03 +01004 Subject: [PATCH] m6/table2: natural scroll tables + inline title buttons5 6 ---7 src/commits.rs | 30 ++++++++++++++++8 src/forge.rs | 93 +++++++++++++++++++++++++++++++++++++++++++++---9 src/releases.rs | 35 ++++++++++++++++++10 static/style.css | 12 +++++++11 4 files changed, 166 insertions(+), 4 deletions(-)12 13 diff --git a/src/commits.rs b/src/commits.rs14 index 9c3b288..d5fa33e 10064415 --- a/src/commits.rs16 +++ b/src/commits.rs17 @@ -322,6 +322,36 @@ mod tests {18 assert!(!out.join("commit").join(format!("{}.html", all[2])).exists());19 }20 21 + /// m6/table-scroll (f0rge #30): long subject + full sha render inside a22 + /// .table-scroll wrapper with td.title / td.sha cells.23 + #[test]24 + fn long_subject_scrolls_with_title_cell() {25 + let dir = mktemp("long-subject");26 + git(&dir, &["init", "-b", "main"]);27 + git(&dir, &["config", "user.name", "hu13-test"]);28 + git(&dir, &["config", "user.email", "[email protected]"]);29 + git(&dir, &["config", "commit.gpgsign", "false"]);30 + let long_subject = "s".repeat(200);31 + std::fs::write(dir.join("x.txt"), "x\n").unwrap();32 + git(&dir, &["add", "-A"]);33 + git(&dir, &["commit", "-m", &long_subject]);34 + let out = mktemp("out");35 + render(&dir, &out, "main", 100, "test", "<nav>", None).expect("render");36 + let list = std::fs::read_to_string(out.join("commits.html")).expect("list");37 + assert!(38 + list.contains("<div class=\"table-scroll\"><table>"),39 + "wrapper"40 + );41 + assert!(list.contains("<td class=\"title\">"), "title cell");42 + assert!(list.contains("<td class=\"sha\">"), "sha cell");43 + assert!(list.contains(&long_subject), "long subject kept");44 + let sha = shas(&dir)[0].clone();45 + assert!(46 + list.contains(&format!("/commit/{sha}.html")),47 + "full sha link"48 + );49 + }50 +51 #[test]52 fn empty_log_renders_none_yet() {53 let dir = mktemp("empty");54 diff --git a/src/forge.rs b/src/forge.rs55 index 1f8af3e..3574c8a 10064456 --- a/src/forge.rs57 +++ b/src/forge.rs58 @@ -947,8 +947,8 @@ fn render_issues_index(59 nav: &str,60 description: Option<&str>,61 ) -> Result<String, String> {62 - let mut body = String::from("<h1>Issues</h1>\n");63 - body.push_str("<p><a class=\"btn-new\" href=\"/new-issue.html\">+ New issue</a></p>\n");64 + let mut body =65 + String::from("<div class=\"page-head\"><h1>Issues</h1>\n<a class=\"btn-new\" href=\"/new-issue.html\">+ New issue</a></div>\n");66 if data.issues.is_empty() {67 body.push_str("<p class=\"muted\">No issues yet. <a href=\"/new-issue.html\">Suggest one</a> — no account needed.</p>\n");68 } else {69 @@ -994,8 +994,8 @@ fn render_issues_index(70 }71 72 fn render_pulls_index(prs: &[Pr], site_name: &str, nav: &str, description: Option<&str>) -> String {73 - let mut body = String::from("<h1>Pulls</h1>\n");74 - body.push_str("<p><a class=\"btn-new\" href=\"/new-pr.html\">+ New PR prompt</a></p>\n");75 + let mut body =76 + String::from("<div class=\"page-head\"><h1>Pulls</h1>\n<a class=\"btn-new\" href=\"/new-pr.html\">+ New PR prompt</a></div>\n");77 if prs.is_empty() {78 body.push_str("<p class=\"muted\">No pull requests yet. <a href=\"/new-pr.html\">Suggest one</a> — no account needed.</p>\n");79 } else {80 @@ -1566,4 +1566,89 @@ mod tests {81 assert!(!data.ci.steps.is_empty(), "real ci steps read");82 assert!(data.ci.latest.is_some(), "real ci latest run read");83 }84 +85 + /// m6/table-scroll (f0rge #30): CSS keeps natural width + nowrap cells86 + /// with a wrapping title floor; hover tint via var.87 + #[test]88 + fn table_scroll_css_rules() {89 + let css = include_str!("../static/style.css");90 + assert!(css.contains("width:max-content"), "natural width");91 + assert!(css.contains("min-width:100%"), "full-bleed floor");92 + assert!(93 + css.contains(".table-scroll th,.table-scroll td") && css.contains("white-space:nowrap"),94 + "cells nowrap"95 + );96 + assert!(97 + css.contains(".table-scroll td.title") && css.contains("min-width:12rem"),98 + "title floor"99 + );100 + assert!(101 + css.contains("tr:hover td") && css.contains("var(--bg-th)"),102 + "hover tint"103 + );104 + }105 +106 + /// m6/table-scroll (f0rge #31): h1 + button share one .page-head row.107 + #[test]108 + fn page_head_css_and_markup() {109 + let css = include_str!("../static/style.css");110 + assert!(css.contains(".page-head"), "page-head rule");111 + assert!(css.contains("align-items:center"), "inline row");112 + let repo = mktemp("page-head");113 + let out = mktemp("page-head-out");114 + render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");115 + for (page, title, btn) in [116 + ("issues/index.html", "<h1>Issues</h1>", "+ New issue"),117 + ("pulls/index.html", "<h1>Pulls</h1>", "+ New PR prompt"),118 + ] {119 + let html = std::fs::read_to_string(out.join(page)).expect(page);120 + let head = html.find("<div class=\"page-head\">").expect("page-head");121 + let h1 = html.find(title).expect("h1");122 + let b = html.find(btn).expect("button");123 + let close = html[head..].find("</div>").expect("close") + head;124 + assert!(125 + h1 > head && b > head && h1 < close && b < close,126 + "{page}: h1+btn in page-head"127 + );128 + }129 + // Tasks has no submit button (no page-head needed).130 + let tasks = std::fs::read_to_string(out.join("tasks/index.html")).expect("tasks");131 + assert!(!tasks.contains("btn-new"), "tasks: no submit button");132 + }133 +134 + /// m6/table-scroll (f0rge #30): 200-char title + long sha stay in a135 + /// .table-scroll wrapper with a td.title cell (scroll, never squash).136 + #[test]137 + fn long_title_and_sha_scroll() {138 + let long_title = "t".repeat(200);139 + let long_sha = "a".repeat(64);140 + let repo = mktemp("long-title");141 + write_forge(142 + &repo,143 + "issues/aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/issue.md",144 + &format!(145 + "---\nid: aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa\ntitle: {long_title}\nstate: open\ncreated: 2026-09-10T03:00:00Z\n---\nbody\n"146 + ),147 + );148 + write_forge(149 + &repo,150 + "prs/bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/pr.md",151 + &format!(152 + "---\nid: bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb\ntitle: {long_title}\nstate: open\nbase: main\nhead: {long_sha}\ncreated: 2026-09-10T03:00:00Z\n---\ndesc\n"153 + ),154 + );155 + let out = mktemp("long-title-out");156 + render_loaded(&load(&repo), &out, "test", "<nav>", None).expect("render");157 + for page in ["issues/index.html", "pulls/index.html"] {158 + let html = std::fs::read_to_string(out.join(page)).expect(page);159 + assert!(160 + html.contains("<div class=\"table-scroll\"><table>"),161 + "{page}: wrapper"162 + );163 + assert!(html.contains("<td class=\"title\">"), "{page}: title cell");164 + assert!(html.contains(&long_title), "{page}: long title kept");165 + }166 + let pulls = std::fs::read_to_string(out.join("pulls/index.html")).expect("pulls");167 + assert!(pulls.contains(&long_sha), "long head sha kept");168 + }169 }170 diff --git a/src/releases.rs b/src/releases.rs171 index 10d39c7..ad238d9 100644172 --- a/src/releases.rs173 +++ b/src/releases.rs174 @@ -462,6 +462,41 @@ mod tests {175 assert_eq!(r.display_title(), "v1.0");176 }177 178 + /// m6/table-scroll (f0rge #30): 200-char release title renders in a179 + /// .table-scroll wrapper with a td.title cell.180 + #[test]181 + fn long_release_title_scrolls_with_title_cell() {182 + let long_title = "r".repeat(200);183 + let rel = Release {184 + version: "v9.9.9".to_string(),185 + title: Some(long_title.clone()),186 + created: "2026-09-10T03:00:00Z".to_string(),187 + sha: Some("a".repeat(64)),188 + body: "notes\n".to_string(),189 + artifacts: Vec::new(),190 + };191 + let repo = std::env::temp_dir().join(format!(192 + "hu13-rel-long-{}-{}",193 + std::process::id(),194 + std::time::SystemTime::now()195 + .duration_since(std::time::UNIX_EPOCH)196 + .map(|d| d.as_nanos())197 + .unwrap_or(0)198 + ));199 + std::fs::create_dir_all(&repo).unwrap();200 + let out = repo.join("out");201 + std::fs::create_dir_all(&out).unwrap();202 + render_loaded(&[rel], &repo, &out, "test", "<nav>", None).expect("render");203 + let index =204 + std::fs::read_to_string(out.join("releases").join("index.html")).expect("index");205 + assert!(206 + index.contains("<div class=\"table-scroll\"><table>"),207 + "wrapper"208 + );209 + assert!(index.contains("<td class=\"title\">"), "title cell");210 + assert!(index.contains(&long_title), "long title kept");211 + }212 +213 #[test]214 fn missing_dir_is_empty() {215 let dir = std::env::temp_dir().join(format!(216 diff --git a/static/style.css b/static/style.css217 index ad2b9da..5234011 100644218 --- a/static/style.css219 +++ b/static/style.css220 @@ -157,3 +157,15 @@ a.ln:hover,a.ln:focus{text-decoration:underline}221 .clone-block h3{margin:1rem 0 .25rem}222 .clone-block p{margin:.5rem 0}223 .clone-cmd code{user-select:all;overflow-wrap:anywhere;word-break:break-all}224 +/* List tables scroll, never squash (m6/table-scroll, f0rge #30): natural225 + width inside .table-scroll; cells nowrap except title cells which wrap226 + with a floor so rows scroll horizontally. Hover tint via var. */227 +.table-scroll table{width:max-content;min-width:100%}228 +.table-scroll th,.table-scroll td{white-space:nowrap}229 +.table-scroll td.title{white-space:normal;overflow-wrap:anywhere;min-width:12rem}230 +.table-scroll tbody tr:hover td{background:var(--bg-th)}231 +/* Page titles with inline buttons (m6/table-scroll, f0rge #31): h1 +232 + button share one flex row; wrap allowed on 360px but stays adjacent. */233 +.page-head{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1rem}234 +.page-head h1{margin:0}235 +.page-head .btn-new{flex:none}236 -- 237 2.55.0238