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