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 {
793}793}
794794
795/* ---- Search hits ---- */795/* ---- 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}
796.search-hit {807.search-hit {
797 padding: 0;808 padding: 0;
798 overflow: hidden;809 overflow: hidden;
@@ -1222,15 +1233,20 @@ pre.code {
1222 border-radius: 0 0 var(--fb-radius) var(--fb-radius);1233 border-radius: 0 0 var(--fb-radius) var(--fb-radius);
1223}1234}
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}
1225table.blob {1245table.blob {
1226 width: 100%;1246 width: 100%;
1227 border-collapse: collapse;1247 border-collapse: collapse;
1228 border: 1px solid var(--fb-border);
1229 border-radius: 0 0 var(--fb-radius) var(--fb-radius);
1230 font-family: var(--fb-font-mono);1248 font-family: var(--fb-font-mono);
1231 font-size: 0.85rem;1249 font-size: 0.85rem;
1232 background: var(--fb-bg-inset);
1233 overflow-x: auto;
1234}1250}
1235table.blob td.lineno {1251table.blob td.lineno {
1236 width: 1%;1252 width: 1%;
@@ -1561,8 +1577,7 @@ td.diff-empty {
1561 overflow-x: auto;1577 overflow-x: auto;
1562 -webkit-overflow-scrolling: touch;1578 -webkit-overflow-scrolling: touch;
1563 }1579 }
1564 /* Blob and diff tables scroll within their own box. */1580 /* Diff tables scroll within their own box (the blob wrapper already does). */
1565 table.blob,
1566 table.diff {1581 table.diff {
1567 display: block;1582 display: block;
1568 overflow-x: auto;1583 overflow-x: auto;
crates/web/src/repo.rs +13 −11
@@ -558,17 +558,19 @@ fn is_markdown(filename: &str) -> bool {
558fn highlighted_code(path: &FsPath, content: &[u8], attr_lang: Option<&str>) -> Markup {558fn highlighted_code(path: &FsPath, content: &[u8], attr_lang: Option<&str>) -> Markup {
559 let highlighted = highlight::highlight(path, content, attr_lang);559 let highlighted = highlight::highlight(path, content, attr_lang);
560 html! {560 html! {
561 table class="blob" {561 div class="blob-code" {
562 tbody {562 table class="blob" {
563 @for (i, line) in highlighted.lines.iter().enumerate() {563 tbody {
564 @let n = i + 1;564 @for (i, line) in highlighted.lines.iter().enumerate() {
565 tr id={ "L" (n) } {565 @let n = i + 1;
566 td class="lineno" { a href={ "#L" (n) } { (n) } }566 tr id={ "L" (n) } {
567 td class="code-line" {567 td class="lineno" { a href={ "#L" (n) } { (n) } }
568 @for span in &line.spans {568 td class="code-line" {
569 @match span.class {569 @for span in &line.spans {
570 Some(class) => { span class=(class) { (span.text) } }570 @match span.class {
571 None => { (span.text) }571 Some(class) => { span class=(class) { (span.text) } }
572 None => { (span.text) }
573 }
572 }574 }
573 }575 }
574 }576 }
crates/web/src/search.rs +17 −10
@@ -263,11 +263,15 @@ async fn entity_matches(
263263
264/// Run the bounded content search across visible repos. Returns matches plus264/// Run the bounded content search across visible repos. Returns matches plus
265/// whether the time budget or result cap was hit.265/// 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.
268type RepoMatches = (String, String, Vec<FileResult>);
269
266async fn content_matches(270async fn content_matches(
267 state: &AppState,271 state: &AppState,
268 query: &ParsedQuery,272 query: &ParsedQuery,
269 viewer: Option<&User>,273 viewer: Option<&User>,
270) -> AppResult<(Vec<(String, Vec<FileResult>)>, bool, bool)> {274) -> AppResult<(Vec<RepoMatches>, bool, bool)> {
271 let mut repos = visible_repos(state, viewer).await?;275 let mut repos = visible_repos(state, viewer).await?;
272 // Honour a repo: qualifier by filtering the candidate set.276 // Honour a repo: qualifier by filtering the candidate set.
273 if let Some(repo_q) = &query.repo {277 if let Some(repo_q) = &query.repo {
@@ -285,7 +289,7 @@ async fn content_matches(
285289
286 let deadline = Instant::now() + Duration::from_secs(state.config.search.timeout_secs.max(1));290 let deadline = Instant::now() + Duration::from_secs(state.config.search.timeout_secs.max(1));
287 let semaphore = Arc::new(Semaphore::new(state.config.search.concurrency.max(1)));291 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
290 for repo in repos {294 for repo in repos {
291 let state = state.clone();295 let state = state.clone();
@@ -294,13 +298,14 @@ async fn content_matches(
294 set.spawn(async move {298 set.spawn(async move {
295 let _permit = semaphore.acquire().await.ok()?;299 let _permit = semaphore.acquire().await.ok()?;
296 let owner = owner_name(&state, &repo.owner_id).await;300 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();
298 let files = crate::repo::git_read(&state, &repo.id, move |repo| {303 let files = crate::repo::git_read(&state, &repo.id, move |repo| {
299 Ok(search_repo_blocking(repo, &rev, &query))304 Ok(search_repo_blocking(repo, &rev, &query))
300 })305 })
301 .await306 .await
302 .ok()?;307 .ok()?;
303 (!files.is_empty()).then(|| (format!("{owner}/{}", repo.path), files))308 (!files.is_empty()).then(|| (format!("{owner}/{}", repo.path), branch, files))
304 });309 });
305 }310 }
306311
@@ -318,7 +323,7 @@ async fn content_matches(
318 }323 }
319 match tokio::time::timeout(remaining, set.join_next()).await {324 match tokio::time::timeout(remaining, set.join_next()).await {
320 Ok(Some(Ok(Some(entry)))) => {325 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>();
322 results.push(entry);327 results.push(entry);
323 if total >= cap {328 if total >= cap {
324 truncated = true;329 truncated = true;
@@ -415,16 +420,18 @@ fn entity_section(entities: &[EntityMatch]) -> Markup {
415 }420 }
416}421}
417422
418/// Render global content matches, grouped by repo then file.423/// Render global content matches, grouped by repo then file, each linking into
419fn content_section(content: &[(String, Vec<FileResult>)]) -> Markup {424/// the blob at the repo's default branch.
425fn content_section(content: &[RepoMatches]) -> Markup {
420 html! {426 html! {
421 @if !content.is_empty() {427 @if !content.is_empty() {
422 section {428 section {
423 h2 { "Code" }429 h2 { "Code" }
424 @for (repo, files) in content {430 @for (repo, rev, files) in content {
425 h3 { (repo) }431 h3 class="search-repo" { a href={ "/" (repo) } { (repo) } }
432 @let base = format!("/{repo}/-/blob/{rev}");
426 @for file in files {433 @for file in files {
427 (file_result(&format!("/{repo}"), None, file))434 (file_result(&base, Some(rev), file))
428 }435 }
429 }436 }
430 }437 }