1 From 84babd06a78fb489937a49846edbf06b31d3628d Mon Sep 17 00:00:00 20012 From: gl0w <gl0w@world>3 Date: Thu, 10 Sep 2026 08:04:30 +01004 Subject: [PATCH] m6/code-gitea: file table + language bar + collapsed clone5 6 ---7 src/build.rs | 671 ++++++++++++++++++++++++++++++++++++++++++-----8 src/gitx.rs | 59 +++++9 static/style.css | 14 +10 3 files changed, 677 insertions(+), 67 deletions(-)11 12 diff --git a/src/build.rs b/src/build.rs13 index d158b5c..f461204 10064414 --- a/src/build.rs15 +++ b/src/build.rs16 @@ -295,14 +295,295 @@ pub fn file_hash(path: &Path) -> Option<String> {17 None18 }19 20 +/// Max commit-message chars shown in file tables (m6 code-gitea,21 +/// f0rge #33, Gitea-style: short message, truncated). Research note:22 +/// live Gitea page (`gitea.com/gitea/tea`, fetched 2026-09-10) renders23 +/// file rows as name / commit-summary / age divs with dirs-first and a24 +/// `<relative-time>` age; hu13 adapts that to a static TABLE with25 +/// build-time relative ages (no JS) per the issue.26 +pub const MSG_TRUNC_CHARS: usize = 72;27 +28 +/// Truncate a commit subject to [`MSG_TRUNC_CHARS`] chars (char-wise,29 +/// never splitting a boundary). Short messages pass through; longer30 +/// ones are cut to 72 chars plus a trailing `…` so truncation is31 +/// visible. Callers escape the result with [`esc`] for HTML.32 +#[must_use]33 +pub fn truncate_msg(s: &str) -> String {34 + let n = s.chars().count();35 + if n <= MSG_TRUNC_CHARS {36 + return s.to_string();37 + }38 + let kept: String = s.chars().take(MSG_TRUNC_CHARS).collect();39 + format!("{kept}…")40 +}41 +42 +/// Current unix time (seconds) for build-time relative ages.43 +#[must_use]44 +pub fn now_unix() -> i64 {45 + std::time::SystemTime::now()46 + .duration_since(std::time::UNIX_EPOCH)47 + .map(|d| d.as_secs() as i64)48 + .unwrap_or(0)49 +}50 +51 +/// Build-time relative age (`3d ago` style, Gitea `relative-time`52 +/// adapted to static text, no JS). `now_ts`/`then_ts` are unix seconds;53 +/// future timestamps clamp to `just now`. Thresholds: ≤10s just now,54 +/// <60s `Ns ago`, <1h `Nm ago`, <1d `Nh ago`, <30d `Nd ago`,55 +/// <365d `Nmo ago`, else `Ny ago`.56 +#[must_use]57 +pub fn relative_age(now_ts: i64, then_ts: i64) -> String {58 + let mut diff = now_ts.saturating_sub(then_ts);59 + if diff < 0 {60 + diff = 0;61 + }62 + if diff <= 10 {63 + return "just now".to_string();64 + }65 + if diff < 60 {66 + return format!("{diff}s ago");67 + }68 + if diff < 3600 {69 + return format!("{}m ago", diff / 60);70 + }71 + if diff < 86_400 {72 + return format!("{}h ago", diff / 3600);73 + }74 + if diff < 86_400 * 30 {75 + return format!("{}d ago", diff / 86_400);76 + }77 + if diff < 86_400 * 365 {78 + return format!("{}mo ago", diff / (86_400 * 30));79 + }80 + format!("{}y ago", diff / (86_400 * 365))81 +}82 +83 +/// Language of a repo-relative path by extension (lowercased).84 +/// Map (m6 code-gitea, f0rge #33): rs/py/js/ts/go/sh/css/html/md85 +/// (+markdown)/toml/yaml (+yml)/json/c/h; everything else is `Other`.86 +/// `Dockerfile`/`Makefile`/extensionless names are `Other` by design.87 +#[must_use]88 +pub fn language_of(path: &str) -> &'static str {89 + let ext = match path.rsplit('.').next() {90 + Some(e) if path.contains('.') => e.to_lowercase(),91 + _ => String::new(),92 + };93 + match ext.as_str() {94 + "rs" => "Rust",95 + "py" => "Python",96 + "js" => "JavaScript",97 + "ts" => "TypeScript",98 + "go" => "Go",99 + "sh" => "Shell",100 + "css" => "CSS",101 + "html" => "HTML",102 + "md" | "markdown" => "Markdown",103 + "toml" => "TOML",104 + "yaml" | "yml" => "YAML",105 + "json" => "JSON",106 + "c" | "h" => "C",107 + _ => "Other",108 + }109 +}110 +111 +/// Tokyo Night hue per language for the stacked bar + legend.112 +#[must_use]113 +pub fn language_color(lang: &str) -> &'static str {114 + match lang {115 + "Rust" => "#f7768e",116 + "Python" => "#7aa2f7",117 + "JavaScript" => "#e0af68",118 + "TypeScript" => "#7dcfff",119 + "Go" => "#73daca",120 + "Shell" => "#9ece6a",121 + "CSS" => "#bb9af7",122 + "HTML" => "#ff9e64",123 + "Markdown" => "#9aa5ce",124 + "TOML" => "#c0caf5",125 + "YAML" => "#ff007c",126 + "JSON" => "#89ddff",127 + "C" => "#1abc9c",128 + _ => "#565f89",129 + }130 +}131 +132 +/// One language share for the mix bar.133 +#[derive(Debug, Clone, PartialEq)]134 +pub struct LangStat {135 + /// Display name (`Rust`, …, `Other`).136 + pub lang: String,137 + /// Total bytes in this language.138 + pub bytes: u64,139 + /// Share of all counted bytes (0–100).140 + pub pct: f64,141 +}142 +143 +/// Group `(path, bytes)` rows into language shares (bytes share).144 +///145 +/// Total is all rows passed in (unknown extensions count as `Other`).146 +/// Languages under 1% merge into `Other`; output sorts bytes-desc.147 +/// Empty input (or zero total) yields an empty vec (caller hides the148 +/// bar and shows the no-languages note instead).149 +#[must_use]150 +pub fn compute_languages(files: &[(String, u64)]) -> Vec<LangStat> {151 + use std::collections::BTreeMap;152 + let mut by_lang: BTreeMap<String, u64> = BTreeMap::new();153 + for (path, size) in files {154 + *by_lang.entry(language_of(path).to_string()).or_default() += *size;155 + }156 + let total: u64 = by_lang.values().sum();157 + if total == 0 {158 + return Vec::new();159 + }160 + let mut main: Vec<(String, u64)> = Vec::new();161 + let mut other_bytes: u64 = by_lang.get("Other").copied().unwrap_or(0);162 + for (lang, bytes) in &by_lang {163 + if lang == "Other" {164 + continue;165 + }166 + let pct = *bytes as f64 * 100.0 / total as f64;167 + if pct < 1.0 {168 + other_bytes += *bytes;169 + } else {170 + main.push((lang.clone(), *bytes));171 + }172 + }173 + if other_bytes > 0 {174 + main.push(("Other".to_string(), other_bytes));175 + }176 + let mut out: Vec<LangStat> = main177 + .into_iter()178 + .map(|(lang, bytes)| LangStat {179 + pct: bytes as f64 * 100.0 / total as f64,180 + lang,181 + bytes,182 + })183 + .collect();184 + out.sort_by_key(|a| std::cmp::Reverse(a.bytes));185 + out186 +}187 +188 +/// Render the stacked language bar + legend (pure build-time, no JS).189 +///190 +/// `files` is `(path, bytes)` for the whole tree. Returns a191 +/// `<section class="langs">` with a Tokyo Night stacked bar and a192 +/// `%` legend when at least one code file exists (a file whose193 +/// extension maps to a known language); otherwise a muted194 +/// `No languages yet.` note and no bar (hidden when no code).195 +/// Everything is escaped; widths are `pct` with one decimal.196 +#[must_use]197 +pub fn language_bar(files: &[(String, u64)]) -> String {198 + let has_code = files.iter().any(|(p, _)| language_of(p) != "Other");199 + if !has_code {200 + return "<p class=\"muted lang-none\">No languages yet.</p>\n".to_string();201 + }202 + let stats = compute_languages(files);203 + if stats.is_empty() {204 + return "<p class=\"muted lang-none\">No languages yet.</p>\n".to_string();205 + }206 + let aria = stats207 + .iter()208 + .map(|s| format!("{} {:.1}%", s.lang, s.pct))209 + .collect::<Vec<_>>()210 + .join(", ");211 + let mut out = format!(212 + "<section class=\"langs\" aria-label=\"Languages\">\n<h2>Languages</h2>\n\213 + <div class=\"lang-bar\" role=\"img\" aria-label=\"{}\">\n",214 + esc(&aria)215 + );216 + for s in &stats {217 + out.push_str(&format!(218 + "<span style=\"width:{:.1}%;background:{}\"></span>",219 + s.pct,220 + language_color(&s.lang)221 + ));222 + }223 + out.push_str("\n</div>\n<ul class=\"lang-legend\">\n");224 + for s in &stats {225 + out.push_str(&format!(226 + "<li><span class=\"lang-dot\" style=\"background:{}\"></span>{} {:.1}%</li>\n",227 + language_color(&s.lang),228 + esc(&s.lang),229 + s.pct230 + ));231 + }232 + out.push_str("</ul>\n</section>\n");233 + out234 +}235 +236 +/// Gitea-style file table (m6 code-gitea, f0rge #33).237 +///238 +/// `entries` stay in `gitx::ls_tree` order (dirs-first, each alpha).239 +/// `parent` is `Some((href, label))` for subdirs (`..` row) or `None`240 +/// at the root. `history` maps full repo-relative path →241 +/// `(short_message, age)` already truncated + aged at build; missing242 +/// entries render empty cells. Names link to `/files/<full>.html`243 +/// (dirs with a `/` suffix); messages are escaped + truncated to244 +/// [`MSG_TRUNC_CHARS`]; ages are precomputed `relative_age` strings.245 +/// Output is a scroll-wrapped TABLE (file-table class) with246 +/// Name / Last commit / Age headers.247 +#[must_use]248 +pub fn file_table_html(249 + dir: &str,250 + entries: &[gitx::TreeEntry],251 + parent: Option<(&str, &str)>,252 + history: &std::collections::BTreeMap<String, (String, String)>,253 +) -> String {254 + let mut out = String::from(255 + "<div class=\"table-scroll\"><table class=\"file-table\">\n\256 + <thead><tr><th>Name</th><th>Last commit</th><th>Age</th></tr></thead>\n<tbody>\n",257 + );258 + if let Some((href, label)) = parent {259 + out.push_str(&format!(260 + "<tr class=\"parent\"><td><a href=\"{}\">{}</a></td>\261 + <td></td><td></td></tr>\n",262 + esc(href),263 + esc(label)264 + ));265 + }266 + for e in entries {267 + let full = if dir.is_empty() {268 + e.name.clone()269 + } else {270 + format!("{dir}/{}", e.name)271 + };272 + let (msg, age) = history.get(&full).cloned().unwrap_or_default();273 + let msg_short = truncate_msg(&msg);274 + if e.is_dir {275 + out.push_str(&format!(276 + "<tr class=\"dir\"><td><a href=\"/files/{}.html\">{}/</a></td>\277 + <td class=\"title\">{}</td><td>{}</td></tr>\n",278 + href_esc(&full),279 + esc(&e.name),280 + esc(&msg_short),281 + esc(&age)282 + ));283 + } else {284 + out.push_str(&format!(285 + "<tr class=\"file\"><td><a href=\"/files/{}.html\">{}</a></td>\286 + <td class=\"title\">{}</td><td>{}</td></tr>\n",287 + href_esc(&full),288 + esc(&e.name),289 + esc(&msg_short),290 + esc(&age)291 + ));292 + }293 + }294 + out.push_str("</tbody>\n</table></div>\n");295 + out296 +}297 +298 /// Prominent Clone & download section for the code tab (m6, f0rge299 -/// issues #20 + #32): stacked no-JS sections with selectable `<pre>`300 -/// commands. When a public origin is configured the primary command is301 +/// issues #20 + #32 + #34): collapsed native `<details>` (closed by302 +/// default, zero JS, keyboard accessible, works file://) with stacked303 +/// no-JS sections and selectable `<pre>` commands inside. When a public304 +/// origin is configured the primary command is305 /// `git clone <origin>/repo.bundle` (exact snapshot note + tarball306 /// secondary, origin remote demoted to "mirror source"); otherwise307 /// bundle-first as before (`git clone <url>` first when a clone URL is308 /// configured, else local bundle) plus the bundle/tarball links with309 -/// sizes and shas.310 +/// sizes and shas. The `<summary>` always shows `Clone & download`311 +/// plus a branch@sha hint.312 #[must_use]313 #[allow(clippy::too_many_arguments)]314 pub fn clone_block(315 @@ -337,9 +618,11 @@ pub fn clone_block(316 human_size(tarball_size)317 ),318 };319 - let mut out = String::from(320 - "<section class=\"clone-block\" aria-label=\"Clone and download\">\n\321 - <h2>Clone & download</h2>\n",322 + let mut out = format!(323 + "<details class=\"clonebox clone-block\" aria-label=\"Clone and download\">\n\324 + <summary>Clone & download <span class=\"muted\">{} @ {}</span></summary>\n",325 + esc(branch),326 + esc(short_sha)327 );328 if let Some(origin) = public_origin.map(str::trim).filter(|s| !s.is_empty()) {329 let base = origin.trim_end_matches('/');330 @@ -364,7 +647,7 @@ pub fn clone_block(331 esc(url)332 ));333 }334 - out.push_str("</section>\n");335 + out.push_str("</details>\n");336 return out;337 }338 if let Some(url) = clone_url {339 @@ -395,7 +678,7 @@ pub fn clone_block(340 served from this server: ask the host for its address.</p>\n",341 );342 }343 - out.push_str("</section>\n");344 + out.push_str("</details>\n");345 out346 }347 348 @@ -527,7 +810,32 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu349 blobs.insert(f.clone(), blob.bytes);350 }351 352 - // Per-path pages.353 + // Per-path pages (m6 code-gitea, f0rge #33): Gitea-style file354 + // TABLEs with last-commit message + build-time relative age.355 + // History is one `git log -1` per tree path (tiny repos only);356 + // failures degrade to empty cells, never a failed build.357 + let now_ts = now_unix();358 + let mut history: BTreeMap<String, (String, String)> = BTreeMap::new();359 + {360 + let mut all_paths: Vec<String> = Vec::new();361 + for (d, ents) in &dir_entries {362 + for e in ents {363 + let full = if d.is_empty() {364 + e.name.clone()365 + } else {366 + format!("{d}/{}", e.name)367 + };368 + all_paths.push(full);369 + }370 + }371 + for fp in all_paths {372 + let cell = match gitx::last_commit_for_path(repo, &branch, &fp) {373 + Ok(Some(pc)) => (pc.subject, relative_age(now_ts, pc.committer_ts)),374 + _ => (String::new(), String::new()),375 + };376 + history.insert(fp, cell);377 + }378 + }379 for dir in &dirs {380 if dir.is_empty() {381 continue; // root is index.html382 @@ -545,40 +853,16 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu383 let parent_label = if parent.is_empty() {384 ".. (root)".to_string()385 } else {386 - format!(".. ({})", esc(&parent))387 + format!(".. ({parent})")388 };389 let mut body = breadcrumb(dir);390 - body.push_str(&format!(391 - "<h1>dir: {}</h1>\n<ul class=\"tree\">\n",392 - esc(dir),393 + body.push_str(&format!("<h1>dir: {}</h1>\n", esc(dir),));394 + body.push_str(&file_table_html(395 + dir,396 + &entries,397 + Some((&parent_href, &parent_label)),398 + &history,399 ));400 - body.push_str(&format!(401 - "<li class=\"parent\"><a href=\"{parent_href}\">{parent_label}</a></li>\n"402 - ));403 - for e in &entries {404 - let full = format!("{dir}/{}", e.name);405 - if e.is_dir {406 - body.push_str(&format!(407 - "<li class=\"dir\"><a href=\"/files/{}.html\">{}/</a></li>\n",408 - href_esc(&full),409 - esc(&e.name)410 - ));411 - } else {412 - let size = blobs413 - .get(&full)414 - .map(|b| human_size(b.len() as u64))415 - .unwrap_or_default();416 - body.push_str(&format!(417 - "<li class=\"file\"><a href=\"/files/{}.html\">{}</a> \418 - <span class=\"file-size\">{size}</span> \419 - (<a href=\"/raw/{}\">raw</a>)</li>\n",420 - href_esc(&full),421 - esc(&e.name),422 - href_esc(&full)423 - ));424 - }425 - }426 - body.push_str("</ul>\n");427 let html =428 crate::layout::page_site(&format!("{dir} — {name}"), &nav, &body, &name, desc_opt);429 let dest = out.join("files").join(format!("{dir}.html"));430 @@ -697,29 +981,18 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu431 432 body.push_str("</div>\n");433 // Single column on mobile; tree+readme sit side by side only at >=768px.434 - body.push_str("<div class=\"cols\"><section><h2>Files</h2>\n<ul class=\"tree\">\n");435 - for e in dir_entries.get("").cloned().unwrap_or_default() {436 - if e.is_dir {437 - body.push_str(&format!(438 - "<li class=\"dir\"><a href=\"/files/{}.html\">{}/</a></li>\n",439 - href_esc(&e.name),440 - esc(&e.name)441 - ));442 - } else {443 - let size = blobs444 - .get(&e.name)445 - .map(|b| human_size(b.len() as u64))446 - .unwrap_or_default();447 - body.push_str(&format!(448 - "<li class=\"file\"><a href=\"/files/{}.html\">{}</a> \449 - <span class=\"file-size\">{size}</span> (<a href=\"/raw/{}\">raw</a>)</li>\n",450 - href_esc(&e.name),451 - esc(&e.name),452 - href_esc(&e.name)453 - ));454 - }455 - }456 - body.push_str("</ul>\n</section>\n<section><h2>README</h2>\n");457 + // Code tab file TABLE (m6 code-gitea, f0rge #33, Gitea-style) plus the458 + // language mix bar (bytes share, Tokyo Night hues, no JS).459 + let root_entries = dir_entries.get("").cloned().unwrap_or_default();460 + let lang_files: Vec<(String, u64)> = blobs461 + .iter()462 + .map(|(k, v)| (k.clone(), v.len() as u64))463 + .collect();464 + let lang_section = language_bar(&lang_files);465 + body.push_str("<div class=\"cols\"><section><h2>Files</h2>\n");466 + body.push_str(&file_table_html("", &root_entries, None, &history));467 + body.push_str(&lang_section);468 + body.push_str("</section>\n<section><h2>README</h2>\n");469 match &readme {470 Some((path, blob)) => {471 body.push_str(&format!(472 @@ -1266,16 +1539,280 @@ mod tests {473 474 #[test]475 fn dir_page_has_parent_row_and_sizes() {476 + // m6/code-gitea (f0rge #33): dir pages are a Gitea-style TABLE477 + // (Name / Last commit / Age); sizes + raw live on file pages478 + // (file_meta_bar), not in the table. Breadcrumb stays.479 let (_repo, out) = build_fixture();480 let html = std::fs::read_to_string(out.join("files/sub.html")).expect("dir page");481 + assert!(482 + html.contains("file-table\">"),483 + "file TABLE, got: {}",484 + &html[..2000.min(html.len())]485 + );486 assert!(487 html.contains("<a href=\"/\">.. (root)</a>"),488 - "parent row to root"489 + "parent .. row to root"490 );491 assert!(html.contains("nested.txt"), "lists file");492 - assert!(html.contains("/raw/sub/nested.txt"), "raw link");493 - // "nested\n" is 7 bytes.494 - assert!(html.contains("7 B"), "file size shown");495 + assert!(html.contains("class=\"crumbs\""), "breadcrumb kept");496 + assert!(html.contains("<th>Name</th>"), "Name header");497 + assert!(html.contains("<th>Last commit</th>"), "message header");498 + assert!(html.contains("<th>Age</th>"), "age header");499 + // Sizes + raw live on the file page itself (meta bar), not the table.500 + let txt = std::fs::read_to_string(out.join("files/notes.txt.html")).expect("txt page");501 + assert!(txt.contains("24 B"), "file size on file page");502 + assert!(txt.contains("/raw/notes.txt"), "raw on file page");503 + }504 +505 + #[test]506 + fn file_table_is_gitea_style_dirs_first() {507 + // f0rge #33 rubric: TABLE (not bullets), dirs-first then files508 + // each alpha, dirs with `/` suffix, parent `..` row, breadcrumb.509 + let (_repo, out) = build_fixture();510 + let index = std::fs::read_to_string(out.join("index.html")).expect("index");511 + assert!(index.contains("file-table\">"), "root TABLE");512 + assert!(513 + !index.contains("<ul class=\"tree\">"),514 + "no bullets on code tab"515 + );516 + assert!(517 + index.contains("class=\"crumbs\"") || index.contains("repo-head"),518 + "page kept"519 + );520 + // Dirs carry a trailing slash link; files do not gain one.521 + assert!(index.contains(">src/</a>"), "dir with / suffix");522 + // Dirs-first: every root dir sorts before every root file.523 + let dir_pos = index.find(">src/</a>").expect("src pos");524 + let file_pos = index.find(">README.md</a>").expect("readme pos");525 + assert!(dir_pos < file_pos, "dirs-first in root TABLE");526 + // Subdir page: parent row + breadcrumb + own TABLE.527 + let sub = std::fs::read_to_string(out.join("files/sub.html")).expect("sub");528 + assert!(sub.contains("file-table\">"), "subdir TABLE");529 + assert!(sub.contains("<tr class=\"parent\">"), "parent row element");530 + assert!(531 + sub.contains("<a href=\"/\">.. (root)</a>"),532 + "parent .. link"533 + );534 + assert!(535 + sub.contains("<nav class=\"crumbs\""),536 + "breadcrumb kept on subdir"537 + );538 + // Message + age columns exist even when history is empty-ish.539 + assert!(sub.contains("<td class=\"title\">"), "message cell");540 + }541 +542 + #[test]543 + fn truncate_msg_cuts_at_72_with_ellipsis() {544 + assert_eq!(truncate_msg("short"), "short");545 + let exactly = "x".repeat(72);546 + assert_eq!(truncate_msg(&exactly), exactly, "72 chars untouched");547 + let long = "y".repeat(100);548 + let got = truncate_msg(&long);549 + assert_eq!(got.chars().count(), 73, "72 + ellipsis, got {got:?}");550 + assert!(got.ends_with("…"), "visible truncation");551 + // Multi-byte chars count as chars, never split a boundary.552 + let uni = "é".repeat(100);553 + let got = truncate_msg(&uni);554 + assert_eq!(got.chars().count(), 73);555 + assert!(got.ends_with("…"));556 + }557 +558 + #[test]559 + fn relative_age_fixed_clock() {560 + // f0rge #33 rubric: fixed-clock fixture, build-time relative ages.561 + let now = 1_786_000_000_i64;562 + assert_eq!(relative_age(now, now), "just now");563 + assert_eq!(relative_age(now, now - 5), "just now");564 + assert_eq!(relative_age(now, now - 30), "30s ago");565 + assert_eq!(relative_age(now, now - 5 * 60), "5m ago");566 + assert_eq!(relative_age(now, now - 3 * 3600), "3h ago");567 + assert_eq!(relative_age(now, now - 3 * 86_400), "3d ago");568 + assert_eq!(relative_age(now, now - 10 * 86_400), "10d ago");569 + assert_eq!(relative_age(now, now - 60 * 86_400), "2mo ago");570 + assert_eq!(relative_age(now, now - 400 * 86_400), "1y ago");571 + assert_eq!(relative_age(now, now + 9999), "just now", "future clamps");572 + }573 +574 + #[test]575 + fn language_math_exact_shares_and_tiny_grouping() {576 + // f0rge #33 rubric: known bytes -> exact %.577 + let files = vec![("a.rs".to_string(), 100_u64), ("b.py".to_string(), 300_u64)];578 + let stats = compute_languages(&files);579 + assert_eq!(stats.len(), 2);580 + assert_eq!(stats[0].lang, "Python");581 + assert_eq!(stats[0].bytes, 300);582 + assert!(583 + (stats[0].pct - 75.0).abs() < 1e-9,584 + "py 75%, got {}",585 + stats[0].pct586 + );587 + assert_eq!(stats[1].lang, "Rust");588 + assert!(589 + (stats[1].pct - 25.0).abs() < 1e-9,590 + "rs 25%, got {}",591 + stats[1].pct592 + );593 + // <1% merges into Other.594 + let mut files = vec![("big.rs".to_string(), 10_000_u64)];595 + files.push(("tiny.py".to_string(), 50_u64));596 + let stats = compute_languages(&files);597 + let langs: Vec<&str> = stats.iter().map(|s| s.lang.as_str()).collect();598 + assert!(599 + !langs.contains(&"Python"),600 + "tiny <1% grouped, got {langs:?}"601 + );602 + assert!(langs.contains(&"Other"), "other carries tiny share");603 + // Extension map covers the issue list; unknown -> Other.604 + for (path, want) in [605 + ("x.rs", "Rust"),606 + ("x.py", "Python"),607 + ("x.js", "JavaScript"),608 + ("x.ts", "TypeScript"),609 + ("x.go", "Go"),610 + ("x.sh", "Shell"),611 + ("x.css", "CSS"),612 + ("x.html", "HTML"),613 + ("x.md", "Markdown"),614 + ("x.toml", "TOML"),615 + ("x.yaml", "YAML"),616 + ("x.yml", "YAML"),617 + ("x.json", "JSON"),618 + ("x.c", "C"),619 + ("x.h", "C"),620 + ("x.txt", "Other"),621 + ("Dockerfile", "Other"),622 + ] {623 + assert_eq!(language_of(path), want, "map for {path}");624 + }625 + }626 +627 + #[test]628 + fn language_bar_hidden_without_code() {629 + // f0rge #33: bar hidden when no code; note shown instead. No JS.630 + let txt_only = vec![631 + ("notes.txt".to_string(), 24_u64),632 + ("data.bin".to_string(), 10_u64),633 + ];634 + let html = language_bar(&txt_only);635 + assert!(html.contains("No languages yet."), "note when no code");636 + assert!(!html.contains("lang-bar"), "bar hidden when no code");637 + assert!(!html.contains("<script"), "no JS in lang block");638 + // With code: stacked bar + legend with %.639 + let code = vec![("a.rs".to_string(), 100_u64), ("b.py".to_string(), 100_u64)];640 + let html = language_bar(&code);641 + assert!(html.contains("lang-bar"), "bar shown with code");642 + assert!(html.contains("lang-legend"), "legend shown");643 + assert!(html.contains("Rust"), "rust legend");644 + assert!(html.contains("50.0%"), "exact share in legend");645 + assert!(html.contains("background:#"), "Tokyo Night hues inline");646 + }647 +648 + #[test]649 + fn language_bar_shows_on_index_with_legend() {650 + // Integration: balanced code repo -> stacked bar + legend.651 + // (The 10-file fixture is 300KB of .txt, so its tiny .rs/.md652 + // shares correctly collapse into Other per the <1% rule.)653 + let dir = mktemp("lang-index");654 + git(&dir, &["init", "-b", "main"]);655 + git(&dir, &["config", "user.name", "hu13-test"]);656 + git(&dir, &["config", "user.email", "[email protected]"]);657 + git(&dir, &["config", "commit.gpgsign", "false"]);658 + std::fs::write(dir.join("a.rs"), "fn a() {}\n".repeat(20)).unwrap();659 + std::fs::write(dir.join("b.py"), "print(1)\n".repeat(20)).unwrap();660 + std::fs::write(dir.join("README.md"), "# hi\n").unwrap();661 + git(&dir, &["add", "-A"]);662 + git(&dir, &["commit", "-m", "lang mix"]);663 + let out = mktemp("out-lang-index");664 + run_build(&dir, &out, &opts()).expect("build");665 + let index = std::fs::read_to_string(out.join("index.html")).expect("index");666 + assert!(index.contains("lang-bar"), "stacked bar on code tab");667 + assert!(index.contains("lang-legend"), "legend on code tab");668 + assert!(index.contains("Rust"), "rust in legend");669 + assert!(index.contains("Python"), "python in legend");670 + assert!(index.contains('%'), "legend carries %");671 + // The big-file fixture still shows a bar (100% Other), never empty.672 + let (_repo2, out2) = build_fixture();673 + let index2 = std::fs::read_to_string(out2.join("index.html")).expect("index2");674 + assert!(index2.contains("lang-bar"), "fixture bar present (Other)");675 + assert!(index2.contains("Other"), "tiny shares group into Other");676 + }677 +678 + #[test]679 + fn escaping_evil_filename_and_message() {680 + // f0rge #33 rubric: escaping everywhere (filename + message).681 + let dir = mktemp("evil-names");682 + git(&dir, &["init", "-b", "main"]);683 + git(&dir, &["config", "user.name", "hu13-test"]);684 + git(&dir, &["config", "user.email", "[email protected]"]);685 + git(&dir, &["config", "commit.gpgsign", "false"]);686 + std::fs::write(dir.join("README.md"), "# hi\n").unwrap();687 + std::fs::write(dir.join("evil<script>.txt"), "evil file\n").unwrap();688 + std::fs::write(dir.join("ok.rs"), "fn main() {}\n").unwrap();689 + git(&dir, &["add", "-A"]);690 + // Evil + over-long message (also exercises 72-char truncation).691 + let evil_msg = "<script>alert(1)</script> ".to_string() + &"A".repeat(100);692 + git(&dir, &["commit", "-m", &evil_msg]);693 + let out = mktemp("out-evil");694 + run_build(&dir, &out, &opts()).expect("build");695 + let index = std::fs::read_to_string(out.join("index.html")).expect("index");696 + assert!(697 + !index.contains("<script>alert(1)</script>"),698 + "no raw script in TABLE"699 + );700 + assert!(index.contains("<script>"), "escaped message in TABLE");701 + assert!(!index.contains("evil<script>.txt"), "no raw filename");702 + assert!(index.contains("evil<script>.txt"), "escaped filename");703 + assert!(index.contains("…"), "long message truncated in TABLE");704 + // File page for the evil name is escaped too.705 + let evil_page = std::fs::read_to_string(out.join("files/evil<script>.txt.html"));706 + assert!(707 + evil_page.is_ok() || out.join("files").exists(),708 + "evil file page emitted"709 + );710 + if let Ok(html) = evil_page {711 + assert!(712 + !html.contains("<script>alert(1)</script>"),713 + "no raw on file page"714 + );715 + }716 + }717 +718 + #[test]719 + fn clone_details_collapsed_with_hint_and_contents() {720 + // f0rge #34 rubric: native <details>, closed by default, all721 + // three methods + sizes inside, branch/sha hint in summary.722 + let repo = fixture_repo();723 + let out = mktemp("out-details");724 + let mut o = opts();725 + o.clone_url_flag = Some("https://example.com/x.git".to_string());726 + run_build(&repo, &out, &o).expect("build");727 + let index = std::fs::read_to_string(out.join("index.html")).expect("index");728 + assert!(index.contains("<details"), "details present");729 + assert!(index.contains("class=\"clonebox"), "clonebox class");730 + assert!(index.contains("clone-block"), "clone-block class kept");731 + assert!(732 + index.contains("<summary>Clone & download"),733 + "summary label"734 + );735 + assert!(index.contains("main"), "branch hint in summary");736 + // Closed by default: no `open` attr on the clone details.737 + let d0 = index738 + .find("<details class=\"clonebox")739 + .expect("details pos");740 + let d1 = index[d0..].find('>').expect("details end") + d0;741 + let tag = &index[d0..d1];742 + assert!(!tag.contains("open"), "closed by default, got {tag:?}");743 + // Contents intact: all three methods + sizes.744 + assert!(745 + index.contains("git clone https://example.com/x.git"),746 + "url method"747 + );748 + assert!(index.contains("git clone repo.bundle"), "bundle method");749 + assert!(index.contains("tar -xzf repo.tar.gz"), "tarball method");750 + assert!(index.contains("repo.bundle"), "bundle size/link");751 + assert!(index.contains("repo.tar.gz"), "tarball size/link");752 + // Keyboard accessible native element, zero JS.753 + assert!(!index.contains("<script"), "zero JS for clone block");754 }755 756 #[test]757 diff --git a/src/gitx.rs b/src/gitx.rs758 index a4e11eb..76462fe 100644759 --- a/src/gitx.rs760 +++ b/src/gitx.rs761 @@ -303,6 +303,65 @@ pub fn rev_parse_short(repo: &Path, rev: &str) -> Result<String, GitError> {762 Ok(s)763 }764 765 +/// One path's last-commit summary for file tables (m6 code-gitea,766 +/// f0rge #33): subject line + committer timestamp (unix, `%ct`).767 +#[derive(Debug, Clone, PartialEq, Eq)]768 +pub struct PathCommit {769 + /// First line of the last commit touching the path (`%s`).770 + pub subject: String,771 + /// Committer date as unix seconds (`%ct`).772 + pub committer_ts: i64,773 +}774 +775 +/// Last commit touching `path` on `branch` (`git log -1`).776 +///777 +/// Returns `None` when the path has no history (empty output). `path`778 +/// is repo-relative (`a/b.txt` or `a/b` for dirs); empty means the779 +/// branch head itself is not queried here (callers skip the root).780 +/// Subject is a single `%s` line (no newlines); callers must still781 +/// escape it for HTML and truncate it for display.782 +pub fn last_commit_for_path(783 + repo: &Path,784 + branch: &str,785 + path: &str,786 +) -> Result<Option<PathCommit>, GitError> {787 + let a_log: &OsStr = OsStr::new("log");788 + let a_one: &OsStr = OsStr::new("-1");789 + let a_fmt: &OsStr = OsStr::new("--format=%s\x1f%ct");790 + let os_branch = OsString::from(branch);791 + let os_sep: &OsStr = OsStr::new("--");792 + let os_path = OsString::from(path);793 + let args: Vec<&OsStr> = vec![794 + a_log,795 + a_one,796 + a_fmt,797 + os_branch.as_os_str(),798 + os_sep,799 + os_path.as_os_str(),800 + ];801 + let out = run_git(repo, &args)?;802 + if !out.status_ok {803 + let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();804 + return Err(GitError::Failed {805 + cmd: cmd_string(repo, &args),806 + status: out.status_str,807 + stderr,808 + });809 + }810 + let text = String::from_utf8_lossy(&out.stdout).trim().to_string();811 + if text.is_empty() {812 + return Ok(None);813 + }814 + let first_line = text.lines().next().unwrap_or("");815 + let mut parts = first_line.splitn(2, '\x1f');816 + let subject = parts.next().unwrap_or("").to_string();817 + let ts: i64 = parts.next().unwrap_or("0").trim().parse().unwrap_or(0);818 + Ok(Some(PathCommit {819 + subject,820 + committer_ts: ts,821 + }))822 +}823 +824 /// Newest-first log for `branch`, up to `n` commits.825 ///826 /// Returns `(sha, subject, author, date)` rows as [`Commit`].827 diff --git a/static/style.css b/static/style.css828 index 5234011..a09ecf2 100644829 --- a/static/style.css830 +++ b/static/style.css831 @@ -169,3 +169,17 @@ a.ln:hover,a.ln:focus{text-decoration:underline}832 .page-head{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1rem}833 .page-head h1{margin:0}834 .page-head .btn-new{flex:none}835 +836 +/* Code-gitea (m6, f0rge #33 + #34): file TABLE, language bar, clone details. */837 +.file-table th:nth-child(3),.file-table td:nth-child(3){white-space:nowrap}838 +.file-table td.title{white-space:normal;overflow-wrap:anywhere;min-width:12rem}839 +.langs{margin:1rem 0}840 +.lang-bar{display:flex;height:.5rem;overflow:hidden;background:var(--bg-th);border:1px solid var(--border)}841 +.lang-bar span{display:block;height:100%}842 +.lang-legend{list-style:none;margin:.5rem 0;padding:0;display:flex;flex-wrap:wrap;gap:.25rem 1rem}843 +.lang-legend li{display:inline-flex;align-items:center;gap:.35rem}844 +.lang-dot{display:inline-block;width:.75rem;height:.75rem;flex:none}845 +details.clonebox{margin:1rem 0;padding:.25rem 1rem 1rem;background:var(--bg-alt);border:1px solid var(--border)}846 +details.clonebox>summary{cursor:pointer;min-height:40px;font-weight:700;color:var(--cyan)}847 +details.clonebox>summary:focus-visible{outline:2px solid var(--blue);outline-offset:2px}848 +.clone-hint,.clonebox summary .muted{font-weight:400}849 -- 850 2.55.0851