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