From 84babd06a78fb489937a49846edbf06b31d3628d Mon Sep 17 00:00:00 2001 From: gl0w Date: Thu, 10 Sep 2026 08:04:30 +0100 Subject: [PATCH] m6/code-gitea: file table + language bar + collapsed clone --- src/build.rs | 671 ++++++++++++++++++++++++++++++++++++++++++----- src/gitx.rs | 59 +++++ static/style.css | 14 + 3 files changed, 677 insertions(+), 67 deletions(-) diff --git a/src/build.rs b/src/build.rs index d158b5c..f461204 100644 --- a/src/build.rs +++ b/src/build.rs @@ -295,14 +295,295 @@ pub fn file_hash(path: &Path) -> Option { None } +/// Max commit-message chars shown in file tables (m6 code-gitea, +/// f0rge #33, Gitea-style: short message, truncated). Research note: +/// live Gitea page (`gitea.com/gitea/tea`, fetched 2026-09-10) renders +/// file rows as name / commit-summary / age divs with dirs-first and a +/// `` age; hu13 adapts that to a static TABLE with +/// build-time relative ages (no JS) per the issue. +pub const MSG_TRUNC_CHARS: usize = 72; + +/// Truncate a commit subject to [`MSG_TRUNC_CHARS`] chars (char-wise, +/// never splitting a boundary). Short messages pass through; longer +/// ones are cut to 72 chars plus a trailing `…` so truncation is +/// visible. Callers escape the result with [`esc`] for HTML. +#[must_use] +pub fn truncate_msg(s: &str) -> String { + let n = s.chars().count(); + if n <= MSG_TRUNC_CHARS { + return s.to_string(); + } + let kept: String = s.chars().take(MSG_TRUNC_CHARS).collect(); + format!("{kept}…") +} + +/// Current unix time (seconds) for build-time relative ages. +#[must_use] +pub fn now_unix() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0) +} + +/// Build-time relative age (`3d ago` style, Gitea `relative-time` +/// adapted to static text, no JS). `now_ts`/`then_ts` are unix seconds; +/// future timestamps clamp to `just now`. Thresholds: ≤10s just now, +/// <60s `Ns ago`, <1h `Nm ago`, <1d `Nh ago`, <30d `Nd ago`, +/// <365d `Nmo ago`, else `Ny ago`. +#[must_use] +pub fn relative_age(now_ts: i64, then_ts: i64) -> String { + let mut diff = now_ts.saturating_sub(then_ts); + if diff < 0 { + diff = 0; + } + if diff <= 10 { + return "just now".to_string(); + } + if diff < 60 { + return format!("{diff}s ago"); + } + if diff < 3600 { + return format!("{}m ago", diff / 60); + } + if diff < 86_400 { + return format!("{}h ago", diff / 3600); + } + if diff < 86_400 * 30 { + return format!("{}d ago", diff / 86_400); + } + if diff < 86_400 * 365 { + return format!("{}mo ago", diff / (86_400 * 30)); + } + format!("{}y ago", diff / (86_400 * 365)) +} + +/// Language of a repo-relative path by extension (lowercased). +/// Map (m6 code-gitea, f0rge #33): rs/py/js/ts/go/sh/css/html/md +/// (+markdown)/toml/yaml (+yml)/json/c/h; everything else is `Other`. +/// `Dockerfile`/`Makefile`/extensionless names are `Other` by design. +#[must_use] +pub fn language_of(path: &str) -> &'static str { + let ext = match path.rsplit('.').next() { + Some(e) if path.contains('.') => e.to_lowercase(), + _ => String::new(), + }; + match ext.as_str() { + "rs" => "Rust", + "py" => "Python", + "js" => "JavaScript", + "ts" => "TypeScript", + "go" => "Go", + "sh" => "Shell", + "css" => "CSS", + "html" => "HTML", + "md" | "markdown" => "Markdown", + "toml" => "TOML", + "yaml" | "yml" => "YAML", + "json" => "JSON", + "c" | "h" => "C", + _ => "Other", + } +} + +/// Tokyo Night hue per language for the stacked bar + legend. +#[must_use] +pub fn language_color(lang: &str) -> &'static str { + match lang { + "Rust" => "#f7768e", + "Python" => "#7aa2f7", + "JavaScript" => "#e0af68", + "TypeScript" => "#7dcfff", + "Go" => "#73daca", + "Shell" => "#9ece6a", + "CSS" => "#bb9af7", + "HTML" => "#ff9e64", + "Markdown" => "#9aa5ce", + "TOML" => "#c0caf5", + "YAML" => "#ff007c", + "JSON" => "#89ddff", + "C" => "#1abc9c", + _ => "#565f89", + } +} + +/// One language share for the mix bar. +#[derive(Debug, Clone, PartialEq)] +pub struct LangStat { + /// Display name (`Rust`, …, `Other`). + pub lang: String, + /// Total bytes in this language. + pub bytes: u64, + /// Share of all counted bytes (0–100). + pub pct: f64, +} + +/// Group `(path, bytes)` rows into language shares (bytes share). +/// +/// Total is all rows passed in (unknown extensions count as `Other`). +/// Languages under 1% merge into `Other`; output sorts bytes-desc. +/// Empty input (or zero total) yields an empty vec (caller hides the +/// bar and shows the no-languages note instead). +#[must_use] +pub fn compute_languages(files: &[(String, u64)]) -> Vec { + use std::collections::BTreeMap; + let mut by_lang: BTreeMap = BTreeMap::new(); + for (path, size) in files { + *by_lang.entry(language_of(path).to_string()).or_default() += *size; + } + let total: u64 = by_lang.values().sum(); + if total == 0 { + return Vec::new(); + } + let mut main: Vec<(String, u64)> = Vec::new(); + let mut other_bytes: u64 = by_lang.get("Other").copied().unwrap_or(0); + for (lang, bytes) in &by_lang { + if lang == "Other" { + continue; + } + let pct = *bytes as f64 * 100.0 / total as f64; + if pct < 1.0 { + other_bytes += *bytes; + } else { + main.push((lang.clone(), *bytes)); + } + } + if other_bytes > 0 { + main.push(("Other".to_string(), other_bytes)); + } + let mut out: Vec = main + .into_iter() + .map(|(lang, bytes)| LangStat { + pct: bytes as f64 * 100.0 / total as f64, + lang, + bytes, + }) + .collect(); + out.sort_by_key(|a| std::cmp::Reverse(a.bytes)); + out +} + +/// Render the stacked language bar + legend (pure build-time, no JS). +/// +/// `files` is `(path, bytes)` for the whole tree. Returns a +/// `
` with a Tokyo Night stacked bar and a +/// `%` legend when at least one code file exists (a file whose +/// extension maps to a known language); otherwise a muted +/// `No languages yet.` note and no bar (hidden when no code). +/// Everything is escaped; widths are `pct` with one decimal. +#[must_use] +pub fn language_bar(files: &[(String, u64)]) -> String { + let has_code = files.iter().any(|(p, _)| language_of(p) != "Other"); + if !has_code { + return "

No languages yet.

\n".to_string(); + } + let stats = compute_languages(files); + if stats.is_empty() { + return "

No languages yet.

\n".to_string(); + } + let aria = stats + .iter() + .map(|s| format!("{} {:.1}%", s.lang, s.pct)) + .collect::>() + .join(", "); + let mut out = format!( + "
\n

Languages

\n\ +
\n", + esc(&aria) + ); + for s in &stats { + out.push_str(&format!( + "", + s.pct, + language_color(&s.lang) + )); + } + out.push_str("\n
\n
    \n"); + for s in &stats { + out.push_str(&format!( + "
  • {} {:.1}%
  • \n", + language_color(&s.lang), + esc(&s.lang), + s.pct + )); + } + out.push_str("
\n
\n"); + out +} + +/// Gitea-style file table (m6 code-gitea, f0rge #33). +/// +/// `entries` stay in `gitx::ls_tree` order (dirs-first, each alpha). +/// `parent` is `Some((href, label))` for subdirs (`..` row) or `None` +/// at the root. `history` maps full repo-relative path → +/// `(short_message, age)` already truncated + aged at build; missing +/// entries render empty cells. Names link to `/files/.html` +/// (dirs with a `/` suffix); messages are escaped + truncated to +/// [`MSG_TRUNC_CHARS`]; ages are precomputed `relative_age` strings. +/// Output is a scroll-wrapped TABLE (file-table class) with +/// Name / Last commit / Age headers. +#[must_use] +pub fn file_table_html( + dir: &str, + entries: &[gitx::TreeEntry], + parent: Option<(&str, &str)>, + history: &std::collections::BTreeMap, +) -> String { + let mut out = String::from( + "
\n\ + \n\n", + ); + if let Some((href, label)) = parent { + out.push_str(&format!( + "\ + \n", + esc(href), + esc(label) + )); + } + for e in entries { + let full = if dir.is_empty() { + e.name.clone() + } else { + format!("{dir}/{}", e.name) + }; + let (msg, age) = history.get(&full).cloned().unwrap_or_default(); + let msg_short = truncate_msg(&msg); + if e.is_dir { + out.push_str(&format!( + "\ + \n", + href_esc(&full), + esc(&e.name), + esc(&msg_short), + esc(&age) + )); + } else { + out.push_str(&format!( + "\ + \n", + href_esc(&full), + esc(&e.name), + esc(&msg_short), + esc(&age) + )); + } + } + out.push_str("\n
NameLast commitAge
{}
{}/{}{}
{}{}{}
\n"); + out +} + /// Prominent Clone & download section for the code tab (m6, f0rge -/// issues #20 + #32): stacked no-JS sections with selectable `
`
-/// commands. When a public origin is configured the primary command is
+/// issues #20 + #32 + #34): collapsed native `
` (closed by +/// default, zero JS, keyboard accessible, works file://) with stacked +/// no-JS sections and selectable `
` commands inside. When a public
+/// origin is configured the primary command is
 /// `git clone /repo.bundle` (exact snapshot note + tarball
 /// secondary, origin remote demoted to "mirror source"); otherwise
 /// bundle-first as before (`git clone ` first when a clone URL is
 /// configured, else local bundle) plus the bundle/tarball links with
-/// sizes and shas.
+/// sizes and shas. The `` always shows `Clone & download`
+/// plus a branch@sha hint.
 #[must_use]
 #[allow(clippy::too_many_arguments)]
 pub fn clone_block(
@@ -337,9 +618,11 @@ pub fn clone_block(
             human_size(tarball_size)
         ),
     };
-    let mut out = String::from(
-        "
\n\ -

Clone & download

\n", + let mut out = format!( + "
\n\ + Clone & download {} @ {}\n", + esc(branch), + esc(short_sha) ); if let Some(origin) = public_origin.map(str::trim).filter(|s| !s.is_empty()) { let base = origin.trim_end_matches('/'); @@ -364,7 +647,7 @@ pub fn clone_block( esc(url) )); } - out.push_str("
\n"); + out.push_str("
\n"); return out; } if let Some(url) = clone_url { @@ -395,7 +678,7 @@ pub fn clone_block( served from this server: ask the host for its address.

\n", ); } - out.push_str("
\n"); + out.push_str("\n"); out } @@ -527,7 +810,32 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result = BTreeMap::new(); + { + let mut all_paths: Vec = Vec::new(); + for (d, ents) in &dir_entries { + for e in ents { + let full = if d.is_empty() { + e.name.clone() + } else { + format!("{d}/{}", e.name) + }; + all_paths.push(full); + } + } + for fp in all_paths { + let cell = match gitx::last_commit_for_path(repo, &branch, &fp) { + Ok(Some(pc)) => (pc.subject, relative_age(now_ts, pc.committer_ts)), + _ => (String::new(), String::new()), + }; + history.insert(fp, cell); + } + } for dir in &dirs { if dir.is_empty() { continue; // root is index.html @@ -545,40 +853,16 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Resultdir: {}\n
    \n", - esc(dir), + body.push_str(&format!("

    dir: {}

    \n", esc(dir),)); + body.push_str(&file_table_html( + dir, + &entries, + Some((&parent_href, &parent_label)), + &history, )); - body.push_str(&format!( - "
  • {parent_label}
  • \n" - )); - for e in &entries { - let full = format!("{dir}/{}", e.name); - if e.is_dir { - body.push_str(&format!( - "
  • {}/
  • \n", - href_esc(&full), - esc(&e.name) - )); - } else { - let size = blobs - .get(&full) - .map(|b| human_size(b.len() as u64)) - .unwrap_or_default(); - body.push_str(&format!( - "
  • {} \ - {size} \ - (raw)
  • \n", - href_esc(&full), - esc(&e.name), - href_esc(&full) - )); - } - } - body.push_str("
\n"); let html = crate::layout::page_site(&format!("{dir} — {name}"), &nav, &body, &name, desc_opt); let dest = out.join("files").join(format!("{dir}.html")); @@ -697,29 +981,18 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result\n"); // Single column on mobile; tree+readme sit side by side only at >=768px. - body.push_str("

Files

\n
    \n"); - for e in dir_entries.get("").cloned().unwrap_or_default() { - if e.is_dir { - body.push_str(&format!( - "
  • {}/
  • \n", - href_esc(&e.name), - esc(&e.name) - )); - } else { - let size = blobs - .get(&e.name) - .map(|b| human_size(b.len() as u64)) - .unwrap_or_default(); - body.push_str(&format!( - "
  • {} \ - {size} (raw)
  • \n", - href_esc(&e.name), - esc(&e.name), - href_esc(&e.name) - )); - } - } - body.push_str("
\n
\n

README

\n"); + // Code tab file TABLE (m6 code-gitea, f0rge #33, Gitea-style) plus the + // language mix bar (bytes share, Tokyo Night hues, no JS). + let root_entries = dir_entries.get("").cloned().unwrap_or_default(); + let lang_files: Vec<(String, u64)> = blobs + .iter() + .map(|(k, v)| (k.clone(), v.len() as u64)) + .collect(); + let lang_section = language_bar(&lang_files); + body.push_str("

Files

\n"); + body.push_str(&file_table_html("", &root_entries, None, &history)); + body.push_str(&lang_section); + body.push_str("
\n

README

\n"); match &readme { Some((path, blob)) => { body.push_str(&format!( @@ -1266,16 +1539,280 @@ mod tests { #[test] fn dir_page_has_parent_row_and_sizes() { + // m6/code-gitea (f0rge #33): dir pages are a Gitea-style TABLE + // (Name / Last commit / Age); sizes + raw live on file pages + // (file_meta_bar), not in the table. Breadcrumb stays. let (_repo, out) = build_fixture(); let html = std::fs::read_to_string(out.join("files/sub.html")).expect("dir page"); + assert!( + html.contains("file-table\">"), + "file TABLE, got: {}", + &html[..2000.min(html.len())] + ); assert!( html.contains(".. (root)"), - "parent row to root" + "parent .. row to root" ); assert!(html.contains("nested.txt"), "lists file"); - assert!(html.contains("/raw/sub/nested.txt"), "raw link"); - // "nested\n" is 7 bytes. - assert!(html.contains("7 B"), "file size shown"); + assert!(html.contains("class=\"crumbs\""), "breadcrumb kept"); + assert!(html.contains("Name"), "Name header"); + assert!(html.contains("Last commit"), "message header"); + assert!(html.contains("Age"), "age header"); + // Sizes + raw live on the file page itself (meta bar), not the table. + let txt = std::fs::read_to_string(out.join("files/notes.txt.html")).expect("txt page"); + assert!(txt.contains("24 B"), "file size on file page"); + assert!(txt.contains("/raw/notes.txt"), "raw on file page"); + } + + #[test] + fn file_table_is_gitea_style_dirs_first() { + // f0rge #33 rubric: TABLE (not bullets), dirs-first then files + // each alpha, dirs with `/` suffix, parent `..` row, breadcrumb. + let (_repo, out) = build_fixture(); + let index = std::fs::read_to_string(out.join("index.html")).expect("index"); + assert!(index.contains("file-table\">"), "root TABLE"); + assert!( + !index.contains("
    "), + "no bullets on code tab" + ); + assert!( + index.contains("class=\"crumbs\"") || index.contains("repo-head"), + "page kept" + ); + // Dirs carry a trailing slash link; files do not gain one. + assert!(index.contains(">src/"), "dir with / suffix"); + // Dirs-first: every root dir sorts before every root file. + let dir_pos = index.find(">src/").expect("src pos"); + let file_pos = index.find(">README.md").expect("readme pos"); + assert!(dir_pos < file_pos, "dirs-first in root TABLE"); + // Subdir page: parent row + breadcrumb + own TABLE. + let sub = std::fs::read_to_string(out.join("files/sub.html")).expect("sub"); + assert!(sub.contains("file-table\">"), "subdir TABLE"); + assert!(sub.contains(""), "parent row element"); + assert!( + sub.contains(".. (root)"), + "parent .. link" + ); + assert!( + sub.contains("