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