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 {
2268 border-top: none;2268 border-top: none;
2269 border-radius: 0 0 var(--fb-radius) var(--fb-radius);2269 border-radius: 0 0 var(--fb-radius) var(--fb-radius);
2270}2270}
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
2272/* The blob box: border, rounded bottom, scroll, and vertical breathing room. */2287/* The blob box: border, rounded bottom, scroll, and vertical breathing room. */
2273.blob-code {2288.blob-code {
crates/git/src/attributes.rs +6 −0
@@ -119,6 +119,12 @@ impl Attributes {
119 pub fn is_vendored(&self) -> bool {119 pub fn is_vendored(&self) -> bool {
120 self.is_set("linguist-vendored")120 self.is_set("linguist-vendored")
121 }121 }
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 }
122}128}
123129
124/// One `.gitattributes` file: its directory prefix (`""` at the tree root) and its130/// 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 {
171 .map(|b| parse_gitmodules(b.content()))171 .map(|b| parse_gitmodules(b.content()))
172 .unwrap_or_default();172 .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
174 let tree = if path.components().next().is_none() {179 let tree = if path.components().next().is_none() {
175 Some(root)180 Some(root)
176 } else {181 } else {
@@ -182,13 +187,27 @@ impl Repo {
182 let mut lfs = std::collections::HashSet::new();187 let mut lfs = std::collections::HashSet::new();
183 if let Some(tree) = tree {188 if let Some(tree) = tree {
184 for entry in &tree {189 for entry in &tree {
185 if entry.kind() == Some(git2::ObjectType::Blob)190 let Some(name) = entry.name() else { continue };
186 && let Ok(obj) = entry.to_object(&self.inner)191 if entry.kind() != Some(git2::ObjectType::Blob) {
187 && let Some(blob) = obj.as_blob()192 continue;
188 && blob.size() < POINTER_MAX193 }
189 && is_lfs_pointer(blob.content())194 let full = if dir_prefix.is_empty() {
190 && let Some(name) = entry.name()195 name.to_string()
191 {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 {
192 lfs.insert(name.to_string());211 lfs.insert(name.to_string());
193 }212 }
194 }213 }
crates/web/src/repo.rs +16 −5
@@ -691,15 +691,23 @@ async fn blob_view(
691 })691 })
692 .await?;692 .await?;
693693
694 // Read the blob and resolve any attribute language override in one pass.694 // Read the blob and resolve attribute-driven facts (language, LFS) in one pass.
695 let (blob, attr_lang) = git_read(state, &ctx.repo.id, {695 let (blob, attr_lang, is_lfs) = git_read(state, &ctx.repo.id, {
696 let rev = rev.clone();696 let rev = rev.clone();
697 let path = path.clone();697 let path = path.clone();
698 move |repo| {698 move |repo| {
699 let blob = repo.blob(&rev, FsPath::new(&path))?;699 let blob = repo.blob(&rev, FsPath::new(&path))?;
700 let attrs = repo.attributes_set(&rev.oid).ok();700 let attrs = repo.attributes_set(&rev.oid).ok();
701 let lang = attrs.and_then(|set| set.attributes(&path).language().map(str::to_string));701 let resolved = attrs.map(|set| set.attributes(&path));
702 Ok((blob, lang))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))
703 }711 }
704 })712 })
705 .await?;713 .await?;
@@ -710,6 +718,7 @@ async fn blob_view(
710 &path,718 &path,
711 &blob,719 &blob,
712 attr_lang.as_deref(),720 attr_lang.as_deref(),
721 is_lfs,
713 source_view,722 source_view,
714 );723 );
715 // For a license file, detect it and show a rights summary above the content.724 // For a license file, detect it and show a rights summary above the content.
@@ -778,6 +787,7 @@ fn render_blob(
778 path: &str,787 path: &str,
779 blob: &git::Blob,788 blob: &git::Blob,
780 attr_lang: Option<&str>,789 attr_lang: Option<&str>,
790 is_lfs: bool,
781 source_view: bool,791 source_view: bool,
782) -> Markup {792) -> Markup {
783 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);793 let base = format!("/{}/{}", ctx.owner.username, ctx.repo.path);
@@ -790,6 +800,7 @@ fn render_blob(
790 html! {800 html! {
791 div class="blob-header" {801 div class="blob-header" {
792 span class="muted" { (blob.size) " bytes" }802 span class="muted" { (blob.size) " bytes" }
803 @if is_lfs { span class="badge lfs-pill" { "LFS" } }
793 div class="blob-actions" {804 div class="blob-actions" {
794 @if previewable {805 @if previewable {
795 a class=(toggle_class(show_preview)) href=(blob_url.clone()) { "Preview" }806 a class=(toggle_class(show_preview)) href=(blob_url.clone()) { "Preview" }
@@ -802,7 +813,7 @@ fn render_blob(
802 }813 }
803 }814 }
804 @if is_image(filename) {815 @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); }
806 } @else if blob.is_binary {817 } @else if blob.is_binary {
807 div class="card" {818 div class="card" {
808 p class="muted" { "Binary file not shown." }819 p class="muted" { "Binary file not shown." }