Skip to main content

kimun_notes/components/note_browser/
mod.rs

1use std::sync::Arc;
2use std::sync::mpsc::Receiver;
3
4use async_trait::async_trait;
5use chrono::NaiveDate;
6use kimun_core::NoteVault;
7use kimun_core::nfs::VaultPath;
8use ratatui::Frame;
9use ratatui::layout::{Constraint, Direction, Layout, Position, Rect};
10use ratatui::style::Style;
11use ratatui::widgets::{Block, Borders, Clear, Paragraph};
12
13use crate::components::Component;
14use crate::components::autocomplete::{
15    self, AutocompleteController, AutocompleteHost, AutocompleteMode, HandleKeyOutcome,
16    TriggerOptions,
17};
18use crate::components::event_state::EventState;
19use crate::components::events::{AppEvent, AppTx, InputEvent, redraw_callback};
20use crate::components::file_list::{FileListComponent, FileListEntry};
21use crate::components::single_line_input::{InputOutcome, SingleLineInput};
22use crate::keys::KeyBindings;
23use crate::settings::icons::Icons;
24use crate::settings::themes::Theme;
25
26pub mod file_finder_provider;
27pub mod link_results_provider;
28pub mod search_provider;
29
30// ---------------------------------------------------------------------------
31// NoteBrowserProvider trait
32// ---------------------------------------------------------------------------
33
34#[async_trait]
35pub trait NoteBrowserProvider: Send + Sync {
36    /// Called on every query change. Empty string = initial/empty state (recent notes).
37    async fn load(&self, query: &str) -> Vec<FileListEntry>;
38
39    /// Whether to prepend a "Create: <query>" entry when query is non-empty.
40    /// Defaults to false. Used by future FileFinderProvider.
41    fn allows_create(&self) -> bool {
42        false
43    }
44}
45
46// ---------------------------------------------------------------------------
47// NoteBrowserModal
48// ---------------------------------------------------------------------------
49
50pub struct NoteBrowserModal {
51    title: String,
52    search_query: SingleLineInput,
53    provider: Arc<dyn NoteBrowserProvider>,
54    file_list: FileListComponent,
55    list_rect: Rect,
56    preview_text: String,
57    vault: Arc<NoteVault>,
58    tx: AppTx,
59    // List async loading
60    load_task: Option<tokio::task::JoinHandle<()>>,
61    load_rx: Option<Receiver<Vec<FileListEntry>>>,
62    // Preview async loading
63    preview_task: Option<tokio::task::JoinHandle<()>>,
64    preview_rx: Option<Receiver<String>>,
65    // Hashtag autocomplete for the search input.
66    autocomplete: AutocompleteController,
67}
68
69/// Snapshot of the search input that satisfies `AutocompleteHost`.
70/// Owned so the controller's borrow doesn't overlap with the search
71/// input's `&mut` borrow during key handling and replacement. Holds
72/// a single-row `Vec<String>` because `EditorSnapshot` borrows a
73/// slice of lines — the search-box buffer is the one row.
74struct SearchBoxHostSnapshot {
75    lines: Vec<String>,
76    /// Cursor as `(row, char_col)` — row is always 0 for the
77    /// single-line search box; char_col derived from the byte
78    /// cursor returned by the input widget.
79    cursor: (usize, usize),
80    caret_pos: Option<(u16, u16)>,
81}
82
83impl AutocompleteHost for SearchBoxHostSnapshot {
84    fn buffer_snapshot(&self) -> crate::components::text_editor::snapshot::EditorSnapshot<'_> {
85        use std::num::NonZeroU64;
86        // content_revision unused (cache_key returns None); supply a
87        // placeholder so the field stays NonZeroU64.
88        let dummy = NonZeroU64::new(1).unwrap();
89        crate::components::text_editor::snapshot::EditorSnapshot::borrowed(
90            &self.lines,
91            self.cursor,
92            dummy,
93        )
94    }
95    fn cache_key(&self) -> Option<std::num::NonZeroU64> {
96        // `None` opts out of the controller's per-buffer cache. The
97        // search-box buffer is single-line and short, so the rebuild
98        // cost per keystroke is negligible — opting out keeps the
99        // modal free of per-keystroke revision bookkeeping.
100        None
101    }
102    fn screen_anchor_for(&self, _byte_offset: usize) -> Option<(u16, u16)> {
103        // Anchor at the caret — same liberty as the editor host. The
104        // popup sits adjacent to the typed text either way.
105        self.caret_pos
106    }
107}
108
109impl NoteBrowserModal {
110    pub fn new(
111        title: impl Into<String>,
112        provider: impl NoteBrowserProvider + 'static,
113        vault: Arc<NoteVault>,
114        key_bindings: KeyBindings,
115        icons: Icons,
116        tx: AppTx,
117    ) -> Self {
118        Self::new_with_query(
119            title,
120            provider,
121            vault,
122            key_bindings,
123            icons,
124            tx,
125            String::new(),
126        )
127    }
128
129    fn new_with_query(
130        title: impl Into<String>,
131        provider: impl NoteBrowserProvider + 'static,
132        vault: Arc<NoteVault>,
133        key_bindings: KeyBindings,
134        icons: Icons,
135        tx: AppTx,
136        initial_query: String,
137    ) -> Self {
138        let file_list = FileListComponent::new(key_bindings, icons);
139        // Search box is plain text, not Markdown — disable the
140        // column-0 header disambiguation (no headers to confuse with)
141        // and disable the exclusion-zone check (literal `` ` `` /
142        // brackets in a query shouldn't suppress hashtag triggers).
143        let mut autocomplete =
144            AutocompleteController::new(vault.clone(), AutocompleteMode::HashtagOnly)
145                .with_trigger_opts(TriggerOptions {
146                    disambiguate_header: false,
147                    apply_exclusion_zone: false,
148                });
149        autocomplete.set_redraw_callback(redraw_callback(tx.clone()));
150        let mut modal = Self {
151            title: title.into(),
152            search_query: SingleLineInput::new(),
153            provider: Arc::new(provider),
154            file_list,
155            list_rect: Rect::default(),
156            preview_text: String::new(),
157            vault,
158            tx: tx.clone(),
159            load_task: None,
160            load_rx: None,
161            preview_task: None,
162            preview_rx: None,
163            autocomplete,
164        };
165        if !initial_query.is_empty() {
166            modal.search_query.set_value(initial_query);
167        }
168        modal.schedule_load(tx);
169        modal
170    }
171
172    // ── Async list loading ─────────────────────────────────────────────────
173
174    fn schedule_load(&mut self, tx: AppTx) {
175        if let Some(handle) = self.load_task.take() {
176            handle.abort();
177        }
178        let query = self.search_query.value().to_string();
179        let provider = Arc::clone(&self.provider);
180        let (result_tx, result_rx) = std::sync::mpsc::channel();
181        self.load_rx = Some(result_rx);
182
183        let handle = tokio::spawn(async move {
184            let entries = provider.load(&query).await;
185            result_tx.send(entries).ok();
186            tx.send(AppEvent::Redraw).ok();
187        });
188        self.load_task = Some(handle);
189    }
190
191    fn poll_load(&mut self) {
192        let Some(rx) = &self.load_rx else { return };
193        match rx.try_recv() {
194            Ok(entries) => {
195                self.file_list.clear();
196                let mut create_entry: Option<FileListEntry> = None;
197                for entry in entries {
198                    if matches!(entry, FileListEntry::CreateNote { .. }) {
199                        create_entry = Some(entry);
200                    } else {
201                        self.file_list.push_entry(entry);
202                    }
203                }
204                if let Some(entry) = create_entry {
205                    self.file_list.prepend_create_entry(entry);
206                }
207                self.load_rx = None;
208                self.load_task = None;
209                self.refresh_preview();
210            }
211            Err(std::sync::mpsc::TryRecvError::Empty) => {}
212            Err(std::sync::mpsc::TryRecvError::Disconnected) => {
213                self.load_rx = None;
214            }
215        }
216    }
217
218    // ── Async preview loading ──────────────────────────────────────────────
219
220    fn schedule_preview(&mut self, path: VaultPath) {
221        if let Some(handle) = self.preview_task.take() {
222            handle.abort();
223        }
224        let vault = Arc::clone(&self.vault);
225        let tx = self.tx.clone();
226        let (result_tx, result_rx) = std::sync::mpsc::channel();
227        self.preview_rx = Some(result_rx);
228
229        let handle = tokio::spawn(async move {
230            let text = vault.get_note_text(&path).await.unwrap_or_default();
231            result_tx.send(text).ok();
232            tx.send(AppEvent::Redraw).ok();
233        });
234        self.preview_task = Some(handle);
235    }
236
237    fn poll_preview(&mut self) {
238        let Some(rx) = &self.preview_rx else { return };
239        match rx.try_recv() {
240            Ok(text) => {
241                self.preview_text = text;
242                self.preview_rx = None;
243                self.preview_task = None;
244            }
245            Err(std::sync::mpsc::TryRecvError::Disconnected) => {
246                self.preview_rx = None;
247            }
248            Err(std::sync::mpsc::TryRecvError::Empty) => {}
249        }
250    }
251
252    fn open_selected_entry(&self, tx: &AppTx) {
253        let Some(entry) = self.file_list.selected_entry() else {
254            return;
255        };
256        if let FileListEntry::CreateNote { path, .. } = entry {
257            let path = path.clone();
258            let vault = Arc::clone(&self.vault);
259            let tx = tx.clone();
260            tokio::spawn(async move {
261                vault.load_or_create_note(&path, None).await.ok();
262                tx.send(AppEvent::OpenPath(path)).ok();
263                tx.send(AppEvent::CloseNoteBrowser).ok();
264            });
265            return;
266        }
267        let path = entry.path().clone();
268        tx.send(AppEvent::OpenPath(path)).ok();
269        tx.send(AppEvent::CloseNoteBrowser).ok();
270    }
271
272    /// Construct the modal with a pre-filled search query.
273    ///
274    /// Behaves exactly like [`new`](Self::new) except the search input is
275    /// pre-populated with `query` (cursor placed at the end) and an initial
276    /// load is triggered for that query string.  Only a single `schedule_load`
277    /// call is made — the query is pre-filled before the task is spawned so
278    /// there is no empty-load race.
279    pub fn with_initial_query<S: Into<String>>(
280        title: impl Into<String>,
281        provider: impl NoteBrowserProvider + 'static,
282        vault: Arc<NoteVault>,
283        key_bindings: KeyBindings,
284        icons: Icons,
285        tx: AppTx,
286        query: S,
287    ) -> Self {
288        Self::new_with_query(
289            title,
290            provider,
291            vault,
292            key_bindings,
293            icons,
294            tx,
295            query.into(),
296        )
297    }
298
299    // ── Test-only accessors ────────────────────────────────────────────────
300
301    /// Returns the current search input text. Test-only.
302    #[cfg(test)]
303    pub(super) fn query_text(&self) -> &str {
304        self.search_query.value()
305    }
306
307    /// Returns the cursor position as a char count (not bytes). Test-only.
308    #[cfg(test)]
309    pub(super) fn cursor_char_count(&self) -> usize {
310        self.search_query.cursor_char_offset()
311    }
312
313    /// Called after selection changes to kick off a preview load for the
314    /// highlighted note, or clear the preview if a non-note entry is selected.
315    fn refresh_preview(&mut self) {
316        let maybe_path = self.file_list.selected_entry().and_then(|e| match e {
317            FileListEntry::Note { path, .. } => Some(path.clone()),
318            _ => None,
319        });
320        if let Some(path) = maybe_path {
321            self.schedule_preview(path);
322        } else {
323            self.preview_text.clear();
324            if let Some(h) = self.preview_task.take() {
325                h.abort();
326            }
327        }
328    }
329
330    // ── Autocomplete ──────────────────────────────────────────────────────
331
332    fn autocomplete_snapshot(&self) -> SearchBoxHostSnapshot {
333        // The search box is a single line — wrap it as a 1-row buffer.
334        // Convert the byte cursor to char column for `EditorSnapshot`.
335        let value = self.search_query.value().to_string();
336        let cursor_byte = self.search_query.cursor_byte();
337        let col = value[..cursor_byte.min(value.len())].chars().count();
338        SearchBoxHostSnapshot {
339            lines: vec![value],
340            cursor: (0, col),
341            caret_pos: self.search_query.last_caret_pos(),
342        }
343    }
344}
345
346// ---------------------------------------------------------------------------
347// Component impl
348// ---------------------------------------------------------------------------
349
350impl Component for NoteBrowserModal {
351    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
352        use ratatui::crossterm::event::{KeyCode, KeyModifiers, MouseButton, MouseEventKind};
353
354        if let InputEvent::Mouse(mouse) = event {
355            // Any mouse interaction inside the modal takes focus away
356            // from the search input — close the popup so it doesn't
357            // paint stale at the old caret coords. Done before the
358            // bounds check so clicks on the preview pane or border
359            // still dismiss the popup.
360            self.autocomplete.close();
361            let r = self.list_rect;
362            if !r.contains(Position {
363                x: mouse.column,
364                y: mouse.row,
365            }) {
366                return EventState::NotConsumed;
367            }
368            match mouse.kind {
369                MouseEventKind::Down(MouseButton::Left) => {
370                    if mouse.row > r.y {
371                        let rel_row = mouse.row - r.y - 1;
372                        let prev = self.file_list.selected_display_idx();
373                        if let Some(idx) = self.file_list.select_at_visual_row(rel_row) {
374                            if prev == Some(idx) {
375                                self.open_selected_entry(tx);
376                            } else {
377                                self.refresh_preview();
378                            }
379                        }
380                    }
381                    EventState::Consumed
382                }
383                MouseEventKind::ScrollUp => {
384                    self.file_list.scroll_up();
385                    EventState::Consumed
386                }
387                MouseEventKind::ScrollDown => {
388                    self.file_list.scroll_down();
389                    EventState::Consumed
390                }
391                _ => EventState::Consumed,
392            }
393        } else {
394            let InputEvent::Key(key) = event else {
395                return EventState::NotConsumed;
396            };
397
398            // Autocomplete popup gets first crack: Up/Down/Tab/Enter/Esc
399            // navigate or accept the suggestion list instead of bubbling
400            // to the modal's own list-nav handling. Falls through when
401            // closed or when the popup doesn't recognise the key.
402            if self.autocomplete.is_open() {
403                let snapshot = self.autocomplete_snapshot();
404                match self.autocomplete.handle_key(*key, &snapshot) {
405                    HandleKeyOutcome::Accepted(action) => {
406                        self.search_query.replace_range_bytes(
407                            action.range.clone(),
408                            &action.new_text,
409                            action.new_cursor_byte,
410                        );
411                        // Reschedule the load so search results reflect
412                        // the accepted suggestion, mirroring what would
413                        // happen if the user had typed the text manually.
414                        self.schedule_load(tx.clone());
415                        return EventState::Consumed;
416                    }
417                    HandleKeyOutcome::Dismissed | HandleKeyOutcome::Consumed => {
418                        return EventState::Consumed;
419                    }
420                    HandleKeyOutcome::NotHandled => {}
421                }
422            }
423
424            // List nav handled directly; everything else forwards to the input.
425            match key.code {
426                KeyCode::Up => {
427                    self.file_list.select_prev();
428                    self.refresh_preview();
429                    return EventState::Consumed;
430                }
431                KeyCode::Down => {
432                    self.file_list.select_next();
433                    self.refresh_preview();
434                    return EventState::Consumed;
435                }
436                _ => {}
437            }
438            // Drop Ctrl/Alt-modified chars so combos don't leak as text.
439            if let KeyCode::Char(_) = key.code {
440                let non_shift = key.modifiers - KeyModifiers::SHIFT;
441                if !non_shift.is_empty() {
442                    return EventState::Consumed;
443                }
444            }
445            let outcome = self.search_query.handle_key(key);
446            // Edits feed the popup (may open / refresh / close it).
447            // Cursor-only navigation (`Consumed`) refreshes an OPEN
448            // popup so it tracks the cursor or closes when the cursor
449            // leaves the trigger range — but it never auto-opens the
450            // popup just because the cursor passed over a hashtag.
451            // Cancel / Submit are exit paths; the popup never survives
452            // them.
453            let snapshot = self.autocomplete_snapshot();
454            match outcome {
455                InputOutcome::Changed => self.autocomplete.sync(&snapshot),
456                InputOutcome::Consumed => self.autocomplete.refresh_if_open(&snapshot),
457                InputOutcome::Cancel | InputOutcome::Submit => {
458                    self.autocomplete.close();
459                }
460                InputOutcome::NotConsumed => {}
461            }
462            match outcome {
463                InputOutcome::Cancel => {
464                    tx.send(AppEvent::CloseNoteBrowser).ok();
465                    EventState::Consumed
466                }
467                InputOutcome::Submit => {
468                    self.open_selected_entry(tx);
469                    EventState::Consumed
470                }
471                InputOutcome::Changed => {
472                    self.schedule_load(tx.clone());
473                    EventState::Consumed
474                }
475                InputOutcome::Consumed => EventState::Consumed,
476                InputOutcome::NotConsumed => EventState::NotConsumed,
477            }
478        }
479    }
480
481    fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme, _focused: bool) {
482        self.poll_load();
483        self.poll_preview();
484
485        let popup_rect = centered_rect(80, 75, area);
486
487        // Clear the area behind the modal so the editor doesn't bleed through.
488        f.render_widget(Clear, popup_rect);
489
490        let outer_block = Block::default()
491            .title(format!(" {} ", self.title))
492            .borders(Borders::ALL)
493            .border_style(theme.border_style(true))
494            .style(theme.panel_style());
495        let inner = outer_block.inner(popup_rect);
496        f.render_widget(outer_block, popup_rect);
497
498        let rows = Layout::default()
499            .direction(Direction::Vertical)
500            .constraints([
501                Constraint::Length(3),
502                Constraint::Min(0),
503                Constraint::Length(1),
504            ])
505            .split(inner);
506
507        // ── Search box ────────────────────────────────────────────────────
508        let search_block = Block::default()
509            .title(" Search ")
510            .borders(Borders::ALL)
511            .border_style(theme.border_style(true))
512            .style(theme.panel_style());
513        let search_inner = search_block.inner(rows[0]);
514        f.render_widget(search_block, rows[0]);
515        self.search_query.render(
516            f,
517            search_inner,
518            Style::default()
519                .fg(theme.fg.to_ratatui())
520                .bg(theme.bg_panel.to_ratatui()),
521            0,
522            true,
523        );
524
525        // ── List + Preview ────────────────────────────────────────────────
526        let columns = Layout::default()
527            .direction(Direction::Horizontal)
528            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
529            .split(rows[1]);
530
531        self.list_rect = columns[0];
532        self.file_list.render(f, columns[0], theme, false);
533
534        let preview_block = Block::default()
535            .title(" Preview ")
536            .borders(Borders::ALL)
537            .border_style(theme.border_style(false))
538            .style(theme.panel_style());
539        let preview_inner = preview_block.inner(columns[1]);
540        f.render_widget(preview_block, columns[1]);
541        f.render_widget(
542            Paragraph::new(self.preview_text.as_str()).style(
543                Style::default()
544                    .fg(theme.fg.to_ratatui())
545                    .bg(theme.bg.to_ratatui()),
546            ),
547            preview_inner,
548        );
549
550        // ── Hint bar ──────────────────────────────────────────────────────
551        f.render_widget(
552            Paragraph::new("↑↓: navigate  |  Enter: open  |  Esc: close")
553                .style(Style::default().fg(theme.fg_secondary.to_ratatui())),
554            rows[2],
555        );
556
557        // ── Autocomplete popup ───────────────────────────────────────────
558        // Drain async results, re-anchor on the search input's freshly
559        // rendered caret position, and clamp the popup to `popup_rect`
560        // (the modal's bounds) so it never spills past the modal's
561        // border into the cleared backdrop.
562        self.autocomplete.poll_results();
563        let live_anchor = self.search_query.last_caret_pos();
564        if let (Some(state), Some(anchor)) = (self.autocomplete.state_mut(), live_anchor) {
565            state.anchor = anchor;
566        }
567        if let Some(state) = self.autocomplete.state() {
568            autocomplete::render(f, state, popup_rect, theme);
569        }
570    }
571
572    fn hint_shortcuts(&self) -> Vec<(String, String)> {
573        vec![
574            ("↑↓".to_string(), "navigate".to_string()),
575            ("Enter".to_string(), "open".to_string()),
576            ("Esc".to_string(), "close".to_string()),
577        ]
578    }
579}
580
581// ---------------------------------------------------------------------------
582// Layout helper
583// ---------------------------------------------------------------------------
584
585fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect {
586    let popup_height = area.height * percent_y / 100;
587    let popup_width = area.width * percent_x / 100;
588    Rect {
589        x: area.x + (area.width.saturating_sub(popup_width)) / 2,
590        y: area.y + (area.height.saturating_sub(popup_height)) / 2,
591        width: popup_width,
592        height: popup_height,
593    }
594}
595
596// ---------------------------------------------------------------------------
597// Shared helpers
598// ---------------------------------------------------------------------------
599
600pub(super) fn format_journal_date(date: NaiveDate) -> String {
601    date.format("%A, %B %-d, %Y").to_string()
602}
603
604// ---------------------------------------------------------------------------
605// Tests
606// ---------------------------------------------------------------------------
607
608#[cfg(test)]
609mod tests {
610    use super::*;
611    use crate::settings::AppSettings;
612    use crate::test_support::{mouse_down_at, temp_vault};
613    use tokio::sync::mpsc::unbounded_channel;
614
615    struct EmptyProvider;
616
617    #[async_trait]
618    impl NoteBrowserProvider for EmptyProvider {
619        async fn load(&self, _query: &str) -> Vec<FileListEntry> {
620            Vec::new()
621        }
622    }
623
624    async fn make_modal() -> NoteBrowserModal {
625        let vault = temp_vault("modal").await;
626        let settings = AppSettings::default();
627        let (tx, _rx) = unbounded_channel();
628        NoteBrowserModal::new(
629            "test",
630            EmptyProvider,
631            vault,
632            settings.key_bindings.clone(),
633            settings.icons(),
634            tx,
635        )
636    }
637
638    /// The modal's mouse handler scopes by `list_rect` (set during render),
639    /// not by any rect carried by `FileListComponent`.  Clicks outside that
640    /// rect must not be consumed.
641    #[tokio::test]
642    async fn modal_mouse_down_outside_list_rect_is_not_consumed() {
643        let mut modal = make_modal().await;
644        modal.list_rect = Rect {
645            x: 10,
646            y: 10,
647            width: 20,
648            height: 10,
649        };
650        let (tx, _rx) = unbounded_channel();
651
652        // Click well outside the list rect.
653        let result = modal.handle_input(&mouse_down_at(0, 0), &tx);
654        assert_eq!(result, EventState::NotConsumed);
655    }
656
657    /// Mirrors the bounds-check used by `SidebarComponent`: a click on the
658    /// modal's list_rect.y row is on the block border and must not panic, and
659    /// must not select anything (the guard `mouse.row > r.y` skips it).
660    #[tokio::test]
661    async fn modal_mouse_down_on_list_border_does_not_panic() {
662        let mut modal = make_modal().await;
663        modal.list_rect = Rect {
664            x: 10,
665            y: 10,
666            width: 20,
667            height: 10,
668        };
669        let (tx, _rx) = unbounded_channel();
670        // Click the very top row of the list rect (the block border).
671        let result = modal.handle_input(&mouse_down_at(15, 10), &tx);
672        assert_eq!(result, EventState::Consumed);
673        assert!(modal.file_list.selected_display_idx().is_none());
674    }
675
676    #[test]
677    fn centered_rect_is_centered() {
678        let area = Rect {
679            x: 0,
680            y: 0,
681            width: 100,
682            height: 40,
683        };
684        let r = centered_rect(80, 75, area);
685        assert_eq!(r.width, 80);
686        assert_eq!(r.height, 30);
687        assert_eq!(r.x, 10); // (100 - 80) / 2
688        assert_eq!(r.y, 5); // (40 - 30) / 2
689    }
690
691    #[test]
692    fn centered_rect_does_not_underflow() {
693        // Very small area — must not panic.
694        let area = Rect {
695            x: 0,
696            y: 0,
697            width: 5,
698            height: 5,
699        };
700        let _ = centered_rect(80, 75, area);
701    }
702
703    // ── initial-query tests ───────────────────────────────────────────────
704
705    #[tokio::test]
706    async fn modal_constructed_with_initial_query_prefills_input() {
707        let vault = temp_vault("modal_iq").await;
708        let settings = AppSettings::default();
709        let (tx, _rx) = unbounded_channel();
710        let modal = NoteBrowserModal::with_initial_query(
711            "test",
712            EmptyProvider,
713            vault,
714            settings.key_bindings.clone(),
715            settings.icons(),
716            tx,
717            "#important",
718        );
719        assert_eq!(modal.query_text(), "#important");
720        assert_eq!(modal.cursor_char_count(), "#important".chars().count());
721    }
722
723    #[tokio::test]
724    async fn modal_new_has_empty_query() {
725        let modal = make_modal().await;
726        assert_eq!(modal.query_text(), "");
727        assert_eq!(modal.cursor_char_count(), 0);
728    }
729
730    /// End-to-end: the modal's hashtag autocomplete plumbing accepts a
731    /// suggestion via Tab and writes the chosen tag into the search input.
732    /// Uses a real vault containing a known tag so the controller's
733    /// query path is exercised too.
734    #[tokio::test]
735    async fn search_box_autocomplete_accept_inserts_tag() {
736        use kimun_core::nfs::VaultPath;
737        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
738
739        let vault = temp_vault("search_autocomplete").await;
740        vault.validate_and_init().await.unwrap();
741        vault
742            .create_note(&VaultPath::note_path_from("/a.md"), "body #projects")
743            .await
744            .unwrap();
745        let settings = AppSettings::default();
746        let (tx, _rx) = unbounded_channel();
747        let mut modal = NoteBrowserModal::new(
748            "test",
749            EmptyProvider,
750            vault,
751            settings.key_bindings.clone(),
752            settings.icons(),
753            tx,
754        );
755
756        // Type `#pro` into the search box.
757        let (tx2, _rx2) = unbounded_channel();
758        for ch in ['#', 'p', 'r', 'o'] {
759            modal.handle_input(
760                &InputEvent::Key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)),
761                &tx2,
762            );
763        }
764        // Prime the caret cache for the controller's anchor lookup so the
765        // popup is allowed to open.
766        modal
767            .search_query
768            .set_last_caret_pos_for_tests(Some((0, 0)));
769        let snapshot = modal.autocomplete_snapshot();
770        modal.autocomplete.sync(&snapshot);
771        // Allow the spawned query task to complete and drain results.
772        tokio::task::yield_now().await;
773        tokio::time::sleep(std::time::Duration::from_millis(30)).await;
774        modal.autocomplete.poll_results();
775
776        assert!(modal.autocomplete.is_open(), "popup should be open");
777
778        // Tab accepts; the chosen tag should replace `pro`.
779        modal.handle_input(
780            &InputEvent::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
781            &tx2,
782        );
783        assert_eq!(modal.search_query.value(), "#projects");
784    }
785
786    /// `with_initial_query` must call `schedule_load` exactly once, with the
787    /// query already pre-filled.  Verified indirectly: the visible state after
788    /// construction must match the supplied query and the cursor must sit at
789    /// the end — just as if a single properly-initialised load were scheduled.
790    #[tokio::test]
791    async fn with_initial_query_does_not_double_schedule() {
792        let vault = temp_vault("modal_iq_once").await;
793        let settings = AppSettings::default();
794        let (tx, _rx) = unbounded_channel();
795        let modal = NoteBrowserModal::with_initial_query(
796            "test",
797            EmptyProvider,
798            vault,
799            settings.key_bindings.clone(),
800            settings.icons(),
801            tx,
802            "#important",
803        );
804        assert_eq!(modal.query_text(), "#important");
805        assert_eq!(modal.cursor_char_count(), "#important".chars().count());
806        // A load task must have been spawned (Some), confirming schedule_load
807        // was called.  If it were called twice the second abort() would race;
808        // that scenario is ruled out by code inspection: new_with_query is the
809        // only call site for schedule_load during construction.
810        assert!(modal.load_task.is_some());
811    }
812}