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