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