Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +19 −0
| @@ -2201,6 +2201,25 @@ pre.code { | |||
| 2201 | text-overflow: ellipsis; | 2201 | text-overflow: ellipsis; |
| 2202 | white-space: nowrap; | 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 | .blob-header { | 2224 | .blob-header { |
| 2206 | display: flex; | 2225 | display: flex; |
crates/git/src/lib.rs +1 −1
| @@ -50,7 +50,7 @@ pub use crate::signature::{SignatureCache, SignatureState, SigningKey, verify_ch | |||
| 50 | pub use crate::types::{ | 50 | pub use crate::types::{ |
| 51 | Blob, BranchInfo, CommitDetail, CommitSummary, DeltaStatus, DiffLine, DiffOpts, EntryKind, | 51 | Blob, BranchInfo, CommitDetail, CommitSummary, DeltaStatus, DiffLine, DiffOpts, EntryKind, |
| 52 | FileDiff, FileDiffs, Hunk, LineOrigin, Oid, Page, Person, RefKind, Resolved, TagInfo, | 52 | FileDiff, FileDiffs, Hunk, LineOrigin, Oid, Page, Person, RefKind, Resolved, TagInfo, |
| 53 | TreeEntry, | 53 | TreeAnnotations, TreeEntry, |
| 54 | }; | 54 | }; |
| 55 | 55 | ||
| 56 | /// An error from the git layer. | 56 | /// An error from the git layer. |
crates/git/src/repo.rs +110 −1
| @@ -20,7 +20,7 @@ use crate::GitError; | |||
| 20 | use crate::types::{ | 20 | use crate::types::{ |
| 21 | Blob, BranchInfo, CommitDetail, CommitSummary, DeltaStatus, DiffLine, DiffOpts, EntryKind, | 21 | Blob, BranchInfo, CommitDetail, CommitSummary, DeltaStatus, DiffLine, DiffOpts, EntryKind, |
| 22 | FileDiff, FileDiffs, Hunk, LineOrigin, Oid, Page, Person, RefKind, Resolved, TagInfo, | 22 | FileDiff, FileDiffs, Hunk, LineOrigin, Oid, Page, Person, RefKind, Resolved, TagInfo, |
| 23 | TreeEntry, | 23 | TreeAnnotations, TreeEntry, |
| 24 | }; | 24 | }; |
| 25 | 25 | ||
| 26 | /// Filemode bits git uses to distinguish tree-entry kinds. | 26 | /// Filemode bits git uses to distinguish tree-entry kinds. |
| @@ -144,6 +144,58 @@ impl Repo { | |||
| 144 | Ok(entries) | 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 | /// Read the file at `path` within revision `rev`. | 199 | /// Read the file at `path` within revision `rev`. |
| 148 | /// | 200 | /// |
| 149 | /// # Errors | 201 | /// # Errors |
| @@ -773,6 +825,40 @@ fn looks_binary(bytes: &[u8]) -> bool { | |||
| 773 | window.contains(&0) | 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 | #[cfg(test)] | 862 | #[cfg(test)] |
| 777 | mod tests { | 863 | mod tests { |
| 778 | #![allow(clippy::unwrap_used, clippy::expect_used)] | 864 | #![allow(clippy::unwrap_used, clippy::expect_used)] |
| @@ -785,6 +871,29 @@ mod tests { | |||
| 785 | use super::*; | 871 | use super::*; |
| 786 | use crate::{create_bare, repo_path}; | 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 | const ID: &str = "01hzxk9m2n8p7q6r5s4t3v2w1x"; | 897 | const ID: &str = "01hzxk9m2n8p7q6r5s4t3v2w1x"; |
| 789 | 898 | ||
| 790 | /// A populated bare repository plus the temp dir keeping it alive. | 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 | Submodule, | 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 | /// One entry in a tree listing. | 96 | /// One entry in a tree listing. |
| 87 | #[derive(Debug, Clone, PartialEq, Eq)] | 97 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 88 | pub struct TreeEntry { | 98 | pub struct TreeEntry { |
crates/web/src/repo.rs +60 −7
| @@ -546,6 +546,12 @@ async fn repo_home( | |||
| 546 | }) | 546 | }) |
| 547 | .await?; | 547 | .await?; |
| 548 | let readme = load_readme(state, &ctx.repo.id, &rev, &entries).await; | 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 | let branches = branch_names(state, &ctx.repo.id).await; | 555 | let branches = branch_names(state, &ctx.repo.id).await; |
| 550 | // The language breakdown of the default branch (best-effort). | 556 | // The language breakdown of the default branch (best-effort). |
| 551 | let langs = git_read(state, &ctx.repo.id, { | 557 | let langs = git_read(state, &ctx.repo.id, { |
| @@ -566,7 +572,7 @@ async fn repo_home( | |||
| 566 | @if !langs.is_empty() { | 572 | @if !langs.is_empty() { |
| 567 | (language_bar(&langs)) | 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 | @if let Some(rendered) = readme { | 576 | @if let Some(rendered) = readme { |
| 571 | div class="card markdown readme" { (rendered) } | 577 | div class="card markdown readme" { (rendered) } |
| 572 | } | 578 | } |
| @@ -643,12 +649,19 @@ async fn tree_view( | |||
| 643 | move |repo| repo.tree_entries(&rev, FsPath::new(&path)) | 649 | move |repo| repo.tree_entries(&rev, FsPath::new(&path)) |
| 644 | }) | 650 | }) |
| 645 | .await?; | 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 | let branches = branch_names(state, &ctx.repo.id).await; | 660 | let branches = branch_names(state, &ctx.repo.id).await; |
| 648 | let body = html! { | 661 | let body = html! { |
| 649 | (repo_header(state, &ctx, Tab::Code, &rev_name, &branches)) | 662 | (repo_header(state, &ctx, Tab::Code, &rev_name, &branches)) |
| 650 | (breadcrumb(&ctx.owner.username, &ctx.repo.path, &rev_name, &path)) | 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 | let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); | 666 | let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string()); |
| 654 | let title = format!("{}/{}: {path}", ctx.owner.username, ctx.repo.path); | 667 | let title = format!("{}/{}: {path}", ctx.owner.username, ctx.repo.path); |
| @@ -2454,6 +2467,7 @@ fn tree_table( | |||
| 2454 | rev: &str, | 2467 | rev: &str, |
| 2455 | dir: &str, | 2468 | dir: &str, |
| 2456 | entries: &[git::TreeEntry], | 2469 | entries: &[git::TreeEntry], |
| 2470 | annotations: &git::TreeAnnotations, | ||
| 2457 | ) -> Markup { | 2471 | ) -> Markup { |
| 2458 | let href = |name: &str, kind: git::EntryKind| { | 2472 | let href = |name: &str, kind: git::EntryKind| { |
| 2459 | let sub = if dir.is_empty() { | 2473 | let sub = if dir.is_empty() { |
| @@ -2478,17 +2492,56 @@ fn tree_table( | |||
| 2478 | html! { | 2492 | html! { |
| 2479 | div class="file-tree card" { | 2493 | div class="file-tree card" { |
| 2480 | @for entry in sorted { | 2494 | @for entry in sorted { |
| 2481 | @let is_dir = entry.kind == git::EntryKind::Directory; | 2495 | @let full = if dir.is_empty() { entry.name.clone() } else { format!("{dir}/{}", entry.name) }; |
| 2482 | a class=(if is_dir { "file-row is-dir" } else { "file-row" }) | 2496 | @if entry.kind == git::EntryKind::Submodule { |
| 2483 | href=(href(&entry.name, entry.kind)) { | 2497 | // A gitlink: link out to the submodule's remote (from .gitmodules). |
| 2484 | span class="file-icon" { (icon(entry_icon(entry.kind))) } | 2498 | @let url = annotations.submodules.get(&full).map(|u| submodule_href(u)); |
| 2485 | span class="file-name" { (entry.name) } | 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 | /// An icon glyph for a tree entry kind. | 2545 | /// An icon glyph for a tree entry kind. |
| 2493 | fn entry_icon(kind: git::EntryKind) -> Icon { | 2546 | fn entry_icon(kind: git::EntryKind) -> Icon { |
| 2494 | match kind { | 2547 | match kind { |