search.rs
⎇
Raw
1//! The search view (`#/search`): index-free name and content search with
2//! streamed results, an explicit stop button, a start-folder picker, and
3//! collapsible per-file match lists.
4//!
5//! Results arrive as they are found (SSE, see `api::search_stream`). The
6//! client caps only what it *renders* (name rows a page of [`FILE_PAGE`] at a
7//! time, match lines per file at [`RENDER_LINE_CAP`]) — the search itself is
8//! unbounded and ends when the walk is done or the user stops it. Stopping
9//! closes the event source; the server notices the dropped connection and
10//! unwinds its walk.
11
12use std::collections::HashMap;
13use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
14use std::sync::{Arc, Mutex};
15
16use api_types::{FileKind, SearchEvent};
17use gloo_timers::future::TimeoutFuture;
18use leptos::prelude::*;
19use wasm_bindgen::JsCast;
20
21use crate::api;
22use crate::components::icon::{Icon, icon_svg};
23use crate::components::toast::{ToastMsg, show_error};
24use crate::editor::EditTarget;
25use crate::i18n::{self, k};
26use crate::icons::{IconName, icon_for};
27use crate::preview::{PreviewTarget, preview_kind};
28use crate::router::{Location, Section, navigate};
29use crate::util::format_size;
30use crate::views::dialogs::Dialog;
31use crate::views::file_view::{FileView, UnsupportedTarget};
32
33/// Name rows rendered at once, and how many more each click of the "show
34/// more" row reveals. Small on purpose: rendering a row costs a fixed amount
35/// of reactive-graph setup, so the initial paint stays well under a frame and
36/// the user pays for more rows only by asking for them.
37const FILE_PAGE: usize = 100;
38/// Name hits kept for paging. The search keeps running and counting past
39/// this; hits beyond it are reported as a plain remainder that cannot be
40/// paged to, since holding every hit of a one-character query would grow
41/// without bound.
42const RETAIN_FILE_CAP: usize = 2000;
43/// Render cap for match lines per file.
44const RENDER_LINE_CAP: usize = 500;
45/// Match cards rendered at once, and how many more each click reveals. Lower
46/// than [`FILE_PAGE`] because a card is much taller than a row — it carries a
47/// header and its matched lines — so a page of them already fills the screen.
48const CARD_PAGE: usize = 20;
49/// Files with content matches kept for paging. As with [`RETAIN_FILE_CAP`],
50/// the search counts past this; the surplus is reported but cannot be paged
51/// to.
52const RETAIN_MATCH_FILE_CAP: usize = 500;
53/// Stream events are applied in batches this many milliseconds apart, not
54/// per event: an open search can emit thousands of events and applying them
55/// one by one would re-render per event and peg the main thread.
56///
57/// A flush applies its whole batch, but in chunks that yield to the browser
58/// between them (see [`apply_chunked`]) rather than in one go.
59const FLUSH_MS: u32 = 120;
60/// Events folded between yields, and items written per chunk.
61///
62// ponytail: two fixed sizes, not a feedback controller. `<For>` re-runs
63// `each` over the whole list per write, so a write costs roughly
64// `CHUNK + list_len` and a slow machine can still overrun a frame. Measure
65// before replacing this with anything adaptive.
66const EVENTS_PER_SLICE: usize = 2048;
67const CHUNK: usize = 64;
68
69/// Hands the main thread back to the browser, then resumes.
70///
71/// A plain macrotask, deliberately, after measuring the two obvious
72/// alternatives:
73///
74/// * `requestAnimationFrame` only fires while the browser is producing
75/// frames. Under a throttled compositor it dropped to 2 fps and the result
76/// list took tens of seconds to fill; a backgrounded tab would stall it
77/// outright.
78/// * `scheduler.yield()` resumes at user-blocking priority, ahead of the
79/// browser's own rendering update. Chunks then pile up unpainted and style
80/// and layout land in one lump at the end — measurably worse (a 110 ms task
81/// versus 85 ms, and settle 630 ms versus 390 ms).
82///
83/// A macrotask lets a rendering update happen between chunks, which is the
84/// point. The cost is `setTimeout`'s 4 ms clamp past nesting depth 5.
85async fn yield_to_browser() {
86 TimeoutFuture::new(0).await;
87}
88
89/// Applies `items` to `write` in chunks, yielding to the browser between
90/// them. One signal write per chunk, so a chunk costs one `<For>` pass.
91async fn apply_chunked<T, F>(items: Vec<T>, my_gen: u64, ctx: &FlushCtx, mut write: F)
92where
93 F: FnMut(Vec<T>),
94{
95 let mut rest = items;
96 while !rest.is_empty() {
97 // A new search (or an unmount) makes the remaining items obsolete.
98 if ctx.stale(my_gen) {
99 return;
100 }
101 let take = CHUNK.min(rest.len());
102 write(rest.drain(..take).collect());
103 yield_to_browser().await;
104 }
105}
106
107#[derive(Clone, Copy, PartialEq, Eq)]
108enum Scope {
109 Name,
110 Content,
111 Both,
112}
113
114impl Scope {
115 const fn param(self) -> &'static str {
116 match self {
117 Scope::Name => "name",
118 Scope::Content => "content",
119 Scope::Both => "both",
120 }
121 }
122}
123
124/// `text` is an `Arc<str>` because `<For each>` clones the whole line vector
125/// on every append; a refcount bump keeps that clone O(1) per line.
126#[derive(Clone)]
127struct MatchLine {
128 line: u64,
129 text: Arc<str>,
130}
131
132/// A file with content matches, kept in arrival order. The card's lines
133/// live in the view-level registry ([`CardState`]) under the same key; the
134/// vector only drives the keyed `<For>`.
135///
136/// `search_gen` stamps the search generation: the `<For>` key includes it so a new
137/// search's items can never match a previous search's nodes (a position
138/// match would keep the old node, with the old search's line state).
139#[derive(Clone)]
140struct MatchFile {
141 root_id: i64,
142 path: Arc<str>,
143 search_gen: u64,
144}
145
146/// Live state of a match card, owned by the search view (the registry
147/// `RwSignal`), not by the card's `<For>` node: one owner per path, and the
148/// lines survive the card being remounted. New lines and collapse toggles
149/// only touch the card that owns them.
150#[derive(Clone, Copy)]
151struct CardState {
152 lines: RwSignal<Vec<MatchLine>>,
153 collapsed: RwSignal<bool>,
154}
155
156/// `path` is an `Arc<str>` for the same reason as [`MatchLine::text`].
157#[derive(Clone)]
158struct FileHit {
159 root_id: i64,
160 path: Arc<str>,
161 size: u64,
162 is_dir: bool,
163 kind: FileKind,
164}
165
166/// The server's closing summary of a finished walk.
167#[derive(Clone, Copy, PartialEq)]
168struct Summary {
169 scanned: usize,
170 skipped: usize,
171 elapsed_ms: u64,
172}
173
174#[derive(Clone, Copy, PartialEq)]
175enum Status {
176 Idle,
177 Searching,
178 /// The walk ended. `stopped` marks an early end: the user pressed Stop,
179 /// or the server hit its match cap. `summary` is `None` when the user
180 /// stopped, because the connection is gone before the summary arrives.
181 Done {
182 stopped: bool,
183 summary: Option<Summary>,
184 },
185}
186
187/// Render `text` with every query hit wrapped in `<mark class="hl-hit">`, as
188/// one HTML string for `inner_html`.
189///
190/// One element per text instead of one per fragment. A fragment used to be a
191/// `#[component]`, and a component costs a reactive owner plus arena slots:
192/// 1000 name rows are ~9000 fragments, which measured at ~700 ms of the
193/// render (the same DOM built from plain JS takes 25 ms).
194///
195/// Both file names and file contents are untrusted, so every character of
196/// `text` goes through [`push_escaped`] and the only markup in the output is
197/// the `<mark>` this function writes.
198///
199/// Matching is case-insensitive: the text is lowercased into a copy with one
200/// lowercase character per original character, so a hit's offsets in the
201/// copy map straight back onto the original.
202///
203// ponytail: `lower` keeps that 1:1 map by taking only the first character of
204// each `to_lowercase()`. A character whose lowercase expands (`İ`) therefore
205// matches on its first character only. Use a real case-folding table if that
206// ever matters.
207fn highlight_html(text: &str, words: &[String]) -> String {
208 let mut out = String::with_capacity(text.len() + 16);
209 let needles: Vec<String> = words
210 .iter()
211 .filter(|w| !w.is_empty())
212 .map(|w| lower(w))
213 .collect();
214 if needles.is_empty() || text.is_empty() {
215 push_escaped(&mut out, text);
216 return out;
217 }
218 // The lowered copy, plus the original byte offset behind every byte of
219 // it. `orig_at[i]` is meaningful at each character boundary `i`, and
220 // carries `text.len()` as its final sentinel.
221 let mut lowered = String::with_capacity(text.len());
222 let mut orig_at: Vec<usize> = Vec::with_capacity(text.len() + 1);
223 for (b, c) in text.char_indices() {
224 let lc = c.to_lowercase().next().unwrap_or(c);
225 lowered.push(lc);
226 for _ in 0..lc.len_utf8() {
227 orig_at.push(b);
228 }
229 }
230 orig_at.push(text.len());
231
232 let mut pos = 0usize;
233 while pos < lowered.len() {
234 // Earliest hit of any word at or after `pos`.
235 let Some((start, n)) = needles
236 .iter()
237 .filter_map(|w| lowered[pos..].find(w.as_str()).map(|i| (pos + i, w.len())))
238 .min_by_key(|(i, _)| *i)
239 else {
240 break;
241 };
242 push_escaped(&mut out, &text[orig_at[pos]..orig_at[start]]);
243 out.push_str("<mark class=\"hl-hit\">");
244 push_escaped(&mut out, &text[orig_at[start]..orig_at[start + n]]);
245 out.push_str("</mark>");
246 pos = start + n;
247 }
248 push_escaped(&mut out, &text[orig_at[pos]..]);
249 out
250}
251
252/// Lowercase, one character out per character in — see [`highlight_html`].
253fn lower(s: &str) -> String {
254 s.chars()
255 .map(|c| c.to_lowercase().next().unwrap_or(c))
256 .collect()
257}
258
259/// Append `s` to `out` with the five HTML-significant characters escaped.
260fn push_escaped(out: &mut String, s: &str) {
261 for c in s.chars() {
262 match c {
263 '&' => out.push_str("&amp;"),
264 '<' => out.push_str("&lt;"),
265 '>' => out.push_str("&gt;"),
266 '"' => out.push_str("&quot;"),
267 '\'' => out.push_str("&#39;"),
268 _ => out.push(c),
269 }
270 }
271}
272
273/// Parse `#/search?q=...&scope=...&root=...&path=...` (the part after `?`).
274/// Returns the query, the scope and the start folder `(root, dir)` when a
275/// root is given.
276fn parse_search_url() -> (String, Scope, Option<(i64, String)>) {
277 let hash = web_sys::window()
278 .and_then(|w| w.location().hash().ok())
279 .unwrap_or_default();
280 let params = hash
281 .split_once('?')
282 .and_then(|(_, qs)| web_sys::UrlSearchParams::new_with_str(qs).ok());
283 let Some(p) = params else {
284 return (String::new(), Scope::Both, None);
285 };
286 let scope = match p.get("scope").as_deref() {
287 Some("name") => Scope::Name,
288 Some("content") => Scope::Content,
289 _ => Scope::Both,
290 };
291 let folder = p
292 .get("root")
293 .and_then(|s| s.trim().parse().ok())
294 .map(|root| (root, p.get("path").unwrap_or_default()));
295 (p.get("q").unwrap_or_default(), scope, folder)
296}
297
298fn search_hash(q: &str, scope: Scope, root: i64, dir: &str) -> String {
299 format!(
300 "#/search?q={}&scope={}&root={root}&path={}",
301 js_sys::encode_uri_component(q),
302 scope.param(),
303 js_sys::encode_uri_component(dir),
304 )
305}
306
307/// Open the search view with `dir` of `root` as the start folder and an
308/// empty query (the browser's "search in this folder").
309pub fn open_search_in(root: i64, dir: &str) {
310 if let Some(w) = web_sys::window() {
311 let _ = w
312 .location()
313 .set_hash(&search_hash("", Scope::Both, root, dir));
314 }
315}
316
317/// Current vertical window scroll, 0 when there is no window.
318fn scroll_y() -> f64 {
319 web_sys::window()
320 .and_then(|w| w.scroll_y().ok())
321 .unwrap_or(0.0)
322}
323
324fn scroll_to_y(y: f64) {
325 if let Some(w) = web_sys::window() {
326 w.scroll_to_with_x_and_y(0.0, y);
327 }
328}
329
330/// Navigate to the folder `path` sits in, so the hit can be seen in context.
331/// A top-level entry goes to the root itself.
332fn goto_parent(root_id: i64, path: &str) {
333 let mut parts = split_rel(path);
334 parts.pop();
335 navigate(&Location {
336 root_id: Some(root_id),
337 path: parts,
338 share_token: None,
339 section: Section::Files,
340 });
341}
342
343#[component]
344pub fn SearchView(
345 me: ReadSignal<Option<api_types::Me>>,
346 open_file: WriteSignal<Option<FileView>>,
347 // While a file view is open it covers the content area, so the search
348 // UI hides (the view stays mounted: an in-flight search keeps running).
349 file_view: ReadSignal<Option<FileView>>,
350 /// The start-folder picker is a shell-level dialog.
351 set_dialog: WriteSignal<Option<Dialog>>,
352) -> impl IntoView {
353 let toast = use_context::<ToastMsg>().expect("toast context");
354 // ---- state -------------------------------------------------------------
355 let (query, set_query) = RwSignal::<String>::new(String::new()).split();
356 let (scope, set_scope) = RwSignal::<Scope>::new(Scope::Both).split();
357 // The running search's query words, for highlighting. Not reactive: rows
358 // read it while they render, and it only changes with a new search.
359 let words = StoredValue::new(Vec::<String>::new());
360 // The folder a search starts in: `(root id, dir relative to the root)`.
361 // Defaults to the top of the caller's first root.
362 let (folder, set_folder) = RwSignal::<Option<(i64, String)>>::new(
363 me.get_untracked()
364 .and_then(|m| m.roots.first().map(|r| (r.id, String::new()))),
365 )
366 .split();
367 let status = RwSignal::new(Status::Idle);
368 let files_sig = RwSignal::<Vec<FileHit>>::new(Vec::new());
369 let (files_r, files_w) = files_sig.split();
370 let files_total = RwSignal::new(0usize);
371 // How many of the retained name hits are rendered. Grows a page at a
372 // time when the user clicks the "show more" row.
373 let shown_files = RwSignal::new(FILE_PAGE);
374 let shown_cards = RwSignal::new(CARD_PAGE);
375 let match_files_sig = RwSignal::<Vec<MatchFile>>::new(Vec::new());
376 let (match_files_r, match_files_w) = match_files_sig.split();
377 let matches_total = RwSignal::new(0usize);
378 // True count of files that had at least one match (the rendered list is
379 // capped), plus the set of seen paths. Non-reactive: the set only feeds
380 // a counter, and it outlives the flush batch it is used in.
381 let match_files_total = RwSignal::new(0usize);
382 let seen_match_files: Arc<Mutex<std::collections::HashSet<Arc<str>>>> =
383 Arc::new(Mutex::new(std::collections::HashSet::new()));
384 // Per-path card state for this view: created in `apply_batch` when a
385 // path is first seen, read by the card's `<For>` child, removed on card
386 // unmount. One owner per path, so the lines survive the card being
387 // remounted. Component-scoped (no static): it is disposed with the view
388 // and reset on every new search.
389 let registry: RwSignal<HashMap<Arc<str>, CardState>> = RwSignal::new(HashMap::new());
390
391 // The open event source, so Stop and unmount can both reach it.
392 // Deliberately *not* reactive: the stream outlives the component and
393 // touching a disposed signal panics.
394 let source: Arc<Mutex<Option<web_sys::EventSource>>> = Arc::new(Mutex::new(None));
395 // Stream events are buffered and applied in batches (see FLUSH_MS).
396 // `search_gen` is bumped on every new search *and on unmount*, so one
397 // check covers both "a stale flush from a previous run" and "the view is
398 // gone, do not touch its signals".
399 let pending: Arc<Mutex<Vec<SearchEvent>>> = Arc::new(Mutex::new(Vec::new()));
400 let flushing: Arc<AtomicBool> = Arc::new(AtomicBool::new(false));
401 let search_gen: Arc<AtomicU64> = Arc::new(AtomicU64::new(0));
402
403 // ---- actions -----------------------------------------------------------
404 let stop_search = Callback::new({
405 let source = source.clone();
406 let pending = pending.clone();
407 let search_gen = search_gen.clone();
408 move |_| {
409 if let Some(s) = source.lock().unwrap().take() {
410 s.close();
411 }
412 // Stop should stop the UI too: discard buffered events and
413 // invalidate the in-flight flush so nothing keeps landing. The
414 // totals are left alone — they count what the server found, and
415 // the "N more" rows report the difference to what is rendered.
416 pending.lock().unwrap().clear();
417 search_gen.fetch_add(1, Ordering::SeqCst);
418 // The server can no longer send its summary (the connection is
419 // gone), so settle the UI here.
420 status.set(Status::Done {
421 stopped: true,
422 summary: None,
423 });
424 }
425 });
426
427 // Buffer stream events, apply them in batches. The first buffered event
428 // schedules the flush; a late flush for an unmounted view or a replaced
429 // search is discarded.
430 let flush_ctx = FlushCtx {
431 pending: pending.clone(),
432 flushing: flushing.clone(),
433 search_gen: search_gen.clone(),
434 files: files_sig,
435 files_total,
436 match_files: match_files_sig,
437 matches_total,
438 match_files_total,
439 seen: seen_match_files.clone(),
440 registry,
441 status,
442 };
443 let on_stream_event = Callback::new({
444 let ctx = flush_ctx;
445 move |ev: SearchEvent| {
446 ctx.pending.lock().unwrap().push(ev);
447 if ctx
448 .flushing
449 .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst)
450 .is_err()
451 {
452 return; // a flush is already pending
453 }
454 let my_gen = ctx.search_gen.load(Ordering::SeqCst);
455 wasm_bindgen_futures::spawn_local(run_flush(FLUSH_MS, my_gen, ctx.clone()));
456 }
457 });
458
459 let start_search = Callback::new({
460 let stop = stop_search;
461 let source = source.clone();
462 let search_gen = search_gen.clone();
463 move |()| {
464 // Untracked reads: starting a search must never register reactive
465 // dependencies (this callback can run from any context).
466 let q = query.get_untracked();
467 let scope = scope.get_untracked();
468 if q.trim().is_empty() {
469 return;
470 }
471 stop.run(()); // stop any in-flight search first
472 *source.lock().unwrap() = None;
473 pending.lock().unwrap().clear();
474 let my_gen = search_gen.fetch_add(1, Ordering::SeqCst) + 1;
475
476 // Exactly one start folder. Falls back to the first root when
477 // nothing is selected yet (a deep link with an unknown id, or
478 // `me` not loaded when the view was built).
479 let Some((root, dir)) = folder.get_untracked().or_else(|| {
480 me.get_untracked()
481 .and_then(|m| m.roots.first().map(|r| (r.id, String::new())))
482 }) else {
483 return;
484 };
485 set_folder.set(Some((root, dir.clone())));
486
487 // Fresh state for this run.
488 files_w.set(Vec::new());
489 files_total.set(0);
490 shown_files.set(FILE_PAGE);
491 shown_cards.set(CARD_PAGE);
492 match_files_w.set(Vec::new());
493 matches_total.set(0);
494 match_files_total.set(0);
495 seen_match_files.lock().unwrap().clear();
496 registry.set(HashMap::new());
497 status.set(Status::Searching);
498 // Write the search into the hash without adding a history entry.
499 if let Some(Ok(hist)) = web_sys::window().map(|w| w.history()) {
500 let _ = hist.replace_state_with_url(
501 &wasm_bindgen::JsValue::NULL,
502 "",
503 Some(&search_hash(&q, scope, root, &dir)),
504 );
505 }
506 words.set_value(query_words(&q));
507
508 let src = match api::search_stream(
509 q.clone(),
510 scope.param(),
511 root,
512 &dir,
513 on_stream_event,
514 on_error_for(toast, status, search_gen.clone(), my_gen),
515 ) {
516 Ok(s) => s,
517 Err(e) => {
518 show_error(toast, e.to_string());
519 status.set(Status::Idle);
520 return;
521 }
522 };
523 *source.lock().unwrap() = Some(src);
524 }
525 });
526
527 // The search view's owner is the shell's view tree; a stream event can
528 // arrive on the main thread after unmount, so bumping the generation here
529 // makes every stream-side callback bail, and the stream is closed.
530 {
531 let source = source.clone();
532 let search_gen = search_gen.clone();
533 on_cleanup(move || {
534 search_gen.fetch_add(1, Ordering::SeqCst);
535 if let Some(s) = source.lock().unwrap().take() {
536 s.close();
537 }
538 });
539 }
540
541 // Collapse/expand all cards. Whether the group is fully collapsed is
542 // derived from the cards themselves (no counter to keep in sync).
543 let collapse_all: Callback<(), ()> = {
544 Callback::new(move |_| {
545 // Copy the states out before writing them: writing an inner
546 // signal while the registry's own borrow is held would rely on
547 // leptos deferring effects to a microtask.
548 let states: Vec<CardState> = registry.with_untracked(|m| m.values().copied().collect());
549 let target =
550 !(!states.is_empty() && states.iter().all(|c| c.collapsed.get_untracked()));
551 for c in states {
552 c.collapsed.set(target);
553 }
554 })
555 };
556
557 // Enter in the query field starts the search.
558 let on_query_key = move |ev: web_sys::KeyboardEvent| {
559 if ev.key() == "Enter" {
560 start_search.run(());
561 }
562 };
563
564 // Deep link: `#/search?root=…&path=…` sets the start folder (the
565 // browser's "search in this folder" arrives like this, with no query);
566 // with a `q` the search also re-runs.
567 {
568 let (q0, s0, f0) = parse_search_url();
569 // Only honour a root the caller actually has; otherwise keep the
570 // default so a stale link still searches something.
571 if let Some((id, dir)) = f0
572 && me
573 .get_untracked()
574 .is_some_and(|m| m.roots.iter().any(|r| r.id == id))
575 {
576 set_folder.set(Some((id, dir)));
577 }
578 if !q0.is_empty() {
579 set_query.set(q0);
580 set_scope.set(s0);
581 // Run the search from setup (an untracked context), not from an
582 // `Effect`: the effect would track `start_search`'s signal reads
583 // and retrigger on the very signal it writes (`folder`),
584 // re-running the search in a loop and pegging the main thread.
585 start_search.run(());
586 }
587 }
588
589 // The start-folder button opens the shared folder picker.
590 let pick_folder = move |_| {
591 let Some(m) = me.get_untracked() else {
592 return;
593 };
594 let Some((root, dir)) = folder
595 .get_untracked()
596 .or_else(|| m.roots.first().map(|r| (r.id, String::new())))
597 else {
598 return;
599 };
600 set_dialog.set(Some(Dialog::Picker {
601 title: i18n::t(k::SEARCH_IN).to_string(),
602 confirm: i18n::t(k::SEARCH_HERE).to_string(),
603 roots: m.roots,
604 root,
605 dir,
606 for_write: false,
607 on_pick: Callback::new(move |(root, dir): (i64, String)| {
608 set_dialog.set(None);
609 set_folder.set(Some((root, dir)));
610 }),
611 }));
612 };
613
614 // Keep the scroll position across opening a result. The window is the
615 // scroller, and while a file is open the search UI is hidden, so the
616 // document shrinks to one viewport and the browser clamps the scroll to
617 // 0. Saving it on open and restoring on close is the only way back —
618 // hiding rather than unmounting is necessary but not sufficient.
619 // Saved by the handlers that open a result, *not* by the effect below:
620 // hiding the search UI is itself an effect, and if it ran first the
621 // scroll was already clamped to 0 by the time we read it.
622 let saved_scroll = StoredValue::new(0.0f64);
623 Effect::new(move |was_open: Option<bool>| {
624 let open = file_view.get().is_some();
625 if was_open == Some(true) && !open {
626 let y = saved_scroll.get_value();
627 // Only once the display flip has been laid out; before that the
628 // document is one viewport tall and the scroll is clamped away
629 // again.
630 wasm_bindgen_futures::spawn_local(async move {
631 yield_to_browser().await;
632 scroll_to_y(y);
633 });
634 }
635 open
636 });
637
638 // One delegated handler for the whole name list, instead of a `Callback`
639 // per row: a callback is an arena entry plus a closure allocation, and at
640 // a thousand rows that is a measurable slice of the render. The row
641 // carries its identity in data attributes; the hit itself is looked up in
642 // the list, which is a linear scan of at most `RETAIN_FILE_CAP` on a
643 // click.
644 let on_row_click = move |ev: web_sys::MouseEvent| {
645 let Some(target) = ev
646 .target()
647 .and_then(|t| t.dyn_into::<web_sys::Element>().ok())
648 else {
649 return;
650 };
651 let Some(row) = target.closest(".srow").ok().flatten() else {
652 return;
653 };
654 let Some(path) = row.get_attribute("data-path") else {
655 return;
656 };
657 let Some(root_id) = row
658 .get_attribute("data-root")
659 .and_then(|s| s.parse::<i64>().ok())
660 else {
661 return;
662 };
663 // The goto button is inside the row, so it is handled here rather
664 // than with its own callback per row.
665 if target.closest(".goto-btn").ok().flatten().is_some() {
666 goto_parent(root_id, &path);
667 return;
668 }
669 let Some(hit) = files_r.with_untracked(|v| {
670 v.iter()
671 .find(|h| h.root_id == root_id && *h.path == *path)
672 .cloned()
673 }) else {
674 return;
675 };
676 if hit.is_dir {
677 navigate(&Location {
678 root_id: Some(root_id),
679 path: split_rel(&path),
680 share_token: None,
681 section: Section::Files,
682 });
683 return;
684 }
685 let name = path.rsplit('/').next().unwrap_or(&path).to_string();
686 saved_scroll.set_value(scroll_y());
687 open_file.set(Some(open_file_view(
688 root_id,
689 path,
690 name,
691 hit.kind,
692 is_writable(me, root_id),
693 )));
694 };
695
696 // ---- render ------------------------------------------------------------
697 view! {
698 // Hidden with `display` rather than unmounted while a file is open
699 // (the same treatment the browser view gets). Unmounting would throw
700 // away every rendered row and rebuild it on close, and the document
701 // would lose its scroll position — see `keep_scroll` above.
702 <div
703 class="search-view"
704 style:display=move || if file_view.get().is_some() { "none" } else { "flex" }
705 >
706 <div class="query-bar">
707 <div class="query-input">
708 <Icon name=IconName::Search class="qi-icon".to_string()/>
709 <input
710 type="text"
711 placeholder=move || i18n::t(k::SEARCH_PLACEHOLDER).to_string()
712 value=move || query.get()
713 on:input=move |ev: web_sys::Event| {
714 if let Some(t) = ev
715 .target()
716 .and_then(|t| t.dyn_into::<web_sys::HtmlInputElement>().ok())
717 {
718 set_query.set(t.value());
719 }
720 }
721 on:keydown=on_query_key
722 autocomplete="off"
723 />
724 </div>
725 <div class="seg">
726 <button
727 class:on=move || scope.get() == Scope::Name
728 on:click=move |_| set_scope.set(Scope::Name)
729 >
730 {i18n::tr(k::SEARCH_NAME)}
731 </button>
732 <button
733 class:on=move || scope.get() == Scope::Content
734 on:click=move |_| set_scope.set(Scope::Content)
735 >
736 {i18n::tr(k::SEARCH_CONTENT)}
737 </button>
738 <button
739 class:on=move || scope.get() == Scope::Both
740 on:click=move |_| set_scope.set(Scope::Both)
741 >
742 {i18n::tr(k::SEARCH_BOTH)}
743 </button>
744 </div>
745 // A search covers one folder (and everything below it). The
746 // button shows it as "Root/dir" and opens the folder picker.
747 <button
748 class="folder-pick"
749 title=move || i18n::t(k::SEARCH_IN).to_string()
750 on:click=pick_folder
751 >
752 <Icon name=IconName::Folder class="fp-icon".to_string()/>
753 <span class="fp-path">
754 {move || {
755 let Some((root, dir)) = folder.get() else {
756 return String::new();
757 };
758 let name = me
759 .get()
760 .and_then(|m| m.roots.into_iter().find(|r| r.id == root))
761 .map(|r| r.name)
762 .unwrap_or_default();
763 if dir.is_empty() { format!("{name}/") } else { format!("{name}/{dir}") }
764 }}
765 </span>
766 </button>
767 {move || {
768 if status.get() == Status::Searching {
769 view! {
770 <button class="btn-stop" on:click=move |_| stop_search.run(())>
771 <Icon name=IconName::Stop class="ic-stop".to_string()/>
772 {i18n::t(k::SEARCH_STOP)}
773 </button>
774 }
775 .into_view()
776 .into_any()
777 } else {
778 view! {
779 <button class="btn-run" on:click=move |_| start_search.run(())>
780 {i18n::t(k::SEARCH_RUN)}
781 </button>
782 }
783 .into_view()
784 .into_any()
785 }
786 }}
787
788 </div>
789 {move || match status.get() {
790 Status::Idle => view! {}.into_view().into_any(),
791 Status::Searching => view! {
792 <div class="status-line">
793 <span class="dot"></span>
794 <span>
795 {i18n::t(k::SEARCHING)}
796 {" · "}
797 {i18n::t_fmt(
798 k::SEARCH_N_RESULTS,
799 &(files_total.get() + matches_total.get()).to_string(),
800 )}
801 </span>
802 </div>
803 }
804 .into_view()
805 .into_any(),
806 Status::Done { stopped, summary } => {
807 let n = (files_total.get() + matches_total.get()).to_string();
808 let head = if stopped {
809 i18n::t_fmt(k::SEARCH_STOPPED, &n)
810 } else {
811 i18n::t_fmt(k::SEARCH_N_RESULTS, &n)
812 };
813 view! {
814 <div class="status-line finished">
815 <span>
816 {head}
817 {summary
818 .map(|s| {
819 view! {
820 <>
821 {" · "}
822 {i18n::t_fmt(k::SEARCH_TOOK, &s.elapsed_ms.to_string())}
823 {" · "}
824 {i18n::t_fmt(k::SEARCH_SCANNED, &s.scanned.to_string())}
825 {(s.skipped > 0)
826 .then(|| {
827 view! {
828 <span class="skip">
829 {" · "}
830 {i18n::t_fmt(
831 k::SEARCH_SKIPPED,
832 &s.skipped.to_string(),
833 )}
834 </span>
835 }
836 })}
837 </>
838 }
839 })}
840 </span>
841 </div>
842 }
843 .into_view()
844 .into_any()
845 }
846 }}
847
848 // The groups are hidden with `display` instead of unmounted:
849 // toggling scope must not rebuild the rows/cards (and the cards'
850 // line signals live in the registry, but keeping the DOM stable
851 // makes the toggle a single attribute write).
852 <div
853 class="result-group"
854 style:display=move || {
855 let show = matches!(scope.get(), Scope::Name | Scope::Both)
856 && (files_total.get() > 0 || status.get() != Status::Idle);
857 if show {
858 "flex"
859 } else {
860 "none"
861 }
862 }
863 >
864 <div class="group-label">
865 <Icon name=IconName::File class="gl-icon".to_string()/>
866 <span>{i18n::tr(k::FILES)}</span>
867 <span class="n">{"· "}{files_total}</span>
868 </div>
869 <div class="entries-list" on:click=on_row_click>
870 <For
871 // Slice inside `with`, so a page's worth is cloned
872 // rather than every retained hit.
873 each=move || {
874 let n = shown_files.get();
875 files_r.with(|v| v.iter().take(n).cloned().collect::<Vec<_>>())
876 }
877 key=move |h: &FileHit| (h.root_id, h.path.clone())
878 children=move |h: FileHit| {
879 // Note: only the first `shown_files` hits reach
880 // here; `each` above slices before cloning.
881 let (dir, name) = split_name(&h.path);
882 let icon = if h.is_dir {
883 IconName::Folder
884 } else {
885 icon_for(h.kind, name)
886 };
887 // Only the name is highlighted. The server matches
888 // the entry's own name, not its path, so a mark
889 // in the directory line would point at text that
890 // was never matched.
891 let name_h = words.with_value(|w| highlight_html(name, w));
892 let dir_s = dir.to_string();
893 let size = if h.is_dir {
894 "—".to_string()
895 } else {
896 format_size(h.size)
897 };
898 // `icon_svg`, not `<Icon/>`: no component owner
899 // per row. Click handling is delegated to the
900 // list (see `on_row_click`), so the row only
901 // carries data attributes.
902 view! {
903 <div
904 class="srow"
905 data-root=h.root_id.to_string()
906 data-path=h.path.to_string()
907 >
908 {icon_svg(icon, "srow-icon")}
909 <div class="srow-main">
910 <span class="srow-name" inner_html=name_h></span>
911 <span class="srow-dir">{dir_s}</span>
912 </div>
913 <span class="ssize">{size}</span>
914 <button
915 class="goto-btn"
916 title=move || i18n::t(k::SEARCH_GOTO).to_string()
917 aria-label=move || i18n::t(k::SEARCH_GOTO).to_string()
918 >
919 {icon_svg(IconName::FolderOpen, "goto-ic")}
920 </button>
921 </div>
922 }
923 }
924 />
925 {move || {
926 let retained = files_r.with(|v| v.len());
927 page_footer(
928 shown_files.get().min(retained),
929 retained,
930 files_total.get(),
931 FILE_PAGE,
932 move || shown_files.update(|n| *n += FILE_PAGE),
933 )
934 }}
935 </div>
936 </div>
937
938 <div
939 class="result-group"
940 style:display=move || {
941 let show = matches!(scope.get(), Scope::Content | Scope::Both)
942 && (!match_files_r.get().is_empty() || status.get() != Status::Idle);
943 if show {
944 "flex"
945 } else {
946 "none"
947 }
948 }
949 >
950 <div class="group-label">
951 <Icon name=IconName::Text class="gl-icon".to_string()/>
952 <span>{i18n::tr(k::SEARCH_CONTENT)}</span>
953 // Closures, not bare calls: these have to re-read the
954 // totals as the search streams in.
955 <span class="n">
956 {"· "}
957 {move || {
958 i18n::t_fmt(k::SEARCH_N_MATCHES, &matches_total.get().to_string())
959 }}
960 {" "}
961 {move || {
962 i18n::t_fmt(k::SEARCH_IN_N_FILES, &match_files_total.get().to_string())
963 }}
964 </span>
965 <span class="grow"></span>
966 <button class="collapse-all" on:click=move |_| collapse_all.run(())>
967 {move || {
968 let all_collapsed = registry.with(|m| {
969 !m.is_empty()
970 && m.values().all(|c| c.collapsed.get())
971 });
972 i18n::t(if all_collapsed {
973 k::SEARCH_EXPAND_ALL
974 } else {
975 k::SEARCH_COLLAPSE_ALL
976 })
977 }}
978 </button>
979 </div>
980 <For
981 // Sliced inside `with`, as for the name rows: a page's
982 // worth is cloned rather than every retained match file.
983 each=move || {
984 let n = shown_cards.get();
985 match_files_r.with(|v| v.iter().take(n).cloned().collect::<Vec<_>>())
986 }
987 key=move |f: &MatchFile| (f.search_gen, f.root_id, f.path.clone())
988 children=move |f: MatchFile| {
989 // The card's live state is created in `apply_batch`
990 // (when the path was first seen) and owned by the
991 // view-level registry: one owner per path, and the
992 // lines survive this node being remounted (e.g. by a
993 // keyed reorder mid-search). The child only reads
994 // the entry; it never removes it, so an unmount
995 // racing a new search cannot delete a fresh entry.
996 // Entries of a finished search are wiped by the next
997 // `start_search` or with the view itself.
998 //
999 // The entry is always there (`apply_batch` inserts it
1000 // before the path reaches `match_files`), but render
1001 // an empty card rather than panic the whole view if
1002 // that ordering ever changes.
1003 let Some(st) = registry.with_untracked(|m| m.get(&f.path).copied()) else {
1004 return view! {}.into_view().into_any();
1005 };
1006 let lines_r = st.lines;
1007 let collapsed = st.collapsed;
1008 let key = f.path.clone();
1009 let root_id = f.root_id;
1010 let (dir, name) = split_name(&f.path);
1011 // A content match came out of the grep, which only
1012 // reads text files, so the kind is known without
1013 // asking the server.
1014 let icon = icon_for(FileKind::Text, name);
1015 // The card's path is *not* highlighted: a content hit
1016 // was found in the file's text, not in its name, so
1017 // marking the name would claim a match that is not
1018 // there and duplicate what the name results show.
1019 let dir_s = dir.to_string();
1020 let name_s = name.to_string();
1021 // One open-callback per card (Copy). The header opens
1022 // the file; the matched lines are text to read, not
1023 // buttons.
1024 let open_file_cb = open_match_cb(root_id, key.clone(), me, open_file);
1025 let goto_path = key.clone();
1026 view! {
1027 <div class="match-card">
1028 // The whole header opens the file, so the two
1029 // buttons in it stop their clicks here.
1030 <div class="match-head" on:click=move |_| {
1031 saved_scroll.set_value(scroll_y());
1032 open_file_cb.run(());
1033 }>
1034 {icon_svg(icon, "mi")}
1035 <span class="mpath">
1036 <span>{dir_s}</span>
1037 {" / "}
1038 <b>{name_s}</b>
1039 </span>
1040 <span class="mcount">
1041 {move || {
1042 let n = lines_r.with(|v| v.len());
1043 i18n::t_fmt(k::SEARCH_N_MATCHES, &n.to_string())
1044 }}
1045 </span>
1046 <button
1047 class="goto-btn"
1048 title=move || i18n::t(k::SEARCH_GOTO).to_string()
1049 aria-label=move || i18n::t(k::SEARCH_GOTO).to_string()
1050 on:click=move |ev: web_sys::MouseEvent| {
1051 ev.stop_propagation();
1052 goto_parent(root_id, &goto_path);
1053 }
1054 >
1055 {icon_svg(IconName::FolderOpen, "goto-ic")}
1056 </button>
1057 <button
1058 class="chev"
1059 on:click=move |ev: web_sys::MouseEvent| {
1060 ev.stop_propagation();
1061 collapsed.update(|v| *v = !*v);
1062 }
1063 >
1064 <span class=move || {
1065 if collapsed.get() {
1066 "chev chev-up".to_string()
1067 } else {
1068 "chev chev-down".to_string()
1069 }
1070 }></span>
1071 </button>
1072 </div>
1073 <div
1074 class="match-lines"
1075 style:display=move || {
1076 if collapsed.get() {
1077 "none"
1078 } else {
1079 "block"
1080 }
1081 }
1082 >
1083 <For
1084 each=move || lines_r.get().into_iter().enumerate()
1085 key=move |p: &(usize, MatchLine)| (p.1.line, p.0)
1086 children=move |p: (usize, MatchLine)| {
1087 let l = p.1;
1088 let text_h = words
1089 .with_value(|w| highlight_html(&l.text, w));
1090 view! {
1091 <div class="mline">
1092 <span class="ln">{l.line}</span>
1093 <span class="tx" inner_html=text_h></span>
1094 </div>
1095 }
1096 }
1097 />
1098 </div>
1099 </div>
1100 }
1101 .into_view()
1102 .into_any()
1103 }
1104 />
1105 {move || {
1106 let retained = match_files_r.with(|v| v.len());
1107 page_footer(
1108 shown_cards.get().min(retained),
1109 retained,
1110 match_files_total.get(),
1111 CARD_PAGE,
1112 move || shown_cards.update(|n| *n += CARD_PAGE),
1113 )
1114 }}
1115 </div>
1116
1117 {move || {
1118 let done = matches!(status.get(), Status::Done { .. });
1119 if done && files_total.get() == 0 && matches_total.get() == 0 {
1120 view! {
1121 <div class="search-empty">{i18n::t(k::SEARCH_NO_RESULTS)}</div>
1122 }
1123 .into_view()
1124 .into_any()
1125 } else {
1126 view! {}.into_view().into_any()
1127 }
1128 }}
1129 </div>
1130 }
1131}
1132
1133/// Everything a flush needs: the shared event queue and the view signals.
1134#[derive(Clone)]
1135struct FlushCtx {
1136 pending: Arc<Mutex<Vec<SearchEvent>>>,
1137 flushing: Arc<AtomicBool>,
1138 search_gen: Arc<AtomicU64>,
1139 files: RwSignal<Vec<FileHit>>,
1140 files_total: RwSignal<usize>,
1141 match_files: RwSignal<Vec<MatchFile>>,
1142 matches_total: RwSignal<usize>,
1143 match_files_total: RwSignal<usize>,
1144 seen: Arc<Mutex<std::collections::HashSet<Arc<str>>>>,
1145 registry: RwSignal<HashMap<Arc<str>, CardState>>,
1146 status: RwSignal<Status>,
1147}
1148
1149impl FlushCtx {
1150 /// True once a new search has started or the view was unmounted — both
1151 /// bump the generation, and after either the view signals must not be
1152 /// written any more.
1153 fn stale(&self, my_gen: u64) -> bool {
1154 self.search_gen.load(Ordering::SeqCst) != my_gen
1155 }
1156}
1157
1158/// Waits `delay_ms`, then applies every event buffered in the meantime.
1159///
1160/// `flushing` is held for the whole (yielding) apply, so only one flush ever
1161/// writes the view state; events arriving during it buffer in `pending` and
1162/// the next flush picks them up right away instead of waiting `FLUSH_MS`.
1163async fn run_flush(delay_ms: u32, my_gen: u64, ctx: FlushCtx) {
1164 TimeoutFuture::new(delay_ms).await;
1165 if !ctx.stale(my_gen) {
1166 let batch: Vec<SearchEvent> = ctx.pending.lock().unwrap().drain(..).collect();
1167 if !batch.is_empty() {
1168 apply_batch(&ctx, batch, my_gen).await;
1169 }
1170 // Applying yields, so more events may have arrived meanwhile. They
1171 // are already `FLUSH_MS` old, so pick them up on the next tick —
1172 // keeping `flushing` held, so no second flush can start.
1173 if !ctx.stale(my_gen) && !ctx.pending.lock().unwrap().is_empty() {
1174 wasm_bindgen_futures::spawn_local(run_flush(0, my_gen, ctx.clone()));
1175 return;
1176 }
1177 }
1178 ctx.flushing.store(false, Ordering::SeqCst);
1179}
1180
1181/// Folds one batch into the view state with a single render per signal:
1182/// events are aggregated into locals first, then each signal is written
1183/// once. (Writing per event would trigger a re-render per event, which is
1184/// what froze the page.)
1185///
1186/// A file seen for the first time gets its registry entry here, before its
1187/// path reaches `match_files`, so the card's `<For>` child always finds it.
1188/// Lines for a file that already has a card are appended to that card's own
1189/// line signal, which leaves every other card's DOM untouched.
1190///
1191/// Counters and the finished status are applied at once — they are cheap and
1192/// the status line should tell the truth immediately. Only the parts that
1193/// build DOM go through [`apply_chunked`], which yields between chunks.
1194async fn apply_batch(ctx: &FlushCtx, batch: Vec<SearchEvent>, my_gen: u64) {
1195 let mut new_files: Vec<FileHit> = Vec::new();
1196 // Rows still allowed in the name list: it must never grow past the cap,
1197 // no matter how events are batched. `with_untracked` is a signposted
1198 // non-reactive, clone-free read of the length (this runs outside any
1199 // tracking context).
1200 let mut file_budget = RETAIN_FILE_CAP.saturating_sub(ctx.files.with_untracked(|v| v.len()));
1201 let mut file_count = 0usize;
1202 let mut mt = 0usize;
1203 let mut mft = 0usize;
1204 let mut new_match_files: Vec<MatchFile> = Vec::new();
1205 let mut mf_len = ctx.match_files.with_untracked(|v| v.len());
1206 // Generation stamp for the card keys (see `MatchFile::search_gen`).
1207 let search_gen = my_gen;
1208 let mut done: Option<Status> = None;
1209
1210 // Group the batch's match events per file, so a new card is seeded with
1211 // all of its lines at once. Grouped through an index rather than by
1212 // comparing against the last entry: several walker threads share one
1213 // channel, so two files grepped concurrently interleave in the stream.
1214 let mut acc: Vec<(i64, Arc<str>, Vec<MatchLine>)> = Vec::new();
1215 let mut acc_at: HashMap<Arc<str>, usize> = HashMap::new();
1216 {
1217 // Folded in slices with a yield between them. This loop allocates and
1218 // hashes a path per *event*, and a one-character query can emit tens
1219 // of thousands of them, so its cost tracks events received rather
1220 // than items rendered — it needs its own yield points, independent of
1221 // `apply_chunked` below. (Measured as a 133 ms task before this.)
1222 let mut since_yield = 0usize;
1223 for ev in batch {
1224 since_yield += 1;
1225 if since_yield >= EVENTS_PER_SLICE {
1226 since_yield = 0;
1227 yield_to_browser().await;
1228 if ctx.stale(my_gen) {
1229 return;
1230 }
1231 }
1232 match ev {
1233 SearchEvent::Done {
1234 stopped,
1235 files: _,
1236 matches: _,
1237 scanned,
1238 skipped,
1239 elapsed_ms,
1240 } => {
1241 done = Some(Status::Done {
1242 stopped,
1243 summary: Some(Summary {
1244 scanned,
1245 skipped,
1246 elapsed_ms,
1247 }),
1248 });
1249 }
1250 SearchEvent::File {
1251 root_id,
1252 path,
1253 size,
1254 is_dir,
1255 kind,
1256 } => {
1257 file_count += 1;
1258 if file_budget > 0 {
1259 file_budget -= 1;
1260 new_files.push(FileHit {
1261 root_id,
1262 path: path.into(),
1263 size,
1264 is_dir,
1265 kind,
1266 });
1267 }
1268 }
1269 SearchEvent::Match {
1270 root_id,
1271 path,
1272 line,
1273 text,
1274 } => {
1275 mt += 1;
1276 let path: Arc<str> = path.into();
1277 // Locked per event rather than for the whole fold: the
1278 // fold yields, and holding the guard across an await
1279 // would keep it locked over a suspension point.
1280 if ctx.seen.lock().unwrap().insert(path.clone()) {
1281 mft += 1;
1282 }
1283 let ml = MatchLine {
1284 line,
1285 text: text.into(),
1286 };
1287 match acc_at.get(&path) {
1288 Some(&i) => acc[i].2.push(ml),
1289 None => {
1290 acc_at.insert(path.clone(), acc.len());
1291 acc.push((root_id, path, vec![ml]));
1292 }
1293 }
1294 }
1295 }
1296 }
1297 }
1298
1299 // One owner per path: a card's lines live in the registry, not in the
1300 // card's `<For>` node, so remounting a card never loses them. Appends to
1301 // existing cards are collected and applied after the registry's borrow
1302 // is released — writing an inner signal from inside `update` would rely
1303 // on leptos deferring effects to a microtask.
1304 let mut appends: Vec<(CardState, Vec<MatchLine>)> = Vec::new();
1305 ctx.registry.update(|reg| {
1306 for (root_id, path, mut lines) in acc {
1307 match reg.get(&path) {
1308 Some(st) => appends.push((*st, lines)),
1309 None => {
1310 if mf_len >= RETAIN_MATCH_FILE_CAP {
1311 // The file will never get a card; its matches are
1312 // counted above and dropped here.
1313 continue;
1314 }
1315 // Seed a new card's collapse state from the group's
1316 // current all-collapsed state, so a live "collapse all"
1317 // is not undone by arriving cards.
1318 let all_collapsed =
1319 !reg.is_empty() && reg.values().all(|c| c.collapsed.get_untracked());
1320 lines.truncate(RENDER_LINE_CAP);
1321 reg.insert(
1322 path.clone(),
1323 CardState {
1324 lines: RwSignal::new(lines),
1325 collapsed: RwSignal::new(all_collapsed),
1326 },
1327 );
1328 mf_len += 1;
1329 new_match_files.push(MatchFile {
1330 root_id,
1331 path,
1332 search_gen,
1333 });
1334 }
1335 }
1336 }
1337 });
1338 // Cheap, so applied immediately: the counters and the finished status.
1339 if file_count > 0 {
1340 ctx.files_total.update(|n| *n += file_count);
1341 }
1342 if mt > 0 {
1343 ctx.matches_total.update(|n| *n += mt);
1344 if mft > 0 {
1345 ctx.match_files_total.update(|n| *n += mft);
1346 }
1347 }
1348 if let Some(s) = done {
1349 ctx.status.set(s);
1350 }
1351
1352 // Expensive, so chunked: everything that builds DOM. Cards first, so a
1353 // file's lines have somewhere to land, then the lines, then the name
1354 // rows.
1355 let files = ctx.files;
1356 let match_files = ctx.match_files;
1357 apply_chunked(new_match_files, my_gen, ctx, move |head| {
1358 match_files.update(|v| v.extend(head));
1359 })
1360 .await;
1361 for (st, lines) in appends {
1362 let room = RENDER_LINE_CAP.saturating_sub(st.lines.with_untracked(|v| v.len()));
1363 if room == 0 {
1364 continue;
1365 }
1366 let lines: Vec<MatchLine> = lines.into_iter().take(room).collect();
1367 apply_chunked(lines, my_gen, ctx, move |head| {
1368 st.lines.update(|v| v.extend(head));
1369 })
1370 .await;
1371 }
1372 apply_chunked(new_files, my_gen, ctx, move |head| {
1373 files.update(|v| v.extend(head));
1374 })
1375 .await;
1376}
1377
1378/// Reports a stream failure, unless the search it belongs to is already gone
1379/// (a new search, or the view unmounted — both bump the generation).
1380fn on_error_for(
1381 toast: ToastMsg,
1382 status: RwSignal<Status>,
1383 search_gen: Arc<AtomicU64>,
1384 my_gen: u64,
1385) -> Callback<String, ()> {
1386 Callback::new(move |msg| {
1387 if search_gen.load(Ordering::SeqCst) != my_gen {
1388 return;
1389 }
1390 show_error(toast, msg);
1391 status.set(Status::Idle);
1392 })
1393}
1394
1395/// Whether the signed-in user may write to `root_id`.
1396fn is_writable(me: ReadSignal<Option<api_types::Me>>, root_id: i64) -> bool {
1397 me.get_untracked().is_some_and(|m| {
1398 m.roots
1399 .iter()
1400 .find(|r| r.id == root_id)
1401 .is_some_and(|r| r.mode.is_writable())
1402 })
1403}
1404
1405fn open_file_view(
1406 root_id: i64,
1407 path: String,
1408 name: String,
1409 kind: FileKind,
1410 is_rw: bool,
1411) -> FileView {
1412 if kind == FileKind::Text {
1413 return FileView::Editor(EditTarget {
1414 root_id,
1415 path,
1416 name,
1417 readonly: !is_rw,
1418 });
1419 }
1420 match preview_kind(kind) {
1421 Some(pk) => FileView::Preview(
1422 PreviewTarget {
1423 root_id,
1424 path,
1425 name,
1426 },
1427 pk,
1428 ),
1429 None => FileView::Unsupported(UnsupportedTarget {
1430 root_id,
1431 path,
1432 name,
1433 kind,
1434 }),
1435 }
1436}
1437
1438/// Open a content match: always a text file (the grep only reads those). One
1439/// callback per card, shared by all of its lines. Free function rather than a
1440/// component closure so the card's `<For>` children closure only captures
1441/// `Copy` signal handles (the surrounding closures must be re-runnable, and
1442/// `move` closures may only move `Copy` captures out of that environment).
1443fn open_match_cb(
1444 root_id: i64,
1445 path: Arc<str>,
1446 me: ReadSignal<Option<api_types::Me>>,
1447 open_file: WriteSignal<Option<FileView>>,
1448) -> Callback<()> {
1449 Callback::new(move |_| {
1450 let name = path.rsplit('/').next().unwrap_or(&path).to_string();
1451 let fv = open_file_view(
1452 root_id,
1453 path.to_string(),
1454 name,
1455 FileKind::Text,
1456 is_writable(me, root_id),
1457 );
1458 open_file.set(Some(fv));
1459 })
1460}
1461
1462/// Footer under a result list: where the user is in the results, and a button
1463/// to reveal the next page.
1464///
1465/// `shown` of `total` is stated plainly rather than only counting what is
1466/// hidden — "Showing 100 of 6027" answers "where am I?", which a bare
1467/// "5927 more…" does not. The button appears only while more results are
1468/// actually retained; past [`RETAIN_FILE_CAP`] the count is all that is left
1469/// to say, so the footer becomes a plain line.
1470///
1471/// Returns nothing at all when everything found is on screen.
1472fn page_footer(
1473 shown: usize,
1474 retained: usize,
1475 total: usize,
1476 page: usize,
1477 reveal: impl Fn() + 'static,
1478) -> AnyView {
1479 if total <= shown {
1480 return view! {}.into_view().into_any();
1481 }
1482 let label = i18n::t_fmt2(k::SEARCH_SHOWN_OF, &shown.to_string(), &total.to_string());
1483 let pageable = retained.saturating_sub(shown);
1484 if pageable == 0 {
1485 return view! { <div class="page-foot">{label}</div> }
1486 .into_view()
1487 .into_any();
1488 }
1489 let next = pageable.min(page);
1490 view! {
1491 <div class="page-foot">
1492 <span class="pf-count">{label}</span>
1493 <button class="pf-more" on:click=move |_| reveal()>
1494 {i18n::t_fmt(k::SEARCH_SHOW_MORE, &next.to_string())}
1495 </button>
1496 </div>
1497 }
1498 .into_view()
1499 .into_any()
1500}
1501
1502/// Lowercased whitespace-split words of the query.
1503fn query_words(query: &str) -> Vec<String> {
1504 query.split_whitespace().map(lower).collect()
1505}
1506
1507fn split_rel(path: &str) -> Vec<String> {
1508 path.split('/')
1509 .filter(|s| !s.is_empty())
1510 .map(String::from)
1511 .collect()
1512}
1513
1514/// `("docs/notes", "runbook.md")` from a relative path.
1515fn split_name(path: &str) -> (&str, &str) {
1516 match path.rfind('/') {
1517 Some(i) if i > 0 => (&path[..i], &path[i + 1..]),
1518 _ => ("", path),
1519 }
1520}
1521
1522#[cfg(test)]
1523mod tests {
1524 use super::*;
1525
1526 fn hl(text: &str, words: &[&str]) -> String {
1527 let w: Vec<String> = words.iter().map(|s| s.to_string()).collect();
1528 highlight_html(text, &w)
1529 }
1530
1531 /// `<mark class="hl-hit">x</mark>` written as `<x>`, so the expectations
1532 /// stay readable.
1533 fn short(html: &str) -> String {
1534 html.replace("<mark class=\"hl-hit\">", "<")
1535 .replace("</mark>", ">")
1536 }
1537
1538 #[test]
1539 fn highlight_marks_hits() {
1540 assert_eq!(short(&hl("Hello world", &["world"])), "Hello <world>");
1541 }
1542
1543 #[test]
1544 fn highlight_is_case_insensitive() {
1545 assert_eq!(short(&hl("HeLLo there", &["hello"])), "<HeLLo> there");
1546 }
1547
1548 #[test]
1549 fn highlight_marks_all_words() {
1550 assert_eq!(
1551 short(&hl("foo bar foo", &["foo", "bar"])),
1552 "<foo> <bar> <foo>"
1553 );
1554 }
1555
1556 #[test]
1557 fn highlight_no_hit_is_plain() {
1558 assert_eq!(hl("abc", &["xyz"]), "abc");
1559 }
1560
1561 #[test]
1562 fn highlight_empty_words_is_plain() {
1563 assert_eq!(hl("abc", &[]), "abc");
1564 assert_eq!(hl("", &["a"]), "");
1565 }
1566
1567 #[test]
1568 fn highlight_escapes_markup() {
1569 // File names and file contents are untrusted: the only markup in the
1570 // output must be the `<mark>` wrapper.
1571 assert_eq!(
1572 hl("<script>alert(1)</script>", &["script"]),
1573 "&lt;<mark class=\"hl-hit\">script</mark>&gt;alert(1)&lt;/\
1574 <mark class=\"hl-hit\">script</mark>&gt;"
1575 );
1576 // Escaping applies inside the highlighted span too.
1577 assert_eq!(hl("a<b", &["<b"]), "a<mark class=\"hl-hit\">&lt;b</mark>");
1578 assert_eq!(hl(r#"& " ' < >"#, &[]), "&amp; &quot; &#39; &lt; &gt;");
1579 }
1580
1581 #[test]
1582 fn highlight_handles_non_ascii() {
1583 // Ö and ö are 2 bytes each: the hit slices must follow character
1584 // boundaries of the original text.
1585 assert_eq!(short(&hl("AÖ aö b", &["ö"])), "A<Ö> a<ö> b");
1586 }
1587}
1588