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 {
595 flex: none;595 flex: none;
596}596}
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
598/* License summary banner shown above a LICENSE file. */638/* License summary banner shown above a LICENSE file. */
599.license-banner {639.license-banner {
600 margin-bottom: 1rem;640 margin-bottom: 1rem;
crates/git/src/repo.rs +32 −0
@@ -549,6 +549,38 @@ impl Repo {
549 Ok(paths)549 Ok(paths)
550 }550 }
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
552 /// The tree at `path` within a commit, or `None` if the path is absent or is584 /// The tree at `path` within a commit, or `None` if the path is absent or is
553 /// not a directory. An empty `path` yields the root tree.585 /// not a directory. An empty `path` yields the root tree.
554 fn subtree_at<'a>(586 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)]
16pub(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.
29pub(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.
64fn 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.
84fn 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)]
150mod 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;
18mod git_http;18mod git_http;
19mod icons;19mod icons;
20mod issues;20mod issues;
21mod languages;
21mod layout;22mod layout;
22mod license;23mod license;
23mod markdown;24mod markdown;
crates/web/src/repo.rs +50 −0
@@ -455,6 +455,45 @@ fn ref_or_default(repo: &Repo, given: &str) -> String {
455455
456// ---- Repo home ----456// ---- 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".
460fn 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
458async fn repo_home(497async fn repo_home(
459 state: &AppState,498 state: &AppState,
460 viewer: Option<User>,499 viewer: Option<User>,
@@ -480,6 +519,14 @@ async fn repo_home(
480 .await?;519 .await?;
481 let readme = load_readme(state, &ctx.repo.id, &rev, &entries).await;520 let readme = load_readme(state, &ctx.repo.id, &rev, &entries).await;
482 let branches = branch_names(state, &ctx.repo.id).await;521 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();
483 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);530 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
484 html! {531 html! {
485 (repo_header(state, &ctx, Tab::Code, &rev_name, &branches))532 (repo_header(state, &ctx, Tab::Code, &rev_name, &branches))
@@ -489,6 +536,9 @@ async fn repo_home(
489 button class="btn" type="submit" { (icon(Icon::Search)) span { "Search" } }536 button class="btn" type="submit" { (icon(Icon::Search)) span { "Search" } }
490 }537 }
491 (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, "", &entries))538 (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, "", &entries))
539 @if !langs.is_empty() {
540 (language_bar(&langs))
541 }
492 @if let Some(rendered) = readme {542 @if let Some(rendered) = readme {
493 div class="card markdown readme" { (rendered) }543 div class="card markdown readme" { (rendered) }
494 }544 }
crates/web/src/tests.rs +41 −0
@@ -980,6 +980,47 @@ async fn new_repo_can_use_sha256_object_format() {
980}980}
981981
982#[tokio::test]982#[tokio::test]
983async 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]
983async fn license_file_shows_a_rights_banner() {1024async fn license_file_shows_a_rights_banner() {
984 let (state, app, _dir) = app_with_git().await;1025 let (state, app, _dir) = app_with_git().await;
985 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();1026 let ada = state.store.user_by_username("ada").await.unwrap().unwrap();