//! The search view (`#/search`): index-free name and content search with //! streamed results, an explicit stop button, a start-folder picker, 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 //! closes the event source; 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::{FILE_PAGE, format_size, page_footer}; use crate::views::dialogs::Dialog; use crate::views::file_view::{FileView, UnsupportedTarget}; /// 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 chunks that yield to the browser /// between them (see [`apply_chunked`]) rather than in one go. const FLUSH_MS: u32 = 120; /// Events folded between yields, and items written per chunk. /// // ponytail: two fixed sizes, not a feedback controller. `` re-runs // `each` over the whole list per write, so a write costs roughly // `CHUNK + list_len` and a slow machine can still overrun a frame. Measure // before replacing this with anything adaptive. const EVENTS_PER_SLICE: usize = 2048; const CHUNK: usize = 64; /// 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; } /// 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; while !rest.is_empty() { // A new search (or an unmount) makes the remaining items obsolete. if ctx.stale(my_gen) { return; } let take = CHUNK.min(rest.len()); write(rest.drain(..take).collect()); yield_to_browser().await; } } #[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, kind: FileKind, } /// The server's closing summary of a finished walk. #[derive(Clone, Copy, PartialEq)] struct Summary { scanned: usize, skipped: usize, elapsed_ms: u64, } #[derive(Clone, Copy, PartialEq)] enum Status { Idle, Searching, /// The walk ended. `stopped` marks an early end: the user pressed Stop, /// or the server hit its match cap. `summary` is `None` when the user /// stopped, because the connection is gone before the summary arrives. Done { stopped: bool, summary: Option, }, } /// 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. /// /// Matching is case-insensitive: the text is lowercased into a copy with one /// lowercase character per original character, so a hit's offsets in the /// copy map straight back onto the original. /// // ponytail: `lower` keeps that 1:1 map by taking only the first character of // each `to_lowercase()`. A character whose lowercase expands (`İ`) therefore // matches on its first character only. Use a real case-folding table if that // ever matters. fn highlight_html(text: &str, words: &[String]) -> String { let mut out = String::with_capacity(text.len() + 16); let needles: Vec = words .iter() .filter(|w| !w.is_empty()) .map(|w| lower(w)) .collect(); if needles.is_empty() || text.is_empty() { push_escaped(&mut out, text); return out; } // The lowered copy, plus the original byte offset behind every byte of // it. `orig_at[i]` is meaningful at each character boundary `i`, and // carries `text.len()` as its final sentinel. let mut lowered = String::with_capacity(text.len()); let mut orig_at: Vec = Vec::with_capacity(text.len() + 1); for (b, c) in text.char_indices() { let lc = c.to_lowercase().next().unwrap_or(c); lowered.push(lc); for _ in 0..lc.len_utf8() { orig_at.push(b); } } orig_at.push(text.len()); let mut pos = 0usize; while pos < lowered.len() { // Earliest hit of any word at or after `pos`. let Some((start, n)) = needles .iter() .filter_map(|w| lowered[pos..].find(w.as_str()).map(|i| (pos + i, w.len()))) .min_by_key(|(i, _)| *i) else { break; }; push_escaped(&mut out, &text[orig_at[pos]..orig_at[start]]); out.push_str(""); push_escaped(&mut out, &text[orig_at[start]..orig_at[start + n]]); out.push_str(""); pos = start + n; } push_escaped(&mut out, &text[orig_at[pos]..]); out } /// Lowercase, one character out per character in — see [`highlight_html`]. fn lower(s: &str) -> String { s.chars() .map(|c| c.to_lowercase().next().unwrap_or(c)) .collect() } /// 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), } } } /// Parse `#/search?q=...&scope=...&root=...&path=...` (the part after `?`). /// Returns the query, the scope and the start folder `(root, dir)` when a /// root is given. fn parse_search_url() -> (String, Scope, Option<(i64, String)>) { let hash = web_sys::window() .and_then(|w| w.location().hash().ok()) .unwrap_or_default(); let params = hash .split_once('?') .and_then(|(_, qs)| web_sys::UrlSearchParams::new_with_str(qs).ok()); let Some(p) = params else { return (String::new(), Scope::Both, None); }; let scope = match p.get("scope").as_deref() { Some("name") => Scope::Name, Some("content") => Scope::Content, _ => Scope::Both, }; let folder = p .get("root") .and_then(|s| s.trim().parse().ok()) .map(|root| (root, p.get("path").unwrap_or_default())); (p.get("q").unwrap_or_default(), scope, folder) } fn search_hash(q: &str, scope: Scope, root: i64, dir: &str) -> String { format!( "#/search?q={}&scope={}&root={root}&path={}", js_sys::encode_uri_component(q), scope.param(), js_sys::encode_uri_component(dir), ) } /// Open the search view with `dir` of `root` as the start folder and an /// empty query (the browser's "search in this folder"). pub fn open_search_in(root: i64, dir: &str) { if let Some(w) = web_sys::window() { let _ = w .location() .set_hash(&search_hash("", Scope::Both, root, dir)); } } /// 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, }); } #[component] pub fn SearchView( me: ReadSignal>, open_file: WriteSignal>, // While a file view is open it covers the content area, so the search // UI hides (the view stays mounted: an in-flight search keeps running). file_view: ReadSignal>, /// The start-folder picker is a shell-level dialog. set_dialog: WriteSignal>, ) -> impl IntoView { let toast = use_context::().expect("toast context"); // ---- state ------------------------------------------------------------- let (query, set_query) = RwSignal::::new(String::new()).split(); let (scope, set_scope) = RwSignal::::new(Scope::Both).split(); // The running search's query words, for highlighting. Not reactive: rows // read it while they render, and it only changes with a new search. let words = StoredValue::new(Vec::::new()); // The folder a search starts in: `(root id, dir relative to the root)`. // Defaults to the top of the caller's first root. let (folder, set_folder) = RwSignal::>::new( me.get_untracked() .and_then(|m| m.roots.first().map(|r| (r.id, String::new()))), ) .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()); // How many registry cards are currently collapsed. Kept next to the // registry instead of derived, so "are they all collapsed?" is a // comparison rather than a scan of every card signal. Entries are only // ever added to the registry (never removed individually), so the count // stays in step. let collapsed_n = RwSignal::new(0usize); // The open event source, so Stop and unmount can both reach it. // Deliberately *not* reactive: the stream outlives the component and // touching a disposed signal panics. let source: Arc>> = Arc::new(Mutex::new(None)); // Stream events are buffered and applied in batches (see FLUSH_MS). // `search_gen` is bumped on every new search *and on unmount*, so one // check covers both "a stale flush from a previous run" and "the view is // gone, do not touch its signals". 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 source = source.clone(); let pending = pending.clone(); let search_gen = search_gen.clone(); move |_| { if let Some(s) = source.lock().unwrap().take() { s.close(); } // 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 summary (the connection is // gone), so settle the UI here. status.set(Status::Done { stopped: true, summary: None, }); } }); // 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(), 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, collapsed_n, 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 source = source.clone(); let search_gen = search_gen.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 *source.lock().unwrap() = None; pending.lock().unwrap().clear(); let my_gen = search_gen.fetch_add(1, Ordering::SeqCst) + 1; // Exactly one start folder. Falls back to the first root when // nothing is selected yet (a deep link with an unknown id, or // `me` not loaded when the view was built). let Some((root, dir)) = folder.get_untracked().or_else(|| { me.get_untracked() .and_then(|m| m.roots.first().map(|r| (r.id, String::new()))) }) else { return; }; set_folder.set(Some((root, dir.clone()))); // 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()); collapsed_n.set(0); status.set(Status::Searching); // Write the search into the hash without adding a history entry. if let Some(Ok(hist)) = web_sys::window().map(|w| w.history()) { let _ = hist.replace_state_with_url( &wasm_bindgen::JsValue::NULL, "", Some(&search_hash(&q, scope, root, &dir)), ); } words.set_value(query_words(&q)); let src = match api::search_stream( q.clone(), scope.param(), root, &dir, on_stream_event, // A plain closure, not a `Callback`: this also runs from the // Search button's render closure, whose owner is disposed // when the status re-renders. A `Callback` created there is // gone by the time the stream fails, and the search never // leaves "searching". Ignored once the search is replaced // or the view unmounted (both bump the generation). { let search_gen = search_gen.clone(); move |msg| { if search_gen.load(Ordering::SeqCst) != my_gen { return; } show_error(toast, msg); status.set(Status::Idle); } }, ) { Ok(s) => s, Err(e) => { show_error(toast, e.to_string()); status.set(Status::Idle); return; } }; *source.lock().unwrap() = Some(src); } }); // The search view's owner is the shell's view tree; a stream event can // arrive on the main thread after unmount, so bumping the generation here // makes every stream-side callback bail, and the stream is closed. { let source = source.clone(); let search_gen = search_gen.clone(); on_cleanup(move || { search_gen.fetch_add(1, Ordering::SeqCst); if let Some(s) = source.lock().unwrap().take() { s.close(); } }); } // Collapse/expand all cards. 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 all_collapsed = !states.is_empty() && collapsed_n.get_untracked() == states.len(); let target = !all_collapsed; let n = states.len(); for c in states { c.collapsed.set(target); } collapsed_n.set(if target { n } else { 0 }); }) }; // 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?root=…&path=…` sets the start folder (the // browser's "search in this folder" arrives like this, with no query); // with a `q` the search also re-runs. { let (q0, s0, f0) = parse_search_url(); // Only honour a root the caller actually has; otherwise keep the // default so a stale link still searches something. if let Some((id, dir)) = f0 && me .get_untracked() .is_some_and(|m| m.roots.iter().any(|r| r.id == id)) { set_folder.set(Some((id, dir))); } if !q0.is_empty() { set_query.set(q0); set_scope.set(s0); // 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 (`folder`), // re-running the search in a loop and pegging the main thread. start_search.run(()); } } // The start-folder button opens the shared folder picker. let pick_folder = move |_| { let Some(m) = me.get_untracked() else { return; }; let Some((root, dir)) = folder .get_untracked() .or_else(|| m.roots.first().map(|r| (r.id, String::new()))) else { return; }; set_dialog.set(Some(Dialog::Picker { title: i18n::t(k::SEARCH_IN).to_string(), confirm: i18n::t(k::SEARCH_HERE).to_string(), roots: m.roots, root, dir, for_write: false, on_pick: Callback::new(move |(root, dir): (i64, String)| { set_folder.set(Some((root, dir))); }), })); }; // 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 its identity in data attributes; the hit itself is looked up in // the list, which is a linear scan of at most `RETAIN_FILE_CAP` on a // click. 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; } let Some(hit) = files_r.with_untracked(|v| { v.iter() .find(|h| h.root_id == root_id && *h.path == *path) .cloned() }) else { return; }; if hit.is_dir { navigate(&Location { root_id: Some(root_id), path: split_rel(&path), share_token: None, section: Section::Files, }); return; } let name = path.rsplit('/').next().unwrap_or(&path).to_string(); saved_scroll.set_value(scroll_y()); open_file.set(Some(open_file_view( root_id, path, name, hit.kind, is_writable(me, root_id), ))); }; // ---- 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 one folder (and everything below it). The // button shows it as "Root/dir" and opens the folder picker. {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 { stopped, summary } => { let n = (files_total.get() + matches_total.get()).to_string(); let head = if stopped { i18n::t_fmt(k::SEARCH_STOPPED, &n) } else { i18n::t_fmt(k::SEARCH_N_RESULTS, &n) }; view! {
{head} {summary .map(|s| { view! { <> {" · "} {i18n::t_fmt(k::SEARCH_TOOK, &s.elapsed_ms.to_string())} {" · "} {i18n::t_fmt(k::SEARCH_SCANNED, &s.scanned.to_string())} {(s.skipped > 0) .then(|| { view! { {" · "} {i18n::t_fmt( k::SEARCH_SKIPPED, &s.skipped.to_string(), )} } })} } })}
} .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 icon = if h.is_dir { IconName::Folder } else { icon_for(h.kind, 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 = words.with_value(|w| highlight_html(name, w)); 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); // A content match came out of the grep, which only // reads text files, so the kind is known without // asking the server. let icon = icon_for(FileKind::Text, 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 { .. }); 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, search_gen: Arc, files: RwSignal>, files_total: RwSignal, match_files: RwSignal>, matches_total: RwSignal, match_files_total: RwSignal, seen: Arc>>>, registry: RwSignal, CardState>>, collapsed_n: RwSignal, status: RwSignal, } impl FlushCtx { /// True once a new search has started or the view was unmounted — both /// bump the generation, and after either the view signals must not be /// written any more. fn stale(&self, my_gen: u64) -> bool { self.search_gen.load(Ordering::SeqCst) != my_gen } } /// 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) { TimeoutFuture::new(delay_ms).await; if !ctx.stale(my_gen) { let batch: Vec = ctx.pending.lock().unwrap().drain(..).collect(); if !batch.is_empty() { apply_batch(&ctx, batch, my_gen).await; } // Applying yields, so more events may have arrived meanwhile. They // are already `FLUSH_MS` old, so pick them up on the next tick — // keeping `flushing` held, so no second flush can start. if !ctx.stale(my_gen) && !ctx.pending.lock().unwrap().is_empty() { wasm_bindgen_futures::spawn_local(run_flush(0, my_gen, ctx.clone())); return; } } ctx.flushing.store(false, Ordering::SeqCst); } /// 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 = my_gen; 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.stale(my_gen) { return; } } match ev { SearchEvent::Done { stopped, files: _, matches: _, scanned, skipped, elapsed_ms, } => { done = Some(Status::Done { stopped, summary: Some(Summary { scanned, skipped, elapsed_ms, }), }); } SearchEvent::File { root_id, path, size, is_dir, kind, } => { file_count += 1; if file_budget > 0 { file_budget -= 1; new_files.push(FileHit { root_id, path: path.into(), size, is_dir, kind, }); } } 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() && ctx.collapsed_n.get_untracked() == reg.len(); if all_collapsed { ctx.collapsed_n.update(|n| *n += 1); } 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; } /// Whether the signed-in user may write to `root_id`. fn is_writable(me: ReadSignal>, root_id: i64) -> bool { me.get_untracked().is_some_and(|m| { m.roots .iter() .find(|r| r.id == root_id) .is_some_and(|r| r.mode.is_writable()) }) } 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 text file (the grep only reads those). 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 name = path.rsplit('/').next().unwrap_or(&path).to_string(); let fv = open_file_view( root_id, path.to_string(), name, FileKind::Text, is_writable(me, root_id), ); open_file.set(Some(fv)); }) } /// Lowercased whitespace-split words of the query. fn query_words(query: &str) -> Vec { query.split_whitespace().map(lower).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 hl(text: &str, words: &[&str]) -> String { let w: Vec = words.iter().map(|s| s.to_string()).collect(); highlight_html(text, &w) } /// `x` written as ``, so the expectations /// stay readable. fn short(html: &str) -> String { html.replace("", "<") .replace("", ">") } #[test] fn highlight_marks_hits() { assert_eq!(short(&hl("Hello world", &["world"])), "Hello "); } #[test] fn highlight_is_case_insensitive() { assert_eq!(short(&hl("HeLLo there", &["hello"])), " there"); } #[test] fn highlight_marks_all_words() { assert_eq!( short(&hl("foo bar foo", &["foo", "bar"])), " " ); } #[test] fn highlight_no_hit_is_plain() { assert_eq!(hl("abc", &["xyz"]), "abc"); } #[test] fn highlight_empty_words_is_plain() { assert_eq!(hl("abc", &[]), "abc"); assert_eq!(hl("", &["a"]), ""); } #[test] fn highlight_escapes_markup() { // File names and file contents are untrusted: the only markup in the // output must be the `` wrapper. assert_eq!( hl("", &["script"]), "<script>alert(1)</\ script>" ); // Escaping applies inside the highlighted span too. assert_eq!(hl("a<b"); assert_eq!(hl(r#"& " ' < >"#, &[]), "& " ' < >"); } #[test] fn highlight_handles_non_ascii() { // Ö and ö are 2 bytes each: the hit slices must follow character // boundaries of the original text. assert_eq!(short(&hl("AÖ aö b", &["ö"])), "A<Ö> a<ö> b"); } }