//! The search view (`#/search`): index-free name and content search with //! streamed results, an explicit stop button, a roots multi-select, and //! collapsible per-file match lists. //! //! Results arrive as they are found (SSE, see `api::search_stream`). The //! client caps only what it *renders* (name rows a page of [`FILE_PAGE`] at a //! time, match lines per file at [`RENDER_LINE_CAP`]) — the search itself is //! unbounded and ends when the walk is done or the user stops it. Stopping //! aborts the fetch; the server notices the dropped connection and unwinds //! its walk. use std::collections::HashMap; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex}; use api_types::{FileKind, SearchEvent}; use gloo_timers::future::TimeoutFuture; use leptos::prelude::*; use wasm_bindgen::JsCast; use crate::api; use crate::components::icon::{Icon, icon_svg}; use crate::components::toast::{ToastMsg, show_error}; use crate::editor::EditTarget; use crate::i18n::{self, k}; use crate::icons::{IconName, icon_for}; use crate::preview::{PreviewTarget, preview_kind}; use crate::router::{Location, Section, navigate}; use crate::util::format_size; use crate::views::file_view::{FileView, UnsupportedTarget}; /// Name rows rendered at once, and how many more each click of the "show /// more" row reveals. Small on purpose: rendering a row costs a fixed amount /// of reactive-graph setup, so the initial paint stays well under a frame and /// the user pays for more rows only by asking for them. const FILE_PAGE: usize = 100; /// Name hits kept for paging. The search keeps running and counting past /// this; hits beyond it are reported as a plain remainder that cannot be /// paged to, since holding every hit of a one-character query would grow /// without bound. const RETAIN_FILE_CAP: usize = 2000; /// Render cap for match lines per file. const RENDER_LINE_CAP: usize = 500; /// Match cards rendered at once, and how many more each click reveals. Lower /// than [`FILE_PAGE`] because a card is much taller than a row — it carries a /// header and its matched lines — so a page of them already fills the screen. const CARD_PAGE: usize = 20; /// Files with content matches kept for paging. As with [`RETAIN_FILE_CAP`], /// the search counts past this; the surplus is reported but cannot be paged /// to. const RETAIN_MATCH_FILE_CAP: usize = 500; /// Stream events are applied in batches this many milliseconds apart, not /// per event: an open search can emit thousands of events and applying them /// one by one would re-render per event and peg the main thread. /// /// A flush applies its whole batch, but in feedback-sized chunks that yield /// to the browser between them (see [`apply_chunked`]) rather than in one go. const FLUSH_MS: u32 = 120; /// Target and back-off thresholds for one chunk plus its yield. /// /// Chunks are sized by feedback, not by a fixed item count: the cost of a row /// depends on the machine, and a hardcoded count either blocks on a slow one /// or trickles for seconds on a fast one. /// /// The signal is the interval between successive resumptions of the render /// loop, which covers the chunk's own work, the `` pass it triggers, and /// anything else the browser did in between. Keeping that near /// [`CHUNK_TARGET_MS`] keeps the main thread free most of the time, so input /// stays responsive while a large result set fills in. const CHUNK_TARGET_MS: f64 = 10.0; const CHUNK_SLOW_MS: f64 = 14.0; /// Events folded between yields. Fixed rather than adaptive: folding an /// event is a bounded allocate-and-hash, so unlike rendering it has no /// machine-dependent cliff worth measuring for. const EVENTS_PER_SLICE: usize = 2048; /// First chunk's size, before there is a measurement to go on. const INITIAL_CHUNK: usize = 64; /// Chunk bounds. The floor keeps forward progress when every item is /// expensive; the ceiling stops one chunk from blocking when the interval /// looks cheap for an unrelated reason. /// The ceiling matters more than it looks: `` re-runs `each` over the /// whole list per write, so a write costs roughly `chunk + list_len`. As the /// list fills, a fixed chunk gets steadily more expensive, and the feedback /// below can only react *after* a round overran. Capping the chunk caps how /// bad that one round can be. const MIN_CHUNK: usize = 16; const MAX_CHUNK: usize = 128; /// `performance.now()`, or 0 when there is no window (never in the browser). fn now_ms() -> f64 { web_sys::window() .and_then(|w| w.performance()) .map(|p| p.now()) .unwrap_or(0.0) } /// Hands the main thread back to the browser, then resumes. /// /// A plain macrotask, deliberately, after measuring the two obvious /// alternatives: /// /// * `requestAnimationFrame` only fires while the browser is producing /// frames. Under a throttled compositor it dropped to 2 fps and the result /// list took tens of seconds to fill; a backgrounded tab would stall it /// outright. /// * `scheduler.yield()` resumes at user-blocking priority, ahead of the /// browser's own rendering update. Chunks then pile up unpainted and style /// and layout land in one lump at the end — measurably worse (a 110 ms task /// versus 85 ms, and settle 630 ms versus 390 ms). /// /// A macrotask lets a rendering update happen between chunks, which is the /// point. The cost is `setTimeout`'s 4 ms clamp past nesting depth 5. async fn yield_to_browser() { TimeoutFuture::new(0).await; } /// Next chunk size from the last round's interval. /// /// Additive increase, multiplicative decrease. Doubling on the way up /// overshoots: it keeps growing until a chunk finally overruns, and *that* /// chunk is the visible stall (measured as a 241 ms spike once the size ran /// away to 1024). Growing an eighth at a time bounds the overshoot, while /// halving still backs off from a bad round immediately. fn next_chunk(prev: usize, interval_ms: f64) -> usize { let next = if interval_ms > CHUNK_SLOW_MS { prev / 2 } else if interval_ms < CHUNK_TARGET_MS { prev + (prev / 8).max(8) } else { prev }; next.clamp(MIN_CHUNK, MAX_CHUNK) } /// Applies `items` to `write` in chunks, yielding to the browser between /// them. One signal write per chunk, so a chunk costs one `` pass. async fn apply_chunked(items: Vec, my_gen: u64, ctx: &FlushCtx, mut write: F) where F: FnMut(Vec), { let mut rest = items; let mut chunk = INITIAL_CHUNK; let mut last_round = now_ms(); while !rest.is_empty() { // A new search (or an unmount) makes the remaining items obsolete. if ctx.guard.load(Ordering::Relaxed) || ctx.search_gen.load(Ordering::SeqCst) != my_gen { return; } let take = chunk.min(rest.len()); let head: Vec = rest.drain(..take).collect(); write(head); yield_to_browser().await; let now = now_ms(); chunk = next_chunk(chunk, now - last_round); last_round = now; } } #[derive(Clone, Copy, PartialEq, Eq)] enum Scope { Name, Content, Both, } impl Scope { const fn param(self) -> &'static str { match self { Scope::Name => "name", Scope::Content => "content", Scope::Both => "both", } } } /// `text` is an `Arc` because `` clones the whole line vector /// on every append; a refcount bump keeps that clone O(1) per line. #[derive(Clone)] struct MatchLine { line: u64, text: Arc, } /// A file with content matches, kept in arrival order. The card's lines /// live in the view-level registry ([`CardState`]) under the same key; the /// vector only drives the keyed ``. /// /// `search_gen` stamps the search generation: the `` key includes it so a new /// search's items can never match a previous search's nodes (a position /// match would keep the old node, with the old search's line state). #[derive(Clone)] struct MatchFile { root_id: i64, path: Arc, search_gen: u64, } /// Live state of a match card, owned by the search view (the registry /// `RwSignal`), not by the card's `` node: one owner per path, and the /// lines survive the card being remounted. New lines and collapse toggles /// only touch the card that owns them. #[derive(Clone, Copy)] struct CardState { lines: RwSignal>, collapsed: RwSignal, } /// `path` is an `Arc` for the same reason as [`MatchLine::text`]. #[derive(Clone)] struct FileHit { root_id: i64, path: Arc, size: u64, is_dir: bool, } #[derive(Clone, Copy, PartialEq)] enum Status { Idle, Searching, /// The walk finished. Done { scanned: usize, skipped: usize, elapsed_ms: u64, }, /// The user pressed Stop. The connection was aborted before the server /// could send its summary, so no scan counts are available. StoppedByUser, /// The server stopped early (match cap reached) and sent its summary. Stopped { scanned: usize, skipped: usize, }, } /// A text fragment: plain or a highlighted hit. Rendered through /// [`FragView`] so a mixed fragment list has one concrete element type /// (Leptos 0.8 views are generic). #[derive(Clone, PartialEq, Eq)] enum Frag { Plain(String), Mark(String), } /// Render `text` with every query hit wrapped in ``, as /// one HTML string for `inner_html`. /// /// One element per text instead of one per fragment. A fragment used to be a /// `#[component]`, and a component costs a reactive owner plus arena slots: /// 1000 name rows are ~9000 fragments, which measured at ~700 ms of the /// render (the same DOM built from plain JS takes 25 ms). /// /// Both file names and file contents are untrusted, so every character of /// `text` goes through [`push_escaped`] and the only markup in the output is /// the `` this function writes. fn highlight_html(text: &str, words: &[String]) -> String { let mut out = String::with_capacity(text.len() + 16); for f in highlight(text, words) { match f { Frag::Plain(s) => push_escaped(&mut out, &s), Frag::Mark(s) => { out.push_str(""); push_escaped(&mut out, &s); out.push_str(""); } } } out } /// Append `s` to `out` with the five HTML-significant characters escaped. fn push_escaped(out: &mut String, s: &str) { for c in s.chars() { match c { '&' => out.push_str("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), '\'' => out.push_str("'"), _ => out.push(c), } } } /// Split `text` into plain/highlighted fragments for the query's words /// (case-insensitive). Name matches are whole-word ANDs and content matches /// are the whole phrase, so highlighting each word covers both. /// /// The text is lowercased once; hits are located in lowercased-char space /// and mapped back to the original by character index, so case mappings /// that change the character count (rare, e.g. `İ`) stay correct. /// /// Worst case is O(words × len²) (the inner search restarts at `pos` for /// every word); acceptable because it runs once per rendered row, not per /// flush. fn highlight(text: &str, words: &[String]) -> Vec { let needles: Vec> = words .iter() .filter(|w| !w.is_empty()) .map(|w| w.to_lowercase().chars().collect()) .collect(); if needles.is_empty() { return if text.is_empty() { Vec::new() } else { vec![Frag::Plain(text.to_string())] }; } let lowered: Vec = text.chars().flat_map(|c| c.to_lowercase()).collect(); // Byte offset just past original character `i`. let mut byte_end: Vec = Vec::with_capacity(text.len()); for (b, ch) in text.char_indices() { byte_end.push(b + ch.len_utf8()); } // Lowered index -> original char index. Only differs from the identity // when some character lowercases to more than one character. let mut l2c: Vec = Vec::new(); if lowered.len() != byte_end.len() { for ci in 0..byte_end.len() { let s = if ci == 0 { 0 } else { byte_end[ci - 1] }; let ch = &text[s..byte_end[ci]]; for _ in ch.to_lowercase().chars() { l2c.push(ci); } } } let char_of = |lp: usize| -> usize { if l2c.is_empty() { lp } else { l2c.get(lp).copied().unwrap_or(byte_end.len()) } }; // Byte offset of original character `ci` (0 for the first). let byte_at = |ci: usize| -> usize { if ci == 0 { 0 } else { byte_end[ci - 1] } }; let mut out: Vec = Vec::new(); let mut pos = 0usize; while pos < lowered.len() { // Earliest hit of any word at or after `pos`. let mut best: Option<(usize, usize)> = None; for w in &needles { let n = w.len(); if pos + n > lowered.len() { continue; } let mut i = pos; while i + n <= lowered.len() { if lowered[i..i + n] == w[..] { if best.is_none_or(|(b, _)| i < b) { best = Some((i, n)); } break; } i += 1; } } let Some((start, n)) = best else { break; }; let s0 = byte_at(char_of(pos)); let s1 = byte_at(char_of(start)); let s2 = byte_at(char_of(start + n)); if s1 > s0 { out.push(Frag::Plain(text[s0..s1].to_string())); } out.push(Frag::Mark(text[s1..s2].to_string())); pos = start + n; } let st = byte_at(char_of(pos)); if st < text.len() { out.push(Frag::Plain(text[st..].to_string())); } out } /// Parse `#/search?q=...&scope=...&root=...` (the part after `?`). /// /// `roots` is still accepted, and its first id taken, so links made when a /// search could span several roots still open. fn parse_search_url() -> (String, Scope, Option) { let hash = web_sys::window() .and_then(|w| w.location().hash().ok()) .unwrap_or_default(); let Some(qs) = hash.split_once('?').map(|(_, q)| q) else { return (String::new(), Scope::Both, None); }; let mut q = String::new(); let mut scope = Scope::Both; let mut root = None; for pair in qs.split('&') { let (key, val) = match pair.split_once('=') { Some(p) => p, None => continue, }; match key { "q" => { q = js_sys::decode_uri_component(val) .map(String::from) .unwrap_or_else(|_| val.to_string()) } "scope" => { scope = match val { "name" => Scope::Name, "content" => Scope::Content, _ => Scope::Both, } } "root" | "roots" => { root = val.split(',').next().and_then(|s| s.trim().parse().ok()); } _ => {} } } (q, scope, root) } /// Write the current search into the hash without adding a history entry. fn set_search_url(q: &str, scope: Scope, root: i64) { let hash = format!( "#/search?q={}&scope={}&root={root}", js_sys::encode_uri_component(q), scope.param() ); if let Some(Ok(hist)) = web_sys::window().map(|w| w.history()) { let _ = hist.replace_state_with_url(&wasm_bindgen::JsValue::NULL, "", Some(&hash)); } } /// Current vertical window scroll, 0 when there is no window. fn scroll_y() -> f64 { web_sys::window() .and_then(|w| w.scroll_y().ok()) .unwrap_or(0.0) } fn scroll_to_y(y: f64) { if let Some(w) = web_sys::window() { w.scroll_to_with_x_and_y(0.0, y); } } /// Navigate to the folder `path` sits in, so the hit can be seen in context. /// A top-level entry goes to the root itself. fn goto_parent(root_id: i64, path: &str) { let mut parts = split_rel(path); parts.pop(); navigate(&Location { root_id: Some(root_id), path: parts, share_token: None, section: Section::Files, }); } /// The value of a `` re-reads `me` live for the names. let (sel_root, set_sel_root) = RwSignal::>::new( me.get_untracked() .and_then(|m| m.roots.first().map(|r| r.id)), ) .split(); let status = RwSignal::new(Status::Idle); let files_sig = RwSignal::>::new(Vec::new()); let (files_r, files_w) = files_sig.split(); let files_total = RwSignal::new(0usize); // How many of the retained name hits are rendered. Grows a page at a // time when the user clicks the "show more" row. let shown_files = RwSignal::new(FILE_PAGE); let shown_cards = RwSignal::new(CARD_PAGE); let match_files_sig = RwSignal::>::new(Vec::new()); let (match_files_r, match_files_w) = match_files_sig.split(); let matches_total = RwSignal::new(0usize); // True count of files that had at least one match (the rendered list is // capped), plus the set of seen paths. Non-reactive: the set only feeds // a counter, and it outlives the flush batch it is used in. let match_files_total = RwSignal::new(0usize); let seen_match_files: Arc>>> = Arc::new(Mutex::new(std::collections::HashSet::new())); // Per-path card state for this view: created in `apply_batch` when a // path is first seen, read by the card's `` child, removed on card // unmount. One owner per path, so the lines survive the card being // remounted. Component-scoped (no static): it is disposed with the view // and reset on every new search. let registry: RwSignal, CardState>> = RwSignal::new(HashMap::new()); // The in-flight AbortController, so Stop and unmount can both reach it. // Deliberately *not* reactive: the fetch task outlives the component and // touching a disposed signal panics. let abort_box: Arc>> = Arc::new(Mutex::new(None)); // Set on unmount so a stream event arriving one tick late cannot touch // disposed signals. let guard: Arc = Arc::new(AtomicBool::new(false)); // Stream events are buffered and applied in batches (see FLUSH_MS); // `gen` is bumped on every new search so a stale flush from a previous // run cannot leak its batch into the fresh state. let pending: Arc>> = Arc::new(Mutex::new(Vec::new())); let flushing: Arc = Arc::new(AtomicBool::new(false)); let search_gen: Arc = Arc::new(AtomicU64::new(0)); // ---- actions ----------------------------------------------------------- let stop_search = Callback::new({ let abort_box = abort_box.clone(); let pending = pending.clone(); let search_gen = search_gen.clone(); move |_| { if let Some(c) = abort_box.lock().unwrap().take() { c.abort(); } // Stop should stop the UI too: discard buffered events and // invalidate the in-flight flush so nothing keeps landing. The // totals are left alone — they count what the server found, and // the "N more" rows report the difference to what is rendered. pending.lock().unwrap().clear(); search_gen.fetch_add(1, Ordering::SeqCst); // The server can no longer send its `Done` summary (the // connection is gone), so settle the UI here. status.set(Status::StoppedByUser); } }); // Buffer stream events, apply them in batches. The first buffered event // schedules the flush; a late flush for an unmounted view or a replaced // search is discarded. let flush_ctx = FlushCtx { pending: pending.clone(), flushing: flushing.clone(), guard: guard.clone(), search_gen: search_gen.clone(), files: files_sig, files_total, match_files: match_files_sig, matches_total, match_files_total, seen: seen_match_files.clone(), registry, status, }; let on_stream_event = Callback::new({ let ctx = flush_ctx; move |ev: SearchEvent| { ctx.pending.lock().unwrap().push(ev); if ctx .flushing .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) .is_err() { return; // a flush is already pending } let my_gen = ctx.search_gen.load(Ordering::SeqCst); wasm_bindgen_futures::spawn_local(run_flush(FLUSH_MS, my_gen, ctx.clone())); } }); let start_search = Callback::new({ let stop = stop_search; let abort_box = abort_box.clone(); let guard = guard.clone(); move |()| { // Untracked reads: starting a search must never register reactive // dependencies (this callback can run from any context). let q = query.get_untracked(); let scope = scope.get_untracked(); if q.trim().is_empty() { return; } stop.run(()); // stop any in-flight search first *abort_box.lock().unwrap() = None; pending.lock().unwrap().clear(); search_gen.fetch_add(1, Ordering::SeqCst); // Exactly one root. Falls back to the first available when // nothing is selected yet (a deep link with an unknown id, or // `me` not loaded when the view was built). let Some(root) = sel_root.get_untracked().or_else(|| { me.get_untracked() .and_then(|m| m.roots.first().map(|r| r.id)) }) else { return; }; let roots = vec![root]; set_sel_root.set(Some(root)); // Fresh state for this run. files_w.set(Vec::new()); files_total.set(0); shown_files.set(FILE_PAGE); shown_cards.set(CARD_PAGE); match_files_w.set(Vec::new()); matches_total.set(0); match_files_total.set(0); seen_match_files.lock().unwrap().clear(); registry.set(HashMap::new()); status.set(Status::Searching); set_search_url(&q, scope, root); set_exec_query.set(q.clone()); let ctrl = match api::search_stream( q.clone(), scope.param(), &roots, on_stream_event, on_error_for(toast, status, guard.clone()), ) { Ok(c) => c, Err(e) => { show_error(toast, e.to_string()); status.set(Status::Idle); return; } }; *abort_box.lock().unwrap() = Some(ctrl); } }); // The search view's owner is the shell's view tree; a stream event can // arrive on the main thread after unmount, so the stream-side callbacks // bail on the (non-reactive) guard, and the fetch is aborted here. { let abort_box = abort_box.clone(); let g = guard.clone(); on_cleanup(move || { g.store(true, Ordering::Relaxed); if let Some(c) = abort_box.lock().unwrap().take() { c.abort(); } }); } // Collapse/expand all cards. Whether the group is fully collapsed is // derived from the cards themselves (no counter to keep in sync). let collapse_all: Callback<(), ()> = { Callback::new(move |_| { // Copy the states out before writing them: writing an inner // signal while the registry's own borrow is held would rely on // leptos deferring effects to a microtask. let states: Vec = registry.with_untracked(|m| m.values().copied().collect()); let target = !(!states.is_empty() && states.iter().all(|c| c.collapsed.get_untracked())); for c in states { c.collapsed.set(target); } }) }; // Enter in the query field starts the search. let on_query_key = move |ev: web_sys::KeyboardEvent| { if ev.key() == "Enter" { start_search.run(()); } }; // Deep link: `#/search?q=...` restores and re-runs the search. { let (q0, s0, r0) = parse_search_url(); if !q0.is_empty() { set_query.set(q0); set_scope.set(s0); // Only honour a root the caller actually has; otherwise keep the // default so a stale link still searches something. if let Some(id) = r0 && me .get_untracked() .is_some_and(|m| m.roots.iter().any(|r| r.id == id)) { set_sel_root.set(Some(id)); } // Run the search from setup (an untracked context), not from an // `Effect`: the effect would track `start_search`'s signal reads // and retrigger on the very signal it writes (`sel_root`), // re-running the search in a loop and pegging the main thread. start_search.run(()); } } // Keep the scroll position across opening a result. The window is the // scroller, and while a file is open the search UI is hidden, so the // document shrinks to one viewport and the browser clamps the scroll to // 0. Saving it on open and restoring on close is the only way back — // hiding rather than unmounting is necessary but not sufficient. // Saved by the handlers that open a result, *not* by the effect below: // hiding the search UI is itself an effect, and if it ran first the // scroll was already clamped to 0 by the time we read it. let saved_scroll = StoredValue::new(0.0f64); Effect::new(move |was_open: Option| { let open = file_view.get().is_some(); if was_open == Some(true) && !open { let y = saved_scroll.get_value(); // Only once the display flip has been laid out; before that the // document is one viewport tall and the scroll is clamped away // again. wasm_bindgen_futures::spawn_local(async move { yield_to_browser().await; scroll_to_y(y); }); } open }); // One delegated handler for the whole name list, instead of a `Callback` // per row: a callback is an arena entry plus a closure allocation, and at // a thousand rows that is a measurable slice of the render. The row // carries what the handler needs in data attributes. let on_row_click = move |ev: web_sys::MouseEvent| { let Some(target) = ev .target() .and_then(|t| t.dyn_into::().ok()) else { return; }; let Some(row) = target.closest(".srow").ok().flatten() else { return; }; let Some(path) = row.get_attribute("data-path") else { return; }; let Some(root_id) = row .get_attribute("data-root") .and_then(|s| s.parse::().ok()) else { return; }; // The goto button is inside the row, so it is handled here rather // than with its own callback per row. if target.closest(".goto-btn").ok().flatten().is_some() { goto_parent(root_id, &path); return; } if row.get_attribute("data-dir").is_some() { navigate(&Location { root_id: Some(root_id), path: split_rel(&path), share_token: None, section: Section::Files, }); return; } let is_rw = me .get_untracked() .map(|m| { m.roots .iter() .find(|r| r.id == root_id) .map(|r| r.mode.is_writable()) .unwrap_or(false) }) .unwrap_or(false); let name = path.rsplit('/').next().unwrap_or(&path).to_string(); let kind = kind_of_name(&name); saved_scroll.set_value(scroll_y()); open_file.set(Some(open_file_view(root_id, path, name, kind, is_rw))); }; // ---- render ------------------------------------------------------------ view! { // Hidden with `display` rather than unmounted while a file is open // (the same treatment the browser view gets). Unmounting would throw // away every rendered row and rebuild it on close, and the document // would lose its scroll position — see `keep_scroll` above.
().ok()) { set_query.set(t.value()); } } on:keydown=on_query_key autocomplete="off" />
// A search covers exactly one root. A native `().ok()) { set_sel_root.set(Some(id)); } } > {move || { let cur = sel_root.get(); me.get() .map(|m| m.roots) .unwrap_or_default() .into_iter() .map(|r| { view! { } }) .collect::>() }} {move || { if status.get() == Status::Searching { view! { } .into_view() .into_any() } else { view! { } .into_view() .into_any() } }}
{move || match status.get() { Status::Idle => view! {}.into_view().into_any(), Status::Searching => view! {
{i18n::t(k::SEARCHING)} {" · "} {i18n::t_fmt( k::SEARCH_N_RESULTS, &(files_total.get() + matches_total.get()).to_string(), )}
} .into_view() .into_any(), Status::Done { scanned, skipped, elapsed_ms } => view! {
{i18n::t_fmt(k::SEARCH_N_RESULTS, &(files_total.get() + matches_total.get()).to_string())} {" · "} {i18n::t_fmt(k::SEARCH_TOOK, &elapsed_ms.to_string())} {" · "} {i18n::t_fmt(k::SEARCH_SCANNED, &scanned.to_string())} {move || { if skipped > 0 { view! { {" · "} {i18n::t_fmt(k::SEARCH_SKIPPED, &skipped.to_string())} } .into_view() .into_any() } else { view! {}.into_view().into_any() } }}
} .into_view() .into_any(), Status::StoppedByUser => view! {
{i18n::t_fmt( k::SEARCH_STOPPED, &(files_total.get() + matches_total.get()).to_string(), )}
} .into_view() .into_any(), Status::Stopped { scanned, skipped } => view! {
{i18n::t_fmt(k::SEARCH_STOPPED, &(files_total.get() + matches_total.get()).to_string())} {" · "} {i18n::t_fmt(k::SEARCH_SCANNED, &scanned.to_string())} {move || { if skipped > 0 { view! { {" · "} {i18n::t_fmt(k::SEARCH_SKIPPED, &skipped.to_string())} } .into_view() .into_any() } else { view! {}.into_view().into_any() } }}
} .into_view() .into_any(), }} // The groups are hidden with `display` instead of unmounted: // toggling scope must not rebuild the rows/cards (and the cards' // line signals live in the registry, but keeping the DOM stable // makes the toggle a single attribute write).
0 || status.get() != Status::Idle); if show { "flex" } else { "none" } } >
{i18n::tr(k::FILES)} {"· "}{files_total}
>()) } key=move |h: &FileHit| (h.root_id, h.path.clone()) children=move |h: FileHit| { // Note: only the first `shown_files` hits reach // here; `each` above slices before cloning. let (dir, name) = split_name(&h.path); let words = query_words(&exec_query.get_untracked()); let icon = if h.is_dir { IconName::Folder } else { icon_for(kind_of_name(name), name) }; // Only the name is highlighted. The server matches // the entry's own name, not its path, so a mark // in the directory line would point at text that // was never matched. let name_h = highlight_html(name, &words); let dir_s = dir.to_string(); let size = if h.is_dir { "—".to_string() } else { format_size(h.size) }; // `icon_svg`, not ``: no component owner // per row. Click handling is delegated to the // list (see `on_row_click`), so the row only // carries data attributes. view! {
{icon_svg(icon, "srow-icon")}
{dir_s}
{size}
} } /> {move || { let retained = files_r.with(|v| v.len()); page_footer( shown_files.get().min(retained), retained, files_total.get(), FILE_PAGE, move || shown_files.update(|n| *n += FILE_PAGE), ) }}
{i18n::tr(k::SEARCH_CONTENT)} // Closures, not bare calls: these have to re-read the // totals as the search streams in. {"· "} {move || { i18n::t_fmt(k::SEARCH_N_MATCHES, &matches_total.get().to_string()) }} {" "} {move || { i18n::t_fmt(k::SEARCH_IN_N_FILES, &match_files_total.get().to_string()) }}
>()) } key=move |f: &MatchFile| (f.search_gen, f.root_id, f.path.clone()) children=move |f: MatchFile| { // The card's live state is created in `apply_batch` // (when the path was first seen) and owned by the // view-level registry: one owner per path, and the // lines survive this node being remounted (e.g. by a // keyed reorder mid-search). The child only reads // the entry; it never removes it, so an unmount // racing a new search cannot delete a fresh entry. // Entries of a finished search are wiped by the next // `start_search` or with the view itself. // // The entry is always there (`apply_batch` inserts it // before the path reaches `match_files`), but render // an empty card rather than panic the whole view if // that ordering ever changes. let Some(st) = registry.with_untracked(|m| m.get(&f.path).copied()) else { return view! {}.into_view().into_any(); }; let lines_r = st.lines; let collapsed = st.collapsed; let key = f.path.clone(); let root_id = f.root_id; let (dir, name) = split_name(&f.path); let icon = icon_for(kind_of_name(name), name); // The card's path is *not* highlighted: a content hit // was found in the file's text, not in its name, so // marking the name would claim a match that is not // there and duplicate what the name results show. let dir_s = dir.to_string(); let name_s = name.to_string(); // One open-callback per card (Copy). The header opens // the file; the matched lines are text to read, not // buttons. let open_file_cb = open_match_cb(root_id, key.clone(), me, open_file); let goto_path = key.clone(); view! {
// The whole header opens the file, so the two // buttons in it stop their clicks here.
{icon_svg(icon, "mi")} {dir_s} {" / "} {name_s} {move || { let n = lines_r.with(|v| v.len()); i18n::t_fmt(k::SEARCH_N_MATCHES, &n.to_string()) }}
{l.line}
} } />
} .into_view() .into_any() } /> {move || { let retained = match_files_r.with(|v| v.len()); page_footer( shown_cards.get().min(retained), retained, match_files_total.get(), CARD_PAGE, move || shown_cards.update(|n| *n += CARD_PAGE), ) }}
{move || { let done = matches!( status.get(), Status::Done { .. } | Status::Stopped { .. } | Status::StoppedByUser ); if done && files_total.get() == 0 && matches_total.get() == 0 { view! {
{i18n::t(k::SEARCH_NO_RESULTS)}
} .into_view() .into_any() } else { view! {}.into_view().into_any() } }} } } /// Everything a flush needs: the shared event queue and the view signals. #[derive(Clone)] struct FlushCtx { pending: Arc>>, flushing: Arc, guard: Arc, search_gen: Arc, files: RwSignal>, files_total: RwSignal, match_files: RwSignal>, matches_total: RwSignal, match_files_total: RwSignal, seen: Arc>>>, registry: RwSignal, CardState>>, status: RwSignal, } /// Waits `delay_ms`, then applies every event buffered in the meantime. /// /// `flushing` is held for the whole (yielding) apply, so only one flush ever /// writes the view state; events arriving during it buffer in `pending` and /// the next flush picks them up right away instead of waiting `FLUSH_MS`. async fn run_flush(delay_ms: u32, my_gen: u64, ctx: FlushCtx) { let _ = TimeoutFuture::new(delay_ms).await; let stale = || ctx.guard.load(Ordering::Relaxed) || ctx.search_gen.load(Ordering::SeqCst) != my_gen; if stale() { ctx.flushing.store(false, Ordering::SeqCst); return; } let batch: Vec = ctx.pending.lock().unwrap().drain(..).collect(); if !batch.is_empty() { apply_batch(&ctx, batch, my_gen).await; } ctx.flushing.store(false, Ordering::SeqCst); // Applying yields, so more events may have arrived meanwhile. They are // already `FLUSH_MS` old, so pick them up on the next frame. if stale() || ctx.pending.lock().unwrap().is_empty() { return; } if ctx .flushing .compare_exchange(false, true, Ordering::SeqCst, Ordering::SeqCst) .is_ok() { wasm_bindgen_futures::spawn_local(run_flush(0, my_gen, ctx.clone())); } } /// Folds one batch into the view state with a single render per signal: /// events are aggregated into locals first, then each signal is written /// once. (Writing per event would trigger a re-render per event, which is /// what froze the page.) /// /// A file seen for the first time gets its registry entry here, before its /// path reaches `match_files`, so the card's `` child always finds it. /// Lines for a file that already has a card are appended to that card's own /// line signal, which leaves every other card's DOM untouched. /// /// Counters and the finished status are applied at once — they are cheap and /// the status line should tell the truth immediately. Only the parts that /// build DOM go through [`apply_chunked`], which yields between chunks. async fn apply_batch(ctx: &FlushCtx, batch: Vec, my_gen: u64) { let mut new_files: Vec = Vec::new(); // Rows still allowed in the name list: it must never grow past the cap, // no matter how events are batched. `with_untracked` is a signposted // non-reactive, clone-free read of the length (this runs outside any // tracking context). let mut file_budget = RETAIN_FILE_CAP.saturating_sub(ctx.files.with_untracked(|v| v.len())); let mut file_count = 0usize; let mut mt = 0usize; let mut mft = 0usize; let mut new_match_files: Vec = Vec::new(); let mut mf_len = ctx.match_files.with_untracked(|v| v.len()); // Generation stamp for the card keys (see `MatchFile::search_gen`). let search_gen = ctx.search_gen.load(Ordering::SeqCst); let mut done: Option = None; // Group the batch's match events per file, so a new card is seeded with // all of its lines at once. Grouped through an index rather than by // comparing against the last entry: several walker threads share one // channel, so two files grepped concurrently interleave in the stream. let mut acc: Vec<(i64, Arc, Vec)> = Vec::new(); let mut acc_at: HashMap, usize> = HashMap::new(); { // Folded in slices with a yield between them. This loop allocates and // hashes a path per *event*, and a one-character query can emit tens // of thousands of them, so its cost tracks events received rather // than items rendered — it needs its own yield points, independent of // `apply_chunked` below. (Measured as a 133 ms task before this.) let mut since_yield = 0usize; for ev in batch { since_yield += 1; if since_yield >= EVENTS_PER_SLICE { since_yield = 0; yield_to_browser().await; if ctx.guard.load(Ordering::Relaxed) || ctx.search_gen.load(Ordering::SeqCst) != my_gen { return; } } match ev { SearchEvent::Done { stopped, files: _, matches: _, scanned, skipped, elapsed_ms, } => { done = Some(if stopped { Status::Stopped { scanned, skipped } } else { Status::Done { scanned, skipped, elapsed_ms, } }); } SearchEvent::File { root_id, path, size, is_dir, } => { file_count += 1; if file_budget > 0 { file_budget -= 1; new_files.push(FileHit { root_id, path: path.into(), size, is_dir, }); } } SearchEvent::Match { root_id, path, line, text, } => { mt += 1; let path: Arc = path.into(); // Locked per event rather than for the whole fold: the // fold yields, and holding the guard across an await // would keep it locked over a suspension point. if ctx.seen.lock().unwrap().insert(path.clone()) { mft += 1; } let ml = MatchLine { line, text: text.into(), }; match acc_at.get(&path) { Some(&i) => acc[i].2.push(ml), None => { acc_at.insert(path.clone(), acc.len()); acc.push((root_id, path, vec![ml])); } } } } } } // One owner per path: a card's lines live in the registry, not in the // card's `` node, so remounting a card never loses them. Appends to // existing cards are collected and applied after the registry's borrow // is released — writing an inner signal from inside `update` would rely // on leptos deferring effects to a microtask. let mut appends: Vec<(CardState, Vec)> = Vec::new(); ctx.registry.update(|reg| { for (root_id, path, mut lines) in acc { match reg.get(&path) { Some(st) => appends.push((*st, lines)), None => { if mf_len >= RETAIN_MATCH_FILE_CAP { // The file will never get a card; its matches are // counted above and dropped here. continue; } // Seed a new card's collapse state from the group's // current all-collapsed state, so a live "collapse all" // is not undone by arriving cards. let all_collapsed = !reg.is_empty() && reg.values().all(|c| c.collapsed.get_untracked()); lines.truncate(RENDER_LINE_CAP); reg.insert( path.clone(), CardState { lines: RwSignal::new(lines), collapsed: RwSignal::new(all_collapsed), }, ); mf_len += 1; new_match_files.push(MatchFile { root_id, path, search_gen, }); } } } }); // Cheap, so applied immediately: the counters and the finished status. if file_count > 0 { ctx.files_total.update(|n| *n += file_count); } if mt > 0 { ctx.matches_total.update(|n| *n += mt); if mft > 0 { ctx.match_files_total.update(|n| *n += mft); } } if let Some(s) = done { ctx.status.set(s); } // Expensive, so chunked: everything that builds DOM. Cards first, so a // file's lines have somewhere to land, then the lines, then the name // rows. let files = ctx.files; let match_files = ctx.match_files; apply_chunked(new_match_files, my_gen, ctx, move |head| { match_files.update(|v| v.extend(head)); }) .await; for (st, lines) in appends { let room = RENDER_LINE_CAP.saturating_sub(st.lines.with_untracked(|v| v.len())); if room == 0 { continue; } let lines: Vec = lines.into_iter().take(room).collect(); apply_chunked(lines, my_gen, ctx, move |head| { st.lines.update(|v| v.extend(head)); }) .await; } apply_chunked(new_files, my_gen, ctx, move |head| { files.update(|v| v.extend(head)); }) .await; } fn on_error_for( toast: ToastMsg, status: RwSignal, guard: Arc, ) -> Callback { Callback::new(move |msg| { if guard.load(Ordering::Relaxed) { return; } show_error(toast, msg); status.set(Status::Idle); }) } fn open_file_view( root_id: i64, path: String, name: String, kind: FileKind, is_rw: bool, ) -> FileView { if kind == FileKind::Text { return FileView::Editor(EditTarget { root_id, path, name, readonly: !is_rw, }); } match preview_kind(kind) { Some(pk) => FileView::Preview( PreviewTarget { root_id, path, name, }, pk, ), None => FileView::Unsupported(UnsupportedTarget { root_id, path, name, kind, }), } } /// Open a content match: always a file. One callback per card, shared by all /// of its lines. Free function rather than a component closure so the card's /// `` children closure only captures `Copy` signal handles (the /// surrounding closures must be re-runnable, and `move` closures may only /// move `Copy` captures out of that environment). fn open_match_cb( root_id: i64, path: Arc, me: ReadSignal>, open_file: WriteSignal>, ) -> Callback<()> { Callback::new(move |_| { let is_rw = me .get_untracked() .map(|m| { m.roots .iter() .find(|r| r.id == root_id) .map(|r| r.mode.is_writable()) .unwrap_or(false) }) .unwrap_or(false); let name = path.rsplit('/').next().unwrap_or(&path).to_string(); let kind = kind_of_name(&name); let fv = open_file_view(root_id, path.to_string(), name, kind, is_rw); open_file.set(Some(fv)); }) } /// Footer under a result list: where the user is in the results, and a button /// to reveal the next page. /// /// `shown` of `total` is stated plainly rather than only counting what is /// hidden — "Showing 100 of 6027" answers "where am I?", which a bare /// "5927 more…" does not. The button appears only while more results are /// actually retained; past [`RETAIN_FILE_CAP`] the count is all that is left /// to say, so the footer becomes a plain line. /// /// Returns nothing at all when everything found is on screen. fn page_footer( shown: usize, retained: usize, total: usize, page: usize, reveal: impl Fn() + 'static, ) -> AnyView { if total <= shown { return view! {}.into_view().into_any(); } let label = i18n::t_fmt2(k::SEARCH_SHOWN_OF, &shown.to_string(), &total.to_string()); let pageable = retained.saturating_sub(shown); if pageable == 0 { return view! {
{label}
} .into_view() .into_any(); } let next = pageable.min(page); view! {
{label}
} .into_view() .into_any() } /// Lowercased whitespace-split words of the query. fn query_words(query: &str) -> Vec { query.split_whitespace().map(|w| w.to_lowercase()).collect() } fn split_rel(path: &str) -> Vec { path.split('/') .filter(|s| !s.is_empty()) .map(String::from) .collect() } /// `("docs/notes", "runbook.md")` from a relative path. fn split_name(path: &str) -> (&str, &str) { match path.rfind('/') { Some(i) if i > 0 => (&path[..i], &path[i + 1..]), _ => ("", path), } } #[cfg(test)] mod tests { use super::*; fn frags(f: &[Frag]) -> Vec { f.iter() .map(|f| match f { Frag::Plain(s) => format!("={s}="), Frag::Mark(s) => format!("<{s}>"), }) .collect() } #[test] fn highlight_marks_hits() { let w = vec!["world".to_string()]; assert_eq!( frags(&highlight("Hello world", &w)), vec!["=Hello =", ""] ); } #[test] fn highlight_is_case_insensitive() { let w = vec!["hello".to_string()]; assert_eq!( frags(&highlight("HeLLo there", &w)), vec!["", "= there="] ); } #[test] fn highlight_marks_all_words() { let w = vec!["foo".to_string(), "bar".to_string()]; assert_eq!( frags(&highlight("foo bar foo", &w)), vec!["", "= =", "", "= =", ""] ); } #[test] fn highlight_no_hit_is_plain() { let w = vec!["xyz".to_string()]; assert_eq!(frags(&highlight("abc", &w)), vec!["=abc="]); } #[test] fn highlight_empty_words_is_plain() { assert_eq!(frags(&highlight("abc", &[])), vec!["=abc="]); assert!(highlight("", &["a".to_string()]).is_empty()); } #[test] fn highlight_html_escapes_markup() { // File names and file contents are untrusted: the only markup in the // output must be the `` wrapper. let w = vec!["script".to_string()]; assert_eq!( highlight_html("", &w), "<script>alert(1)</\ script>" ); // Escaping applies inside the highlighted span too. assert_eq!( highlight_html("a<b" ); assert_eq!( highlight_html(r#"& " ' < >"#, &[]), "& " ' < >" ); } #[test] fn highlight_handles_non_ascii() { // Ö (2 bytes) and ö (2 bytes) vs. the ASCII around them: the hit // slices must follow character boundaries of the original text. let w = vec!["ö".to_string()]; let out = highlight("AÖ aö b", &w); assert_eq!(frags(&out), vec!["=A=", "<Ö>", "= a=", "<ö>", "= b="]); // The plain and mark fragments must reassemble the input. let joined: String = out .iter() .map(|f| match f { Frag::Plain(s) | Frag::Mark(s) => s.as_str(), }) .collect(); assert_eq!(joined, "AÖ aö b"); } }