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