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