fabrica

hanna/fabrica

feat(web): LFS pill and submodule links in the tree view

423c5ec · hanna committed on 2026-07-26

Add git::Repo::tree_annotations(rev, dir), which reads .gitmodules for a
submodule path→URL map and sniffs small blobs for the git-LFS pointer
signature. The tree view uses it to:

- tag LFS-tracked files with an "LFS" pill on the right, and
- render submodule (gitlink) entries as links to their remote (git@ and
  ssh:// URLs are rewritten to browsable https), or a plain row when the
  URL is unknown.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
5 files changed · +200 −9UnifiedSplit
assets/base.css +19 −0
@@ -2201,6 +2201,25 @@ pre.code {
22012201 text-overflow: ellipsis;
22022202 white-space: nowrap;
22032203 }
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+}
22042223
22052224 .blob-header {
22062225 display: flex;
crates/git/src/lib.rs +1 −1
@@ -50,7 +50,7 @@ pub use crate::signature::{SignatureCache, SignatureState, SigningKey, verify_ch
5050 pub use crate::types::{
5151 Blob, BranchInfo, CommitDetail, CommitSummary, DeltaStatus, DiffLine, DiffOpts, EntryKind,
5252 FileDiff, FileDiffs, Hunk, LineOrigin, Oid, Page, Person, RefKind, Resolved, TagInfo,
53- TreeEntry,
53+ TreeAnnotations, TreeEntry,
5454 };
5555
5656 /// An error from the git layer.
crates/git/src/repo.rs +110 −1
@@ -20,7 +20,7 @@ use crate::GitError;
2020 use crate::types::{
2121 Blob, BranchInfo, CommitDetail, CommitSummary, DeltaStatus, DiffLine, DiffOpts, EntryKind,
2222 FileDiff, FileDiffs, Hunk, LineOrigin, Oid, Page, Person, RefKind, Resolved, TagInfo,
23- TreeEntry,
23+ TreeAnnotations, TreeEntry,
2424 };
2525
2626 /// Filemode bits git uses to distinguish tree-entry kinds.
@@ -144,6 +144,58 @@ impl Repo {
144144 Ok(entries)
145145 }
146146
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+
147199 /// Read the file at `path` within revision `rev`.
148200 ///
149201 /// # Errors
@@ -773,6 +825,40 @@ fn looks_binary(bytes: &[u8]) -> bool {
773825 window.contains(&0)
774826 }
775827
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+
776862 #[cfg(test)]
777863 mod tests {
778864 #![allow(clippy::unwrap_used, clippy::expect_used)]
@@ -785,6 +871,29 @@ mod tests {
785871 use super::*;
786872 use crate::{create_bare, repo_path};
787873
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+
788897 const ID: &str = "01hzxk9m2n8p7q6r5s4t3v2w1x";
789898
790899 /// 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 {
8383 Submodule,
8484 }
8585
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+
8696 /// One entry in a tree listing.
8797 #[derive(Debug, Clone, PartialEq, Eq)]
8898 pub struct TreeEntry {
crates/web/src/repo.rs +60 −7
@@ -546,6 +546,12 @@ async fn repo_home(
546546 })
547547 .await?;
548548 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();
549555 let branches = branch_names(state, &ctx.repo.id).await;
550556 // The language breakdown of the default branch (best-effort).
551557 let langs = git_read(state, &ctx.repo.id, {
@@ -566,7 +572,7 @@ async fn repo_home(
566572 @if !langs.is_empty() {
567573 (language_bar(&langs))
568574 }
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))
570576 @if let Some(rendered) = readme {
571577 div class="card markdown readme" { (rendered) }
572578 }
@@ -643,12 +649,19 @@ async fn tree_view(
643649 move |repo| repo.tree_entries(&rev, FsPath::new(&path))
644650 })
645651 .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();
646659
647660 let branches = branch_names(state, &ctx.repo.id).await;
648661 let body = html! {
649662 (repo_header(state, &ctx, Tab::Code, &rev_name, &branches))
650663 (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))
652665 };
653666 let (jar, chrome) = build_chrome(state, jar, viewer, uri.path().to_string());
654667 let title = format!("{}/{}: {path}", ctx.owner.username, ctx.repo.path);
@@ -2454,6 +2467,7 @@ fn tree_table(
24542467 rev: &str,
24552468 dir: &str,
24562469 entries: &[git::TreeEntry],
2470+ annotations: &git::TreeAnnotations,
24572471 ) -> Markup {
24582472 let href = |name: &str, kind: git::EntryKind| {
24592473 let sub = if dir.is_empty() {
@@ -2478,17 +2492,56 @@ fn tree_table(
24782492 html! {
24792493 div class="file-tree card" {
24802494 @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+ }
24862522 }
24872523 }
24882524 }
24892525 }
24902526 }
24912527
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+
24922545 /// An icon glyph for a tree entry kind.
24932546 fn entry_icon(kind: git::EntryKind) -> Icon {
24942547 match kind {