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