Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +19 −0
| @@ -2201,6 +2201,25 @@ pre.code { | |||
| 2201 | 2201 | text-overflow: ellipsis; | |
| 2202 | 2202 | white-space: nowrap; | |
| 2203 | 2203 | } | |
| 2204 | + | /* A small pill to the right of a tree entry (LFS-tracked file, submodule). */ | |
| 2205 | + | .tree-pill { | |
| 2206 | + | margin-left: auto; | |
| 2207 | + | flex: none; | |
| 2208 | + | padding: 0.02rem 0.4rem; | |
| 2209 | + | font-size: 0.72em; | |
| 2210 | + | font-weight: 600; | |
| 2211 | + | letter-spacing: 0.03em; | |
| 2212 | + | color: var(--fb-fg-muted); | |
| 2213 | + | border: 1px solid var(--fb-border); | |
| 2214 | + | border-radius: 999px; | |
| 2215 | + | } | |
| 2216 | + | .lfs-pill { | |
| 2217 | + | color: var(--fb-accent); | |
| 2218 | + | border-color: var(--fb-accent); | |
| 2219 | + | } | |
| 2220 | + | .file-row.is-submodule .file-icon { | |
| 2221 | + | color: var(--fb-accent); | |
| 2222 | + | } | |
| 2204 | 2223 | ||
| 2205 | 2224 | .blob-header { | |
| 2206 | 2225 | display: flex; | |
crates/git/src/lib.rs +1 −1
| @@ -50,7 +50,7 @@ pub use crate::signature::{SignatureCache, SignatureState, SigningKey, verify_ch | |||
| 50 | 50 | pub use crate::types::{ | |
| 51 | 51 | Blob, BranchInfo, CommitDetail, CommitSummary, DeltaStatus, DiffLine, DiffOpts, EntryKind, | |
| 52 | 52 | FileDiff, FileDiffs, Hunk, LineOrigin, Oid, Page, Person, RefKind, Resolved, TagInfo, | |
| 53 | - | TreeEntry, | |
| 53 | + | TreeAnnotations, TreeEntry, | |
| 54 | 54 | }; | |
| 55 | 55 | ||
| 56 | 56 | /// An error from the git layer. | |
crates/git/src/repo.rs +110 −1
| @@ -20,7 +20,7 @@ use crate::GitError; | |||
| 20 | 20 | use crate::types::{ | |
| 21 | 21 | Blob, BranchInfo, CommitDetail, CommitSummary, DeltaStatus, DiffLine, DiffOpts, EntryKind, | |
| 22 | 22 | FileDiff, FileDiffs, Hunk, LineOrigin, Oid, Page, Person, RefKind, Resolved, TagInfo, | |
| 23 | - | TreeEntry, | |
| 23 | + | TreeAnnotations, TreeEntry, | |
| 24 | 24 | }; | |
| 25 | 25 | ||
| 26 | 26 | /// Filemode bits git uses to distinguish tree-entry kinds. | |
| @@ -144,6 +144,58 @@ impl Repo { | |||
| 144 | 144 | Ok(entries) | |
| 145 | 145 | } | |
| 146 | 146 | ||
| 147 | + | /// Annotate the directory `path` at `rev`: which file entries are git-LFS | |
| 148 | + | /// pointers (small blobs whose content is a pointer), and the whole-repo | |
| 149 | + | /// submodule→URL map from `.gitmodules`. Best-effort — a missing or malformed | |
| 150 | + | /// `.gitmodules`, or an unreadable blob, simply yields fewer annotations. | |
| 151 | + | /// | |
| 152 | + | /// # Errors | |
| 153 | + | /// | |
| 154 | + | /// Returns [`GitError`] only if the revision's commit or root tree is missing. | |
| 155 | + | pub fn tree_annotations( | |
| 156 | + | &self, | |
| 157 | + | rev: &Resolved, | |
| 158 | + | path: &Path, | |
| 159 | + | ) -> Result<TreeAnnotations, GitError> { | |
| 160 | + | /// Pointer files are tiny; skip reading anything larger. | |
| 161 | + | const POINTER_MAX: usize = 1024; | |
| 162 | + | ||
| 163 | + | let commit = self.commit_at(&rev.oid)?; | |
| 164 | + | let root = commit.tree()?; | |
| 165 | + | ||
| 166 | + | let submodules = root | |
| 167 | + | .get_path(Path::new(".gitmodules")) | |
| 168 | + | .ok() | |
| 169 | + | .and_then(|e| e.to_object(&self.inner).ok()) | |
| 170 | + | .and_then(|o| o.into_blob().ok()) | |
| 171 | + | .map(|b| parse_gitmodules(b.content())) | |
| 172 | + | .unwrap_or_default(); | |
| 173 | + | ||
| 174 | + | let tree = if path.components().next().is_none() { | |
| 175 | + | Some(root) | |
| 176 | + | } else { | |
| 177 | + | root.get_path(path) | |
| 178 | + | .ok() | |
| 179 | + | .and_then(|e| e.to_object(&self.inner).ok()) | |
| 180 | + | .and_then(|o| o.into_tree().ok()) | |
| 181 | + | }; | |
| 182 | + | let mut lfs = std::collections::HashSet::new(); | |
| 183 | + | if let Some(tree) = tree { | |
| 184 | + | for entry in &tree { | |
| 185 | + | if entry.kind() == Some(git2::ObjectType::Blob) | |
| 186 | + | && let Ok(obj) = entry.to_object(&self.inner) | |
| 187 | + | && let Some(blob) = obj.as_blob() | |
| 188 | + | && blob.size() < POINTER_MAX | |
| 189 | + | && is_lfs_pointer(blob.content()) | |
| 190 | + | && let Some(name) = entry.name() | |
| 191 | + | { | |
| 192 | + | lfs.insert(name.to_string()); | |
| 193 | + | } | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | Ok(TreeAnnotations { lfs, submodules }) | |
| 197 | + | } | |
| 198 | + | ||
| 147 | 199 | /// Read the file at `path` within revision `rev`. | |
| 148 | 200 | /// | |
| 149 | 201 | /// # Errors | |
| @@ -773,6 +825,40 @@ fn looks_binary(bytes: &[u8]) -> bool { | |||
| 773 | 825 | window.contains(&0) | |
| 774 | 826 | } | |
| 775 | 827 | ||
| 828 | + | /// Whether a blob's content is a git-LFS pointer (its mandatory first line). | |
| 829 | + | fn is_lfs_pointer(content: &[u8]) -> bool { | |
| 830 | + | content.starts_with(b"version https://git-lfs.github.com/spec/v1") | |
| 831 | + | } | |
| 832 | + | ||
| 833 | + | /// Parse a `.gitmodules` file into a `submodule path → url` map. The format is | |
| 834 | + | /// git-config: `[submodule "name"]` sections each with `path` and `url` keys. | |
| 835 | + | fn parse_gitmodules(content: &[u8]) -> HashMap<String, String> { | |
| 836 | + | let text = String::from_utf8_lossy(content); | |
| 837 | + | let mut map = HashMap::new(); | |
| 838 | + | let mut path: Option<String> = None; | |
| 839 | + | let mut url: Option<String> = None; | |
| 840 | + | for line in text.lines() { | |
| 841 | + | let line = line.trim(); | |
| 842 | + | if line.starts_with('[') { | |
| 843 | + | if let (Some(p), Some(u)) = (path.take(), url.take()) { | |
| 844 | + | map.insert(p, u); | |
| 845 | + | } | |
| 846 | + | } else if let Some(rest) = line.strip_prefix("path") { | |
| 847 | + | if let Some(v) = rest.trim_start().strip_prefix('=') { | |
| 848 | + | path = Some(v.trim().to_string()); | |
| 849 | + | } | |
| 850 | + | } else if let Some(rest) = line.strip_prefix("url") { | |
| 851 | + | if let Some(v) = rest.trim_start().strip_prefix('=') { | |
| 852 | + | url = Some(v.trim().to_string()); | |
| 853 | + | } | |
| 854 | + | } | |
| 855 | + | } | |
| 856 | + | if let (Some(p), Some(u)) = (path, url) { | |
| 857 | + | map.insert(p, u); | |
| 858 | + | } | |
| 859 | + | map | |
| 860 | + | } | |
| 861 | + | ||
| 776 | 862 | #[cfg(test)] | |
| 777 | 863 | mod tests { | |
| 778 | 864 | #![allow(clippy::unwrap_used, clippy::expect_used)] | |
| @@ -785,6 +871,29 @@ mod tests { | |||
| 785 | 871 | use super::*; | |
| 786 | 872 | use crate::{create_bare, repo_path}; | |
| 787 | 873 | ||
| 874 | + | #[test] | |
| 875 | + | fn detects_lfs_pointer_content() { | |
| 876 | + | let pointer = b"version https://git-lfs.github.com/spec/v1\noid sha256:abc\nsize 12\n"; | |
| 877 | + | assert!(is_lfs_pointer(pointer)); | |
| 878 | + | assert!(!is_lfs_pointer(b"#!/bin/sh\necho hi\n")); | |
| 879 | + | assert!(!is_lfs_pointer(b"")); | |
| 880 | + | } | |
| 881 | + | ||
| 882 | + | #[test] | |
| 883 | + | fn parses_gitmodules_path_and_url() { | |
| 884 | + | let text = b"[submodule \"vendor/lib\"]\n\tpath = vendor/lib\n\turl = https://example.com/lib.git\n\ | |
| 885 | + | [submodule \"x\"]\n\tpath = tools/x\n\turl = git@host:org/x.git\n"; | |
| 886 | + | let map = parse_gitmodules(text); | |
| 887 | + | assert_eq!( | |
| 888 | + | map.get("vendor/lib").map(String::as_str), | |
| 889 | + | Some("https://example.com/lib.git") | |
| 890 | + | ); | |
| 891 | + | assert_eq!( | |
| 892 | + | map.get("tools/x").map(String::as_str), | |
| 893 | + | Some("git@host:org/x.git") | |
| 894 | + | ); | |
| 895 | + | } | |
| 896 | + | ||
| 788 | 897 | const ID: &str = "01hzxk9m2n8p7q6r5s4t3v2w1x"; | |
| 789 | 898 | ||
| 790 | 899 | /// A populated bare repository plus the temp dir keeping it alive. | |
crates/git/src/types.rs +10 −0
| @@ -83,6 +83,16 @@ pub enum EntryKind { | |||
| 83 | 83 | Submodule, | |
| 84 | 84 | } | |
| 85 | 85 | ||
| 86 | + | /// Extra per-directory facts for the tree view that need reading blobs: which | |
| 87 | + | /// file entries are git-LFS pointers, and each submodule's remote URL. | |
| 88 | + | #[derive(Debug, Clone, Default, PartialEq, Eq)] | |
| 89 | + | pub struct TreeAnnotations { | |
| 90 | + | /// Entry names in the listed directory whose blob is a git-LFS pointer. | |
| 91 | + | pub lfs: std::collections::HashSet<String>, | |
| 92 | + | /// Submodule repo-path → remote URL, parsed from `.gitmodules`. | |
| 93 | + | pub submodules: std::collections::HashMap<String, String>, | |
| 94 | + | } | |
| 95 | + | ||
| 86 | 96 | /// One entry in a tree listing. | |
| 87 | 97 | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 88 | 98 | pub struct TreeEntry { | |
crates/web/src/repo.rs +60 −7
| @@ -546,6 +546,12 @@ async fn repo_home( | |||
| 546 | 546 | }) | |
| 547 | 547 | .await?; | |
| 548 | 548 | let readme = load_readme(state, &ctx.repo.id, &rev, &entries).await; | |
| 549 | + | let annotations = git_read(state, &ctx.repo.id, { | |
| 550 | + | let rev = rev.clone(); | |
| 551 | + | move |repo| repo.tree_annotations(&rev, FsPath::new("")) | |
| 552 | + | }) | |
| 553 | + | .await | |
| 554 | + | .unwrap_or_default(); | |
| 549 | 555 | let branches = branch_names(state, &ctx.repo.id).await; | |
| 550 | 556 | // The language breakdown of the default branch (best-effort). | |
| 551 | 557 | let langs = git_read(state, &ctx.repo.id, { | |
| @@ -566,7 +572,7 @@ async fn repo_home( | |||
| 566 | 572 | @if !langs.is_empty() { | |
| 567 | 573 | (language_bar(&langs)) | |
| 568 | 574 | } | |
| 569 | - | (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, "", &entries)) | |
| 575 | + | (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, "", &entries, &annotations)) | |
| 570 | 576 | @if let Some(rendered) = readme { | |
| 571 | 577 | div class="card markdown readme" { (rendered) } | |
| 572 | 578 | } | |
| @@ -643,12 +649,19 @@ async fn tree_view( | |||
| 643 | 649 | move |repo| repo.tree_entries(&rev, FsPath::new(&path)) | |
| 644 | 650 | }) | |
| 645 | 651 | .await?; | |
| 652 | + | let annotations = git_read(state, &ctx.repo.id, { | |
| 653 | + | let rev = rev.clone(); | |
| 654 | + | let path = path.clone(); | |
| 655 | + | move |repo| repo.tree_annotations(&rev, FsPath::new(&path)) | |
| 656 | + | }) | |
| 657 | + | .await | |
| 658 | + | .unwrap_or_default(); | |
| 646 | 659 | ||
| 647 | 660 | let branches = branch_names(state, &ctx.repo.id).await; | |
| 648 | 661 | let body = html! { | |
| 649 | 662 | (repo_header(state, &ctx, Tab::Code, &rev_name, &branches)) | |
| 650 | 663 | (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path)) | |
| 651 | - | (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, &path, &entries)) | |
| 664 | + | (tree_table(&ctx.owner.username, &ctx.repo.path, &rev_name, &path, &entries, &annotations)) | |
| 652 | 665 | }; | |
| 653 | 666 | let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); | |
| 654 | 667 | let title = format!("{}/{}: {path}", ctx.owner.username, ctx.repo.path); | |
| @@ -2454,6 +2467,7 @@ fn tree_table( | |||
| 2454 | 2467 | rev: &str, | |
| 2455 | 2468 | dir: &str, | |
| 2456 | 2469 | entries: &[git::TreeEntry], | |
| 2470 | + | annotations: &git::TreeAnnotations, | |
| 2457 | 2471 | ) -> Markup { | |
| 2458 | 2472 | let href = |name: &str, kind: git::EntryKind| { | |
| 2459 | 2473 | let sub = if dir.is_empty() { | |
| @@ -2478,17 +2492,56 @@ fn tree_table( | |||
| 2478 | 2492 | html! { | |
| 2479 | 2493 | div class="file-tree card" { | |
| 2480 | 2494 | @for entry in sorted { | |
| 2481 | - | @let is_dir = entry.kind == git::EntryKind::Directory; | |
| 2482 | - | a class=(if is_dir { "file-row is-dir" } else { "file-row" }) | |
| 2483 | - | href=(href(&entry.name, entry.kind)) { | |
| 2484 | - | span class="file-icon" { (icon(entry_icon(entry.kind))) } | |
| 2485 | - | span class="file-name" { (entry.name) } | |
| 2495 | + | @let full = if dir.is_empty() { entry.name.clone() } else { format!("{dir}/{}", entry.name) }; | |
| 2496 | + | @if entry.kind == git::EntryKind::Submodule { | |
| 2497 | + | // A gitlink: link out to the submodule's remote (from .gitmodules). | |
| 2498 | + | @let url = annotations.submodules.get(&full).map(|u| submodule_href(u)); | |
| 2499 | + | @if let Some(url) = url { | |
| 2500 | + | a class="file-row is-submodule" href=(url) rel="noopener" { | |
| 2501 | + | span class="file-icon" { (icon(Icon::Box)) } | |
| 2502 | + | span class="file-name" { (entry.name) } | |
| 2503 | + | span class="tree-pill" { "submodule" } | |
| 2504 | + | } | |
| 2505 | + | } @else { | |
| 2506 | + | span class="file-row is-submodule" { | |
| 2507 | + | span class="file-icon" { (icon(Icon::Box)) } | |
| 2508 | + | span class="file-name" { (entry.name) } | |
| 2509 | + | span class="tree-pill" { "submodule" } | |
| 2510 | + | } | |
| 2511 | + | } | |
| 2512 | + | } @else { | |
| 2513 | + | @let is_dir = entry.kind == git::EntryKind::Directory; | |
| 2514 | + | a class=(if is_dir { "file-row is-dir" } else { "file-row" }) | |
| 2515 | + | href=(href(&entry.name, entry.kind)) { | |
| 2516 | + | span class="file-icon" { (icon(entry_icon(entry.kind))) } | |
| 2517 | + | span class="file-name" { (entry.name) } | |
| 2518 | + | @if annotations.lfs.contains(&entry.name) { | |
| 2519 | + | span class="tree-pill lfs-pill" { "LFS" } | |
| 2520 | + | } | |
| 2521 | + | } | |
| 2486 | 2522 | } | |
| 2487 | 2523 | } | |
| 2488 | 2524 | } | |
| 2489 | 2525 | } | |
| 2490 | 2526 | } | |
| 2491 | 2527 | ||
| 2528 | + | /// Turn a submodule's `.gitmodules` URL into a browsable https link (best-effort). | |
| 2529 | + | fn submodule_href(url: &str) -> String { | |
| 2530 | + | let u = url.trim(); | |
| 2531 | + | if let Some(rest) = u.strip_prefix("git@") | |
| 2532 | + | && let Some((host, path)) = rest.split_once(':') | |
| 2533 | + | { | |
| 2534 | + | return format!("https://{host}/{}", path.trim_end_matches(".git")); | |
| 2535 | + | } | |
| 2536 | + | if let Some(rest) = u.strip_prefix("ssh://") { | |
| 2537 | + | let rest = rest.strip_prefix("git@").unwrap_or(rest); | |
| 2538 | + | if let Some((host, path)) = rest.split_once('/') { | |
| 2539 | + | return format!("https://{host}/{}", path.trim_end_matches(".git")); | |
| 2540 | + | } | |
| 2541 | + | } | |
| 2542 | + | u.trim_end_matches(".git").to_string() | |
| 2543 | + | } | |
| 2544 | + | ||
| 2492 | 2545 | /// An icon glyph for a tree entry kind. | |
| 2493 | 2546 | fn entry_icon(kind: git::EntryKind) -> Icon { | |
| 2494 | 2547 | match kind { | |