#31 m6/code-gitea

mergedpassopened
details
branches
master ← m6/code-gitea
updated
id
d653a940-39a3-48c3-85e8-7c448bdd0e1a

Patch

src/build.rs+604 −67
diff --git a/src/build.rs b/src/build.rsindex d158b5c..f461204 100644--- a/src/build.rs+++ b/src/build.rs@@ -295,14 +295,295 @@ pub fn file_hash(path: &Path) -> Option<String> {295295     None296296 }297297 298+/// Max commit-message chars shown in file tables (m6 code-gitea,299+/// f0rge #33, Gitea-style: short message, truncated). Research note:300+/// live Gitea page (`gitea.com/gitea/tea`, fetched 2026-09-10) renders301+/// file rows as name / commit-summary / age divs with dirs-first and a302+/// `<relative-time>` age; hu13 adapts that to a static TABLE with303+/// build-time relative ages (no JS) per the issue.304+pub const MSG_TRUNC_CHARS: usize = 72;305+306+/// Truncate a commit subject to [`MSG_TRUNC_CHARS`] chars (char-wise,307+/// never splitting a boundary). Short messages pass through; longer308+/// ones are cut to 72 chars plus a trailing `…` so truncation is309+/// visible. Callers escape the result with [`esc`] for HTML.310+#[must_use]311+pub fn truncate_msg(s: &str) -> String {312+    let n = s.chars().count();313+    if n <= MSG_TRUNC_CHARS {314+        return s.to_string();315+    }316+    let kept: String = s.chars().take(MSG_TRUNC_CHARS).collect();317+    format!("{kept}…")318+}319+320+/// Current unix time (seconds) for build-time relative ages.321+#[must_use]322+pub fn now_unix() -> i64 {323+    std::time::SystemTime::now()324+        .duration_since(std::time::UNIX_EPOCH)325+        .map(|d| d.as_secs() as i64)326+        .unwrap_or(0)327+}328+329+/// Build-time relative age (`3d ago` style, Gitea `relative-time`330+/// adapted to static text, no JS). `now_ts`/`then_ts` are unix seconds;331+/// future timestamps clamp to `just now`. Thresholds: ≤10s just now,332+/// <60s `Ns ago`, <1h `Nm ago`, <1d `Nh ago`, <30d `Nd ago`,333+/// <365d `Nmo ago`, else `Ny ago`.334+#[must_use]335+pub fn relative_age(now_ts: i64, then_ts: i64) -> String {336+    let mut diff = now_ts.saturating_sub(then_ts);337+    if diff < 0 {338+        diff = 0;339+    }340+    if diff <= 10 {341+        return "just now".to_string();342+    }343+    if diff < 60 {344+        return format!("{diff}s ago");345+    }346+    if diff < 3600 {347+        return format!("{}m ago", diff / 60);348+    }349+    if diff < 86_400 {350+        return format!("{}h ago", diff / 3600);351+    }352+    if diff < 86_400 * 30 {353+        return format!("{}d ago", diff / 86_400);354+    }355+    if diff < 86_400 * 365 {356+        return format!("{}mo ago", diff / (86_400 * 30));357+    }358+    format!("{}y ago", diff / (86_400 * 365))359+}360+361+/// Language of a repo-relative path by extension (lowercased).362+/// Map (m6 code-gitea, f0rge #33): rs/py/js/ts/go/sh/css/html/md363+/// (+markdown)/toml/yaml (+yml)/json/c/h; everything else is `Other`.364+/// `Dockerfile`/`Makefile`/extensionless names are `Other` by design.365+#[must_use]366+pub fn language_of(path: &str) -> &'static str {367+    let ext = match path.rsplit('.').next() {368+        Some(e) if path.contains('.') => e.to_lowercase(),369+        _ => String::new(),370+    };371+    match ext.as_str() {372+        "rs" => "Rust",373+        "py" => "Python",374+        "js" => "JavaScript",375+        "ts" => "TypeScript",376+        "go" => "Go",377+        "sh" => "Shell",378+        "css" => "CSS",379+        "html" => "HTML",380+        "md" | "markdown" => "Markdown",381+        "toml" => "TOML",382+        "yaml" | "yml" => "YAML",383+        "json" => "JSON",384+        "c" | "h" => "C",385+        _ => "Other",386+    }387+}388+389+/// Tokyo Night hue per language for the stacked bar + legend.390+#[must_use]391+pub fn language_color(lang: &str) -> &'static str {392+    match lang {393+        "Rust" => "#f7768e",394+        "Python" => "#7aa2f7",395+        "JavaScript" => "#e0af68",396+        "TypeScript" => "#7dcfff",397+        "Go" => "#73daca",398+        "Shell" => "#9ece6a",399+        "CSS" => "#bb9af7",400+        "HTML" => "#ff9e64",401+        "Markdown" => "#9aa5ce",402+        "TOML" => "#c0caf5",403+        "YAML" => "#ff007c",404+        "JSON" => "#89ddff",405+        "C" => "#1abc9c",406+        _ => "#565f89",407+    }408+}409+410+/// One language share for the mix bar.411+#[derive(Debug, Clone, PartialEq)]412+pub struct LangStat {413+    /// Display name (`Rust`, …, `Other`).414+    pub lang: String,415+    /// Total bytes in this language.416+    pub bytes: u64,417+    /// Share of all counted bytes (0–100).418+    pub pct: f64,419+}420+421+/// Group `(path, bytes)` rows into language shares (bytes share).422+///423+/// Total is all rows passed in (unknown extensions count as `Other`).424+/// Languages under 1% merge into `Other`; output sorts bytes-desc.425+/// Empty input (or zero total) yields an empty vec (caller hides the426+/// bar and shows the no-languages note instead).427+#[must_use]428+pub fn compute_languages(files: &[(String, u64)]) -> Vec<LangStat> {429+    use std::collections::BTreeMap;430+    let mut by_lang: BTreeMap<String, u64> = BTreeMap::new();431+    for (path, size) in files {432+        *by_lang.entry(language_of(path).to_string()).or_default() += *size;433+    }434+    let total: u64 = by_lang.values().sum();435+    if total == 0 {436+        return Vec::new();437+    }438+    let mut main: Vec<(String, u64)> = Vec::new();439+    let mut other_bytes: u64 = by_lang.get("Other").copied().unwrap_or(0);440+    for (lang, bytes) in &by_lang {441+        if lang == "Other" {442+            continue;443+        }444+        let pct = *bytes as f64 * 100.0 / total as f64;445+        if pct < 1.0 {446+            other_bytes += *bytes;447+        } else {448+            main.push((lang.clone(), *bytes));449+        }450+    }451+    if other_bytes > 0 {452+        main.push(("Other".to_string(), other_bytes));453+    }454+    let mut out: Vec<LangStat> = main455+        .into_iter()456+        .map(|(lang, bytes)| LangStat {457+            pct: bytes as f64 * 100.0 / total as f64,458+            lang,459+            bytes,460+        })461+        .collect();462+    out.sort_by_key(|a| std::cmp::Reverse(a.bytes));463+    out464+}465+466+/// Render the stacked language bar + legend (pure build-time, no JS).467+///468+/// `files` is `(path, bytes)` for the whole tree. Returns a469+/// `<section class="langs">` with a Tokyo Night stacked bar and a470+/// `%` legend when at least one code file exists (a file whose471+/// extension maps to a known language); otherwise a muted472+/// `No languages yet.` note and no bar (hidden when no code).473+/// Everything is escaped; widths are `pct` with one decimal.474+#[must_use]475+pub fn language_bar(files: &[(String, u64)]) -> String {476+    let has_code = files.iter().any(|(p, _)| language_of(p) != "Other");477+    if !has_code {478+        return "<p class=\"muted lang-none\">No languages yet.</p>\n".to_string();479+    }480+    let stats = compute_languages(files);481+    if stats.is_empty() {482+        return "<p class=\"muted lang-none\">No languages yet.</p>\n".to_string();483+    }484+    let aria = stats485+        .iter()486+        .map(|s| format!("{} {:.1}%", s.lang, s.pct))487+        .collect::<Vec<_>>()488+        .join(", ");489+    let mut out = format!(490+        "<section class=\"langs\" aria-label=\"Languages\">\n<h2>Languages</h2>\n\491+         <div class=\"lang-bar\" role=\"img\" aria-label=\"{}\">\n",492+        esc(&aria)493+    );494+    for s in &stats {495+        out.push_str(&format!(496+            "<span style=\"width:{:.1}%;background:{}\"></span>",497+            s.pct,498+            language_color(&s.lang)499+        ));500+    }501+    out.push_str("\n</div>\n<ul class=\"lang-legend\">\n");502+    for s in &stats {503+        out.push_str(&format!(504+            "<li><span class=\"lang-dot\" style=\"background:{}\"></span>{} {:.1}%</li>\n",505+            language_color(&s.lang),506+            esc(&s.lang),507+            s.pct508+        ));509+    }510+    out.push_str("</ul>\n</section>\n");511+    out512+}513+514+/// Gitea-style file table (m6 code-gitea, f0rge #33).515+///516+/// `entries` stay in `gitx::ls_tree` order (dirs-first, each alpha).517+/// `parent` is `Some((href, label))` for subdirs (`..` row) or `None`518+/// at the root. `history` maps full repo-relative path →519+/// `(short_message, age)` already truncated + aged at build; missing520+/// entries render empty cells. Names link to `/files/<full>.html`521+/// (dirs with a `/` suffix); messages are escaped + truncated to522+/// [`MSG_TRUNC_CHARS`]; ages are precomputed `relative_age` strings.523+/// Output is a scroll-wrapped TABLE (file-table class) with524+/// Name / Last commit / Age headers.525+#[must_use]526+pub fn file_table_html(527+    dir: &str,528+    entries: &[gitx::TreeEntry],529+    parent: Option<(&str, &str)>,530+    history: &std::collections::BTreeMap<String, (String, String)>,531+) -> String {532+    let mut out = String::from(533+        "<div class=\"table-scroll\"><table class=\"file-table\">\n\534+         <thead><tr><th>Name</th><th>Last commit</th><th>Age</th></tr></thead>\n<tbody>\n",535+    );536+    if let Some((href, label)) = parent {537+        out.push_str(&format!(538+            "<tr class=\"parent\"><td><a href=\"{}\">{}</a></td>\539+             <td></td><td></td></tr>\n",540+            esc(href),541+            esc(label)542+        ));543+    }544+    for e in entries {545+        let full = if dir.is_empty() {546+            e.name.clone()547+        } else {548+            format!("{dir}/{}", e.name)549+        };550+        let (msg, age) = history.get(&full).cloned().unwrap_or_default();551+        let msg_short = truncate_msg(&msg);552+        if e.is_dir {553+            out.push_str(&format!(554+                "<tr class=\"dir\"><td><a href=\"/files/{}.html\">{}/</a></td>\555+                 <td class=\"title\">{}</td><td>{}</td></tr>\n",556+                href_esc(&full),557+                esc(&e.name),558+                esc(&msg_short),559+                esc(&age)560+            ));561+        } else {562+            out.push_str(&format!(563+                "<tr class=\"file\"><td><a href=\"/files/{}.html\">{}</a></td>\564+                 <td class=\"title\">{}</td><td>{}</td></tr>\n",565+                href_esc(&full),566+                esc(&e.name),567+                esc(&msg_short),568+                esc(&age)569+            ));570+        }571+    }572+    out.push_str("</tbody>\n</table></div>\n");573+    out574+}575+298576 /// 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 is577+/// issues #20 + #32 + #34): collapsed native `<details>` (closed by578+/// default, zero JS, keyboard accessible, works file://) with stacked579+/// no-JS sections and selectable `<pre>` commands inside. When a public580+/// origin is configured the primary command is301581 /// `git clone <origin>/repo.bundle` (exact snapshot note + tarball302582 /// secondary, origin remote demoted to "mirror source"); otherwise303583 /// bundle-first as before (`git clone <url>` first when a clone URL is304584 /// configured, else local bundle) plus the bundle/tarball links with305-/// sizes and shas.585+/// sizes and shas. The `<summary>` always shows `Clone & download`586+/// plus a branch@sha hint.306587 #[must_use]307588 #[allow(clippy::too_many_arguments)]308589 pub fn clone_block(@@ -337,9 +618,11 @@ pub fn clone_block(337618             human_size(tarball_size)338619         ),339620     };340-    let mut out = String::from(341-        "<section class=\"clone-block\" aria-label=\"Clone and download\">\n\342-         <h2>Clone &amp; download</h2>\n",621+    let mut out = format!(622+        "<details class=\"clonebox clone-block\" aria-label=\"Clone and download\">\n\623+         <summary>Clone &amp; download <span class=\"muted\">{} @ {}</span></summary>\n",624+        esc(branch),625+        esc(short_sha)343626     );344627     if let Some(origin) = public_origin.map(str::trim).filter(|s| !s.is_empty()) {345628         let base = origin.trim_end_matches('/');@@ -364,7 +647,7 @@ pub fn clone_block(364647                 esc(url)365648             ));366649         }367-        out.push_str("</section>\n");650+        out.push_str("</details>\n");368651         return out;369652     }370653     if let Some(url) = clone_url {@@ -395,7 +678,7 @@ pub fn clone_block(395678              served from this server: ask the host for its address.</p>\n",396679         );397680     }398-    out.push_str("</section>\n");681+    out.push_str("</details>\n");399682     out400683 }401684 @@ -527,7 +810,32 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu527810         blobs.insert(f.clone(), blob.bytes);528811     }529812 530-    // Per-path pages.813+    // Per-path pages (m6 code-gitea, f0rge #33): Gitea-style file814+    // TABLEs with last-commit message + build-time relative age.815+    // History is one `git log -1` per tree path (tiny repos only);816+    // failures degrade to empty cells, never a failed build.817+    let now_ts = now_unix();818+    let mut history: BTreeMap<String, (String, String)> = BTreeMap::new();819+    {820+        let mut all_paths: Vec<String> = Vec::new();821+        for (d, ents) in &dir_entries {822+            for e in ents {823+                let full = if d.is_empty() {824+                    e.name.clone()825+                } else {826+                    format!("{d}/{}", e.name)827+                };828+                all_paths.push(full);829+            }830+        }831+        for fp in all_paths {832+            let cell = match gitx::last_commit_for_path(repo, &branch, &fp) {833+                Ok(Some(pc)) => (pc.subject, relative_age(now_ts, pc.committer_ts)),834+                _ => (String::new(), String::new()),835+            };836+            history.insert(fp, cell);837+        }838+    }531839     for dir in &dirs {532840         if dir.is_empty() {533841             continue; // root is index.html@@ -545,40 +853,16 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu545853         let parent_label = if parent.is_empty() {546854             ".. (root)".to_string()547855         } else {548-            format!(".. ({})", esc(&parent))856+            format!(".. ({parent})")549857         };550858         let mut body = breadcrumb(dir);551-        body.push_str(&format!(552-            "<h1>dir: {}</h1>\n<ul class=\"tree\">\n",553-            esc(dir),859+        body.push_str(&format!("<h1>dir: {}</h1>\n", esc(dir),));860+        body.push_str(&file_table_html(861+            dir,862+            &entries,863+            Some((&parent_href, &parent_label)),864+            &history,554865         ));555-        body.push_str(&format!(556-            "<li class=\"parent\"><a href=\"{parent_href}\">{parent_label}</a></li>\n"557-        ));558-        for e in &entries {559-            let full = format!("{dir}/{}", e.name);560-            if e.is_dir {561-                body.push_str(&format!(562-                    "<li class=\"dir\"><a href=\"/files/{}.html\">{}/</a></li>\n",563-                    href_esc(&full),564-                    esc(&e.name)565-                ));566-            } else {567-                let size = blobs568-                    .get(&full)569-                    .map(|b| human_size(b.len() as u64))570-                    .unwrap_or_default();571-                body.push_str(&format!(572-                    "<li class=\"file\"><a href=\"/files/{}.html\">{}</a> \573-                     <span class=\"file-size\">{size}</span> \574-                     (<a href=\"/raw/{}\">raw</a>)</li>\n",575-                    href_esc(&full),576-                    esc(&e.name),577-                    href_esc(&full)578-                ));579-            }580-        }581-        body.push_str("</ul>\n");582866         let html =583867             crate::layout::page_site(&format!("{dir} — {name}"), &nav, &body, &name, desc_opt);584868         let dest = out.join("files").join(format!("{dir}.html"));@@ -697,29 +981,18 @@ pub fn run_build(repo: &Path, out: &Path, opts: &BuildOptions) -> Result<BuildSu697981 698982     body.push_str("</div>\n");699983     // Single column on mobile; tree+readme sit side by side only at >=768px.700-    body.push_str("<div class=\"cols\"><section><h2>Files</h2>\n<ul class=\"tree\">\n");701-    for e in dir_entries.get("").cloned().unwrap_or_default() {702-        if e.is_dir {703-            body.push_str(&format!(704-                "<li class=\"dir\"><a href=\"/files/{}.html\">{}/</a></li>\n",705-                href_esc(&e.name),706-                esc(&e.name)707-            ));708-        } else {709-            let size = blobs710-                .get(&e.name)711-                .map(|b| human_size(b.len() as u64))712-                .unwrap_or_default();713-            body.push_str(&format!(714-                "<li class=\"file\"><a href=\"/files/{}.html\">{}</a> \715-                 <span class=\"file-size\">{size}</span> (<a href=\"/raw/{}\">raw</a>)</li>\n",716-                href_esc(&e.name),717-                esc(&e.name),718-                href_esc(&e.name)719-            ));720-        }721-    }722-    body.push_str("</ul>\n</section>\n<section><h2>README</h2>\n");984+    // Code tab file TABLE (m6 code-gitea, f0rge #33, Gitea-style) plus the985+    // language mix bar (bytes share, Tokyo Night hues, no JS).986+    let root_entries = dir_entries.get("").cloned().unwrap_or_default();987+    let lang_files: Vec<(String, u64)> = blobs988+        .iter()989+        .map(|(k, v)| (k.clone(), v.len() as u64))990+        .collect();991+    let lang_section = language_bar(&lang_files);992+    body.push_str("<div class=\"cols\"><section><h2>Files</h2>\n");993+    body.push_str(&file_table_html("", &root_entries, None, &history));994+    body.push_str(&lang_section);995+    body.push_str("</section>\n<section><h2>README</h2>\n");723996     match &readme {724997         Some((path, blob)) => {725998             body.push_str(&format!(@@ -1266,16 +1539,280 @@ mod tests {12661539 12671540     #[test]12681541     fn dir_page_has_parent_row_and_sizes() {1542+        // m6/code-gitea (f0rge #33): dir pages are a Gitea-style TABLE1543+        // (Name / Last commit / Age); sizes + raw live on file pages1544+        // (file_meta_bar), not in the table. Breadcrumb stays.12691545         let (_repo, out) = build_fixture();12701546         let html = std::fs::read_to_string(out.join("files/sub.html")).expect("dir page");1547+        assert!(1548+            html.contains("file-table\">"),1549+            "file TABLE, got: {}",1550+            &html[..2000.min(html.len())]1551+        );12711552         assert!(12721553             html.contains("<a href=\"/\">.. (root)</a>"),1273-            "parent row to root"1554+            "parent .. row to root"12741555         );12751556         assert!(html.contains("nested.txt"), "lists file");1276-        assert!(html.contains("/raw/sub/nested.txt"), "raw link");1277-        // "nested\n" is 7 bytes.1278-        assert!(html.contains("7 B"), "file size shown");1557+        assert!(html.contains("class=\"crumbs\""), "breadcrumb kept");1558+        assert!(html.contains("<th>Name</th>"), "Name header");1559+        assert!(html.contains("<th>Last commit</th>"), "message header");1560+        assert!(html.contains("<th>Age</th>"), "age header");1561+        // Sizes + raw live on the file page itself (meta bar), not the table.1562+        let txt = std::fs::read_to_string(out.join("files/notes.txt.html")).expect("txt page");1563+        assert!(txt.contains("24 B"), "file size on file page");1564+        assert!(txt.contains("/raw/notes.txt"), "raw on file page");1565+    }1566+1567+    #[test]1568+    fn file_table_is_gitea_style_dirs_first() {1569+        // f0rge #33 rubric: TABLE (not bullets), dirs-first then files1570+        // each alpha, dirs with `/` suffix, parent `..` row, breadcrumb.1571+        let (_repo, out) = build_fixture();1572+        let index = std::fs::read_to_string(out.join("index.html")).expect("index");1573+        assert!(index.contains("file-table\">"), "root TABLE");1574+        assert!(1575+            !index.contains("<ul class=\"tree\">"),1576+            "no bullets on code tab"1577+        );1578+        assert!(1579+            index.contains("class=\"crumbs\"") || index.contains("repo-head"),1580+            "page kept"1581+        );1582+        // Dirs carry a trailing slash link; files do not gain one.1583+        assert!(index.contains(">src/</a>"), "dir with / suffix");1584+        // Dirs-first: every root dir sorts before every root file.1585+        let dir_pos = index.find(">src/</a>").expect("src pos");1586+        let file_pos = index.find(">README.md</a>").expect("readme pos");1587+        assert!(dir_pos < file_pos, "dirs-first in root TABLE");1588+        // Subdir page: parent row + breadcrumb + own TABLE.1589+        let sub = std::fs::read_to_string(out.join("files/sub.html")).expect("sub");1590+        assert!(sub.contains("file-table\">"), "subdir TABLE");1591+        assert!(sub.contains("<tr class=\"parent\">"), "parent row element");1592+        assert!(1593+            sub.contains("<a href=\"/\">.. (root)</a>"),1594+            "parent .. link"1595+        );1596+        assert!(1597+            sub.contains("<nav class=\"crumbs\""),1598+            "breadcrumb kept on subdir"1599+        );1600+        // Message + age columns exist even when history is empty-ish.1601+        assert!(sub.contains("<td class=\"title\">"), "message cell");1602+    }1603+1604+    #[test]1605+    fn truncate_msg_cuts_at_72_with_ellipsis() {1606+        assert_eq!(truncate_msg("short"), "short");1607+        let exactly = "x".repeat(72);1608+        assert_eq!(truncate_msg(&exactly), exactly, "72 chars untouched");1609+        let long = "y".repeat(100);1610+        let got = truncate_msg(&long);1611+        assert_eq!(got.chars().count(), 73, "72 + ellipsis, got {got:?}");1612+        assert!(got.ends_with("…"), "visible truncation");1613+        // Multi-byte chars count as chars, never split a boundary.1614+        let uni = "é".repeat(100);1615+        let got = truncate_msg(&uni);1616+        assert_eq!(got.chars().count(), 73);1617+        assert!(got.ends_with("…"));1618+    }1619+1620+    #[test]1621+    fn relative_age_fixed_clock() {1622+        // f0rge #33 rubric: fixed-clock fixture, build-time relative ages.1623+        let now = 1_786_000_000_i64;1624+        assert_eq!(relative_age(now, now), "just now");1625+        assert_eq!(relative_age(now, now - 5), "just now");1626+        assert_eq!(relative_age(now, now - 30), "30s ago");1627+        assert_eq!(relative_age(now, now - 5 * 60), "5m ago");1628+        assert_eq!(relative_age(now, now - 3 * 3600), "3h ago");1629+        assert_eq!(relative_age(now, now - 3 * 86_400), "3d ago");1630+        assert_eq!(relative_age(now, now - 10 * 86_400), "10d ago");1631+        assert_eq!(relative_age(now, now - 60 * 86_400), "2mo ago");1632+        assert_eq!(relative_age(now, now - 400 * 86_400), "1y ago");1633+        assert_eq!(relative_age(now, now + 9999), "just now", "future clamps");1634+    }1635+1636+    #[test]1637+    fn language_math_exact_shares_and_tiny_grouping() {1638+        // f0rge #33 rubric: known bytes -> exact %.1639+        let files = vec![("a.rs".to_string(), 100_u64), ("b.py".to_string(), 300_u64)];1640+        let stats = compute_languages(&files);1641+        assert_eq!(stats.len(), 2);1642+        assert_eq!(stats[0].lang, "Python");1643+        assert_eq!(stats[0].bytes, 300);1644+        assert!(1645+            (stats[0].pct - 75.0).abs() < 1e-9,1646+            "py 75%, got {}",1647+            stats[0].pct1648+        );1649+        assert_eq!(stats[1].lang, "Rust");1650+        assert!(1651+            (stats[1].pct - 25.0).abs() < 1e-9,1652+            "rs 25%, got {}",1653+            stats[1].pct1654+        );1655+        // <1% merges into Other.1656+        let mut files = vec![("big.rs".to_string(), 10_000_u64)];1657+        files.push(("tiny.py".to_string(), 50_u64));1658+        let stats = compute_languages(&files);1659+        let langs: Vec<&str> = stats.iter().map(|s| s.lang.as_str()).collect();1660+        assert!(1661+            !langs.contains(&"Python"),1662+            "tiny <1% grouped, got {langs:?}"1663+        );1664+        assert!(langs.contains(&"Other"), "other carries tiny share");1665+        // Extension map covers the issue list; unknown -> Other.1666+        for (path, want) in [1667+            ("x.rs", "Rust"),1668+            ("x.py", "Python"),1669+            ("x.js", "JavaScript"),1670+            ("x.ts", "TypeScript"),1671+            ("x.go", "Go"),1672+            ("x.sh", "Shell"),1673+            ("x.css", "CSS"),1674+            ("x.html", "HTML"),1675+            ("x.md", "Markdown"),1676+            ("x.toml", "TOML"),1677+            ("x.yaml", "YAML"),1678+            ("x.yml", "YAML"),1679+            ("x.json", "JSON"),1680+            ("x.c", "C"),1681+            ("x.h", "C"),1682+            ("x.txt", "Other"),1683+            ("Dockerfile", "Other"),1684+        ] {1685+            assert_eq!(language_of(path), want, "map for {path}");1686+        }1687+    }1688+1689+    #[test]1690+    fn language_bar_hidden_without_code() {1691+        // f0rge #33: bar hidden when no code; note shown instead. No JS.1692+        let txt_only = vec![1693+            ("notes.txt".to_string(), 24_u64),1694+            ("data.bin".to_string(), 10_u64),1695+        ];1696+        let html = language_bar(&txt_only);1697+        assert!(html.contains("No languages yet."), "note when no code");1698+        assert!(!html.contains("lang-bar"), "bar hidden when no code");1699+        assert!(!html.contains("<script"), "no JS in lang block");1700+        // With code: stacked bar + legend with %.1701+        let code = vec![("a.rs".to_string(), 100_u64), ("b.py".to_string(), 100_u64)];1702+        let html = language_bar(&code);1703+        assert!(html.contains("lang-bar"), "bar shown with code");1704+        assert!(html.contains("lang-legend"), "legend shown");1705+        assert!(html.contains("Rust"), "rust legend");1706+        assert!(html.contains("50.0%"), "exact share in legend");1707+        assert!(html.contains("background:#"), "Tokyo Night hues inline");1708+    }1709+1710+    #[test]1711+    fn language_bar_shows_on_index_with_legend() {1712+        // Integration: balanced code repo -> stacked bar + legend.1713+        // (The 10-file fixture is 300KB of .txt, so its tiny .rs/.md1714+        // shares correctly collapse into Other per the <1% rule.)1715+        let dir = mktemp("lang-index");1716+        git(&dir, &["init", "-b", "main"]);1717+        git(&dir, &["config", "user.name", "hu13-test"]);1718+        git(&dir, &["config", "user.email", "[email protected]"]);1719+        git(&dir, &["config", "commit.gpgsign", "false"]);1720+        std::fs::write(dir.join("a.rs"), "fn a() {}\n".repeat(20)).unwrap();1721+        std::fs::write(dir.join("b.py"), "print(1)\n".repeat(20)).unwrap();1722+        std::fs::write(dir.join("README.md"), "# hi\n").unwrap();1723+        git(&dir, &["add", "-A"]);1724+        git(&dir, &["commit", "-m", "lang mix"]);1725+        let out = mktemp("out-lang-index");1726+        run_build(&dir, &out, &opts()).expect("build");1727+        let index = std::fs::read_to_string(out.join("index.html")).expect("index");1728+        assert!(index.contains("lang-bar"), "stacked bar on code tab");1729+        assert!(index.contains("lang-legend"), "legend on code tab");1730+        assert!(index.contains("Rust"), "rust in legend");1731+        assert!(index.contains("Python"), "python in legend");1732+        assert!(index.contains('%'), "legend carries %");1733+        // The big-file fixture still shows a bar (100% Other), never empty.1734+        let (_repo2, out2) = build_fixture();1735+        let index2 = std::fs::read_to_string(out2.join("index.html")).expect("index2");1736+        assert!(index2.contains("lang-bar"), "fixture bar present (Other)");1737+        assert!(index2.contains("Other"), "tiny shares group into Other");1738+    }1739+1740+    #[test]1741+    fn escaping_evil_filename_and_message() {1742+        // f0rge #33 rubric: escaping everywhere (filename + message).1743+        let dir = mktemp("evil-names");1744+        git(&dir, &["init", "-b", "main"]);1745+        git(&dir, &["config", "user.name", "hu13-test"]);1746+        git(&dir, &["config", "user.email", "[email protected]"]);1747+        git(&dir, &["config", "commit.gpgsign", "false"]);1748+        std::fs::write(dir.join("README.md"), "# hi\n").unwrap();1749+        std::fs::write(dir.join("evil<script>.txt"), "evil file\n").unwrap();1750+        std::fs::write(dir.join("ok.rs"), "fn main() {}\n").unwrap();1751+        git(&dir, &["add", "-A"]);1752+        // Evil + over-long message (also exercises 72-char truncation).1753+        let evil_msg = "<script>alert(1)</script> ".to_string() + &"A".repeat(100);1754+        git(&dir, &["commit", "-m", &evil_msg]);1755+        let out = mktemp("out-evil");1756+        run_build(&dir, &out, &opts()).expect("build");1757+        let index = std::fs::read_to_string(out.join("index.html")).expect("index");1758+        assert!(1759+            !index.contains("<script>alert(1)</script>"),1760+            "no raw script in TABLE"1761+        );1762+        assert!(index.contains("&lt;script&gt;"), "escaped message in TABLE");1763+        assert!(!index.contains("evil<script>.txt"), "no raw filename");1764+        assert!(index.contains("evil&lt;script&gt;.txt"), "escaped filename");1765+        assert!(index.contains("…"), "long message truncated in TABLE");1766+        // File page for the evil name is escaped too.1767+        let evil_page = std::fs::read_to_string(out.join("files/evil<script>.txt.html"));1768+        assert!(1769+            evil_page.is_ok() || out.join("files").exists(),1770+            "evil file page emitted"1771+        );1772+        if let Ok(html) = evil_page {1773+            assert!(1774+                !html.contains("<script>alert(1)</script>"),1775+                "no raw on file page"1776+            );1777+        }1778+    }1779+1780+    #[test]1781+    fn clone_details_collapsed_with_hint_and_contents() {1782+        // f0rge #34 rubric: native <details>, closed by default, all1783+        // three methods + sizes inside, branch/sha hint in summary.1784+        let repo = fixture_repo();1785+        let out = mktemp("out-details");1786+        let mut o = opts();1787+        o.clone_url_flag = Some("https://example.com/x.git".to_string());1788+        run_build(&repo, &out, &o).expect("build");1789+        let index = std::fs::read_to_string(out.join("index.html")).expect("index");1790+        assert!(index.contains("<details"), "details present");1791+        assert!(index.contains("class=\"clonebox"), "clonebox class");1792+        assert!(index.contains("clone-block"), "clone-block class kept");1793+        assert!(1794+            index.contains("<summary>Clone &amp; download"),1795+            "summary label"1796+        );1797+        assert!(index.contains("main"), "branch hint in summary");1798+        // Closed by default: no `open` attr on the clone details.1799+        let d0 = index1800+            .find("<details class=\"clonebox")1801+            .expect("details pos");1802+        let d1 = index[d0..].find('>').expect("details end") + d0;1803+        let tag = &index[d0..d1];1804+        assert!(!tag.contains("open"), "closed by default, got {tag:?}");1805+        // Contents intact: all three methods + sizes.1806+        assert!(1807+            index.contains("git clone https://example.com/x.git"),1808+            "url method"1809+        );1810+        assert!(index.contains("git clone repo.bundle"), "bundle method");1811+        assert!(index.contains("tar -xzf repo.tar.gz"), "tarball method");1812+        assert!(index.contains("repo.bundle"), "bundle size/link");1813+        assert!(index.contains("repo.tar.gz"), "tarball size/link");1814+        // Keyboard accessible native element, zero JS.1815+        assert!(!index.contains("<script"), "zero JS for clone block");12791816     }12801817 12811818     #[test]
src/gitx.rs+59 −0
diff --git a/src/gitx.rs b/src/gitx.rsindex a4e11eb..76462fe 100644--- a/src/gitx.rs+++ b/src/gitx.rs@@ -303,6 +303,65 @@ pub fn rev_parse_short(repo: &Path, rev: &str) -> Result<String, GitError> {303303     Ok(s)304304 }305305 306+/// One path's last-commit summary for file tables (m6 code-gitea,307+/// f0rge #33): subject line + committer timestamp (unix, `%ct`).308+#[derive(Debug, Clone, PartialEq, Eq)]309+pub struct PathCommit {310+    /// First line of the last commit touching the path (`%s`).311+    pub subject: String,312+    /// Committer date as unix seconds (`%ct`).313+    pub committer_ts: i64,314+}315+316+/// Last commit touching `path` on `branch` (`git log -1`).317+///318+/// Returns `None` when the path has no history (empty output). `path`319+/// is repo-relative (`a/b.txt` or `a/b` for dirs); empty means the320+/// branch head itself is not queried here (callers skip the root).321+/// Subject is a single `%s` line (no newlines); callers must still322+/// escape it for HTML and truncate it for display.323+pub fn last_commit_for_path(324+    repo: &Path,325+    branch: &str,326+    path: &str,327+) -> Result<Option<PathCommit>, GitError> {328+    let a_log: &OsStr = OsStr::new("log");329+    let a_one: &OsStr = OsStr::new("-1");330+    let a_fmt: &OsStr = OsStr::new("--format=%s\x1f%ct");331+    let os_branch = OsString::from(branch);332+    let os_sep: &OsStr = OsStr::new("--");333+    let os_path = OsString::from(path);334+    let args: Vec<&OsStr> = vec![335+        a_log,336+        a_one,337+        a_fmt,338+        os_branch.as_os_str(),339+        os_sep,340+        os_path.as_os_str(),341+    ];342+    let out = run_git(repo, &args)?;343+    if !out.status_ok {344+        let stderr = String::from_utf8_lossy(&out.stderr).trim().to_string();345+        return Err(GitError::Failed {346+            cmd: cmd_string(repo, &args),347+            status: out.status_str,348+            stderr,349+        });350+    }351+    let text = String::from_utf8_lossy(&out.stdout).trim().to_string();352+    if text.is_empty() {353+        return Ok(None);354+    }355+    let first_line = text.lines().next().unwrap_or("");356+    let mut parts = first_line.splitn(2, '\x1f');357+    let subject = parts.next().unwrap_or("").to_string();358+    let ts: i64 = parts.next().unwrap_or("0").trim().parse().unwrap_or(0);359+    Ok(Some(PathCommit {360+        subject,361+        committer_ts: ts,362+    }))363+}364+306365 /// Newest-first log for `branch`, up to `n` commits.307366 ///308367 /// Returns `(sha, subject, author, date)` rows as [`Commit`].
static/style.css+14 −1
diff --git a/static/style.css b/static/style.cssindex 5234011..a09ecf2 100644--- a/static/style.css+++ b/static/style.css@@ -169,3 +169,17 @@ a.ln:hover,a.ln:focus{text-decoration:underline}169169 .page-head{display:flex;flex-wrap:wrap;align-items:center;gap:.5rem 1rem}170170 .page-head h1{margin:0}171171 .page-head .btn-new{flex:none}172+173+/* Code-gitea (m6, f0rge #33 + #34): file TABLE, language bar, clone details. */174+.file-table th:nth-child(3),.file-table td:nth-child(3){white-space:nowrap}175+.file-table td.title{white-space:normal;overflow-wrap:anywhere;min-width:12rem}176+.langs{margin:1rem 0}177+.lang-bar{display:flex;height:.5rem;overflow:hidden;background:var(--bg-th);border:1px solid var(--border)}178+.lang-bar span{display:block;height:100%}179+.lang-legend{list-style:none;margin:.5rem 0;padding:0;display:flex;flex-wrap:wrap;gap:.25rem 1rem}180+.lang-legend li{display:inline-flex;align-items:center;gap:.35rem}181+.lang-dot{display:inline-block;width:.75rem;height:.75rem;flex:none}182+details.clonebox{margin:1rem 0;padding:.25rem 1rem 1rem;background:var(--bg-alt);border:1px solid var(--border)}183+details.clonebox>summary{cursor:pointer;min-height:40px;font-weight:700;color:var(--cyan)}184+details.clonebox>summary:focus-visible{outline:2px solid var(--blue);outline-offset:2px}185+.clone-hint,.clonebox summary .muted{font-weight:400}172-- 2.55.0

Comments (0)

No comments yet.

Reviews (0)

No reviews yet.

all pulls