Skip to main content

kimun_notes/components/note_browser/
mod.rs

1use std::sync::Arc;
2use std::sync::mpsc::Receiver;
3
4use chrono::NaiveDate;
5use kimun_core::NoteVault;
6use kimun_core::nfs::VaultPath;
7use ratatui::Frame;
8use ratatui::layout::{Constraint, Direction, Layout, Rect};
9use ratatui::style::Style;
10use ratatui::widgets::{Block, Borders, Paragraph};
11
12use crate::components::autocomplete::AutocompleteMode;
13use crate::components::event_state::EventState;
14use crate::components::events::{AppEvent, AppTx, AppTxExt, InputEvent, redraw_callback};
15use crate::components::file_list::FileListEntry;
16use crate::components::overlay::{Overlay, OverlayKind, OverlayMsg};
17use crate::components::panel::{ModalBg, ModalSpec, modal_chrome};
18use crate::components::preview_highlight;
19use crate::components::saved_search_breadcrumb::SavedSearchBreadcrumb;
20use crate::components::search_list::{
21    KeyReaction, RowSource, SearchList, SearchMouse, VaultSuggestions,
22};
23use crate::keys::KeyBindings;
24use crate::keys::action_shortcuts::ActionShortcuts;
25use crate::settings::icons::Icons;
26use crate::settings::themes::Theme;
27
28pub mod file_finder_provider;
29pub mod link_results_provider;
30pub mod search_provider;
31
32// ---------------------------------------------------------------------------
33// NoteBrowserModal
34// ---------------------------------------------------------------------------
35
36/// The Ctrl+K note browser. It hosts a [`SearchList`] engine (query input +
37/// async-loaded result list + hashtag autocomplete) and adds the two things
38/// unique to the browser: a live preview pane for the selected note and the
39/// open-on-enter glue that emits [`AppEvent::OpenPath`].
40/// What the modal is scoped to — drives the input prefix glyph and whether
41/// the §9 query highlighter applies.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum BrowserScope {
44    /// Full query syntax (Ctrl-K, tag/backlink leaves): `⌕` prefix +
45    /// syntax highlighting.
46    Query,
47    /// Fuzzy file finding (Ctrl-O): plain input.
48    Files,
49}
50
51pub struct NoteBrowserModal {
52    scope: BrowserScope,
53    /// Input prefix glyph for the scope (`⌕` query / `▤` files).
54    prefix_glyph: &'static str,
55    title: String,
56    list: SearchList<FileListEntry>,
57    vault: Arc<NoteVault>,
58    tx: AppTx,
59    preview_text: String,
60    // Preview async loading
61    preview_task: Option<tokio::task::JoinHandle<()>>,
62    preview_rx: Option<Receiver<String>>,
63    /// Path the preview pane is currently showing (or loading). Compared at
64    /// render time against the engine's selected row so an async server-side
65    /// reload that auto-selects a different row still refreshes the preview.
66    preview_path: Option<VaultPath>,
67    /// Used to resolve the save-current-query shortcut for the hint bar.
68    key_bindings: KeyBindings,
69    /// The saved-search breadcrumb shown on the search border. Owns its
70    /// sticky/clear/edited state machine; the modal only forwards query events.
71    /// See [`SavedSearchBreadcrumb`].
72    saved_search: SavedSearchBreadcrumb,
73    /// Last create/open error (e.g. a failed `Create: …`), shown in the hint
74    /// bar until the next keystroke. Cleared on input.
75    error: Option<String>,
76}
77
78impl NoteBrowserModal {
79    pub fn new(
80        title: impl Into<String>,
81        scope: BrowserScope,
82        provider: impl RowSource<FileListEntry>,
83        vault: Arc<NoteVault>,
84        key_bindings: KeyBindings,
85        icons: Icons,
86        tx: AppTx,
87    ) -> Self {
88        Self::new_with_query(
89            title,
90            scope,
91            provider,
92            vault,
93            key_bindings,
94            icons,
95            tx,
96            String::new(),
97        )
98    }
99
100    /// Construct the modal with a pre-filled search query.
101    ///
102    /// Behaves exactly like [`new`](Self::new) except the search input is
103    /// pre-populated with `query` (cursor placed at the end) and the initial
104    /// load is triggered for that query string.
105    #[allow(clippy::too_many_arguments)]
106    pub fn with_initial_query<S: Into<String>>(
107        title: impl Into<String>,
108        scope: BrowserScope,
109        provider: impl RowSource<FileListEntry>,
110        vault: Arc<NoteVault>,
111        key_bindings: KeyBindings,
112        icons: Icons,
113        tx: AppTx,
114        query: S,
115    ) -> Self {
116        Self::new_with_query(
117            title,
118            scope,
119            provider,
120            vault,
121            key_bindings,
122            icons,
123            tx,
124            query.into(),
125        )
126    }
127
128    #[allow(clippy::too_many_arguments)]
129    fn new_with_query(
130        title: impl Into<String>,
131        scope: BrowserScope,
132        provider: impl RowSource<FileListEntry>,
133        vault: Arc<NoteVault>,
134        key_bindings: KeyBindings,
135        icons: Icons,
136        tx: AppTx,
137        initial_query: String,
138    ) -> Self {
139        let prefix_glyph = match scope {
140            BrowserScope::Query => icons.rail_find,
141            BrowserScope::Files => icons.rail_files,
142        };
143        let mut builder = SearchList::builder(provider, redraw_callback(tx.clone()))
144            .initial_query(initial_query)
145            .icons(icons)
146            .autocomplete(
147                Arc::new(VaultSuggestions {
148                    vault: vault.clone(),
149                }),
150                AutocompleteMode::SearchQuery,
151            );
152        if scope == BrowserScope::Query {
153            builder = builder.highlight_query();
154        }
155        let list = builder.build();
156        let mut modal = Self {
157            scope,
158            prefix_glyph,
159            title: title.into(),
160            list,
161            vault,
162            tx,
163            preview_text: String::new(),
164            preview_task: None,
165            preview_rx: None,
166            preview_path: None,
167            key_bindings,
168            saved_search: SavedSearchBreadcrumb::default(),
169            error: None,
170        };
171        modal.refresh_preview(None);
172        modal
173    }
174
175    /// The lowercase text needles the preview emphasizes: the query's plain
176    /// search terms (Query scope only — the fuzzy Files scope matches names,
177    /// not content).
178    fn preview_needles(&self) -> Vec<String> {
179        if self.scope != BrowserScope::Query {
180            return Vec::new();
181        }
182        crate::components::query_highlight::emphasis_needles(self.list.query())
183    }
184
185    /// The emphasis payload an open from this modal carries: the query's
186    /// needles (spec §5.1), Query scope only.
187    fn emphasis(&self) -> Option<Vec<String>> {
188        let needles = self.preview_needles();
189        (!needles.is_empty()).then_some(needles)
190    }
191
192    // ── Async preview loading ──────────────────────────────────────────────
193
194    fn schedule_preview(&mut self, path: VaultPath) {
195        if let Some(handle) = self.preview_task.take() {
196            handle.abort();
197        }
198        let vault = Arc::clone(&self.vault);
199        let tx = self.tx.clone();
200        let (result_tx, result_rx) = std::sync::mpsc::channel();
201        self.preview_rx = Some(result_rx);
202
203        let handle = tokio::spawn(async move {
204            let text = vault.get_note_text(&path).await.unwrap_or_default();
205            result_tx.send(text).ok();
206            tx.send(AppEvent::Redraw).ok();
207        });
208        self.preview_task = Some(handle);
209    }
210
211    fn poll_preview(&mut self) {
212        let Some(rx) = &self.preview_rx else { return };
213        match rx.try_recv() {
214            Ok(text) => {
215                self.preview_text = text;
216                self.preview_rx = None;
217                self.preview_task = None;
218            }
219            Err(std::sync::mpsc::TryRecvError::Disconnected) => {
220                self.preview_rx = None;
221            }
222            Err(std::sync::mpsc::TryRecvError::Empty) => {}
223        }
224    }
225
226    /// Called after selection changes to kick off a preview load for the
227    /// highlighted note, or clear the preview if a non-note entry is selected.
228    fn refresh_preview(&mut self, selected: Option<&FileListEntry>) {
229        let maybe_path = selected.and_then(|e| match e {
230            FileListEntry::Note { path, .. } => Some(path.clone()),
231            _ => None,
232        });
233        if let Some(path) = maybe_path {
234            self.schedule_preview(path);
235        } else {
236            self.preview_text.clear();
237            if let Some(h) = self.preview_task.take() {
238                h.abort();
239            }
240        }
241    }
242
243    /// The note path the engine currently has selected, if the selected row is
244    /// a note (non-note rows yield `None`).
245    fn selected_note_path(&self) -> Option<VaultPath> {
246        self.list.selected_row().and_then(|e| match e {
247            FileListEntry::Note { path, .. } => Some(path.clone()),
248            _ => None,
249        })
250    }
251
252    /// Refresh the preview for whatever the engine currently has selected.
253    fn refresh_preview_from_list(&mut self) {
254        let path = self.selected_note_path();
255        self.preview_path = path.clone();
256        match path {
257            Some(path) => self.schedule_preview(path),
258            None => {
259                self.preview_text.clear();
260                if let Some(h) = self.preview_task.take() {
261                    h.abort();
262                }
263            }
264        }
265    }
266
267    /// Open the engine's selected row: create-then-open for a `CreateNote`,
268    /// or open directly for an existing `Note`. Emits only `OpenPath`; the
269    /// editor's `OpenPath` handler closes this overlay (restoring focus to the
270    /// editor), so no separate `CloseOverlay` is sent.
271    fn open_selected(&self, tx: &AppTx) {
272        let Some(entry) = self.list.selected_row() else {
273            return;
274        };
275        if let FileListEntry::CreateNote { path, .. } = entry {
276            let path = path.clone();
277            let vault = Arc::clone(&self.vault);
278            let tx = tx.clone();
279            tokio::spawn(async move {
280                match vault.load_or_create_note(&path, None).await {
281                    Ok((_, created)) => tx.announce_and_open(path, created),
282                    Err(e) => {
283                        tx.send(AppEvent::DialogError(e.to_string())).ok();
284                    }
285                }
286            });
287            return;
288        }
289        let path = entry.path().clone();
290        tx.send(AppEvent::OpenPath {
291            path,
292            emphasis: self.emphasis(),
293        })
294        .ok();
295    }
296
297    /// The saved-search breadcrumb label for the search border, or `None` when
298    /// no saved search is active.
299    #[cfg(test)]
300    fn saved_search_breadcrumb(&self) -> Option<String> {
301        self.saved_search.label(self.list.query())
302    }
303
304    // ── Test-only accessors ────────────────────────────────────────────────
305
306    /// Returns the current search input text. Test-only.
307    #[cfg(test)]
308    pub(super) fn query_text(&self) -> &str {
309        self.list.query()
310    }
311}
312
313// ---------------------------------------------------------------------------
314// Overlay impl
315// ---------------------------------------------------------------------------
316
317impl Overlay for NoteBrowserModal {
318    fn kind(&self) -> OverlayKind {
319        OverlayKind::NoteBrowser
320    }
321
322    fn query(&self) -> Option<&str> {
323        Some(self.list.query())
324    }
325
326    fn saved_search_provenance(&self) -> Option<&str> {
327        self.saved_search.name()
328    }
329
330    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
331        match event {
332            InputEvent::Mouse(mouse) => match self.list.handle_mouse(mouse) {
333                SearchMouse::Activated(_) => {
334                    self.open_selected(tx);
335                    EventState::Consumed
336                }
337                SearchMouse::Context(_) | SearchMouse::Selected(_) | SearchMouse::Scrolled => {
338                    self.refresh_preview_from_list();
339                    EventState::Consumed
340                }
341                // No content sub-region is recorded by this host, so these
342                // are unreachable.
343                SearchMouse::ContentScrollUp | SearchMouse::ContentScrollDown => {
344                    EventState::Consumed
345                }
346                SearchMouse::None => EventState::NotConsumed,
347            },
348            InputEvent::Key(key) => {
349                // Any keypress clears a stale create/open error.
350                self.error = None;
351                match self.list.handle_key(key) {
352                    KeyReaction::Submit => {
353                        self.open_selected(tx);
354                        EventState::Consumed
355                    }
356                    KeyReaction::Cancel => {
357                        tx.send(AppEvent::CloseOverlay).ok();
358                        EventState::Consumed
359                    }
360                    KeyReaction::Consumed => {
361                        // Forward the query event to the breadcrumb: a `?name`
362                        // expansion pins it, an emptied field clears it, a manual
363                        // edit keeps it (sticky).
364                        let accepted = self.list.take_accepted_saved_search();
365                        let blank = self.list.query().trim().is_empty();
366                        self.saved_search
367                            .on_query_consumed(accepted, self.list.query(), blank);
368                        self.refresh_preview_from_list();
369                        EventState::Consumed
370                    }
371                    KeyReaction::Intercepted(_) | KeyReaction::Unhandled => EventState::NotConsumed,
372                }
373            }
374            _ => EventState::NotConsumed,
375        }
376    }
377
378    fn handle_app_message(
379        &mut self,
380        msg: &AppEvent,
381        _vault: &Arc<NoteVault>,
382        _tx: &AppTx,
383    ) -> OverlayMsg {
384        // A failed `Create: …` (or other open error) surfaces here while the
385        // modal stays open, so the user sees why nothing happened.
386        if let AppEvent::DialogError(text) = msg {
387            self.error = Some(text.clone());
388            OverlayMsg::Consumed
389        } else {
390            OverlayMsg::NotConsumed
391        }
392    }
393
394    fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
395        self.poll_preview();
396
397        let popup_rect = crate::components::centered_rect(75, 75, area);
398
399        // Modal chrome (spec §6): hard background, focus-green border.
400        let modal_style = Style::default()
401            .fg(theme.fg.to_ratatui())
402            .bg(theme.bg_hard.to_ratatui());
403        let title = format!(" {} ", self.title);
404        let inner = modal_chrome(
405            f,
406            popup_rect,
407            theme,
408            ModalSpec {
409                title: Some(&title),
410                bg: ModalBg::Hard,
411                ..Default::default()
412            },
413        );
414
415        let rows = Layout::default()
416            .direction(Direction::Vertical)
417            .constraints([
418                Constraint::Length(3),
419                Constraint::Min(0),
420                Constraint::Length(1),
421            ])
422            .split(inner);
423
424        // ── Search box ────────────────────────────────────────────────────
425        // A saved-search breadcrumb (`‹ name ›` / `‹ name • edited ›`) titles
426        // the search box when a `?name` expansion is active.
427        let search_title = self
428            .saved_search
429            .border_title(self.list.query(), " Search ");
430        let result_count = self.list.match_count();
431        let search_block = Block::default()
432            .title(search_title)
433            .title(
434                ratatui::text::Line::from(ratatui::text::Span::styled(
435                    format!(" {result_count} results "),
436                    Style::default().fg(theme.gray.to_ratatui()),
437                ))
438                .right_aligned(),
439            )
440            .borders(Borders::ALL)
441            .border_style(theme.border_style(true))
442            .style(modal_style);
443        let search_inner = search_block.inner(rows[0]);
444        f.render_widget(search_block, rows[0]);
445        // Scope prefix glyph to the input's left, the input shifted past it.
446        let prefix = format!("{} ", self.prefix_glyph);
447        let prefix_w = unicode_width::UnicodeWidthStr::width(prefix.as_str()) as u16;
448        f.render_widget(
449            Paragraph::new(prefix).style(
450                Style::default()
451                    .fg(theme.yellow.to_ratatui())
452                    .bg(theme.bg_hard.to_ratatui()),
453            ),
454            Rect {
455                width: prefix_w.min(search_inner.width),
456                ..search_inner
457            },
458        );
459        let input_rect = Rect {
460            x: search_inner.x.saturating_add(prefix_w),
461            width: search_inner.width.saturating_sub(prefix_w),
462            ..search_inner
463        };
464        self.list.render_query(f, input_rect, theme, true);
465
466        // ── List + Preview ────────────────────────────────────────────────
467        let columns = Layout::default()
468            .direction(Direction::Horizontal)
469            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
470            .split(rows[1]);
471
472        // The engine hit-tests a click as `row - rect.y` against the recorded
473        // rect, where row 0 is the first item. The list renders into the block's
474        // INNER area, so record that same inner rect.
475        let list_block = Block::default()
476            .borders(Borders::ALL)
477            .border_style(theme.border_style(false))
478            .style(modal_style);
479        let list_inner = list_block.inner(columns[0]);
480        f.render_widget(list_block, columns[0]);
481        self.list.render(f, list_inner, theme, false);
482        self.list.set_list_rect(list_inner);
483        // The whole popup is wheel-scrollable (search box and preview included).
484        self.list.set_panel_rect(popup_rect);
485
486        // Authoritative preview trigger: `list.render` just polled, which is
487        // where an async server-side reload lands and may auto-select a new
488        // row 0. If the selected note path differs from what the preview is
489        // showing, refresh. Guarded by the path diff so there's no redraw loop.
490        if self.selected_note_path() != self.preview_path {
491            self.refresh_preview_from_list();
492        }
493
494        // Preview header: filename, plus the match count when the query
495        // carries text terms (spec §6: `filename · N matches`).
496        let needles = self.preview_needles();
497        let match_count = count_matches(&self.preview_text, &needles);
498        let preview_title = match (&self.preview_path, match_count) {
499            (Some(path), Some(n)) => {
500                format!(" {} · {} matches ", path.get_name(), n)
501            }
502            (Some(path), None) => format!(" {} ", path.get_name()),
503            (None, _) => " Preview ".to_string(),
504        };
505        let preview_block = Block::default()
506            .title(preview_title)
507            .borders(Borders::ALL)
508            .border_style(theme.border_style(false))
509            .style(modal_style);
510        let preview_inner = preview_block.inner(columns[1]);
511        f.render_widget(preview_block, columns[1]);
512        f.render_widget(
513            Paragraph::new(highlight_matches(
514                &self.preview_text,
515                &needles,
516                theme,
517                modal_style,
518            )),
519            preview_inner,
520        );
521
522        // ── Hint bar (or last error) ──────────────────────────────────────
523        let hint = match &self.error {
524            Some(err) => Paragraph::new(format!("⚠ {err}"))
525                .style(Style::default().fg(theme.red.to_ratatui())),
526            None => Paragraph::new("↑↓: navigate  |  Enter: open  |  Esc: close")
527                .style(Style::default().fg(theme.fg_secondary.to_ratatui())),
528        };
529        f.render_widget(hint, rows[2]);
530
531        // ── Autocomplete popup ───────────────────────────────────────────
532        // Clamp to the modal's bounds so it never spills past the border.
533        self.list.render_autocomplete(f, popup_rect, theme);
534    }
535
536    fn hint_shortcuts(&self) -> Vec<(String, String)> {
537        let mut hints = vec![
538            ("↑↓".to_string(), "navigate".to_string()),
539            ("Enter".to_string(), "open".to_string()),
540            ("Esc".to_string(), "close".to_string()),
541        ];
542        if let Some(k) = self
543            .key_bindings
544            .first_combo_for(&ActionShortcuts::SaveCurrentQuery)
545        {
546            hints.push((k, "save query".to_string()));
547        }
548        hints
549    }
550}
551
552// ---------------------------------------------------------------------------
553// Shared helpers
554// ---------------------------------------------------------------------------
555
556pub(super) fn format_journal_date(date: NaiveDate) -> String {
557    date.format("%A, %B %-d, %Y").to_string()
558}
559
560// ---------------------------------------------------------------------------
561// Tests
562// ---------------------------------------------------------------------------
563
564/// The number of highlighted matches in `text`, or `None` when there are no
565/// needles (the preview header shows a count only for queries with text terms).
566/// Counts the same ranges [`highlight_matches`] bolds — via the shared
567/// [`preview_highlight::match_ranges`] — so the header never disagrees with the
568/// visible highlights (overlapping needles are deduped, folds counted).
569fn count_matches(text: &str, needles: &[String]) -> Option<usize> {
570    if needles.is_empty() {
571        return None;
572    }
573    Some(preview_highlight::match_ranges(text, needles).len())
574}
575
576/// The preview text with needle matches emphasized in `yellow` (spec §6).
577/// Matching is byte-safe via [`preview_highlight::match_ranges`], so non-ASCII
578/// case folds (e.g. `İ`, `ẞ`) are highlighted too, not dropped.
579fn highlight_matches<'a>(
580    text: &'a str,
581    needles: &[String],
582    theme: &Theme,
583    base: Style,
584) -> ratatui::text::Text<'a> {
585    use ratatui::text::{Line, Span};
586    if needles.is_empty() {
587        return ratatui::text::Text::styled(text, base);
588    }
589    let emphasis = base.patch(
590        Style::default()
591            .fg(theme.color_search_match.to_ratatui())
592            .add_modifier(ratatui::style::Modifier::BOLD),
593    );
594    let mut lines = Vec::new();
595    for line in text.lines() {
596        let ranges = preview_highlight::match_ranges(line, needles);
597        if ranges.is_empty() {
598            lines.push(Line::styled(line, base));
599            continue;
600        }
601        // Borrowed spans into `text` ('a) — zero-copy; shared segment walk.
602        let spans = preview_highlight::style_ranges(line, &ranges, |s, hit| {
603            Span::styled(s, if hit { emphasis } else { base })
604        });
605        lines.push(Line::from(spans));
606    }
607    ratatui::text::Text::from(lines)
608}
609
610#[cfg(test)]
611mod tests {
612    use super::*;
613    use crate::components::search_list::{Emit, RowSource};
614    use crate::settings::AppSettings;
615    use crate::test_support::temp_vault;
616    use async_trait::async_trait;
617    use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
618    use tokio::sync::mpsc::unbounded_channel;
619
620    #[test]
621    fn count_matches_matches_highlighted_ranges() {
622        // No needles → no header count.
623        assert_eq!(count_matches("anything", &[]), None);
624        // Overlapping needles count once (deduped, longest-first) — the header
625        // must equal the number of bolded ranges, not the raw per-needle sum.
626        let needles = vec!["foo".to_string(), "foobar".to_string()];
627        assert_eq!(count_matches("foobar", &needles), Some(1));
628        // Distinct occurrences each count.
629        assert_eq!(count_matches("foo and foo", &["foo".to_string()]), Some(2));
630    }
631
632    /// A one-shot source that yields a single existing note so submit has
633    /// something to open.
634    struct OneNoteSource {
635        path: VaultPath,
636    }
637
638    #[async_trait]
639    impl RowSource<FileListEntry> for OneNoteSource {
640        async fn load(&self, _query: &str, emit: Emit<FileListEntry>) {
641            emit.replace(vec![FileListEntry::Note {
642                path: self.path.clone(),
643                title: "Note".to_string(),
644                filename: self.path.to_string(),
645                journal_date: None,
646                is_open: false,
647            }]);
648        }
649    }
650
651    async fn make_modal_with(source: impl RowSource<FileListEntry>, tx: AppTx) -> NoteBrowserModal {
652        let vault = temp_vault("modal").await;
653        let settings = AppSettings::default();
654        NoteBrowserModal::new(
655            "test",
656            BrowserScope::Query,
657            source,
658            vault,
659            settings.key_bindings.clone(),
660            settings.icons(),
661            tx,
662        )
663    }
664
665    #[tokio::test]
666    async fn dialog_error_surfaces_then_clears_on_keystroke() {
667        let (tx, _rx) = unbounded_channel();
668        let path = VaultPath::note_path_from("/a.md");
669        let mut modal = make_modal_with(OneNoteSource { path }, tx.clone()).await;
670        let vault = temp_vault("modal_err").await;
671
672        let consumed =
673            modal.handle_app_message(&AppEvent::DialogError("boom".to_string()), &vault, &tx);
674        assert!(matches!(consumed, OverlayMsg::Consumed));
675        assert_eq!(modal.error.as_deref(), Some("boom"));
676
677        // Any keystroke clears the error.
678        modal.handle_input(
679            &InputEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)),
680            &tx,
681        );
682        assert_eq!(modal.error, None, "keystroke should clear the error");
683    }
684
685    #[tokio::test]
686    async fn modal_constructed_with_initial_query_prefills_input() {
687        let vault = temp_vault("modal_iq").await;
688        let settings = AppSettings::default();
689        let (tx, _rx) = unbounded_channel();
690        let modal = NoteBrowserModal::with_initial_query(
691            "test",
692            BrowserScope::Query,
693            OneNoteSource {
694                path: VaultPath::note_path_from("/a.md"),
695            },
696            vault,
697            settings.key_bindings.clone(),
698            settings.icons(),
699            tx,
700            "#important",
701        );
702        assert_eq!(modal.query_text(), "#important");
703    }
704
705    /// Pressing Enter on a selected note emits OpenPath only. The editor's
706    /// OpenPath handler closes the overlay, so the modal does NOT also emit
707    /// CloseOverlay (that would be redundant).
708    #[tokio::test]
709    async fn submit_opens_selected_note() {
710        let (tx, mut rx) = unbounded_channel();
711        let path = VaultPath::note_path_from("/a.md");
712        let mut modal = make_modal_with(OneNoteSource { path: path.clone() }, tx.clone()).await;
713        // Let the one-shot load deliver its row and the engine select it.
714        modal.list.poll_until_idle().await;
715
716        Overlay::handle_input(
717            &mut modal,
718            &InputEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
719            &tx,
720        );
721
722        let mut events = Vec::new();
723        while let Ok(ev) = rx.try_recv() {
724            events.push(ev);
725        }
726        assert!(
727            events
728                .iter()
729                .any(|e| matches!(e, AppEvent::OpenPath { path: p, .. } if *p == path)),
730            "expected OpenPath, got {events:?}"
731        );
732        assert!(
733            !events.iter().any(|e| matches!(e, AppEvent::CloseOverlay)),
734            "select must not emit CloseOverlay; editor's OpenPath handler closes the overlay, got {events:?}"
735        );
736    }
737
738    /// Selecting a note row updates the tracked `preview_path`; this is the
739    /// state the render-time diff compares against to detect stale previews
740    /// after an async reload.
741    #[tokio::test]
742    async fn refresh_preview_tracks_selected_path() {
743        let (tx, _rx) = unbounded_channel();
744        let path = VaultPath::note_path_from("/a.md");
745        let mut modal = make_modal_with(OneNoteSource { path: path.clone() }, tx.clone()).await;
746        modal.list.poll_until_idle().await;
747        assert_eq!(modal.preview_path, None, "no path tracked before refresh");
748
749        modal.refresh_preview_from_list();
750        assert_eq!(
751            modal.preview_path,
752            Some(path),
753            "preview_path should track the selected note"
754        );
755    }
756
757    /// Pressing Esc closes the modal.
758    #[tokio::test]
759    async fn esc_closes_modal() {
760        let (tx, mut rx) = unbounded_channel();
761        let mut modal = make_modal_with(
762            OneNoteSource {
763                path: VaultPath::note_path_from("/a.md"),
764            },
765            tx.clone(),
766        )
767        .await;
768        Overlay::handle_input(
769            &mut modal,
770            &InputEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
771            &tx,
772        );
773        let mut sent = false;
774        while let Ok(ev) = rx.try_recv() {
775            if matches!(ev, AppEvent::CloseOverlay) {
776                sent = true;
777            }
778        }
779        assert!(sent, "expected CloseOverlay on Esc");
780    }
781
782    /// Accepting a `?name` expansion in the Ctrl+K browser pins the saved-search
783    /// breadcrumb and runs the stored query.
784    #[tokio::test(flavor = "multi_thread")]
785    async fn accepting_saved_search_pins_breadcrumb() {
786        let vault = temp_vault("modal-ss").await;
787        vault.validate_and_init().await.unwrap();
788        vault.save_search("todo-week", "#todo").await.unwrap();
789        let settings = AppSettings::default();
790        let (tx, _rx) = unbounded_channel();
791        let mut modal = NoteBrowserModal::new(
792            "test",
793            BrowserScope::Query,
794            OneNoteSource {
795                path: VaultPath::note_path_from("/a.md"),
796            },
797            vault,
798            settings.key_bindings.clone(),
799            settings.icons(),
800            tx.clone(),
801        );
802
803        // Type a leading `?` and a prefix, draining the async popup between
804        // keystrokes so the suggestion lands before we accept.
805        for ch in ['?', 't', 'o'] {
806            Overlay::handle_input(
807                &mut modal,
808                &InputEvent::Key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)),
809                &tx,
810            );
811            for _ in 0..30 {
812                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
813                modal.list.poll();
814            }
815        }
816        Overlay::handle_input(
817            &mut modal,
818            &InputEvent::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
819            &tx,
820        );
821
822        assert_eq!(modal.query_text(), "#todo");
823        assert_eq!(
824            modal.saved_search_breadcrumb().as_deref(),
825            Some("todo-week")
826        );
827        // The overlay exposes the provenance so the save-search dialog can
828        // pre-fill its name field.
829        assert_eq!(Overlay::saved_search_provenance(&modal), Some("todo-week"));
830    }
831}