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