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 | 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 | 807 | .search-hit { | |
| 797 | 808 | padding: 0; | |
| 798 | 809 | overflow: hidden; | |
| @@ -1222,15 +1233,20 @@ pre.code { | |||
| 1222 | 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 | 1245 | table.blob { | |
| 1226 | 1246 | width: 100%; | |
| 1227 | 1247 | border-collapse: collapse; | |
| 1228 | - | border: 1px solid var(--fb-border); | |
| 1229 | - | border-radius: 0 0 var(--fb-radius) var(--fb-radius); | |
| 1230 | 1248 | font-family: var(--fb-font-mono); | |
| 1231 | 1249 | font-size: 0.85rem; | |
| 1232 | - | background: var(--fb-bg-inset); | |
| 1233 | - | overflow-x: auto; | |
| 1234 | 1250 | } | |
| 1235 | 1251 | table.blob td.lineno { | |
| 1236 | 1252 | width: 1%; | |
| @@ -1561,8 +1577,7 @@ td.diff-empty { | |||
| 1561 | 1577 | overflow-x: auto; | |
| 1562 | 1578 | -webkit-overflow-scrolling: touch; | |
| 1563 | 1579 | } | |
| 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). */ | |
| 1566 | 1581 | table.diff { | |
| 1567 | 1582 | display: block; | |
| 1568 | 1583 | overflow-x: auto; | |
crates/web/src/repo.rs +13 −11
| @@ -558,17 +558,19 @@ fn is_markdown(filename: &str) -> bool { | |||
| 558 | 558 | fn highlighted_code(path: &FsPath, content: &[u8], attr_lang: Option<&str>) -> Markup { | |
| 559 | 559 | let highlighted = highlight::highlight(path, content, attr_lang); | |
| 560 | 560 | 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 | + | } | |
| 572 | 574 | } | |
| 573 | 575 | } | |
| 574 | 576 | } | |
crates/web/src/search.rs +17 −10
| @@ -263,11 +263,15 @@ async fn entity_matches( | |||
| 263 | 263 | ||
| 264 | 264 | /// Run the bounded content search across visible repos. Returns matches plus | |
| 265 | 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 | 270 | async fn content_matches( | |
| 267 | 271 | state: &AppState, | |
| 268 | 272 | query: &ParsedQuery, | |
| 269 | 273 | viewer: Option<&User>, | |
| 270 | - | ) -> AppResult<(Vec<(String, Vec<FileResult>)>, bool, bool)> { | |
| 274 | + | ) -> AppResult<(Vec<RepoMatches>, bool, bool)> { | |
| 271 | 275 | let mut repos = visible_repos(state, viewer).await?; | |
| 272 | 276 | // Honour a repo: qualifier by filtering the candidate set. | |
| 273 | 277 | if let Some(repo_q) = &query.repo { | |
| @@ -285,7 +289,7 @@ async fn content_matches( | |||
| 285 | 289 | ||
| 286 | 290 | let deadline = Instant::now() + Duration::from_secs(state.config.search.timeout_secs.max(1)); | |
| 287 | 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 | 294 | for repo in repos { | |
| 291 | 295 | let state = state.clone(); | |
| @@ -294,13 +298,14 @@ async fn content_matches( | |||
| 294 | 298 | set.spawn(async move { | |
| 295 | 299 | let _permit = semaphore.acquire().await.ok()?; | |
| 296 | 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 | 303 | let files = crate::repo::git_read(&state, &repo.id, move |repo| { | |
| 299 | 304 | Ok(search_repo_blocking(repo, &rev, &query)) | |
| 300 | 305 | }) | |
| 301 | 306 | .await | |
| 302 | 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 | 324 | match tokio::time::timeout(remaining, set.join_next()).await { | |
| 320 | 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 | 327 | results.push(entry); | |
| 323 | 328 | if total >= cap { | |
| 324 | 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. | |
| 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 { | |
| 420 | 426 | html! { | |
| 421 | 427 | @if !content.is_empty() { | |
| 422 | 428 | section { | |
| 423 | 429 | 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}"); | |
| 426 | 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 | } | |