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