steid

@jamesgill /

feat: finding a string in a repository, and landing on the line

`git grep` over one revision, fixed-string. Not a regular expression: a search
box that reads `[` as syntax fails on the code people actually search for, and
nothing in the UI would explain why.

This is the one command in `git_query` whose exit status is an answer — grep
exits 1 when it matched nothing — so the module's rule that a non-zero exit is
always a fault gets its single, named exception rather than being quietly
relaxed. The output is read with `-z`, because the default separator is a colon
and a path may contain one; parsing lives in the application layer where it can
be tested on captured samples instead of on a repository that has to be built
first.

Every state is a page: no commits, no query, no matches, more matches than the
cap, a query too long to be anything but a paste, and a grep the read timeout
killed. The last is why the timeout was made visible in `GitQueryError` — a
search that takes twenty seconds is a search to narrow, not a 500.

The blob's line-number cells gain `id="L{n}"` so a result opens the file at the
line rather than at the top of it, and the toolbar's deliberately empty right
edge finally has the box `ui.md` reserved for it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EXVUnompCMVxQ28a9T3HAx
JamesPatrickGill authored 15 hours agoparentb7a57dbBrowse files1b7587dbde6cd1e49c609f4478b3877f7504c6c0

10 files changed+1245 −13

src/application/mod.rs+2 −0View file
@@ -15,6 +15,7 @@ pub mod login;
1515 pub mod port;
1616 pub mod profile;
1717 pub mod repo;
18+pub mod search;
1819 pub mod session;
1920 pub mod summary;
2021 pub mod token;
@@ -35,6 +36,7 @@ pub use repo::{
3536 NewRepo, RepoEdit, RepoSummary, RepoView, create_repo, delete_repo, list_repos, update_repo,
3637 view_repo,
3738 };
39+pub use search::{MAX_QUERY_BYTES, SEARCH_LIMIT, SearchFile, SearchResults, Searched, search_repo};
3840 pub use session::{SESSION_LIFETIME, end_session, record_session, resolve_actor, sweep_expired};
3941 pub use summary::{Licence, RepoFacts, repo_summary};
4042 pub use token::{
src/application/port.rs+23 −2View file
@@ -8,8 +8,8 @@ use std::{path::PathBuf, pin::Pin};
88 use tokio::io::AsyncRead;
99
1010 use crate::domain::{
11 BranchRow, CommitSummary, DomainError, GitRef, ObjectId, OrgName, PasswordHash, RefName,
12 RepoName, RepoPath, TagRow, TagSummary, TreeEntry,
11+ BranchRow, CommitSummary, DomainError, GitRef, GrepHit, ObjectId, OrgName, PasswordHash,
12+ RefName, RepoName, RepoPath, TagRow, TagSummary, TreeEntry,
1313 };
1414
1515 /// Hashes and verifies passwords.
@@ -421,6 +421,27 @@ pub trait GitQuery: Send + Sync {
421421 handle: &OrgName,
422422 name: &RepoName,
423423 ) -> impl Future<Output = Result<Vec<TagRow>, GitQueryError>> + Send;
424+
425+ /// Lines matching a fixed string, in the revision's tree.
426+ ///
427+ /// Fixed-string, not a regular expression: a search box that quietly reads `[` as
428+ /// syntax is a search box that fails on real code. Binary files are skipped, so a
429+ /// grep never reports a match nobody can read.
430+ ///
431+ /// `limit` caps the *hits carried back*, not what git does — `git grep` has no
432+ /// portable maximum, so it runs to completion and the surplus is dropped here. Ask
433+ /// for one more than is to be shown and the caller knows it truncated.
434+ ///
435+ /// An empty list is a real answer: no matches is not a failure, and neither is a
436+ /// query with nothing in the repository to match.
437+ fn grep(
438+ &self,
439+ handle: &OrgName,
440+ name: &RepoName,
441+ commit: &ObjectId,
442+ query: &str,
443+ limit: usize,
444+ ) -> impl Future<Output = Result<Vec<GrepHit>, GitQueryError>> + Send;
424445 }
425446 /// A repository could not be read.
426447 #[derive(Debug)]
src/application/search.rs+638 −0View file
@@ -0,0 +1,638 @@
1+//! Searching a repository's code.
2+//!
3+//! `git grep` over one revision's tree, fixed-string. The parsing of git's output lives
4+//! here rather than in the adapter because it is a parse of a documented text format,
5+//! and a parse with tests on captured samples is worth more than one that can only be
6+//! exercised by building a repository first.
7+//!
8+//! Authorization is not re-implemented: like everything in [`browse`](super::browse) it
9+//! goes through [`view_repo`](super::repo::view_repo), so a repository invisible on its
10+//! page cannot be searched either — including by guessing at the shape of its contents,
11+//! which an unauthorized search would leak a byte at a time.
12+
13+use crate::domain::{
14+ Actor, GrepHit, ObjectId, OrgName, RefName, RepoName, RepoPath,
15+ repository::{MembershipRepository, OrgRepository, RepoRepository},
16+};
17+
18+use super::{error::Result, port::GitQuery, repo::view_repo};
19+
20+/// How many matching lines a search shows.
21+///
22+/// A cap rather than paging, matching [`LOG_LIMIT`](super::browse::LOG_LIMIT): a search
23+/// that needs its 201st result needs a narrower query, and the page says so rather than
24+/// pretending the rest are not there.
25+pub const SEARCH_LIMIT: usize = 200;
26+
27+/// The longest query accepted, in bytes.
28+///
29+/// Nothing anybody types is this long; what is this long is a paste, and it becomes an
30+/// argument to a subprocess. Bytes rather than characters because that is what the
31+/// argument list is measured in.
32+pub const MAX_QUERY_BYTES: usize = 200;
33+
34+/// The longest line of a match carried back.
35+///
36+/// A minified bundle is one line, and a repository holding one should not be able to
37+/// make a search page megabytes long. Cut lines are marked so the page is not silently
38+/// lying about what is in the file.
39+pub const MAX_LINE_CHARS: usize = 500;
40+
41+/// One file's matches, in the order git reported them.
42+#[derive(Debug, Clone, PartialEq, Eq)]
43+pub struct SearchFile {
44+ pub path: RepoPath,
45+ pub matches: Vec<GrepHit>,
46+}
47+
48+/// What a search found.
49+#[derive(Debug, Clone, PartialEq, Eq)]
50+pub struct SearchResults {
51+ /// The revision searched, resolved — so the page can link its results to blobs and
52+ /// pre-fill its own form with a revision that is known to exist.
53+ pub rev: RefName,
54+ pub query: String,
55+ /// Grouped by file, files in the order their first match appeared.
56+ pub files: Vec<SearchFile>,
57+ pub matches: usize,
58+ /// Whether there were more matches than [`SEARCH_LIMIT`].
59+ pub truncated: bool,
60+}
61+
62+/// The states a search page can be in.
63+///
64+/// Every one of them is a page rather than an error, which is the point of the enum:
65+/// a repository with nothing in it, a query nobody should have sent, and a repository
66+/// too large to grep in the time allowed are all things to say, not 500s.
67+#[derive(Debug, Clone, PartialEq, Eq)]
68+pub enum Searched {
69+ /// No commits, so nothing to search.
70+ Empty,
71+ /// Longer than [`MAX_QUERY_BYTES`]. Refused rather than quietly shortened —
72+ /// searching for something other than what was typed and saying nothing is worse
73+ /// than saying no.
74+ QueryTooLong {
75+ rev: RefName,
76+ },
77+ /// git was killed by the read timeout. The page says so and keeps the form filled,
78+ /// because the answer to a search that took too long is a narrower search.
79+ TimedOut {
80+ rev: RefName,
81+ query: String,
82+ },
83+ Found(SearchResults),
84+}
85+
86+/// Searches one revision of a repository for a fixed string.
87+///
88+/// `rev` of `None` means the default branch, which is what the search box on a
89+/// repository's page asks for.
90+///
91+/// **Two or three `git` processes**: resolving the revision, plus the default-branch
92+/// lookup when the caller did not name one, plus the grep itself — and none of the last
93+/// when the query is empty, which is the state the page opens in.
94+///
95+/// `Ok(None)` means the repository is invisible, absent, or the named revision is not
96+/// there — one answer, for the reason [`view_repo`](super::repo::view_repo) gives.
97+#[allow(clippy::too_many_arguments)]
98+pub async fn search_repo(
99+ handle: &OrgName,
100+ name: &RepoName,
101+ rev: Option<&RefName>,
102+ query: &str,
103+ actor: &Actor,
104+ orgs: &impl OrgRepository,
105+ memberships: &impl MembershipRepository,
106+ repos: &impl RepoRepository,
107+ queries: &impl GitQuery,
108+) -> Result<Option<Searched>> {
109+ if view_repo(handle, name, actor, orgs, memberships, repos)
110+ .await?
111+ .is_none()
112+ {
113+ return Ok(None);
114+ }
115+
116+ let rev = match rev {
117+ Some(rev) => rev.clone(),
118+ None => match queries.default_branch(handle, name).await? {
119+ Some(branch) => branch,
120+ None => return Ok(Some(Searched::Empty)),
121+ },
122+ };
123+
124+ // Resolved before grepping: `git grep` on a revision that is not there is fatal, and
125+ // this module's adapter treats a fatal git as a real fault. A revision that does not
126+ // resolve is a 404, the same as it is on the tree pages.
127+ let Some(commit) = queries.resolve(handle, name, &rev).await? else {
128+ return Ok(None);
129+ };
130+
131+ let query = query.trim();
132+
133+ if query.len() > MAX_QUERY_BYTES {
134+ return Ok(Some(Searched::QueryTooLong { rev }));
135+ }
136+
137+ // No query is the page's opening state, not a search for nothing. Spending a
138+ // subprocess to learn that everything matches the empty string would be worse than
139+ // useless: `git grep -F ""` matches every line of every file.
140+ if query.is_empty() {
141+ return Ok(Some(Searched::Found(SearchResults {
142+ rev,
143+ query: String::new(),
144+ files: Vec::new(),
145+ matches: 0,
146+ truncated: false,
147+ })));
148+ }
149+
150+ // One more than is shown, which is how truncation is detected without a second
151+ // question.
152+ let hits = match queries
153+ .grep(handle, name, &commit, query, SEARCH_LIMIT + 1)
154+ .await
155+ {
156+ Ok(hits) => hits,
157+ Err(error) if error.is_timeout() => {
158+ return Ok(Some(Searched::TimedOut {
159+ rev,
160+ query: query.to_owned(),
161+ }));
162+ }
163+ Err(error) => return Err(error.into()),
164+ };
165+
166+ let truncated = hits.len() > SEARCH_LIMIT;
167+ let mut hits = hits;
168+ hits.truncate(SEARCH_LIMIT);
169+
170+ Ok(Some(Searched::Found(SearchResults {
171+ rev,
172+ query: query.to_owned(),
173+ matches: hits.len(),
174+ files: group_by_file(hits),
175+ truncated,
176+ })))
177+}
178+
179+/// Collects hits into one entry per file, keeping git's order.
180+///
181+/// `git grep` already emits a file's matches together, so this is a fold rather than a
182+/// sort — and keeping git's order means the first file on the page is the first file in
183+/// the tree, which is at least a stable answer.
184+fn group_by_file(hits: Vec<GrepHit>) -> Vec<SearchFile> {
185+ let mut files: Vec<SearchFile> = Vec::new();
186+
187+ for hit in hits {
188+ match files.last_mut() {
189+ Some(file) if file.path == hit.path => file.matches.push(hit),
190+ _ => files.push(SearchFile {
191+ path: hit.path.clone(),
192+ matches: vec![hit],
193+ }),
194+ }
195+ }
196+
197+ files
198+}
199+
200+/// Parses `git grep -z -n --column` output for one revision.
201+///
202+/// Each record is `<commit>:<path> NUL <line> NUL <column> NUL <text> LF`. `-z` is what
203+/// makes this parseable at all: without it the separator is a colon, and a path may
204+/// contain colons — so `src/a:b.rs:12:3:text` has no unambiguous reading. With it the
205+/// only ambiguity left is a newline inside a *path*, which is why records are found by
206+/// walking the NULs rather than by splitting on lines.
207+///
208+/// Anything unreadable ends the parse rather than failing it: a partial answer from a
209+/// search is more use than an error page, and the alternative is one strange path in one
210+/// repository breaking the whole feature.
211+fn parse_grep(stdout: &[u8], prefix: &str, limit: usize) -> Vec<GrepHit> {
212+ let mut hits = Vec::new();
213+ let mut rest = stdout;
214+
215+ while hits.len() < limit && !rest.is_empty() {
216+ let Some((path, after)) = take_field(rest) else {
217+ break;
218+ };
219+ let Some((line, after)) = take_field(after) else {
220+ break;
221+ };
222+ let Some((column, after)) = take_field(after) else {
223+ break;
224+ };
225+
226+ // The text runs to the end of the line, and a matched line cannot itself contain
227+ // a newline — that is what makes it a line.
228+ let (text, after) = match after.iter().position(|byte| *byte == b'\n') {
229+ Some(end) => (&after[..end], &after[end + 1..]),
230+ None => (after, &after[after.len()..]),
231+ };
232+
233+ rest = after;
234+
235+ // Lossy: a path or a line that is not UTF-8 still gets shown, with replacement
236+ // characters, rather than disappearing from the results. Dropping a match
237+ // silently would make a search quietly incomplete, which is worse than ugly.
238+ let path = String::from_utf8_lossy(path);
239+ let Some(path) = path.strip_prefix(prefix) else {
240+ continue;
241+ };
242+ let Ok(path) = RepoPath::new(path) else {
243+ continue;
244+ };
245+
246+ let Ok(line) = String::from_utf8_lossy(line).parse() else {
247+ continue;
248+ };
249+ let Ok(column) = String::from_utf8_lossy(column).parse() else {
250+ continue;
251+ };
252+
253+ hits.push(GrepHit {
254+ path,
255+ line,
256+ column,
257+ text: cut(&String::from_utf8_lossy(text)),
258+ });
259+ }
260+
261+ hits
262+}
263+
264+/// Splits off one NUL-terminated field.
265+fn take_field(bytes: &[u8]) -> Option<(&[u8], &[u8])> {
266+ let end = bytes.iter().position(|byte| *byte == 0)?;
267+
268+ Some((&bytes[..end], &bytes[end + 1..]))
269+}
270+
271+/// A matched line, shortened if it is absurd.
272+fn cut(line: &str) -> String {
273+ if line.chars().count() <= MAX_LINE_CHARS {
274+ return line.to_owned();
275+ }
276+
277+ let mut cut: String = line.chars().take(MAX_LINE_CHARS).collect();
278+ cut.push('…');
279+ cut
280+}
281+
282+/// Parses git's output for a resolved commit.
283+///
284+/// The prefix is `{commit}:`, which git puts before every path when it is grepping a
285+/// tree rather than a working copy. Passed rather than derived so the parser has no
286+/// opinion about what a commit id looks like.
287+pub fn parse_grep_output(stdout: &[u8], commit: &ObjectId, limit: usize) -> Vec<GrepHit> {
288+ parse_grep(stdout, &format!("{}:", commit.as_str()), limit)
289+}
290+
291+#[cfg(test)]
292+mod tests {
293+ use std::time::SystemTime;
294+
295+ use super::*;
296+ use crate::{
297+ domain::{
298+ Membership, MembershipId, OrgId, Organization, RepoId, Repository, UserId, Visibility,
299+ },
300+ infrastructure::{
301+ git::InMemoryGitQuery,
302+ repository::{InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryRepoRepo},
303+ },
304+ };
305+
306+ const COMMIT: &str = "a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0";
307+
308+ fn commit() -> ObjectId {
309+ ObjectId::new(COMMIT).expect("a valid object id")
310+ }
311+
312+ /// Builds the bytes git writes: `<commit>:<path> NUL <line> NUL <column> NUL <text> LF`.
313+ fn record(path: &str, line: u32, column: u32, text: &str) -> Vec<u8> {
314+ let mut bytes = format!("{COMMIT}:{path}").into_bytes();
315+ bytes.push(0);
316+ bytes.extend_from_slice(line.to_string().as_bytes());
317+ bytes.push(0);
318+ bytes.extend_from_slice(column.to_string().as_bytes());
319+ bytes.push(0);
320+ bytes.extend_from_slice(text.as_bytes());
321+ bytes.push(b'\n');
322+ bytes
323+ }
324+
325+ // --- parsing ------------------------------------------------------------------
326+
327+ #[test]
328+ fn a_match_is_a_path_a_line_a_column_and_the_line_itself() {
329+ let out = record("src/main.rs", 12, 5, " let needle = 1;");
330+
331+ assert_eq!(
332+ parse_grep_output(&out, &commit(), 10),
333+ vec![GrepHit {
334+ path: RepoPath::new("src/main.rs").expect("valid"),
335+ line: 12,
336+ column: 5,
337+ text: " let needle = 1;".to_owned(),
338+ }]
339+ );
340+ }
341+
342+ #[test]
343+ fn a_path_with_a_space_survives() {
344+ // The reason the separator is a NUL rather than a colon: a name may contain
345+ // almost anything, and splitting on punctuation misreads real repositories.
346+ let mut out = record("docs/design notes.md", 3, 1, "needle");
347+ out.extend(record("src/b.rs", 9, 2, "needle again"));
348+
349+ let hits = parse_grep_output(&out, &commit(), 10);
350+
351+ assert_eq!(hits.len(), 2);
352+ assert_eq!(hits[0].path.as_str(), "docs/design notes.md");
353+ assert_eq!(hits[1].path.as_str(), "src/b.rs");
354+ }
355+
356+ #[test]
357+ fn a_path_steid_cannot_address_is_left_out_of_the_results() {
358+ // `RepoPath` refuses a colon, because git reads one as "revision:path" — so a
359+ // file named that way cannot be browsed either. A result linking to a page that
360+ // must 404 is worse than a result that is not there.
361+ let out = record("src/a:b.rs", 9, 2, "needle");
362+
363+ assert_eq!(parse_grep_output(&out, &commit(), 10), Vec::new());
364+ }
365+
366+ #[test]
367+ fn a_line_containing_colons_is_carried_whole() {
368+ let out = record("Cargo.toml", 4, 1, "url = \"https://example.com:8443/x\"");
369+
370+ let hits = parse_grep_output(&out, &commit(), 10);
371+
372+ assert_eq!(hits[0].text, "url = \"https://example.com:8443/x\"");
373+ }
374+
375+ #[test]
376+ fn a_binary_file_is_simply_absent() {
377+ // `-I` means git never reports one, so there is nothing to skip here — this
378+ // pins the expectation rather than the code: output from a repository full of
379+ // binaries is output with no records in it.
380+ assert_eq!(parse_grep_output(b"", &commit(), 10), Vec::new());
381+ }
382+
383+ #[test]
384+ fn a_record_for_another_commit_is_ignored() {
385+ // Cannot happen from one grep, and if it ever did it would put a result under a
386+ // revision the page is not showing — which would link to the wrong file.
387+ let out = record("src/main.rs", 1, 1, "needle");
388+
389+ assert_eq!(
390+ parse_grep_output(&out, &ObjectId::new("0".repeat(40)).expect("valid"), 10),
391+ Vec::new()
392+ );
393+ }
394+
395+ #[test]
396+ fn the_limit_stops_the_parse_rather_than_the_output() {
397+ let mut out = Vec::new();
398+ for line in 1..=10 {
399+ out.extend(record("src/main.rs", line, 1, "needle"));
400+ }
401+
402+ assert_eq!(parse_grep_output(&out, &commit(), 3).len(), 3);
403+ }
404+
405+ #[test]
406+ fn an_absurdly_long_line_is_cut() {
407+ let long = "x".repeat(MAX_LINE_CHARS + 50);
408+ let out = record("bundle.js", 1, 1, &long);
409+
410+ let hits = parse_grep_output(&out, &commit(), 10);
411+
412+ assert_eq!(hits[0].text.chars().count(), MAX_LINE_CHARS + 1);
413+ assert!(hits[0].text.ends_with('…'));
414+ }
415+
416+ #[test]
417+ fn truncated_output_stops_where_it_stops() {
418+ // A killed git can leave a half-written record. Half a result is better than an
419+ // error page.
420+ let mut out = record("src/main.rs", 1, 1, "needle");
421+ out.extend_from_slice(format!("{COMMIT}:src/other.rs").as_bytes());
422+
423+ assert_eq!(parse_grep_output(&out, &commit(), 10).len(), 1);
424+ }
425+
426+ #[test]
427+ fn matches_are_grouped_by_file_in_gits_order() {
428+ let mut out = record("src/a.rs", 1, 1, "needle");
429+ out.extend(record("src/a.rs", 9, 1, "needle"));
430+ out.extend(record("src/b.rs", 2, 1, "needle"));
431+
432+ let files = group_by_file(parse_grep_output(&out, &commit(), 10));
433+
434+ assert_eq!(files.len(), 2);
435+ assert_eq!(files[0].path.as_str(), "src/a.rs");
436+ assert_eq!(files[0].matches.len(), 2);
437+ assert_eq!(files[1].matches.len(), 1);
438+ }
439+
440+ // --- the use case -------------------------------------------------------------
441+
442+ struct Fixture {
443+ orgs: InMemoryOrgRepo,
444+ memberships: InMemoryMembershipRepo,
445+ repos: InMemoryRepoRepo,
446+ handle: OrgName,
447+ owner: Actor,
448+ stranger: Actor,
449+ }
450+
451+ async fn fixture(visibility: Visibility) -> Fixture {
452+ let orgs = InMemoryOrgRepo::new();
453+ let memberships = InMemoryMembershipRepo::new();
454+ let repos = InMemoryRepoRepo::new();
455+
456+ let org = Organization::new(OrgId::generate(), "acme", None).expect("valid org");
457+ orgs.save(&org).await.expect("save org");
458+
459+ let owner = UserId::generate();
460+ memberships
461+ .save(&Membership::new(
462+ MembershipId::generate(),
463+ org.id.clone(),
464+ owner.clone(),
465+ crate::domain::Role::Owner,
466+ ))
467+ .await
468+ .expect("save membership");
469+
470+ repos
471+ .save(
472+ &Repository::new(
473+ RepoId::generate(),
474+ org.id.clone(),
475+ "steid",
476+ None,
477+ visibility,
478+ SystemTime::now(),
479+ )
480+ .expect("valid repository"),
481+ )
482+ .await
483+ .expect("save repo");
484+
485+ Fixture {
486+ orgs,
487+ memberships,
488+ repos,
489+ handle: org.name,
490+ owner: Actor::User(owner),
491+ stranger: Actor::Anonymous,
492+ }
493+ }
494+
495+ fn repo_name() -> RepoName {
496+ RepoName::new("steid").expect("valid repository name")
497+ }
498+
499+ impl Fixture {
500+ async fn search(
501+ &self,
502+ actor: &Actor,
503+ query: &str,
504+ queries: &InMemoryGitQuery,
505+ ) -> Result<Option<Searched>> {
506+ search_repo(
507+ &self.handle,
508+ &repo_name(),
509+ None,
510+ query,
511+ actor,
512+ &self.orgs,
513+ &self.memberships,
514+ &self.repos,
515+ queries,
516+ )
517+ .await
518+ }
519+ }
520+
521+ fn hit(path: &str, line: u32) -> GrepHit {
522+ GrepHit {
523+ path: RepoPath::new(path).expect("valid"),
524+ line,
525+ column: 1,
526+ text: "needle".to_owned(),
527+ }
528+ }
529+
530+ #[tokio::test]
531+ async fn a_search_comes_back_grouped_by_file() {
532+ let f = fixture(Visibility::Public).await;
533+ let queries = InMemoryGitQuery::new().with_grep_hits(vec![
534+ hit("src/a.rs", 1),
535+ hit("src/a.rs", 4),
536+ hit("b.rs", 2),
537+ ]);
538+
539+ let Some(Searched::Found(results)) = f.search(&f.owner, "needle", &queries).await.unwrap()
540+ else {
541+ panic!("expected results");
542+ };
543+
544+ assert_eq!(results.matches, 3);
545+ assert_eq!(results.files.len(), 2);
546+ assert!(!results.truncated);
547+ }
548+
549+ #[tokio::test]
550+ async fn more_matches_than_the_cap_are_reported_as_truncated() {
551+ let f = fixture(Visibility::Public).await;
552+ let hits = (1..=(SEARCH_LIMIT as u32 + 1))
553+ .map(|line| hit("src/a.rs", line))
554+ .collect();
555+ let queries = InMemoryGitQuery::new().with_grep_hits(hits);
556+
557+ let Some(Searched::Found(results)) = f.search(&f.owner, "needle", &queries).await.unwrap()
558+ else {
559+ panic!("expected results");
560+ };
561+
562+ assert!(results.truncated);
563+ assert_eq!(results.matches, SEARCH_LIMIT);
564+ }
565+
566+ #[tokio::test]
567+ async fn an_empty_query_searches_nothing_at_all() {
568+ // Not one subprocess: `-F ""` matches every line of every file, so the opening
569+ // state of the page must not reach git.
570+ let f = fixture(Visibility::Public).await;
571+ let queries = InMemoryGitQuery::new().with_grep_hits(vec![hit("src/a.rs", 1)]);
572+
573+ let Some(Searched::Found(results)) = f.search(&f.owner, " ", &queries).await.unwrap()
574+ else {
575+ panic!("expected results");
576+ };
577+
578+ assert_eq!(results.query, "");
579+ assert!(results.files.is_empty());
580+ }
581+
582+ #[tokio::test]
583+ async fn an_over_long_query_is_refused_rather_than_shortened() {
584+ let f = fixture(Visibility::Public).await;
585+ let query = "x".repeat(MAX_QUERY_BYTES + 1);
586+
587+ assert!(matches!(
588+ f.search(&f.owner, &query, &InMemoryGitQuery::new())
589+ .await
590+ .unwrap(),
591+ Some(Searched::QueryTooLong { .. })
592+ ));
593+ }
594+
595+ #[tokio::test]
596+ async fn an_empty_repository_has_nothing_to_search() {
597+ let f = fixture(Visibility::Public).await;
598+
599+ assert_eq!(
600+ f.search(&f.owner, "needle", &InMemoryGitQuery::empty())
601+ .await
602+ .unwrap(),
603+ Some(Searched::Empty)
604+ );
605+ }
606+
607+ #[tokio::test]
608+ async fn a_timeout_is_a_state_rather_than_a_failure() {
609+ let f = fixture(Visibility::Public).await;
610+ let queries = InMemoryGitQuery::new().with_slow_grep();
611+
612+ assert!(matches!(
613+ f.search(&f.owner, "needle", &queries).await.unwrap(),
614+ Some(Searched::TimedOut { .. })
615+ ));
616+ }
617+
618+ #[tokio::test]
619+ async fn a_private_repository_cannot_be_searched_by_a_stranger() {
620+ // A search that ran would leak file contents a line at a time, which is a
621+ // worse leak than the page it is refused on.
622+ let f = fixture(Visibility::Private).await;
623+ let queries = InMemoryGitQuery::new().with_grep_hits(vec![hit("secret.txt", 1)]);
624+
625+ assert!(
626+ f.search(&f.stranger, "needle", &queries)
627+ .await
628+ .unwrap()
629+ .is_none()
630+ );
631+ assert!(
632+ f.search(&f.owner, "needle", &queries)
633+ .await
634+ .unwrap()
635+ .is_some()
636+ );
637+ }
638+}
src/domain/mod.rs+2 −1View file
@@ -25,7 +25,8 @@ pub use error::DomainError;
2525 pub use id::{MembershipId, OrgId, RepoId, TokenId, UserId};
2626 pub use membership::{Membership, Role};
2727 pub use object::{
28 BranchRow, CommitSummary, EntryKind, GitRef, ObjectId, RefKind, TagRow, TagSummary, TreeEntry,
28+ BranchRow, CommitSummary, EntryKind, GitRef, GrepHit, ObjectId, RefKind, TagRow, TagSummary,
29+ TreeEntry,
2930 };
3031 pub use org::{OrgName, Organization};
3132 pub use password::PasswordHash;
src/domain/object.rs+19 −1View file
@@ -7,7 +7,7 @@
77
88 use std::{fmt, time::SystemTime};
99
10use super::{DomainError, RefName};
10+use super::{DomainError, RefName, RepoPath};
1111
1212 /// The id of a git object, hex-encoded.
1313 ///
@@ -229,6 +229,24 @@ pub struct TagRow {
229229 pub created_at: SystemTime,
230230 }
231231
232+/// One matching line, as `git grep` reports it.
233+///
234+/// Carries the whole line rather than the matched fragment: a line can hold more than
235+/// one match, and deciding which parts to mark is the page's job, not the port's.
236+#[derive(Debug, Clone, PartialEq, Eq)]
237+pub struct GrepHit {
238+ pub path: RepoPath,
239+ /// 1-based, the way git counts and the way a `#L…` anchor spells it.
240+ pub line: u32,
241+ /// 1-based, at the first match on the line. Not used for rendering — the whole
242+ /// line is re-scanned for every occurrence — but it is what git was asked for and
243+ /// it is the answer to "where", which nothing else here holds.
244+ pub column: u32,
245+ /// The line itself, without its newline, and cut if it is absurdly long — see
246+ /// `application::search`.
247+ pub text: String,
248+}
249+
232250 #[cfg(test)]
233251 mod tests {
234252 use super::*;
src/infrastructure/git.rs+36 −2View file
@@ -26,8 +26,8 @@ use crate::{
2626 GitStorageError,
2727 },
2828 domain::{
29 BranchRow, CommitSummary, GitRef, ObjectId, OrgName, RefKind, RefName, RepoName, RepoPath,
30 TagRow, TagSummary, TreeEntry,
29+ BranchRow, CommitSummary, GitRef, GrepHit, ObjectId, OrgName, RefKind, RefName, RepoName,
30+ RepoPath, TagRow, TagSummary, TreeEntry,
3131 },
3232 };
3333
@@ -632,6 +632,12 @@ pub struct InMemoryGitQuery {
632632 /// answer different questions and a test usually wants only one of them.
633633 branch_rows: Vec<BranchRow>,
634634 tag_rows: Vec<TagRow>,
635+ /// What any grep answers with, regardless of the query — a fake that matched text
636+ /// would be a second, worse implementation of `git grep`.
637+ grep_hits: Vec<GrepHit>,
638+ /// Makes the next grep report the read timeout, which is a page state rather than
639+ /// a failure and therefore worth a test.
640+ slow_grep: bool,
635641 }
636642
637643 impl InMemoryGitQuery {
@@ -724,6 +730,19 @@ impl InMemoryGitQuery {
724730 self
725731 }
726732
733+ /// Seeds what a search finds. Order is kept, because grouping by file depends on
734+ /// git's ordering and a fake that reordered would hide that.
735+ pub fn with_grep_hits(mut self, hits: Vec<GrepHit>) -> Self {
736+ self.grep_hits = hits;
737+ self
738+ }
739+
740+ /// A repository whose grep is killed by the timeout.
741+ pub fn with_slow_grep(mut self) -> Self {
742+ self.slow_grep = true;
743+ self
744+ }
745+
727746 fn with_ref(mut self, name: &str, kind: RefKind) -> Self {
728747 self.refs.push(GitRef {
729748 name: RefName::from_trusted(name),
@@ -835,6 +854,21 @@ impl GitQuery for InMemoryGitQuery {
835854 ) -> Result<Vec<TagRow>, GitQueryError> {
836855 Ok(self.tag_rows.clone())
837856 }
857+
858+ async fn grep(
859+ &self,
860+ _handle: &OrgName,
861+ _name: &RepoName,
862+ _commit: &ObjectId,
863+ _query: &str,
864+ limit: usize,
865+ ) -> Result<Vec<GrepHit>, GitQueryError> {
866+ if self.slow_grep {
867+ return Err(GitQueryError::timed_out(std::time::Duration::from_secs(20)));
868+ }
869+
870+ Ok(self.grep_hits.iter().take(limit).cloned().collect())
871+ }
838872 }
839873
840874 #[cfg(test)]
src/infrastructure/git_query.rs+145 −4View file
@@ -38,10 +38,13 @@ use std::{
3838 use tokio::io::AsyncWriteExt;
3939
4040 use crate::{
41 application::port::{Blob, GitQuery, GitQueryError},
41+ application::{
42+ port::{Blob, GitQuery, GitQueryError},
43+ search::parse_grep_output,
44+ },
4245 domain::{
43 BranchRow, CommitSummary, EntryKind, GitRef, ObjectId, OrgName, RefKind, RefName, RepoName,
44 RepoPath, TagRow, TagSummary, TreeEntry,
46+ BranchRow, CommitSummary, EntryKind, GitRef, GrepHit, ObjectId, OrgName, RefKind, RefName,
47+ RepoName, RepoPath, TagRow, TagSummary, TreeEntry,
4548 },
4649 infrastructure::git::git_command,
4750 };
@@ -391,6 +394,47 @@ impl GitQuery for DiskGitQuery {
391394
392395 parse_tags(&output.stdout)
393396 }
397+
398+ async fn grep(
399+ &self,
400+ handle: &OrgName,
401+ name: &RepoName,
402+ commit: &ObjectId,
403+ query: &str,
404+ limit: usize,
405+ ) -> Result<Vec<GrepHit>, GitQueryError> {
406+ let repo = self.repo_path(handle, name);
407+
408+ // **The one command here whose exit status is an answer**: `git grep` exits 1
409+ // when it found nothing, which is not a failure. Everything else in this module
410+ // keeps the rule that a non-zero exit is a fault; this is the exception, and it
411+ // is spelled out in the call rather than hidden in the helper.
412+ //
413+ // `-F` fixed strings, `-I` skips binary files, `-n --column` locate the match,
414+ // and `-z` makes the output parseable — see `parse_grep_output`. The query is
415+ // passed after `-e`, so a query starting with `-` is a search rather than a
416+ // flag, and the commit is one git resolved rather than anything from a URL.
417+ let output = run_allowing(
418+ &repo,
419+ [
420+ OsStr::new("grep"),
421+ OsStr::new("-I"),
422+ OsStr::new("-n"),
423+ OsStr::new("-z"),
424+ OsStr::new("-F"),
425+ OsStr::new("--column"),
426+ OsStr::new("--no-color"),
427+ OsStr::new("-e"),
428+ OsStr::new(query),
429+ OsStr::new(commit.as_str()),
430+ OsStr::new("--"),
431+ ],
432+ &[NO_MATCHES],
433+ )
434+ .await?;
435+
436+ Ok(parse_grep_output(&output.stdout, commit, limit))
437+ }
394438 }
395439
396440 /// What `cat-file --batch-check` said about one object.
@@ -891,9 +935,43 @@ where
891935 run_within(repo, args, GIT_TIMEOUT).await
892936 }
893937
938+/// What `git grep` exits with when it matched nothing.
939+///
940+/// A value, not a failure — the one place in this module where git's exit status
941+/// carries an answer. Named so the exception is legible at the call site.
942+const NO_MATCHES: i32 = 1;
943+
944+/// [`run`] for a command whose exit status is partly an answer.
945+///
946+/// Exists for `git grep` alone. Every other command here is asked about something
947+/// `cat-file --batch-check` has already confirmed exists, which is what makes the
948+/// module's "a non-zero exit is always a fault" rule hold; grep is the one command
949+/// whose whole job is to find nothing sometimes.
950+async fn run_allowing<I, S>(repo: &Path, args: I, allowed: &[i32]) -> Result<Output, GitQueryError>
951+where
952+ I: IntoIterator<Item = S>,
953+ S: AsRef<OsStr>,
954+{
955+ run_within_allowing(repo, args, GIT_TIMEOUT, allowed).await
956+}
957+
894958 /// [`run`] with an explicit limit, so the timeout path can be tested without waiting
895959 /// twenty seconds for it.
896960 async fn run_within<I, S>(repo: &Path, args: I, limit: Duration) -> Result<Output, GitQueryError>
961+where
962+ I: IntoIterator<Item = S>,
963+ S: AsRef<OsStr>,
964+{
965+ run_within_allowing(repo, args, limit, &[]).await
966+}
967+
968+/// The whole of running git: isolation, the timeout, and the status rule.
969+async fn run_within_allowing<I, S>(
970+ repo: &Path,
971+ args: I,
972+ limit: Duration,
973+ allowed: &[i32],
974+) -> Result<Output, GitQueryError>
897975 where
898976 I: IntoIterator<Item = S>,
899977 S: AsRef<OsStr>,
@@ -915,7 +993,12 @@ where
915993 Err(_elapsed) => return Err(GitQueryError::timed_out(limit)),
916994 };
917995
918 if !output.status.success() {
996+ let expected = output
997+ .status
998+ .code()
999+ .is_some_and(|code| allowed.contains(&code));
1000+
1001+ if !output.status.success() && !expected {
9191002 return Err(GitQueryError::new(format!(
9201003 "git exited with {}: {}",
9211004 output.status,
@@ -1071,6 +1154,64 @@ mod tests {
10711154 .collect()
10721155 }
10731156
1157+ // --- grep ------------------------------------------------------------------
1158+
1159+ async fn hits(query: &str, limit: usize) -> Vec<GrepHit> {
1160+ let (_dir, query_port) = populated();
1161+ let commit = query_port
1162+ .resolve(&handle(), &repo_name(), &rev("main"))
1163+ .await
1164+ .expect("should read")
1165+ .expect("main resolves");
1166+
1167+ query_port
1168+ .grep(&handle(), &repo_name(), &commit, query, limit)
1169+ .await
1170+ .expect("should grep")
1171+ }
1172+
1173+ #[tokio::test]
1174+ async fn a_search_finds_the_line_it_matched() {
1175+ let found = hits("fn main", 10).await;
1176+
1177+ assert_eq!(found.len(), 1);
1178+ assert_eq!(found[0].path.as_str(), "src/deep/file.rs");
1179+ assert_eq!(found[0].line, 1);
1180+ assert_eq!(found[0].text, "fn main() {}");
1181+ }
1182+
1183+ #[tokio::test]
1184+ async fn a_search_that_matches_nothing_is_not_a_failure() {
1185+ // `git grep` exits 1 here, which every other command in this module would treat
1186+ // as a fault. This is the one exception, and this test is what pins it.
1187+ assert_eq!(hits("nothing matches this", 10).await, Vec::new());
1188+ }
1189+
1190+ #[tokio::test]
1191+ async fn a_binary_file_is_never_reported() {
1192+ // `bin.dat` contains 0x00 0x01 0xff 0xfe 0x80, so a byte-wise search would hit
1193+ // it. `-I` is what keeps unreadable matches off the page.
1194+ let found = hits("\u{fffd}", 10).await;
1195+
1196+ assert!(
1197+ found.iter().all(|hit| hit.path.as_str() != "bin.dat"),
1198+ "a binary file should never appear in results"
1199+ );
1200+ }
1201+
1202+ #[tokio::test]
1203+ async fn a_search_is_a_fixed_string_not_a_pattern() {
1204+ // `.` would match every line if this were a regular expression.
1205+ assert_eq!(hits("hello.again", 10).await, Vec::new());
1206+ }
1207+
1208+ #[tokio::test]
1209+ async fn the_limit_bounds_what_comes_back() {
1210+ // Every file in the fixture contains an `e` somewhere, so this is more than one
1211+ // match without depending on how many.
1212+ assert_eq!(hits("e", 2).await.len(), 2);
1213+ }
1214+
10741215 // --- an empty repository ---------------------------------------------------
10751216
10761217 #[tokio::test]
src/infrastructure/web/browse.rs+24 −3View file
@@ -50,6 +50,7 @@ use super::{
5050 layout::wide,
5151 refs::{branches_url, tags_url},
5252 repo::{Tab, clone_url_for, repo_for, repo_header},
53+ search::search_form,
5354 };
5455
5556 /// `{rev}` from the path, raw — validation is [`RefName`]'s job.
@@ -598,8 +599,11 @@ fn civil_from_days(days: i64) -> (i64, u32, u32) {
598599 ///
599600 /// The revision switcher, then what else the repository has. The counts are links now
600601 /// that `/branches` and `/tags` exist — they were plain text only because a dead link is
601/// worse than a number. The right of the row is deliberately empty; a code-search box
602/// lands there.
602+/// worse than a number.
603+///
604+/// The search box sits on the right, the width of the About sidebar above it, so the
605+/// two columns of the landing page line up. It searches the revision being browsed,
606+/// which is what somebody looking at a tag means by "search this".
603607 #[component]
604608 pub(super) async fn repo_toolbar(
605609 handle: &str,
@@ -628,6 +632,16 @@ pub(super) async fn repo_toolbar(
628632 (counted(refs.tags.len(), "tag", "tags"))
629633 </a>
630634 </span>
635+
636+ <div class="w-full sm:ml-auto sm:w-auto">
637+ search_form(
638+ handle: handle,
639+ name: name,
640+ rev: rev,
641+ query: "",
642+ full_width: false,
643+ )
644+ </div>
631645 </div>
632646 }
633647 }
@@ -987,6 +1001,10 @@ pub(super) async fn blob(
9871001 /// Highlighting only ever changes what is *inside* the code cell — the rows, the
9881002 /// numbers and the scroll container are the same whether a language was recognised or
9891003 /// not, so nothing else on the page has to know.
1004+///
1005+///
1006+/// Each number cell carries an `id="L{n}"`, which is what a search result links to: a
1007+/// match on line 400 of a long file should land on line 400.
9901008 #[component]
9911009 async fn source(text: &str, file_name: &str) -> Result {
9921010 let Source { lines, too_large } = source_lines(file_name, text);
@@ -1003,7 +1021,10 @@ async fn source(text: &str, file_name: &str) -> Result {
10031021 <tbody>
10041022 for (index, line) in lines.into_iter().enumerate() {
10051023 <tr>
1006 <td class="w-px select-none border-r border-border px-3 text-right align-top text-muted-foreground">
1024+ <td
1025+ id=(format!("L{}", index + 1))
1026+ class="w-px select-none border-r border-border px-3 text-right align-top text-muted-foreground"
1027+ >
10071028 ((index + 1).to_string())
10081029 </td>
10091030 <td class="whitespace-pre px-4 align-top">
src/infrastructure/web/mod.rs+1 −0View file
@@ -14,6 +14,7 @@ pub mod rate_limit;
1414 pub mod refs;
1515 pub mod repo;
1616 pub mod repo_settings;
17+pub mod search;
1718 pub mod security_headers;
1819 pub mod session_cookie;
1920 pub mod settings;
src/infrastructure/web/search.rs+355 −0View file
@@ -0,0 +1,355 @@
1+//! Code search — `/{handle}/repos/{name}/search?q=…&rev=…`.
2+//!
3+//! A plain `GET` form and a page of results. No JavaScript: the query is in the URL, so
4+//! a search is a link that can be shared, bookmarked and gone back to, which is most of
5+//! what a search box is for.
6+//!
7+//! Every state is a page rather than an error — see [`Searched`]. A repository with no
8+//! commits, a query that was too long, and a grep the timeout killed all render the form
9+//! with an explanation, because in each case the next thing the visitor does is type
10+//! again.
11+
12+use topcoat::{
13+ Result,
14+ context::Cx,
15+ icon::{icon, iconify::iconify_icon},
16+ router::{
17+ error::{RouterErrorExt, not_found},
18+ page, query_params,
19+ },
20+ view::{component, view},
21+};
22+
23+use crate::{
24+ application::{
25+ MAX_QUERY_BYTES, SEARCH_LIMIT, SearchFile, SearchResults, Searched, search_repo,
26+ },
27+ domain::RefName,
28+};
29+
30+use super::{
31+ browse::tree_url,
32+ context::{current_actor, memberships, orgs, queries, repos, server_error},
33+ layout::wide,
34+ repo::{Tab, repo_for, repo_header},
35+};
36+
37+/// `?q=` and `?rev=`.
38+///
39+/// Both optional: `/search` with nothing on it is the form, which is the state the page
40+/// opens in when somebody clicks through to it rather than typing a query first.
41+#[query_params(error = bad_request)]
42+struct Query {
43+ q: Option<String>,
44+ rev: Option<String>,
45+}
46+
47+/// The search page.
48+///
49+/// **Three or four `git` processes** when a query is present — the default-branch
50+/// lookup when the URL names no revision, resolving it, and the grep — and one fewer
51+/// with an empty query, which never reaches grep at all.
52+#[page("/{handle}/repos/{name}/search")]
53+async fn search_page(cx: &Cx) -> Result {
54+ let repo = repo_for(cx).await?;
55+ let params = query_params::<Query>(cx)?;
56+ let query = params.q.clone().unwrap_or_default();
57+
58+ // A malformed revision is a page that does not exist, the same answer the tree
59+ // routes give it.
60+ let rev = params
61+ .rev
62+ .as_deref()
63+ .filter(|rev| !rev.is_empty())
64+ .map(RefName::new)
65+ .transpose()
66+ .map_err(|_| not_found())?;
67+
68+ let searched = search_repo(
69+ &repo.handle,
70+ &repo.name,
71+ rev.as_ref(),
72+ &query,
73+ &current_actor(cx).await?,
74+ &orgs(cx),
75+ &memberships(cx),
76+ &repos(cx),
77+ &queries(cx),
78+ )
79+ .await
80+ .map_err(server_error)?
81+ .ok_or_not_found()?;
82+
83+ let handle = repo.handle.as_str();
84+ let name = repo.name.as_str();
85+
86+ // The revision the search actually ran against, so the form round-trips it and the
87+ // header's Commits tab points where the results do.
88+ let at = match &searched {
89+ Searched::Empty => String::new(),
90+ Searched::QueryTooLong { rev } | Searched::TimedOut { rev, .. } => rev.as_str().to_owned(),
91+ Searched::Found(results) => results.rev.as_str().to_owned(),
92+ };
93+
94+ view! {
95+ wide(
96+ repo_header(repo: &repo, rev: at.as_str(), active: Tab::Code)
97+
98+ search_form(
99+ handle: handle,
100+ name: name,
101+ rev: at.as_str(),
102+ query: query.as_str(),
103+ full_width: true,
104+ )
105+
106+ <div class="mt-4">
107+ match &searched {
108+ Searched::Empty => note(
109+ "This repository has no commits yet, so there is nothing to search."
110+ ),
111+ Searched::QueryTooLong { .. } => note(
112+ (format!(
113+ "That search is too long. Searches are at most {MAX_QUERY_BYTES} bytes."
114+ ))
115+ ),
116+ Searched::TimedOut { .. } => note(
117+ "Search took too long and was stopped. Try a longer or more specific string."
118+ ),
119+ Searched::Found(results) if results.query.is_empty() => note(
120+ "Type a string to search this repository's code."
121+ ),
122+ Searched::Found(results) if results.files.is_empty() => note(
123+ (format!("No matches for “{}”.", results.query))
124+ ),
125+ Searched::Found(results) => results_list(
126+ handle: handle,
127+ name: name,
128+ results: results,
129+ ),
130+ }
131+ </div>
132+ )
133+ }
134+}
135+
136+/// Anything the page says instead of results.
137+///
138+/// One component for every non-result state, because they are the same shape: a
139+/// sentence in a bordered panel, under a form that still holds what was typed.
140+#[component]
141+async fn note(#[default] child: topcoat::view::View) -> Result {
142+ view! {
143+ <p class="rounded-lg border border-border px-4 py-10 text-center text-sm text-muted-foreground">
144+ (child)
145+ </p>
146+ }
147+}
148+
149+/// The search box.
150+///
151+/// A plain `GET` form, so submitting it produces the URL the results live at. The
152+/// revision rides along as a hidden field: a search of a tag has to stay a search of
153+/// that tag when it is re-submitted.
154+///
155+/// `wide` is the difference between the two places it appears — the width of the About
156+/// sidebar in the repository toolbar, full width on the search page itself.
157+#[component]
158+pub(super) async fn search_form(
159+ handle: &str,
160+ name: &str,
161+ rev: &str,
162+ query: &str,
163+ /// Full width on the search page, the width of the About sidebar in the repository
164+ /// toolbar. Named `full_width` rather than `wide` because a `#[component]` called
165+ /// `wide` already exists in this scope and would shadow the binding — see
166+ /// `CLAUDE.md`.
167+ full_width: bool,
168+) -> Result {
169+ view! {
170+ <form
171+ method="get"
172+ action=(format!("/{handle}/repos/{name}/search"))
173+ class=(if full_width {
174+ "flex w-full items-center"
175+ } else {
176+ "flex w-full items-center sm:w-72"
177+ })
178+ >
179+ if !rev.is_empty() {
180+ <input type="hidden" name="rev" value=(rev) />
181+ }
182+ <label class="relative flex w-full items-center">
183+ <span class="pointer-events-none absolute left-2.5 text-muted-foreground">
184+ icon(
185+ data: iconify_icon!("feather:search"),
186+ label: "Search code",
187+ attrs: topcoat::view::attributes! { class="size-3.5" },
188+ )
189+ </span>
190+ <input
191+ type="search"
192+ name="q"
193+ value=(query)
194+ placeholder="Search code…"
195+ maxlength=(MAX_QUERY_BYTES.to_string())
196+ class="w-full rounded-lg border border-border bg-surface py-1 pl-8 pr-2.5 text-xs placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
197+ />
198+ </label>
199+ </form>
200+ }
201+}
202+
203+/// The results, grouped by file.
204+#[component]
205+async fn results_list(handle: &str, name: &str, results: &SearchResults) -> Result {
206+ let files = results.files.len();
207+
208+ view! {
209+ <p class="mb-2 text-xs text-muted-foreground">
210+ (format!(
211+ "{} in {} — in ",
212+ counted(results.matches, "match", "matches"),
213+ counted(files, "file", "files"),
214+ ))
215+ <span class="font-mono">(results.rev.as_str())</span>
216+ if results.truncated {
217+ (format!(". Showing the first {SEARCH_LIMIT} matches; narrow the search to see the rest"))
218+ }
219+ </p>
220+
221+ <div class="space-y-4">
222+ for file in &results.files {
223+ file_matches(
224+ handle: handle,
225+ name: name,
226+ rev: &results.rev,
227+ query: results.query.as_str(),
228+ file: file,
229+ )
230+ }
231+ </div>
232+ }
233+}
234+
235+/// One file's matches: the path, then a row per line.
236+#[component]
237+async fn file_matches(
238+ handle: &str,
239+ name: &str,
240+ rev: &RefName,
241+ query: &str,
242+ file: &SearchFile,
243+) -> Result {
244+ let blob = tree_url(handle, name, rev, &file.path);
245+
246+ view! {
247+ <section class="overflow-hidden rounded-lg border border-border">
248+ <div class="border-b border-border px-4 py-2">
249+ <a href=(&blob) class="font-mono text-sm hover:underline">(file.path.as_str())</a>
250+ </div>
251+
252+ <ul class="divide-y divide-border">
253+ for hit in &file.matches {
254+ <li class="flex items-start gap-3 px-4 py-1.5 font-mono text-xs">
255+ // The line number is the link, so a result opens the file at the
256+ // line rather than at the top of a long one.
257+ <a
258+ href=(format!("{blob}#L{}", hit.line))
259+ class="w-10 shrink-0 text-right text-muted-foreground hover:text-foreground"
260+ >(hit.line.to_string())</a>
261+ <code class="min-w-0 overflow-x-auto whitespace-pre">
262+ for (matched, part) in highlight(&hit.text, query) {
263+ match matched {
264+ true => <mark class="rounded-xs bg-primary/25 text-foreground">(part)</mark>,
265+ false => (part),
266+ }
267+ }
268+ </code>
269+ </li>
270+ }
271+ </ul>
272+ </section>
273+ }
274+}
275+
276+/// A count and the thing it counts, pluralised.
277+fn counted(count: usize, one: &str, many: &str) -> String {
278+ format!("{count} {}", if count == 1 { one } else { many })
279+}
280+
281+/// Splits a line into matched and unmatched runs.
282+///
283+/// Every occurrence, not just the one git reported a column for: a line matches once as
284+/// far as `git grep` is concerned, but a reader looking at the line wants to see all of
285+/// them. Case-sensitive, because the search is — marking something the search would not
286+/// have found would be a lie about why the line is here.
287+fn highlight(line: &str, query: &str) -> Vec<(bool, String)> {
288+ if query.is_empty() {
289+ return vec![(false, line.to_owned())];
290+ }
291+
292+ let mut parts = Vec::new();
293+ let mut rest = line;
294+
295+ while let Some(at) = rest.find(query) {
296+ if at > 0 {
297+ parts.push((false, rest[..at].to_owned()));
298+ }
299+
300+ parts.push((true, query.to_owned()));
301+ rest = &rest[at + query.len()..];
302+ }
303+
304+ if !rest.is_empty() {
305+ parts.push((false, rest.to_owned()));
306+ }
307+
308+ parts
309+}
310+
311+#[cfg(test)]
312+mod tests {
313+ use super::*;
314+
315+ #[test]
316+ fn a_match_is_split_out_of_the_line_around_it() {
317+ assert_eq!(
318+ highlight("let needle = 1;", "needle"),
319+ vec![
320+ (false, "let ".to_owned()),
321+ (true, "needle".to_owned()),
322+ (false, " = 1;".to_owned()),
323+ ]
324+ );
325+ }
326+
327+ #[test]
328+ fn every_occurrence_on_the_line_is_marked() {
329+ assert_eq!(
330+ highlight("ab ab", "ab"),
331+ vec![
332+ (true, "ab".to_owned()),
333+ (false, " ".to_owned()),
334+ (true, "ab".to_owned()),
335+ ]
336+ );
337+ }
338+
339+ #[test]
340+ fn highlighting_is_case_sensitive_because_the_search_is() {
341+ assert_eq!(
342+ highlight("Needle needle", "needle"),
343+ vec![(false, "Needle ".to_owned()), (true, "needle".to_owned()),]
344+ );
345+ }
346+
347+ #[test]
348+ fn a_line_with_nothing_to_mark_stays_one_piece() {
349+ assert_eq!(
350+ highlight("nothing here", "needle"),
351+ vec![(false, "nothing here".to_owned())]
352+ );
353+ assert_eq!(highlight("x", ""), vec![(false, "x".to_owned())]);
354+ }
355+}