.forge/prs/d653a940-39a3-48c3-85e8-7c448bdd0e1a/patch.diff 34.1 KiB 851 lines Raw
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 &amp; download</h2>\n",322 +    let mut out = format!(323 +        "<details class=\"clonebox clone-block\" aria-label=\"Clone and download\">\n\324 +         <summary>Clone &amp; 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("&lt;script&gt;"), "escaped message in TABLE");701 +        assert!(!index.contains("evil<script>.txt"), "no raw filename");702 +        assert!(index.contains("evil&lt;script&gt;.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 &amp; 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