Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +40 −0
| @@ -595,6 +595,46 @@ body.drawer-open .drawer-backdrop { | |||
| 595 | flex: none; | 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 | /* 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 | } |
| 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 | /// The tree at `path` within a commit, or `None` if the path is absent or is | 584 | /// 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)] | ||
| 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 | mod git_http; | 18 | mod git_http; |
| 19 | mod icons; | 19 | mod icons; |
| 20 | mod issues; | 20 | mod issues; |
| 21 | mod languages; | ||
| 21 | mod layout; | 22 | mod layout; |
| 22 | mod license; | 23 | mod license; |
| 23 | mod markdown; | 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 | // ---- Repo home ---- | 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 | async fn repo_home( | 497 | async 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 | } |
| 981 | 981 | ||
| 982 | #[tokio::test] | 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 | async fn license_file_shows_a_rights_banner() { | 1024 | async 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(); |