Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +40 −0
| @@ -595,6 +595,46 @@ body.drawer-open .drawer-backdrop { | |||
| 595 | 595 | flex: none; | |
| 596 | 596 | } | |
| 597 | 597 | ||
| 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 | 638 | /* License summary banner shown above a LICENSE file. */ | |
| 599 | 639 | .license-banner { | |
| 600 | 640 | margin-bottom: 1rem; | |
crates/git/src/repo.rs +32 −0
| @@ -549,6 +549,38 @@ impl Repo { | |||
| 549 | 549 | Ok(paths) | |
| 550 | 550 | } | |
| 551 | 551 | ||
| 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 | 584 | /// The tree at `path` within a commit, or `None` if the path is absent or is | |
| 553 | 585 | /// not a directory. An empty `path` yields the root tree. | |
| 554 | 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)] | |
| 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; | |||
| 18 | 18 | mod git_http; | |
| 19 | 19 | mod icons; | |
| 20 | 20 | mod issues; | |
| 21 | + | mod languages; | |
| 21 | 22 | mod layout; | |
| 22 | 23 | mod license; | |
| 23 | 24 | mod markdown; | |
crates/web/src/repo.rs +50 −0
| @@ -455,6 +455,45 @@ fn ref_or_default(repo: &Repo, given: &str) -> String { | |||
| 455 | 455 | ||
| 456 | 456 | // ---- Repo home ---- | |
| 457 | 457 | ||
| 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 | + | ||
| 458 | 497 | async fn repo_home( | |
| 459 | 498 | state: &AppState, | |
| 460 | 499 | viewer: Option<User>, | |
| @@ -480,6 +519,14 @@ async fn repo_home( | |||
| 480 | 519 | .await?; | |
| 481 | 520 | let readme = load_readme(state, &ctx.repo.id, &rev, &entries).await; | |
| 482 | 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 | 530 | let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path); | |
| 484 | 531 | html! { | |
| 485 | 532 | (repo_header(state, &ctx, Tab::Code, &rev_name, &branches)) | |
| @@ -489,6 +536,9 @@ async fn repo_home( | |||
| 489 | 536 | button class="btn" type="submit" { (icon(Icon::Search)) span { "Search" } } | |
| 490 | 537 | } | |
| 491 | 538 | (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, "", &entries)) | |
| 539 | + | @if !langs.is_empty() { | |
| 540 | + | (language_bar(&langs)) | |
| 541 | + | } | |
| 492 | 542 | @if let Some(rendered) = readme { | |
| 493 | 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 | } | |
| 981 | 981 | ||
| 982 | 982 | #[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] | |
| 983 | 1024 | async fn license_file_shows_a_rights_banner() { | |
| 984 | 1025 | let (state, app, _dir) = app_with_git().await; | |
| 985 | 1026 | let ada = state.store.user_by_username("ada").await.unwrap().unwrap(); | |