fabrica

hanna/fabrica

feat(web): detect LFS via .gitattributes and fix image preview

d528561 · hanna committed on 2026-07-26

- LFS-tracking is now determined by `.gitattributes filter=lfs` (the
  authoritative signal) in addition to the pointer-content sniff, so a
  file tracked by LFS shows the pill even when its blob was committed
  inline. Adds git::Attributes::is_lfs and threads it through the tree
  view and blob view (an "LFS" pill now shows in the blob header too).
- The inline image preview sits flush under the header (no baseline gap)
  and is clipped to the card's rounded bottom corners.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
4 files changed · +63 −12UnifiedSplit
assets/base.css +15 −0
@@ -2268,6 +2268,21 @@ pre.code {
22682268 border-top: none;
22692269 border-radius: 0 0 var(--fb-radius) var(--fb-radius);
22702270 }
2271+/* Inline image preview: flush under the header (no baseline gap), clipped to the
2272+ rounded bottom corners. */
2273+.blob-image {
2274+ background: var(--fb-bg-inset);
2275+ border: 1px solid var(--fb-border);
2276+ border-top: none;
2277+ border-radius: 0 0 var(--fb-radius) var(--fb-radius);
2278+ overflow: hidden;
2279+ line-height: 0;
2280+}
2281+.blob-image img {
2282+ display: block;
2283+ max-width: 100%;
2284+ margin: 0 auto;
2285+}
22712286
22722287 /* The blob box: border, rounded bottom, scroll, and vertical breathing room. */
22732288 .blob-code {
crates/git/src/attributes.rs +6 −0
@@ -119,6 +119,12 @@ impl Attributes {
119119 pub fn is_vendored(&self) -> bool {
120120 self.is_set("linguist-vendored")
121121 }
122+
123+ /// Whether the path is managed by git-LFS (`filter=lfs` in `.gitattributes`).
124+ #[must_use]
125+ pub fn is_lfs(&self) -> bool {
126+ matches!(self.map.get("filter"), Some(AttrValue::Value(v)) if v == "lfs")
127+ }
122128 }
123129
124130 /// One `.gitattributes` file: its directory prefix (`""` at the tree root) and its
crates/git/src/repo.rs +26 −7
@@ -171,6 +171,11 @@ impl Repo {
171171 .map(|b| parse_gitmodules(b.content()))
172172 .unwrap_or_default();
173173
174+ // `.gitattributes` filter=lfs is the authoritative "tracked by LFS" signal
175+ // (a file can be LFS-tracked even if a given blob was committed inline).
176+ let attrs = self.attributes_set(&rev.oid).ok();
177+ let dir_prefix = path_string(path);
178+
174179 let tree = if path.components().next().is_none() {
175180 Some(root)
176181 } else {
@@ -182,13 +187,27 @@ impl Repo {
182187 let mut lfs = std::collections::HashSet::new();
183188 if let Some(tree) = tree {
184189 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- {
190+ let Some(name) = entry.name() else { continue };
191+ if entry.kind() != Some(git2::ObjectType::Blob) {
192+ continue;
193+ }
194+ let full = if dir_prefix.is_empty() {
195+ name.to_string()
196+ } else {
197+ format!("{dir_prefix}/{name}")
198+ };
199+ let tracked = attrs
200+ .as_ref()
201+ .is_some_and(|set| set.attributes(&full).is_lfs());
202+ let pointer = entry
203+ .to_object(&self.inner)
204+ .ok()
205+ .and_then(|o| {
206+ o.as_blob()
207+ .map(|b| b.size() < POINTER_MAX && is_lfs_pointer(b.content()))
208+ })
209+ .unwrap_or(false);
210+ if tracked || pointer {
192211 lfs.insert(name.to_string());
193212 }
194213 }
crates/web/src/repo.rs +16 −5
@@ -691,15 +691,23 @@ async fn blob_view(
691691 })
692692 .await?;
693693
694- // Read the blob and resolve any attribute language override in one pass.
695- let (blob, attr_lang) = git_read(state, &ctx.repo.id, {
694+ // Read the blob and resolve attribute-driven facts (language, LFS) in one pass.
695+ let (blob, attr_lang, is_lfs) = git_read(state, &ctx.repo.id, {
696696 let rev = rev.clone();
697697 let path = path.clone();
698698 move |repo| {
699699 let blob = repo.blob(&rev, FsPath::new(&path))?;
700700 let attrs = repo.attributes_set(&rev.oid).ok();
701- let lang = attrs.and_then(|set| set.attributes(&path).language().map(str::to_string));
702- Ok((blob, lang))
701+ let resolved = attrs.map(|set| set.attributes(&path));
702+ let lang = resolved
703+ .as_ref()
704+ .and_then(|a| a.language().map(str::to_string));
705+ let is_lfs = resolved.as_ref().is_some_and(git::Attributes::is_lfs)
706+ || (blob.size < 1024
707+ && blob
708+ .content
709+ .starts_with(b"version https://git-lfs.github.com/spec/v1"));
710+ Ok((blob, lang, is_lfs))
703711 }
704712 })
705713 .await?;
@@ -710,6 +718,7 @@ async fn blob_view(
710718 &path,
711719 &blob,
712720 attr_lang.as_deref(),
721+ is_lfs,
713722 source_view,
714723 );
715724 // For a license file, detect it and show a rights summary above the content.
@@ -778,6 +787,7 @@ fn render_blob(
778787 path: &str,
779788 blob: &git::Blob,
780789 attr_lang: Option<&str>,
790+ is_lfs: bool,
781791 source_view: bool,
782792 ) -> Markup {
783793 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
@@ -790,6 +800,7 @@ fn render_blob(
790800 html! {
791801 div class="blob-header" {
792802 span class="muted" { (blob.size) " bytes" }
803+ @if is_lfs { span class="badge lfs-pill" { "LFS" } }
793804 div class="blob-actions" {
794805 @if previewable {
795806 a class=(toggle_class(show_preview)) href=(blob_url.clone()) { "Preview" }
@@ -802,7 +813,7 @@ fn render_blob(
802813 }
803814 }
804815 @if is_image(filename) {
805- p { img src=(raw_url) alt=(filename) style="max-width:100%"; }
816+ div class="blob-image" { img src=(raw_url) alt=(filename); }
806817 } @else if blob.is_binary {
807818 div class="card" {
808819 p class="muted" { "Binary file not shown." }