#31 m6/code-gitea
Patch
src/build.rs+604 −67--- 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 & download</h2>\n",621+ let mut out = format!(622+ "<details class=\"clonebox clone-block\" aria-label=\"Clone and download\">\n\623+ <summary>Clone & 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("<script>"), "escaped message in TABLE");1763+ assert!(!index.contains("evil<script>.txt"), "no raw filename");1764+ assert!(index.contains("evil<script>.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 & 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--- 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--- 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--
Comments (0)
No comments yet.
Reviews (0)
No reviews yet.