fabrica

hanna/fabrica

feat(web): repository language breakdown bar

52b640c · hanna committed on 2026-07-25

Add a dependency-free language classifier (filename/extension → language + a
Linguist-palette color, ignoring lock files and minified assets) and a git
blob_sizes walk (cheap odb header reads, capped) to weight by bytes. The repo
home shows a proportional language stripe plus a legend of the top languages,
lumping the tail into "Other".

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
6 files changed · +346 −0UnifiedSplit
assets/base.css +40 −0
@@ -595,6 +595,46 @@ body.drawer-open .drawer-backdrop {
595595 flex: none;
596596 }
597597
598+/* Repository language breakdown bar. */
599+.lang-bar-wrap {
600+ margin: 1rem 0;
601+}
602+.lang-bar {
603+ display: flex;
604+ height: 0.6rem;
605+ border-radius: 999px;
606+ overflow: hidden;
607+ background: var(--fb-bg-inset);
608+}
609+.lang-seg {
610+ height: 100%;
611+}
612+.lang-seg:not(:last-child) {
613+ margin-right: 2px;
614+}
615+.lang-legend {
616+ list-style: none;
617+ display: flex;
618+ flex-wrap: wrap;
619+ gap: 0.35rem 1.1rem;
620+ margin-top: 0.75rem;
621+}
622+.lang-legend li {
623+ display: flex;
624+ align-items: center;
625+ gap: 0.4rem;
626+ font-size: 0.9em;
627+}
628+.lang-dot {
629+ width: 0.7rem;
630+ height: 0.7rem;
631+ border-radius: 50%;
632+ flex: none;
633+}
634+.lang-legend .lang-name {
635+ font-weight: 600;
636+}
637+
598638 /* License summary banner shown above a LICENSE file. */
599639 .license-banner {
600640 margin-bottom: 1rem;
crates/git/src/repo.rs +32 −0
@@ -549,6 +549,38 @@ impl Repo {
549549 Ok(paths)
550550 }
551551
552+ /// Every regular-file path in `rev`'s tree paired with its blob size in bytes,
553+ /// for language statistics. Sizes come from the object header (no content is
554+ /// decompressed). Bounded to 20000 files so a pathological tree can't stall a
555+ /// page.
556+ ///
557+ /// # Errors
558+ ///
559+ /// Returns [`GitError`] if the revision or its tree cannot be read.
560+ pub fn blob_sizes(&self, rev: &Resolved) -> Result<Vec<(String, u64)>, GitError> {
561+ const CAP: usize = 20_000;
562+ let commit = self.commit_at(&rev.oid)?;
563+ let tree = commit.tree()?;
564+ let odb = self.inner.odb()?;
565+ let mut out: Vec<(String, u64)> = Vec::new();
566+ tree.walk(git2::TreeWalkMode::PreOrder, |root, entry| {
567+ if out.len() >= CAP {
568+ return git2::TreeWalkResult::Abort;
569+ }
570+ let is_blob = entry.filemode() != i32::from(git2::FileMode::Tree)
571+ && entry.filemode() != i32::from(git2::FileMode::Commit)
572+ && entry.filemode() != i32::from(git2::FileMode::Link);
573+ if is_blob
574+ && let Some(name) = entry.name()
575+ && let Ok((size, _)) = odb.read_header(entry.id())
576+ {
577+ out.push((format!("{root}{name}"), size as u64));
578+ }
579+ git2::TreeWalkResult::Ok
580+ })?;
581+ Ok(out)
582+ }
583+
552584 /// The tree at `path` within a commit, or `None` if the path is absent or is
553585 /// not a directory. An empty `path` yields the root tree.
554586 fn subtree_at<'a>(
crates/web/src/languages.rs +182 −0
@@ -0,0 +1,182 @@
1+// This Source Code Form is subject to the terms of the Mozilla Public
2+// License, v. 2.0. If a copy of the MPL was not distributed with this
3+// file, You can obtain one at https://mozilla.org/MPL/2.0/.
4+
5+//! Repository language breakdown for the language bar.
6+//!
7+//! A pragmatic, dependency-free classifier: files are mapped to a language by
8+//! filename or extension against a curated table (colors follow GitHub Linguist),
9+//! then weighted by blob size. Unrecognized extensions and obvious non-code files
10+//! (lock files, minified assets) are ignored, which keeps the bar focused on the
11+//! languages a project is actually written in — it is an approximation, not
12+//! Linguist.
13+
14+/// One language's share of a repository.
15+#[derive(Debug, Clone, PartialEq)]
16+pub(crate) struct LangStat {
17+ /// Display name, e.g. "Rust".
18+ pub name: &'static str,
19+ /// Hex color (Linguist palette), e.g. "#dea584".
20+ pub color: &'static str,
21+ /// Total bytes attributed to this language.
22+ pub bytes: u64,
23+ /// Percentage of the counted total, 0–100.
24+ pub percent: f64,
25+}
26+
27+/// Compute the language breakdown from `(path, size)` pairs, largest first.
28+/// Returns an empty vec when nothing is recognized.
29+pub(crate) fn breakdown(files: &[(String, u64)]) -> Vec<LangStat> {
30+ use std::collections::HashMap;
31+ let mut totals: HashMap<&'static str, (&'static str, u64)> = HashMap::new();
32+ let mut total: u64 = 0;
33+ for (path, size) in files {
34+ if *size == 0 || is_ignored(path) {
35+ continue;
36+ }
37+ if let Some((name, color)) = classify(path) {
38+ let entry = totals.entry(name).or_insert((color, 0));
39+ entry.1 += *size;
40+ total += *size;
41+ }
42+ }
43+ if total == 0 {
44+ return Vec::new();
45+ }
46+ let mut stats: Vec<LangStat> = totals
47+ .into_iter()
48+ .map(|(name, (color, bytes))| LangStat {
49+ name,
50+ color,
51+ bytes,
52+ #[allow(clippy::cast_precision_loss)]
53+ percent: (bytes as f64) * 100.0 / (total as f64),
54+ })
55+ .collect();
56+ // Largest first; ties broken by name for a stable order.
57+ stats.sort_by(|a, b| b.bytes.cmp(&a.bytes).then_with(|| a.name.cmp(b.name)));
58+ stats
59+}
60+
61+/// Files never counted toward language stats (lock files, minified/vendored
62+/// assets, and generated maps).
63+#[allow(clippy::case_sensitive_file_extension_comparisons)] // `name` is lowercased first.
64+fn is_ignored(path: &str) -> bool {
65+ let name = path.rsplit('/').next().unwrap_or(path).to_ascii_lowercase();
66+ name.ends_with(".min.js")
67+ || name.ends_with(".min.css")
68+ || name.ends_with(".map")
69+ || matches!(
70+ name.as_str(),
71+ "package-lock.json"
72+ | "yarn.lock"
73+ | "pnpm-lock.yaml"
74+ | "cargo.lock"
75+ | "poetry.lock"
76+ | "composer.lock"
77+ | "gemfile.lock"
78+ | "flake.lock"
79+ )
80+}
81+
82+/// Classify a path to `(language, color)` by exact filename first, then by the
83+/// longest matching extension.
84+fn classify(path: &str) -> Option<(&'static str, &'static str)> {
85+ let name = path.rsplit('/').next().unwrap_or(path).to_ascii_lowercase();
86+ // A few languages are keyed by filename rather than extension.
87+ let by_name = match name.as_str() {
88+ "dockerfile" => Some(("Dockerfile", "#384d54")),
89+ "makefile" => Some(("Makefile", "#427819")),
90+ "cmakelists.txt" => Some(("CMake", "#DA3434")),
91+ _ => None,
92+ };
93+ if by_name.is_some() {
94+ return by_name;
95+ }
96+ // Longest-suffix extension match (so `.tar.gz`-style compounds could extend).
97+ let ext = name.rsplit('.').next()?;
98+ // Guard against extension-less files (rsplit returns the whole name).
99+ if !name.contains('.') {
100+ return None;
101+ }
102+ Some(match ext {
103+ "rs" => ("Rust", "#dea584"),
104+ "py" | "pyi" => ("Python", "#3572A5"),
105+ "js" | "cjs" | "mjs" | "jsx" => ("JavaScript", "#f1e05a"),
106+ "ts" | "tsx" => ("TypeScript", "#3178c6"),
107+ "go" => ("Go", "#00ADD8"),
108+ "c" | "h" => ("C", "#555555"),
109+ "cc" | "cpp" | "cxx" | "hpp" | "hh" => ("C++", "#f34b7d"),
110+ "cs" => ("C#", "#178600"),
111+ "java" => ("Java", "#b07219"),
112+ "kt" | "kts" => ("Kotlin", "#A97BFF"),
113+ "swift" => ("Swift", "#F05138"),
114+ "rb" => ("Ruby", "#701516"),
115+ "php" => ("PHP", "#4F5D95"),
116+ "sh" | "bash" | "zsh" => ("Shell", "#89e051"),
117+ "html" | "htm" => ("HTML", "#e34c26"),
118+ "css" => ("CSS", "#563d7c"),
119+ "scss" | "sass" => ("SCSS", "#c6538c"),
120+ "vue" => ("Vue", "#41b883"),
121+ "svelte" => ("Svelte", "#ff3e00"),
122+ "astro" => ("Astro", "#ff5a03"),
123+ "md" | "markdown" => ("Markdown", "#083fa1"),
124+ "mdx" => ("MDX", "#fcb32c"),
125+ "json" => ("JSON", "#292929"),
126+ "yml" | "yaml" => ("YAML", "#cb171e"),
127+ "toml" => ("TOML", "#9c4221"),
128+ "nix" => ("Nix", "#7e7eff"),
129+ "lua" => ("Lua", "#000080"),
130+ "ex" | "exs" => ("Elixir", "#6e4a7e"),
131+ "erl" => ("Erlang", "#B83998"),
132+ "hs" => ("Haskell", "#5e5086"),
133+ "ml" | "mli" => ("OCaml", "#ef7a08"),
134+ "clj" | "cljs" => ("Clojure", "#db5855"),
135+ "scala" => ("Scala", "#c22d40"),
136+ "dart" => ("Dart", "#00B4AB"),
137+ "r" => ("R", "#198CE7"),
138+ "jl" => ("Julia", "#a270ba"),
139+ "zig" => ("Zig", "#ec915c"),
140+ "sql" => ("SQL", "#e38c00"),
141+ "vim" => ("Vim Script", "#199f4b"),
142+ "tf" => ("HCL", "#844FBA"),
143+ "proto" => ("Protocol Buffers", "#3765a3"),
144+ "gradle" => ("Gradle", "#02303a"),
145+ _ => return None,
146+ })
147+}
148+
149+#[cfg(test)]
150+mod tests {
151+ use super::*;
152+
153+ #[test]
154+ fn classifies_by_extension_and_name() {
155+ assert_eq!(classify("src/main.rs").map(|l| l.0), Some("Rust"));
156+ assert_eq!(classify("a/b/Dockerfile").map(|l| l.0), Some("Dockerfile"));
157+ assert_eq!(classify("weird_no_ext"), None);
158+ assert_eq!(classify("x.unknownext"), None);
159+ }
160+
161+ #[test]
162+ fn breakdown_sums_and_ranks() {
163+ let files = vec![
164+ ("a.rs".to_string(), 300),
165+ ("b.rs".to_string(), 100),
166+ ("c.py".to_string(), 100),
167+ ("Cargo.lock".to_string(), 9999), // ignored
168+ ("readme".to_string(), 50), // no extension → ignored
169+ ];
170+ let stats = breakdown(&files);
171+ assert_eq!(stats.len(), 2);
172+ assert_eq!(stats[0].name, "Rust");
173+ assert_eq!(stats[0].bytes, 400);
174+ assert!((stats[0].percent - 80.0).abs() < 0.01);
175+ assert_eq!(stats[1].name, "Python");
176+ }
177+
178+ #[test]
179+ fn empty_when_nothing_recognized() {
180+ assert!(breakdown(&[("data.bin".to_string(), 10)]).is_empty());
181+ }
182+}
crates/web/src/lib.rs +1 −0
@@ -18,6 +18,7 @@ mod error;
1818 mod git_http;
1919 mod icons;
2020 mod issues;
21+mod languages;
2122 mod layout;
2223 mod license;
2324 mod markdown;
crates/web/src/repo.rs +50 −0
@@ -455,6 +455,45 @@ fn ref_or_default(repo: &Repo, given: &str) -> String {
455455
456456 // ---- Repo home ----
457457
458+/// The Linguist-style language bar: a proportional stripe plus a legend. Shows
459+/// the top languages, lumping the remainder into "Other".
460+fn language_bar(langs: &[crate::languages::LangStat]) -> Markup {
461+ const MAX_SHOWN: usize = 8;
462+ // Collapse the long tail into a single grey "Other" segment.
463+ let (head, tail) = langs.split_at(langs.len().min(MAX_SHOWN));
464+ let other: f64 = tail.iter().map(|l| l.percent).sum();
465+ let pct = |p: f64| format!("{p:.1}%");
466+ html! {
467+ div class="card lang-bar-wrap" {
468+ div class="lang-bar" aria-hidden="true" {
469+ @for l in head {
470+ span class="lang-seg"
471+ style=(format!("width:{:.2}%;background:{}", l.percent, l.color)) {}
472+ }
473+ @if other > 0.0 {
474+ span class="lang-seg" style=(format!("width:{other:.2}%;background:#8b8b8b")) {}
475+ }
476+ }
477+ ul class="lang-legend" {
478+ @for l in head {
479+ li {
480+ span class="lang-dot" style=(format!("background:{}", l.color)) {}
481+ span class="lang-name" { (l.name) }
482+ span class="muted" { (pct(l.percent)) }
483+ }
484+ }
485+ @if other > 0.0 {
486+ li {
487+ span class="lang-dot" style="background:#8b8b8b" {}
488+ span class="lang-name" { "Other" }
489+ span class="muted" { (pct(other)) }
490+ }
491+ }
492+ }
493+ }
494+ }
495+}
496+
458497 async fn repo_home(
459498 state: &AppState,
460499 viewer: Option<User>,
@@ -480,6 +519,14 @@ async fn repo_home(
480519 .await?;
481520 let readme = load_readme(state, &ctx.repo.id, &rev, &entries).await;
482521 let branches = branch_names(state, &ctx.repo.id).await;
522+ // The language breakdown of the default branch (best-effort).
523+ let langs = git_read(state, &ctx.repo.id, {
524+ let rev = rev.clone();
525+ move |repo| repo.blob_sizes(&rev)
526+ })
527+ .await
528+ .map(|files| crate::languages::breakdown(&files))
529+ .unwrap_or_default();
483530 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
484531 html! {
485532 (repo_header(state, &ctx, Tab::Code, &rev_name, &branches))
@@ -489,6 +536,9 @@ async fn repo_home(
489536 button class="btn" type="submit" { (icon(Icon::Search)) span { "Search" } }
490537 }
491538 (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, "", &entries))
539+ @if !langs.is_empty() {
540+ (language_bar(&langs))
541+ }
492542 @if let Some(rendered) = readme {
493543 div class="card markdown readme" { (rendered) }
494544 }
crates/web/src/tests.rs +41 −0
@@ -980,6 +980,47 @@ async fn new_repo_can_use_sha256_object_format() {
980980 }
981981
982982 #[tokio::test]
983+async fn repo_home_shows_a_language_bar() {
984+ let (state, app, _dir) = app_with_git().await;
985+ let ada = state.store.user_by_username("ada").await.unwrap().unwrap();
986+ let repo = state
987+ .store
988+ .create_repo(NewRepo {
989+ owner_id: ada.id,
990+ group_id: None,
991+ name: "proj".to_string(),
992+ path: "proj".to_string(),
993+ description: None,
994+ visibility: model::Visibility::Public,
995+ default_branch: "main".to_string(),
996+ })
997+ .await
998+ .unwrap();
999+ let hook = state.config.storage.data_dir.join("fabrica");
1000+ git::create_bare(&state.config.storage.repo_dir, &repo.id, "main", &hook).unwrap();
1001+ let path = git::repo_path(&state.config.storage.repo_dir, &repo.id).unwrap();
1002+ let raw = git2::Repository::open_bare(&path).unwrap();
1003+ seed_commit(
1004+ &raw,
1005+ "main",
1006+ &[],
1007+ &[
1008+ ("main.rs", "fn main() { println!(\"hi\"); }\n"),
1009+ ("util.py", "print('hi')\n"),
1010+ ],
1011+ "init",
1012+ 1,
1013+ );
1014+
1015+ let res = app.oneshot(get("/ada/proj")).await.unwrap();
1016+ assert_eq!(res.status(), StatusCode::OK);
1017+ let html = body_string(res).await;
1018+ assert!(html.contains("lang-bar"), "language bar rendered");
1019+ assert!(html.contains("Rust"), "Rust detected");
1020+ assert!(html.contains("Python"), "Python detected");
1021+}
1022+
1023+#[tokio::test]
9831024 async fn license_file_shows_a_rights_banner() {
9841025 let (state, app, _dir) = app_with_git().await;
9851026 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();