fabrica

hanna/fabrica

fix(web): clickable global search results; blob box padding/rounding

28d6424 · hanna committed on 2026-07-25

Global code-search hits now link to the blob at each repo's default branch
(they were plain text — only in-repo search linked them), with the repo
heading linking to the repo. Wrap the code blob in a bordered, rounded,
scrollable box with vertical padding so its bottom corners round and the last
line isn't flush against the edge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
3 files changed · +51 −27UnifiedSplit
assets/base.css +21 −6
@@ -793,6 +793,17 @@ select:focus {
793793 }
794794
795795 /* ---- Search hits ---- */
796+.search-repo {
797+ margin: 1.5rem 0 0.6rem;
798+ font-family: var(--fb-font-mono);
799+ font-size: 0.95rem;
800+}
801+.search-repo a {
802+ color: var(--fb-fg);
803+}
804+.search-repo a:hover {
805+ color: var(--fb-accent);
806+}
796807 .search-hit {
797808 padding: 0;
798809 overflow: hidden;
@@ -1222,15 +1233,20 @@ pre.code {
12221233 border-radius: 0 0 var(--fb-radius) var(--fb-radius);
12231234 }
12241235
1236+/* The blob box: border, rounded bottom, scroll, and vertical breathing room. */
1237+.blob-code {
1238+ border: 1px solid var(--fb-border);
1239+ border-top: none;
1240+ border-radius: 0 0 var(--fb-radius) var(--fb-radius);
1241+ background: var(--fb-bg-inset);
1242+ overflow-x: auto;
1243+ padding: 0.5rem 0;
1244+}
12251245 table.blob {
12261246 width: 100%;
12271247 border-collapse: collapse;
1228- border: 1px solid var(--fb-border);
1229- border-radius: 0 0 var(--fb-radius) var(--fb-radius);
12301248 font-family: var(--fb-font-mono);
12311249 font-size: 0.85rem;
1232- background: var(--fb-bg-inset);
1233- overflow-x: auto;
12341250 }
12351251 table.blob td.lineno {
12361252 width: 1%;
@@ -1561,8 +1577,7 @@ td.diff-empty {
15611577 overflow-x: auto;
15621578 -webkit-overflow-scrolling: touch;
15631579 }
1564- /* Blob and diff tables scroll within their own box. */
1565- table.blob,
1580+ /* Diff tables scroll within their own box (the blob wrapper already does). */
15661581 table.diff {
15671582 display: block;
15681583 overflow-x: auto;
crates/web/src/repo.rs +13 −11
@@ -558,17 +558,19 @@ fn is_markdown(filename: &str) -> bool {
558558 fn highlighted_code(path: &FsPath, content: &[u8], attr_lang: Option<&str>) -> Markup {
559559 let highlighted = highlight::highlight(path, content, attr_lang);
560560 html! {
561- table class="blob" {
562- tbody {
563- @for (i, line) in highlighted.lines.iter().enumerate() {
564- @let n = i + 1;
565- tr id={ "L" (n) } {
566- td class="lineno" { a href={ "#L" (n) } { (n) } }
567- td class="code-line" {
568- @for span in &line.spans {
569- @match span.class {
570- Some(class) => { span class=(class) { (span.text) } }
571- None => { (span.text) }
561+ div class="blob-code" {
562+ table class="blob" {
563+ tbody {
564+ @for (i, line) in highlighted.lines.iter().enumerate() {
565+ @let n = i + 1;
566+ tr id={ "L" (n) } {
567+ td class="lineno" { a href={ "#L" (n) } { (n) } }
568+ td class="code-line" {
569+ @for span in &line.spans {
570+ @match span.class {
571+ Some(class) => { span class=(class) { (span.text) } }
572+ None => { (span.text) }
573+ }
572574 }
573575 }
574576 }
crates/web/src/search.rs +17 −10
@@ -263,11 +263,15 @@ async fn entity_matches(
263263
264264 /// Run the bounded content search across visible repos. Returns matches plus
265265 /// whether the time budget or result cap was hit.
266+/// A repo's content matches: its `owner/path` label, its default branch (for
267+/// blob links), and the per-file results.
268+type RepoMatches = (String, String, Vec<FileResult>);
269+
266270 async fn content_matches(
267271 state: &AppState,
268272 query: &ParsedQuery,
269273 viewer: Option<&User>,
270-) -> AppResult<(Vec<(String, Vec<FileResult>)>, bool, bool)> {
274+) -> AppResult<(Vec<RepoMatches>, bool, bool)> {
271275 let mut repos = visible_repos(state, viewer).await?;
272276 // Honour a repo: qualifier by filtering the candidate set.
273277 if let Some(repo_q) = &query.repo {
@@ -285,7 +289,7 @@ async fn content_matches(
285289
286290 let deadline = Instant::now() + Duration::from_secs(state.config.search.timeout_secs.max(1));
287291 let semaphore = Arc::new(Semaphore::new(state.config.search.concurrency.max(1)));
288- let mut set: JoinSet<Option<(String, Vec<FileResult>)>> = JoinSet::new();
292+ let mut set: JoinSet<Option<RepoMatches>> = JoinSet::new();
289293
290294 for repo in repos {
291295 let state = state.clone();
@@ -294,13 +298,14 @@ async fn content_matches(
294298 set.spawn(async move {
295299 let _permit = semaphore.acquire().await.ok()?;
296300 let owner = owner_name(&state, &repo.owner_id).await;
297- let rev = repo.default_branch.clone();
301+ let branch = repo.default_branch.clone();
302+ let rev = branch.clone();
298303 let files = crate::repo::git_read(&state, &repo.id, move |repo| {
299304 Ok(search_repo_blocking(repo, &rev, &query))
300305 })
301306 .await
302307 .ok()?;
303- (!files.is_empty()).then(|| (format!("{owner}/{}", repo.path), files))
308+ (!files.is_empty()).then(|| (format!("{owner}/{}", repo.path), branch, files))
304309 });
305310 }
306311
@@ -318,7 +323,7 @@ async fn content_matches(
318323 }
319324 match tokio::time::timeout(remaining, set.join_next()).await {
320325 Ok(Some(Ok(Some(entry)))) => {
321- total += entry.1.iter().map(|f| f.lines.len().max(1)).sum::<usize>();
326+ total += entry.2.iter().map(|f| f.lines.len().max(1)).sum::<usize>();
322327 results.push(entry);
323328 if total >= cap {
324329 truncated = true;
@@ -415,16 +420,18 @@ fn entity_section(entities: &[EntityMatch]) -> Markup {
415420 }
416421 }
417422
418-/// Render global content matches, grouped by repo then file.
419-fn content_section(content: &[(String, Vec<FileResult>)]) -> Markup {
423+/// Render global content matches, grouped by repo then file, each linking into
424+/// the blob at the repo's default branch.
425+fn content_section(content: &[RepoMatches]) -> Markup {
420426 html! {
421427 @if !content.is_empty() {
422428 section {
423429 h2 { "Code" }
424- @for (repo, files) in content {
425- h3 { (repo) }
430+ @for (repo, rev, files) in content {
431+ h3 class="search-repo" { a href={ "/" (repo) } { (repo) } }
432+ @let base = format!("/{repo}/-/blob/{rev}");
426433 @for file in files {
427- (file_result(&format!("/{repo}"), None, file))
434+ (file_result(&base, Some(rev), file))
428435 }
429436 }
430437 }