Skip to main content

kimun_notes/components/
saved_searches_modal.rs

1//! Global "Saved Searches" picker modal.
2//!
3//! A query box on top of a list of the vault's saved searches, with a pinned
4//! virtual "Backlinks (current note)" entry at the top. Typing filters by name
5//! and by a leading 1–9 quick-select index (an exact index match ranks first).
6//! Enter emits [`AppEvent::SavedSearchSelected`] (the editor runs the query in
7//! the panel and closes this overlay itself); Esc emits
8//! [`AppEvent::CloseOverlay`]; Delete removes the selected user entry.
9//!
10//! Hosts a [`SearchList`] engine: the vault load is a load-once
11//! [`RowSource`] (`reload_on_query == false`), name/index ranking is the
12//! [`Filter::Rank`] closure, the pinned backlinks row is supplied as the
13//! engine's `leading_row`, and Delete is intercepted by the modal.
14
15use std::sync::Arc;
16
17use async_trait::async_trait;
18use kimun_core::{NoteVault, SavedSearch};
19use ratatui::Frame;
20use ratatui::layout::{Constraint, Direction, Layout, Rect};
21use ratatui::style::{Modifier, Style};
22use ratatui::widgets::{Block, Borders, ListItem, Paragraph};
23
24use crate::components::event_state::EventState;
25use crate::components::events::{AppEvent, AppTx, InputEvent, redraw_callback};
26use crate::components::overlay::{Overlay, OverlayKind};
27use crate::components::panel::{ModalSpec, modal_chrome};
28use crate::components::search_list::{
29    Emit, Filter, KeyReaction, RowSource, SearchList, SearchMouse, SearchRow,
30};
31use crate::keys::key_combo::KeyCombo;
32use crate::keys::{KeyBindings, key_event_to_combo};
33use crate::settings::icons::Icons;
34use crate::settings::themes::Theme;
35
36// ---------------------------------------------------------------------------
37// Model (pure, unit-tested)
38// ---------------------------------------------------------------------------
39
40/// One row in the modal. `index` is the 1–9 quick-select number (only the
41/// first nine USER searches get one). The virtual backlinks entry is pinned
42/// at the top (supplied as the engine's `leading_row`) and is never numbered
43/// or deletable.
44#[derive(Debug, Clone, PartialEq, Eq)]
45pub struct SearchItem {
46    pub index: Option<u8>,
47    pub name: String,
48    pub query: String,
49    pub is_virtual: bool,
50}
51
52impl SearchItem {
53    /// A normal (user) saved-search item with a quick-select index.
54    pub fn saved(index: u8, name: &str, query: &str) -> Self {
55        Self {
56            index: Some(index),
57            name: name.to_string(),
58            query: query.to_string(),
59            is_virtual: false,
60        }
61    }
62}
63
64impl SearchRow for SearchItem {
65    fn to_list_item(&self, theme: &Theme, _icons: &Icons, _selected: bool) -> ListItem<'static> {
66        let prefix = match self.index {
67            Some(n) => format!("{n} "),
68            None => "  ".to_string(),
69        };
70        let label = if self.is_virtual {
71            format!("{prefix}* {}", self.name)
72        } else {
73            format!("{prefix}{}", self.name)
74        };
75        let style = if self.is_virtual {
76            Style::default()
77                .fg(theme.accent.to_ratatui())
78                .add_modifier(Modifier::ITALIC)
79        } else {
80            Style::default().fg(theme.fg.to_ratatui())
81        };
82        ListItem::new(label).style(style)
83    }
84
85    fn visual_height(&self) -> u16 {
86        1
87    }
88
89    /// The virtual backlinks row is filter-exempt: returning `None` makes the
90    /// engine keep it present regardless of the query (it is also prepended by
91    /// the engine when the rank closure drops it).
92    fn match_text(&self) -> Option<&str> {
93        if self.is_virtual {
94            None
95        } else {
96            Some(&self.name)
97        }
98    }
99}
100
101pub const VIRTUAL_BACKLINKS_NAME: &str = "Backlinks (current note)";
102pub const VIRTUAL_BACKLINKS_QUERY: &str = "<{note}";
103
104pub struct SavedSearchesModel;
105
106impl SavedSearchesModel {
107    /// Build the USER rows from the vault's saved searches: the first nine get
108    /// quick-select indices 1..=9, the rest are unnumbered. The pinned virtual
109    /// backlinks row is NOT included here — the engine supplies it via
110    /// [`RowSource::leading_row`].
111    pub fn user_items(user: Vec<SavedSearch>) -> Vec<SearchItem> {
112        user.into_iter()
113            .enumerate()
114            .map(|(i, s)| SearchItem {
115                index: if i < 9 { Some((i + 1) as u8) } else { None },
116                name: s.name,
117                query: s.query,
118                is_virtual: false,
119            })
120            .collect()
121    }
122}
123
124/// Rank `rows` (USER rows only) by `filter`, returning DISPLAY INDICES into the
125/// slice. An exact leading-index match (filter parses to a u8 equal to a row's
126/// `index`) ranks that row first; otherwise a case-insensitive name substring
127/// match. Stable order preserves the source order within a rank. Empty filter →
128/// all indices in order. The engine re-adds any filter-exempt rows (the virtual
129/// backlinks row) that this closure omits, so it may ignore the virtual row.
130pub fn rank_to_indices(rows: &[SearchItem], filter: &str) -> Vec<usize> {
131    let f = filter.trim();
132    if f.is_empty() {
133        return (0..rows.len()).collect();
134    }
135    let as_index: Option<u8> = f.parse().ok();
136    let needle = f.to_lowercase();
137    let mut ranked: Vec<(usize, u8)> = Vec::new(); // (index, rank: 0 = best)
138    for (i, it) in rows.iter().enumerate() {
139        let exact_index = as_index.is_some() && it.index == as_index;
140        let name_match = it.name.to_lowercase().contains(&needle);
141        if exact_index {
142            ranked.push((i, 0));
143        } else if name_match {
144            ranked.push((i, 1));
145        }
146    }
147    // stable sort by rank keeps original relative order within a rank
148    ranked.sort_by_key(|(_, r)| *r);
149    ranked.into_iter().map(|(i, _)| i).collect()
150}
151
152// ---------------------------------------------------------------------------
153// RowSource
154// ---------------------------------------------------------------------------
155
156/// Loads the vault's saved searches once (`reload_on_query == false`); the
157/// local [`Filter::Rank`] narrows the set per keystroke. The virtual backlinks
158/// row is supplied by [`leading_row`](RowSource::leading_row), not the load.
159///
160/// Deletes are routed THROUGH the load (via `pending_delete`) so the delete and
161/// the subsequent list-read happen in one ordered async step — avoiding the
162/// race where a separately-spawned delete and a `reload()` interleave and the
163/// reload reads pre-delete state.
164struct SavedSearchSource {
165    vault: Arc<NoteVault>,
166    pending_delete: Arc<std::sync::Mutex<Option<String>>>,
167}
168
169#[async_trait]
170impl RowSource<SearchItem> for SavedSearchSource {
171    async fn load(&self, _query: &str, emit: Emit<SearchItem>) {
172        // Drain any pending delete BEFORE listing, so the list read below is
173        // ordered strictly after the delete completes.
174        let to_delete = self.pending_delete.lock().unwrap().take();
175        if let Some(name) = to_delete {
176            self.vault.delete_saved_search(&name).await.ok();
177        }
178        let user = self.vault.list_saved_searches().await.unwrap_or_default();
179        emit.replace(SavedSearchesModel::user_items(user));
180    }
181
182    fn leading_row(&self, _query: &str) -> Option<SearchItem> {
183        Some(SearchItem {
184            index: None,
185            name: VIRTUAL_BACKLINKS_NAME.to_string(),
186            query: VIRTUAL_BACKLINKS_QUERY.to_string(),
187            is_virtual: true,
188        })
189    }
190
191    fn reload_on_query(&self) -> bool {
192        false
193    }
194}
195
196// ---------------------------------------------------------------------------
197// SavedSearchesModal widget
198// ---------------------------------------------------------------------------
199
200pub struct SavedSearchesModal {
201    list: SearchList<SearchItem>,
202    /// Shared with the [`SavedSearchSource`]: setting this then calling
203    /// `list.reload()` makes the source delete-then-list in one ordered load.
204    pending_delete: Arc<std::sync::Mutex<Option<String>>>,
205    delete_combo: KeyCombo,
206}
207
208impl SavedSearchesModal {
209    pub fn new(vault: Arc<NoteVault>, _key_bindings: KeyBindings, icons: Icons, tx: AppTx) -> Self {
210        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
211        let delete_combo = key_event_to_combo(&KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE))
212            .expect("Delete maps to a key combo");
213        let pending_delete = Arc::new(std::sync::Mutex::new(None));
214        let list = SearchList::builder(
215            SavedSearchSource {
216                vault,
217                pending_delete: pending_delete.clone(),
218            },
219            redraw_callback(tx),
220        )
221        .filter(Filter::Rank(Arc::new(rank_to_indices)))
222        .icons(icons)
223        .intercept(vec![delete_combo])
224        .build();
225        Self {
226            list,
227            pending_delete,
228            delete_combo,
229        }
230    }
231}
232
233// ---------------------------------------------------------------------------
234// Overlay impl
235// ---------------------------------------------------------------------------
236
237impl Overlay for SavedSearchesModal {
238    fn kind(&self) -> OverlayKind {
239        OverlayKind::SavedSearches
240    }
241
242    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
243        match event {
244            InputEvent::Mouse(mouse) => match self.list.handle_mouse(mouse) {
245                SearchMouse::Activated(_) => {
246                    if let Some(item) = self.list.selected_row() {
247                        tx.send(AppEvent::SavedSearchSelected {
248                            query: item.query.clone(),
249                            name: item.name.clone(),
250                        })
251                        .ok();
252                    }
253                    EventState::Consumed
254                }
255                SearchMouse::Context(_) | SearchMouse::Selected(_) | SearchMouse::Scrolled => {
256                    EventState::Consumed
257                }
258                // No content sub-region is recorded by this host, so these
259                // are unreachable.
260                SearchMouse::ContentScrollUp | SearchMouse::ContentScrollDown => {
261                    EventState::Consumed
262                }
263                SearchMouse::None => EventState::NotConsumed,
264            },
265            InputEvent::Key(key) => match self.list.handle_key(key) {
266                KeyReaction::Intercepted(c) if c == self.delete_combo => {
267                    if let Some(item) = self.list.selected_row().filter(|i| !i.is_virtual) {
268                        // Hand the name to the source and re-run the load: the
269                        // source deletes-then-lists in one ordered async step, so
270                        // the new rows can never reflect pre-delete state.
271                        *self.pending_delete.lock().unwrap() = Some(item.name.clone());
272                        self.list.reload();
273                    }
274                    EventState::Consumed
275                }
276                KeyReaction::Submit => {
277                    if let Some(item) = self.list.selected_row() {
278                        tx.send(AppEvent::SavedSearchSelected {
279                            query: item.query.clone(),
280                            name: item.name.clone(),
281                        })
282                        .ok();
283                    }
284                    EventState::Consumed
285                }
286                KeyReaction::Cancel => {
287                    tx.send(AppEvent::CloseOverlay).ok();
288                    EventState::Consumed
289                }
290                KeyReaction::Consumed => EventState::Consumed,
291                KeyReaction::Intercepted(_) | KeyReaction::Unhandled => EventState::NotConsumed,
292            },
293            _ => EventState::NotConsumed,
294        }
295    }
296
297    fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
298        let popup_rect = crate::components::centered_rect(60, 60, area);
299
300        let inner = modal_chrome(
301            f,
302            popup_rect,
303            theme,
304            ModalSpec {
305                title: Some(" Saved Searches "),
306                ..Default::default()
307            },
308        );
309
310        let rows = Layout::default()
311            .direction(Direction::Vertical)
312            .constraints([
313                Constraint::Length(3),
314                Constraint::Min(0),
315                Constraint::Length(1),
316            ])
317            .split(inner);
318
319        // ── Filter box ──────────────────────────────────────────────────────
320        let filter_block = Block::default()
321            .title(" Filter ")
322            .borders(Borders::ALL)
323            .border_style(theme.border_style(true))
324            .style(theme.panel_style());
325        let filter_inner = filter_block.inner(rows[0]);
326        f.render_widget(filter_block, rows[0]);
327        self.list.render_query(f, filter_inner, theme, true);
328
329        // ── List ─────────────────────────────────────────────────────────────
330        let list_block = Block::default()
331            .borders(Borders::ALL)
332            .border_style(theme.border_style(false))
333            .style(theme.panel_style());
334        let list_inner = list_block.inner(rows[1]);
335        f.render_widget(list_block, rows[1]);
336        self.list.render(f, list_inner, theme, false);
337        // The engine hit-tests `row - rect.y` (row 0 = first item); the list
338        // renders into the block's inner area, so record that same rect.
339        self.list.set_list_rect(list_inner);
340        // The whole popup is wheel-scrollable (filter box and hint bar included).
341        self.list.set_panel_rect(popup_rect);
342
343        // ── Hint bar ──────────────────────────────────────────────────────────
344        f.render_widget(
345            Paragraph::new("↑↓ navigate | Enter open | Del delete | Esc close")
346                .style(Style::default().fg(theme.fg_secondary.to_ratatui())),
347            rows[2],
348        );
349    }
350
351    fn hint_shortcuts(&self) -> Vec<(String, String)> {
352        vec![
353            ("↑↓".to_string(), "navigate".to_string()),
354            ("Enter".to_string(), "open".to_string()),
355            ("Del".to_string(), "delete".to_string()),
356            ("Esc".to_string(), "close".to_string()),
357        ]
358    }
359}
360
361// ---------------------------------------------------------------------------
362// Tests
363// ---------------------------------------------------------------------------
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use crate::settings::AppSettings;
369    use crate::test_support::temp_vault;
370    use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
371    use tokio::sync::mpsc::unbounded_channel;
372
373    /// Drive the modal's engine to idle, giving the background load real
374    /// wall-clock time to land (the vault read runs on a worker thread under
375    /// the multi-thread runtime).
376    async fn poll_engine_idle(modal: &mut SavedSearchesModal) {
377        // Generous ceiling: the spawned vault read runs on a worker thread, and
378        // under the full parallel suite those workers are contended, so a tight
379        // budget races the load. Early-breaks the instant the load lands, so the
380        // common path stays fast; the high cap only matters under heavy load.
381        for _ in 0..600 {
382            modal.list.poll();
383            if !modal.list.is_loading() {
384                break;
385            }
386            tokio::task::yield_now().await;
387            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
388        }
389        modal.list.poll();
390    }
391
392    #[test]
393    fn user_items_skip_virtual_and_number_first_nine() {
394        let user: Vec<SavedSearch> = (0..11)
395            .map(|i| SavedSearch {
396                name: format!("s{i}"),
397                query: format!("#{i}"),
398            })
399            .collect();
400        let items = SavedSearchesModel::user_items(user);
401        // No virtual row here — it is supplied by leading_row.
402        assert!(items.iter().all(|i| !i.is_virtual));
403        assert_eq!(items[0].index, Some(1));
404        assert_eq!(items[8].index, Some(9));
405        assert_eq!(items[9].index, None); // 10th user search unnumbered
406    }
407
408    #[test]
409    fn rank_exact_index_first() {
410        let items = vec![
411            SearchItem::saved(1, "todo", "#todo"),
412            SearchItem::saved(2, "backlinks-ish", "<{note}"),
413            SearchItem::saved(3, "two-things", "#a"),
414        ];
415        let idx = rank_to_indices(&items, "2");
416        assert_eq!(items[idx[0]].name, "backlinks-ish"); // index 2 wins
417        let idx = rank_to_indices(&items, "tod");
418        assert_eq!(items[idx[0]].name, "todo");
419    }
420
421    #[test]
422    fn rank_empty_filter_returns_all_in_order() {
423        let items = vec![
424            SearchItem::saved(1, "a", "#a"),
425            SearchItem::saved(2, "b", "#b"),
426        ];
427        let idx = rank_to_indices(&items, "");
428        assert_eq!(idx, vec![0, 1]);
429    }
430
431    #[test]
432    fn rank_name_substring_only_matches() {
433        let items = vec![
434            SearchItem::saved(1, "todo", "#todo"),
435            SearchItem::saved(2, "ideas", "#ideas"),
436        ];
437        let idx = rank_to_indices(&items, "ide");
438        assert_eq!(idx.len(), 1);
439        assert_eq!(items[idx[0]].name, "ideas");
440    }
441
442    #[tokio::test(flavor = "multi_thread")]
443    async fn delete_removes_row_via_ordered_reload() {
444        let vault = temp_vault("saved_searches_delete").await;
445        vault
446            .save_search("todo", "#todo")
447            .await
448            .expect("save search");
449        vault
450            .save_search("ideas", "#ideas")
451            .await
452            .expect("save search");
453        let settings = AppSettings::default();
454        let (tx, _rx) = unbounded_channel();
455        let mut modal = SavedSearchesModal::new(
456            vault.clone(),
457            settings.key_bindings.clone(),
458            settings.icons(),
459            tx.clone(),
460        );
461        poll_engine_idle(&mut modal).await;
462
463        // Select the first USER row (skip the pinned virtual backlinks row).
464        Overlay::handle_input(
465            &mut modal,
466            &InputEvent::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE)),
467            &tx,
468        );
469        let target = modal
470            .list
471            .selected_row()
472            .filter(|i| !i.is_virtual)
473            .expect("a non-virtual row is selected")
474            .name
475            .clone();
476
477        // Delete: this sets pending_delete and reloads (delete-then-list, ordered).
478        Overlay::handle_input(
479            &mut modal,
480            &InputEvent::Key(KeyEvent::new(KeyCode::Delete, KeyModifiers::NONE)),
481            &tx,
482        );
483        poll_engine_idle(&mut modal).await;
484
485        // Vault state: the deleted name is gone, one user search remains.
486        let remaining = vault.list_saved_searches().await.expect("list");
487        assert_eq!(remaining.len(), 1, "one saved search should remain");
488        assert!(
489            !remaining.iter().any(|s| s.name == target),
490            "deleted name {target} should be gone from the vault"
491        );
492
493        // Visible list no longer contains the deleted row.
494        let visible: Vec<String> = modal
495            .list
496            .visible_rows()
497            .iter()
498            .map(|r| r.name.clone())
499            .collect();
500        assert!(
501            !visible.contains(&target),
502            "deleted name {target} should be gone from the visible rows, got {visible:?}"
503        );
504    }
505
506    /// Pressing Enter emits SavedSearchSelected only. The editor's handler for
507    /// that event closes the overlay itself (focusing the Query panel), so the
508    /// modal does NOT also emit CloseOverlay (that would be redundant).
509    #[tokio::test]
510    async fn enter_emits_selected_not_close() {
511        let vault = temp_vault("saved_searches_modal").await;
512        vault
513            .save_search("todo", "#todo")
514            .await
515            .expect("save search");
516        vault
517            .save_search("ideas", "#ideas")
518            .await
519            .expect("save search");
520        let settings = AppSettings::default();
521        let (tx, mut rx) = unbounded_channel();
522        let mut modal = SavedSearchesModal::new(
523            vault,
524            settings.key_bindings.clone(),
525            settings.icons(),
526            tx.clone(),
527        );
528        modal.list.poll_until_idle().await;
529
530        Overlay::handle_input(
531            &mut modal,
532            &InputEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
533            &tx,
534        );
535
536        let mut events = Vec::new();
537        while let Ok(ev) = rx.try_recv() {
538            events.push(ev);
539        }
540        assert!(
541            events
542                .iter()
543                .any(|e| matches!(e, AppEvent::SavedSearchSelected { .. })),
544            "expected SavedSearchSelected, got {events:?}"
545        );
546        assert!(
547            !events.iter().any(|e| matches!(e, AppEvent::CloseOverlay)),
548            "select must not emit CloseOverlay; editor's SavedSearchSelected handler closes the overlay, got {events:?}"
549        );
550    }
551}