steid

@jamesgill /

1b7587dfeat: finding a string in a repository, and landing on the line20h
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
12use 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
23use crate::{
24 application::{
25 MAX_QUERY_BYTES, SEARCH_LIMIT, SearchFile, SearchResults, Searched, search_repo,
26 },
27 domain::RefName,
28};
29
30use 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)]
42struct 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")]
53async 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]
141async 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]
158pub(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]
205async 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]
237async 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.
277fn 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.
287fn 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)]
312mod 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}