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, Clear, 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::saved_search_breadcrumb::SavedSearchBreadcrumb;
18use crate::components::search_list::{
19    KeyReaction, RowSource, SearchList, SearchMouse, VaultSuggestions,
20};
21use crate::keys::KeyBindings;
22use crate::keys::action_shortcuts::ActionShortcuts;
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// NoteBrowserModal
32// ---------------------------------------------------------------------------
33
34/// The Ctrl+K note browser. It hosts a [`SearchList`] engine (query input +
35/// async-loaded result list + hashtag autocomplete) and adds the two things
36/// unique to the browser: a live preview pane for the selected note and the
37/// open-on-enter glue that emits [`AppEvent::OpenPath`].
38pub struct NoteBrowserModal {
39    title: String,
40    list: SearchList<FileListEntry>,
41    vault: Arc<NoteVault>,
42    tx: AppTx,
43    preview_text: String,
44    // Preview async loading
45    preview_task: Option<tokio::task::JoinHandle<()>>,
46    preview_rx: Option<Receiver<String>>,
47    /// Path the preview pane is currently showing (or loading). Compared at
48    /// render time against the engine's selected row so an async server-side
49    /// reload that auto-selects a different row still refreshes the preview.
50    preview_path: Option<VaultPath>,
51    /// Used to resolve the save-current-query shortcut for the hint bar.
52    key_bindings: KeyBindings,
53    /// The saved-search breadcrumb shown on the search border. Owns its
54    /// sticky/clear/edited state machine; the modal only forwards query events.
55    /// See [`SavedSearchBreadcrumb`].
56    saved_search: SavedSearchBreadcrumb,
57}
58
59impl NoteBrowserModal {
60    pub fn new(
61        title: impl Into<String>,
62        provider: impl RowSource<FileListEntry>,
63        vault: Arc<NoteVault>,
64        key_bindings: KeyBindings,
65        icons: Icons,
66        tx: AppTx,
67    ) -> Self {
68        Self::new_with_query(
69            title,
70            provider,
71            vault,
72            key_bindings,
73            icons,
74            tx,
75            String::new(),
76        )
77    }
78
79    /// Construct the modal with a pre-filled search query.
80    ///
81    /// Behaves exactly like [`new`](Self::new) except the search input is
82    /// pre-populated with `query` (cursor placed at the end) and the initial
83    /// load is triggered for that query string.
84    pub fn with_initial_query<S: Into<String>>(
85        title: impl Into<String>,
86        provider: impl RowSource<FileListEntry>,
87        vault: Arc<NoteVault>,
88        key_bindings: KeyBindings,
89        icons: Icons,
90        tx: AppTx,
91        query: S,
92    ) -> Self {
93        Self::new_with_query(
94            title,
95            provider,
96            vault,
97            key_bindings,
98            icons,
99            tx,
100            query.into(),
101        )
102    }
103
104    fn new_with_query(
105        title: impl Into<String>,
106        provider: impl RowSource<FileListEntry>,
107        vault: Arc<NoteVault>,
108        key_bindings: KeyBindings,
109        icons: Icons,
110        tx: AppTx,
111        initial_query: String,
112    ) -> Self {
113        let list = SearchList::builder(provider, redraw_callback(tx.clone()))
114            .initial_query(initial_query)
115            .icons(icons)
116            .autocomplete(
117                Arc::new(VaultSuggestions {
118                    vault: vault.clone(),
119                }),
120                AutocompleteMode::SearchQuery,
121            )
122            .build();
123        let mut modal = Self {
124            title: title.into(),
125            list,
126            vault,
127            tx,
128            preview_text: String::new(),
129            preview_task: None,
130            preview_rx: None,
131            preview_path: None,
132            key_bindings,
133            saved_search: SavedSearchBreadcrumb::default(),
134        };
135        modal.refresh_preview(None);
136        modal
137    }
138
139    // ── Async preview loading ──────────────────────────────────────────────
140
141    fn schedule_preview(&mut self, path: VaultPath) {
142        if let Some(handle) = self.preview_task.take() {
143            handle.abort();
144        }
145        let vault = Arc::clone(&self.vault);
146        let tx = self.tx.clone();
147        let (result_tx, result_rx) = std::sync::mpsc::channel();
148        self.preview_rx = Some(result_rx);
149
150        let handle = tokio::spawn(async move {
151            let text = vault.get_note_text(&path).await.unwrap_or_default();
152            result_tx.send(text).ok();
153            tx.send(AppEvent::Redraw).ok();
154        });
155        self.preview_task = Some(handle);
156    }
157
158    fn poll_preview(&mut self) {
159        let Some(rx) = &self.preview_rx else { return };
160        match rx.try_recv() {
161            Ok(text) => {
162                self.preview_text = text;
163                self.preview_rx = None;
164                self.preview_task = None;
165            }
166            Err(std::sync::mpsc::TryRecvError::Disconnected) => {
167                self.preview_rx = None;
168            }
169            Err(std::sync::mpsc::TryRecvError::Empty) => {}
170        }
171    }
172
173    /// Called after selection changes to kick off a preview load for the
174    /// highlighted note, or clear the preview if a non-note entry is selected.
175    fn refresh_preview(&mut self, selected: Option<&FileListEntry>) {
176        let maybe_path = selected.and_then(|e| match e {
177            FileListEntry::Note { path, .. } => Some(path.clone()),
178            _ => None,
179        });
180        if let Some(path) = maybe_path {
181            self.schedule_preview(path);
182        } else {
183            self.preview_text.clear();
184            if let Some(h) = self.preview_task.take() {
185                h.abort();
186            }
187        }
188    }
189
190    /// The note path the engine currently has selected, if the selected row is
191    /// a note (non-note rows yield `None`).
192    fn selected_note_path(&self) -> Option<VaultPath> {
193        self.list.selected_row().and_then(|e| match e {
194            FileListEntry::Note { path, .. } => Some(path.clone()),
195            _ => None,
196        })
197    }
198
199    /// Refresh the preview for whatever the engine currently has selected.
200    fn refresh_preview_from_list(&mut self) {
201        let path = self.selected_note_path();
202        self.preview_path = path.clone();
203        match path {
204            Some(path) => self.schedule_preview(path),
205            None => {
206                self.preview_text.clear();
207                if let Some(h) = self.preview_task.take() {
208                    h.abort();
209                }
210            }
211        }
212    }
213
214    /// Open the engine's selected row: create-then-open for a `CreateNote`,
215    /// or open directly for an existing `Note`. Emits only `OpenPath`; the
216    /// editor's `OpenPath` handler closes this overlay (restoring focus to the
217    /// editor), so no separate `CloseOverlay` is sent.
218    fn open_selected(&self, tx: &AppTx) {
219        let Some(entry) = self.list.selected_row() else {
220            return;
221        };
222        if let FileListEntry::CreateNote { path, .. } = entry {
223            let path = path.clone();
224            let vault = Arc::clone(&self.vault);
225            let tx = tx.clone();
226            tokio::spawn(async move {
227                vault.load_or_create_note(&path, None).await.ok();
228                tx.send(AppEvent::OpenPath(path)).ok();
229            });
230            return;
231        }
232        let path = entry.path().clone();
233        tx.send(AppEvent::OpenPath(path)).ok();
234    }
235
236    /// The saved-search breadcrumb label for the search border, or `None` when
237    /// no saved search is active.
238    #[cfg(test)]
239    fn saved_search_breadcrumb(&self) -> Option<String> {
240        self.saved_search.label(self.list.query())
241    }
242
243    // ── Test-only accessors ────────────────────────────────────────────────
244
245    /// Returns the current search input text. Test-only.
246    #[cfg(test)]
247    pub(super) fn query_text(&self) -> &str {
248        self.list.query()
249    }
250}
251
252// ---------------------------------------------------------------------------
253// Overlay impl
254// ---------------------------------------------------------------------------
255
256impl Overlay for NoteBrowserModal {
257    fn kind(&self) -> OverlayKind {
258        OverlayKind::NoteBrowser
259    }
260
261    fn query(&self) -> Option<&str> {
262        Some(self.list.query())
263    }
264
265    fn saved_search_provenance(&self) -> Option<&str> {
266        self.saved_search.name()
267    }
268
269    fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
270        match event {
271            InputEvent::Mouse(mouse) => match self.list.handle_mouse(mouse) {
272                SearchMouse::Activated(_) => {
273                    self.open_selected(tx);
274                    EventState::Consumed
275                }
276                SearchMouse::Selected(_) | SearchMouse::Scrolled => {
277                    self.refresh_preview_from_list();
278                    EventState::Consumed
279                }
280                // No content sub-region is recorded by this host, so these
281                // are unreachable.
282                SearchMouse::ContentScrollUp | SearchMouse::ContentScrollDown => {
283                    EventState::Consumed
284                }
285                SearchMouse::None => EventState::NotConsumed,
286            },
287            InputEvent::Key(key) => match self.list.handle_key(key) {
288                KeyReaction::Submit => {
289                    self.open_selected(tx);
290                    EventState::Consumed
291                }
292                KeyReaction::Cancel => {
293                    tx.send(AppEvent::CloseOverlay).ok();
294                    EventState::Consumed
295                }
296                KeyReaction::Consumed => {
297                    // Forward the query event to the breadcrumb: a `?name`
298                    // expansion pins it, an emptied field clears it, a manual
299                    // edit keeps it (sticky).
300                    let accepted = self.list.take_accepted_saved_search();
301                    let blank = self.list.query().trim().is_empty();
302                    self.saved_search
303                        .on_query_consumed(accepted, self.list.query(), blank);
304                    self.refresh_preview_from_list();
305                    EventState::Consumed
306                }
307                KeyReaction::Intercepted(_) | KeyReaction::Unhandled => EventState::NotConsumed,
308            },
309            _ => EventState::NotConsumed,
310        }
311    }
312
313    fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
314        self.poll_preview();
315
316        let popup_rect = crate::components::centered_rect(80, 75, area);
317
318        // Clear the area behind the modal so the editor doesn't bleed through.
319        f.render_widget(Clear, popup_rect);
320
321        let outer_block = Block::default()
322            .title(format!(" {} ", self.title))
323            .borders(Borders::ALL)
324            .border_style(theme.border_style(true))
325            .style(theme.panel_style());
326        let inner = outer_block.inner(popup_rect);
327        f.render_widget(outer_block, popup_rect);
328
329        let rows = Layout::default()
330            .direction(Direction::Vertical)
331            .constraints([
332                Constraint::Length(3),
333                Constraint::Min(0),
334                Constraint::Length(1),
335            ])
336            .split(inner);
337
338        // ── Search box ────────────────────────────────────────────────────
339        // A saved-search breadcrumb (`‹ name ›` / `‹ name • edited ›`) titles
340        // the search box when a `?name` expansion is active.
341        let search_title = self
342            .saved_search
343            .border_title(self.list.query(), " Search ");
344        let search_block = Block::default()
345            .title(search_title)
346            .borders(Borders::ALL)
347            .border_style(theme.border_style(true))
348            .style(theme.panel_style());
349        let search_inner = search_block.inner(rows[0]);
350        f.render_widget(search_block, rows[0]);
351        self.list.render_query(f, search_inner, theme, true);
352
353        // ── List + Preview ────────────────────────────────────────────────
354        let columns = Layout::default()
355            .direction(Direction::Horizontal)
356            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
357            .split(rows[1]);
358
359        // The engine hit-tests a click as `row - rect.y` against the recorded
360        // rect, where row 0 is the first item. The list renders into the block's
361        // INNER area, so record that same inner rect.
362        let list_block = Block::default()
363            .borders(Borders::ALL)
364            .border_style(theme.border_style(false))
365            .style(theme.panel_style());
366        let list_inner = list_block.inner(columns[0]);
367        f.render_widget(list_block, columns[0]);
368        self.list.render(f, list_inner, theme, false);
369        self.list.set_list_rect(list_inner);
370        // The whole popup is wheel-scrollable (search box and preview included).
371        self.list.set_panel_rect(popup_rect);
372
373        // Authoritative preview trigger: `list.render` just polled, which is
374        // where an async server-side reload lands and may auto-select a new
375        // row 0. If the selected note path differs from what the preview is
376        // showing, refresh. Guarded by the path diff so there's no redraw loop.
377        if self.selected_note_path() != self.preview_path {
378            self.refresh_preview_from_list();
379        }
380
381        let preview_block = Block::default()
382            .title(" Preview ")
383            .borders(Borders::ALL)
384            .border_style(theme.border_style(false))
385            .style(theme.panel_style());
386        let preview_inner = preview_block.inner(columns[1]);
387        f.render_widget(preview_block, columns[1]);
388        f.render_widget(
389            Paragraph::new(self.preview_text.as_str()).style(
390                Style::default()
391                    .fg(theme.fg.to_ratatui())
392                    .bg(theme.bg.to_ratatui()),
393            ),
394            preview_inner,
395        );
396
397        // ── Hint bar ──────────────────────────────────────────────────────
398        f.render_widget(
399            Paragraph::new("↑↓: navigate  |  Enter: open  |  Esc: close")
400                .style(Style::default().fg(theme.fg_secondary.to_ratatui())),
401            rows[2],
402        );
403
404        // ── Autocomplete popup ───────────────────────────────────────────
405        // Clamp to the modal's bounds so it never spills past the border.
406        self.list.render_autocomplete(f, popup_rect, theme);
407    }
408
409    fn hint_shortcuts(&self) -> Vec<(String, String)> {
410        let mut hints = vec![
411            ("↑↓".to_string(), "navigate".to_string()),
412            ("Enter".to_string(), "open".to_string()),
413            ("Esc".to_string(), "close".to_string()),
414        ];
415        if let Some(k) = self
416            .key_bindings
417            .first_combo_for(&ActionShortcuts::SaveCurrentQuery)
418        {
419            hints.push((k, "save query".to_string()));
420        }
421        hints
422    }
423}
424
425// ---------------------------------------------------------------------------
426// Shared helpers
427// ---------------------------------------------------------------------------
428
429pub(super) fn format_journal_date(date: NaiveDate) -> String {
430    date.format("%A, %B %-d, %Y").to_string()
431}
432
433// ---------------------------------------------------------------------------
434// Tests
435// ---------------------------------------------------------------------------
436
437#[cfg(test)]
438mod tests {
439    use super::*;
440    use crate::components::search_list::{Emit, RowSource};
441    use crate::settings::AppSettings;
442    use crate::test_support::temp_vault;
443    use async_trait::async_trait;
444    use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
445    use tokio::sync::mpsc::unbounded_channel;
446
447    /// A one-shot source that yields a single existing note so submit has
448    /// something to open.
449    struct OneNoteSource {
450        path: VaultPath,
451    }
452
453    #[async_trait]
454    impl RowSource<FileListEntry> for OneNoteSource {
455        async fn load(&self, _query: &str, emit: Emit<FileListEntry>) {
456            emit.replace(vec![FileListEntry::Note {
457                path: self.path.clone(),
458                title: "Note".to_string(),
459                filename: self.path.to_string(),
460                journal_date: None,
461            }]);
462        }
463    }
464
465    async fn make_modal_with(source: impl RowSource<FileListEntry>, tx: AppTx) -> NoteBrowserModal {
466        let vault = temp_vault("modal").await;
467        let settings = AppSettings::default();
468        NoteBrowserModal::new(
469            "test",
470            source,
471            vault,
472            settings.key_bindings.clone(),
473            settings.icons(),
474            tx,
475        )
476    }
477
478    #[tokio::test]
479    async fn modal_constructed_with_initial_query_prefills_input() {
480        let vault = temp_vault("modal_iq").await;
481        let settings = AppSettings::default();
482        let (tx, _rx) = unbounded_channel();
483        let modal = NoteBrowserModal::with_initial_query(
484            "test",
485            OneNoteSource {
486                path: VaultPath::note_path_from("/a.md"),
487            },
488            vault,
489            settings.key_bindings.clone(),
490            settings.icons(),
491            tx,
492            "#important",
493        );
494        assert_eq!(modal.query_text(), "#important");
495    }
496
497    /// Pressing Enter on a selected note emits OpenPath only. The editor's
498    /// OpenPath handler closes the overlay, so the modal does NOT also emit
499    /// CloseOverlay (that would be redundant).
500    #[tokio::test]
501    async fn submit_opens_selected_note() {
502        let (tx, mut rx) = unbounded_channel();
503        let path = VaultPath::note_path_from("/a.md");
504        let mut modal = make_modal_with(OneNoteSource { path: path.clone() }, tx.clone()).await;
505        // Let the one-shot load deliver its row and the engine select it.
506        modal.list.poll_until_idle().await;
507
508        Overlay::handle_input(
509            &mut modal,
510            &InputEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
511            &tx,
512        );
513
514        let mut events = Vec::new();
515        while let Ok(ev) = rx.try_recv() {
516            events.push(ev);
517        }
518        assert!(
519            events
520                .iter()
521                .any(|e| matches!(e, AppEvent::OpenPath(p) if *p == path)),
522            "expected OpenPath, got {events:?}"
523        );
524        assert!(
525            !events.iter().any(|e| matches!(e, AppEvent::CloseOverlay)),
526            "select must not emit CloseOverlay; editor's OpenPath handler closes the overlay, got {events:?}"
527        );
528    }
529
530    /// Selecting a note row updates the tracked `preview_path`; this is the
531    /// state the render-time diff compares against to detect stale previews
532    /// after an async reload.
533    #[tokio::test]
534    async fn refresh_preview_tracks_selected_path() {
535        let (tx, _rx) = unbounded_channel();
536        let path = VaultPath::note_path_from("/a.md");
537        let mut modal = make_modal_with(OneNoteSource { path: path.clone() }, tx.clone()).await;
538        modal.list.poll_until_idle().await;
539        assert_eq!(modal.preview_path, None, "no path tracked before refresh");
540
541        modal.refresh_preview_from_list();
542        assert_eq!(
543            modal.preview_path,
544            Some(path),
545            "preview_path should track the selected note"
546        );
547    }
548
549    /// Pressing Esc closes the modal.
550    #[tokio::test]
551    async fn esc_closes_modal() {
552        let (tx, mut rx) = unbounded_channel();
553        let mut modal = make_modal_with(
554            OneNoteSource {
555                path: VaultPath::note_path_from("/a.md"),
556            },
557            tx.clone(),
558        )
559        .await;
560        Overlay::handle_input(
561            &mut modal,
562            &InputEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
563            &tx,
564        );
565        let mut sent = false;
566        while let Ok(ev) = rx.try_recv() {
567            if matches!(ev, AppEvent::CloseOverlay) {
568                sent = true;
569            }
570        }
571        assert!(sent, "expected CloseOverlay on Esc");
572    }
573
574    /// Accepting a `?name` expansion in the Ctrl+K browser pins the saved-search
575    /// breadcrumb and runs the stored query.
576    #[tokio::test(flavor = "multi_thread")]
577    async fn accepting_saved_search_pins_breadcrumb() {
578        let vault = temp_vault("modal-ss").await;
579        vault.validate_and_init().await.unwrap();
580        vault.save_search("todo-week", "#todo").await.unwrap();
581        let settings = AppSettings::default();
582        let (tx, _rx) = unbounded_channel();
583        let mut modal = NoteBrowserModal::new(
584            "test",
585            OneNoteSource {
586                path: VaultPath::note_path_from("/a.md"),
587            },
588            vault,
589            settings.key_bindings.clone(),
590            settings.icons(),
591            tx.clone(),
592        );
593
594        // Type a leading `?` and a prefix, draining the async popup between
595        // keystrokes so the suggestion lands before we accept.
596        for ch in ['?', 't', 'o'] {
597            Overlay::handle_input(
598                &mut modal,
599                &InputEvent::Key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)),
600                &tx,
601            );
602            for _ in 0..30 {
603                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
604                modal.list.poll();
605            }
606        }
607        Overlay::handle_input(
608            &mut modal,
609            &InputEvent::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
610            &tx,
611        );
612
613        assert_eq!(modal.query_text(), "#todo");
614        assert_eq!(
615            modal.saved_search_breadcrumb().as_deref(),
616            Some("todo-week")
617        );
618        // The overlay exposes the provenance so the save-search dialog can
619        // pre-fill its name field.
620        assert_eq!(Overlay::saved_search_provenance(&modal), Some("todo-week"));
621    }
622}