Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
assets/base.css +21 −6
| @@ -793,6 +793,17 @@ select:focus { | |||
| 793 | } | 793 | } |
| 794 | 794 | ||
| 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 | } |
| 1224 | 1235 | ||
| 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 | } | ||
| 1225 | table.blob { | 1245 | table.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 | } |
| 1235 | table.blob td.lineno { | 1251 | table.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 { | |||
| 558 | fn highlighted_code(path: &FsPath, content: &[u8], attr_lang: Option<&str>) -> Markup { | 558 | fn 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( | |||
| 263 | 263 | ||
| 264 | /// Run the bounded content search across visible repos. Returns matches plus | 264 | /// 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. | ||
| 268 | type RepoMatches = (String, String, Vec<FileResult>); | ||
| 269 | |||
| 266 | async fn content_matches( | 270 | async 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( | |||
| 285 | 289 | ||
| 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(); |
| 289 | 293 | ||
| 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 | .await | 306 | .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 | } |
| 306 | 311 | ||
| @@ -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 | } |
| 417 | 422 | ||
| 418 | /// Render global content matches, grouped by repo then file. | 423 | /// Render global content matches, grouped by repo then file, each linking into |
| 419 | fn content_section(content: &[(String, Vec<FileResult>)]) -> Markup { | 424 | /// the blob at the repo's default branch. |
| 425 | fn 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 | } |