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!(
+ "\nLanguages \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");
+ 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(""), "message cell");
+ }
+
+ #[test]
+ fn truncate_msg_cuts_at_72_with_ellipsis() {
+ assert_eq!(truncate_msg("short"), "short");
+ let exactly = "x".repeat(72);
+ assert_eq!(truncate_msg(&exactly), exactly, "72 chars untouched");
+ let long = "y".repeat(100);
+ let got = truncate_msg(&long);
+ assert_eq!(got.chars().count(), 73, "72 + ellipsis, got {got:?}");
+ assert!(got.ends_with("…"), "visible truncation");
+ // Multi-byte chars count as chars, never split a boundary.
+ let uni = "é".repeat(100);
+ let got = truncate_msg(&uni);
+ assert_eq!(got.chars().count(), 73);
+ assert!(got.ends_with("…"));
+ }
+
+ #[test]
+ fn relative_age_fixed_clock() {
+ // f0rge #33 rubric: fixed-clock fixture, build-time relative ages.
+ let now = 1_786_000_000_i64;
+ assert_eq!(relative_age(now, now), "just now");
+ assert_eq!(relative_age(now, now - 5), "just now");
+ assert_eq!(relative_age(now, now - 30), "30s ago");
+ assert_eq!(relative_age(now, now - 5 * 60), "5m ago");
+ assert_eq!(relative_age(now, now - 3 * 3600), "3h ago");
+ assert_eq!(relative_age(now, now - 3 * 86_400), "3d ago");
+ assert_eq!(relative_age(now, now - 10 * 86_400), "10d ago");
+ assert_eq!(relative_age(now, now - 60 * 86_400), "2mo ago");
+ assert_eq!(relative_age(now, now - 400 * 86_400), "1y ago");
+ assert_eq!(relative_age(now, now + 9999), "just now", "future clamps");
+ }
+
+ #[test]
+ fn language_math_exact_shares_and_tiny_grouping() {
+ // f0rge #33 rubric: known bytes -> exact %.
+ let files = vec![("a.rs".to_string(), 100_u64), ("b.py".to_string(), 300_u64)];
+ let stats = compute_languages(&files);
+ assert_eq!(stats.len(), 2);
+ assert_eq!(stats[0].lang, "Python");
+ assert_eq!(stats[0].bytes, 300);
+ assert!(
+ (stats[0].pct - 75.0).abs() < 1e-9,
+ "py 75%, got {}",
+ stats[0].pct
+ );
+ assert_eq!(stats[1].lang, "Rust");
+ assert!(
+ (stats[1].pct - 25.0).abs() < 1e-9,
+ "rs 25%, got {}",
+ stats[1].pct
+ );
+ // <1% merges into Other.
+ let mut files = vec![("big.rs".to_string(), 10_000_u64)];
+ files.push(("tiny.py".to_string(), 50_u64));
+ let stats = compute_languages(&files);
+ let langs: Vec<&str> = stats.iter().map(|s| s.lang.as_str()).collect();
+ assert!(
+ !langs.contains(&"Python"),
+ "tiny <1% grouped, got {langs:?}"
+ );
+ assert!(langs.contains(&"Other"), "other carries tiny share");
+ // Extension map covers the issue list; unknown -> Other.
+ for (path, want) in [
+ ("x.rs", "Rust"),
+ ("x.py", "Python"),
+ ("x.js", "JavaScript"),
+ ("x.ts", "TypeScript"),
+ ("x.go", "Go"),
+ ("x.sh", "Shell"),
+ ("x.css", "CSS"),
+ ("x.html", "HTML"),
+ ("x.md", "Markdown"),
+ ("x.toml", "TOML"),
+ ("x.yaml", "YAML"),
+ ("x.yml", "YAML"),
+ ("x.json", "JSON"),
+ ("x.c", "C"),
+ ("x.h", "C"),
+ ("x.txt", "Other"),
+ ("Dockerfile", "Other"),
+ ] {
+ assert_eq!(language_of(path), want, "map for {path}");
+ }
+ }
+
+ #[test]
+ fn language_bar_hidden_without_code() {
+ // f0rge #33: bar hidden when no code; note shown instead. No JS.
+ let txt_only = vec![
+ ("notes.txt".to_string(), 24_u64),
+ ("data.bin".to_string(), 10_u64),
+ ];
+ let html = language_bar(&txt_only);
+ assert!(html.contains("No languages yet."), "note when no code");
+ assert!(!html.contains("lang-bar"), "bar hidden when no code");
+ assert!(!html.contains(" ".to_string() + &"A".repeat(100);
+ git(&dir, &["commit", "-m", &evil_msg]);
+ let out = mktemp("out-evil");
+ run_build(&dir, &out, &opts()).expect("build");
+ let index = std::fs::read_to_string(out.join("index.html")).expect("index");
+ assert!(
+ !index.contains(""),
+ "no raw script in TABLE"
+ );
+ assert!(index.contains("<script>"), "escaped message in TABLE");
+ assert!(!index.contains("evil"),
+ "no raw on file page"
+ );
+ }
+ }
+
+ #[test]
+ fn clone_details_collapsed_with_hint_and_contents() {
+ // f0rge #34 rubric: native , closed by default, all
+ // three methods + sizes inside, branch/sha hint in summary.
+ let repo = fixture_repo();
+ let out = mktemp("out-details");
+ let mut o = opts();
+ o.clone_url_flag = Some("https://example.com/x.git".to_string());
+ run_build(&repo, &out, &o).expect("build");
+ let index = std::fs::read_to_string(out.join("index.html")).expect("index");
+ assert!(index.contains("Clone & download"),
+ "summary label"
+ );
+ assert!(index.contains("main"), "branch hint in summary");
+ // Closed by default: no `open` attr on the clone details.
+ let d0 = index
+ .find("').expect("details end") + d0;
+ let tag = &index[d0..d1];
+ assert!(!tag.contains("open"), "closed by default, got {tag:?}");
+ // Contents intact: all three methods + sizes.
+ assert!(
+ index.contains("git clone https://example.com/x.git"),
+ "url method"
+ );
+ assert!(index.contains("git clone repo.bundle"), "bundle method");
+ assert!(index.contains("tar -xzf repo.tar.gz"), "tarball method");
+ assert!(index.contains("repo.bundle"), "bundle size/link");
+ assert!(index.contains("repo.tar.gz"), "tarball size/link");
+ // Keyboard accessible native element, zero JS.
+ assert!(!index.contains("