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