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 {
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}
22042223
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
50pub use crate::types::{50pub 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};
5555
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;
20use crate::types::{20use 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};
2525
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 }
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
147 /// Read the file at `path` within revision `rev`.199 /// Read the file at `path` within revision `rev`.
148 ///200 ///
149 /// # Errors201 /// # Errors
@@ -773,6 +825,40 @@ fn looks_binary(bytes: &[u8]) -> bool {
773 window.contains(&0)825 window.contains(&0)
774}826}
775827
828/// Whether a blob's content is a git-LFS pointer (its mandatory first line).
829fn 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.
835fn 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)]
777mod tests {863mod 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};
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
788 const ID: &str = "01hzxk9m2n8p7q6r5s4t3v2w1x";897 const ID: &str = "01hzxk9m2n8p7q6r5s4t3v2w1x";
789898
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}
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)]
89pub 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)]
88pub struct TreeEntry {98pub 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();
646659
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}
24912527
2528/// Turn a submodule's `.gitmodules` URL into a browsable https link (best-effort).
2529fn 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.
2493fn entry_icon(kind: git::EntryKind) -> Icon {2546fn entry_icon(kind: git::EntryKind) -> Icon {
2494 match kind {2547 match kind {