Skip to main content

kimun_notes/components/note_browser/
mod.rs

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