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`] and `adr/0006`.
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. See `adr/0006`.
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 handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
266        match event {
267            InputEvent::Mouse(mouse) => match self.list.handle_mouse(mouse) {
268                SearchMouse::Activated(_) => {
269                    self.open_selected(tx);
270                    EventState::Consumed
271                }
272                SearchMouse::Selected(_) | SearchMouse::Scrolled => {
273                    self.refresh_preview_from_list();
274                    EventState::Consumed
275                }
276                SearchMouse::None => EventState::NotConsumed,
277            },
278            InputEvent::Key(key) => match self.list.handle_key(key) {
279                KeyReaction::Submit => {
280                    self.open_selected(tx);
281                    EventState::Consumed
282                }
283                KeyReaction::Cancel => {
284                    tx.send(AppEvent::CloseOverlay).ok();
285                    EventState::Consumed
286                }
287                KeyReaction::Consumed => {
288                    // Forward the query event to the breadcrumb: a `?name`
289                    // expansion pins it, an emptied field clears it, a manual
290                    // edit keeps it (sticky). See `adr/0006`.
291                    let accepted = self.list.take_accepted_saved_search();
292                    let blank = self.list.query().trim().is_empty();
293                    self.saved_search
294                        .on_query_consumed(accepted, self.list.query(), blank);
295                    self.refresh_preview_from_list();
296                    EventState::Consumed
297                }
298                KeyReaction::Intercepted(_) | KeyReaction::Unhandled => EventState::NotConsumed,
299            },
300            _ => EventState::NotConsumed,
301        }
302    }
303
304    fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
305        self.poll_preview();
306
307        let popup_rect = crate::components::centered_rect(80, 75, area);
308
309        // Clear the area behind the modal so the editor doesn't bleed through.
310        f.render_widget(Clear, popup_rect);
311
312        let outer_block = Block::default()
313            .title(format!(" {} ", self.title))
314            .borders(Borders::ALL)
315            .border_style(theme.border_style(true))
316            .style(theme.panel_style());
317        let inner = outer_block.inner(popup_rect);
318        f.render_widget(outer_block, popup_rect);
319
320        let rows = Layout::default()
321            .direction(Direction::Vertical)
322            .constraints([
323                Constraint::Length(3),
324                Constraint::Min(0),
325                Constraint::Length(1),
326            ])
327            .split(inner);
328
329        // ── Search box ────────────────────────────────────────────────────
330        // A saved-search breadcrumb (`‹ name ›` / `‹ name • edited ›`) titles
331        // the search box when a `?name` expansion is active. See `adr/0006`.
332        let search_title = self
333            .saved_search
334            .border_title(self.list.query(), " Search ");
335        let search_block = Block::default()
336            .title(search_title)
337            .borders(Borders::ALL)
338            .border_style(theme.border_style(true))
339            .style(theme.panel_style());
340        let search_inner = search_block.inner(rows[0]);
341        f.render_widget(search_block, rows[0]);
342        self.list.render_query(f, search_inner, theme, true);
343
344        // ── List + Preview ────────────────────────────────────────────────
345        let columns = Layout::default()
346            .direction(Direction::Horizontal)
347            .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
348            .split(rows[1]);
349
350        // The engine hit-tests a click as `row - rect.y` against the recorded
351        // rect, where row 0 is the first item. The list renders into the block's
352        // INNER area, so record that same inner rect.
353        let list_block = Block::default()
354            .borders(Borders::ALL)
355            .border_style(theme.border_style(false))
356            .style(theme.panel_style());
357        let list_inner = list_block.inner(columns[0]);
358        f.render_widget(list_block, columns[0]);
359        self.list.render(f, list_inner, theme, false);
360        self.list.set_list_rect(list_inner);
361
362        // Authoritative preview trigger: `list.render` just polled, which is
363        // where an async server-side reload lands and may auto-select a new
364        // row 0. If the selected note path differs from what the preview is
365        // showing, refresh. Guarded by the path diff so there's no redraw loop.
366        if self.selected_note_path() != self.preview_path {
367            self.refresh_preview_from_list();
368        }
369
370        let preview_block = Block::default()
371            .title(" Preview ")
372            .borders(Borders::ALL)
373            .border_style(theme.border_style(false))
374            .style(theme.panel_style());
375        let preview_inner = preview_block.inner(columns[1]);
376        f.render_widget(preview_block, columns[1]);
377        f.render_widget(
378            Paragraph::new(self.preview_text.as_str()).style(
379                Style::default()
380                    .fg(theme.fg.to_ratatui())
381                    .bg(theme.bg.to_ratatui()),
382            ),
383            preview_inner,
384        );
385
386        // ── Hint bar ──────────────────────────────────────────────────────
387        f.render_widget(
388            Paragraph::new("↑↓: navigate  |  Enter: open  |  Esc: close")
389                .style(Style::default().fg(theme.fg_secondary.to_ratatui())),
390            rows[2],
391        );
392
393        // ── Autocomplete popup ───────────────────────────────────────────
394        // Clamp to the modal's bounds so it never spills past the border.
395        self.list.render_autocomplete(f, popup_rect, theme);
396    }
397
398    fn hint_shortcuts(&self) -> Vec<(String, String)> {
399        let mut hints = vec![
400            ("↑↓".to_string(), "navigate".to_string()),
401            ("Enter".to_string(), "open".to_string()),
402            ("Esc".to_string(), "close".to_string()),
403        ];
404        if let Some(k) = self
405            .key_bindings
406            .first_combo_for(&ActionShortcuts::SaveCurrentQuery)
407        {
408            hints.push((k, "save query".to_string()));
409        }
410        hints
411    }
412}
413
414// ---------------------------------------------------------------------------
415// Shared helpers
416// ---------------------------------------------------------------------------
417
418pub(super) fn format_journal_date(date: NaiveDate) -> String {
419    date.format("%A, %B %-d, %Y").to_string()
420}
421
422// ---------------------------------------------------------------------------
423// Tests
424// ---------------------------------------------------------------------------
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429    use crate::components::search_list::{Emit, RowSource};
430    use crate::settings::AppSettings;
431    use crate::test_support::temp_vault;
432    use async_trait::async_trait;
433    use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
434    use tokio::sync::mpsc::unbounded_channel;
435
436    /// A one-shot source that yields a single existing note so submit has
437    /// something to open.
438    struct OneNoteSource {
439        path: VaultPath,
440    }
441
442    #[async_trait]
443    impl RowSource<FileListEntry> for OneNoteSource {
444        async fn load(&self, _query: &str, emit: Emit<FileListEntry>) {
445            emit.replace(vec![FileListEntry::Note {
446                path: self.path.clone(),
447                title: "Note".to_string(),
448                filename: self.path.to_string(),
449                journal_date: None,
450            }]);
451        }
452    }
453
454    async fn make_modal_with(source: impl RowSource<FileListEntry>, tx: AppTx) -> NoteBrowserModal {
455        let vault = temp_vault("modal").await;
456        let settings = AppSettings::default();
457        NoteBrowserModal::new(
458            "test",
459            source,
460            vault,
461            settings.key_bindings.clone(),
462            settings.icons(),
463            tx,
464        )
465    }
466
467    #[tokio::test]
468    async fn modal_constructed_with_initial_query_prefills_input() {
469        let vault = temp_vault("modal_iq").await;
470        let settings = AppSettings::default();
471        let (tx, _rx) = unbounded_channel();
472        let modal = NoteBrowserModal::with_initial_query(
473            "test",
474            OneNoteSource {
475                path: VaultPath::note_path_from("/a.md"),
476            },
477            vault,
478            settings.key_bindings.clone(),
479            settings.icons(),
480            tx,
481            "#important",
482        );
483        assert_eq!(modal.query_text(), "#important");
484    }
485
486    /// Pressing Enter on a selected note emits OpenPath only. The editor's
487    /// OpenPath handler closes the overlay, so the modal does NOT also emit
488    /// CloseOverlay (that would be redundant).
489    #[tokio::test]
490    async fn submit_opens_selected_note() {
491        let (tx, mut rx) = unbounded_channel();
492        let path = VaultPath::note_path_from("/a.md");
493        let mut modal = make_modal_with(OneNoteSource { path: path.clone() }, tx.clone()).await;
494        // Let the one-shot load deliver its row and the engine select it.
495        modal.list.poll_until_idle().await;
496
497        Overlay::handle_input(
498            &mut modal,
499            &InputEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
500            &tx,
501        );
502
503        let mut events = Vec::new();
504        while let Ok(ev) = rx.try_recv() {
505            events.push(ev);
506        }
507        assert!(
508            events
509                .iter()
510                .any(|e| matches!(e, AppEvent::OpenPath(p) if *p == path)),
511            "expected OpenPath, got {events:?}"
512        );
513        assert!(
514            !events.iter().any(|e| matches!(e, AppEvent::CloseOverlay)),
515            "select must not emit CloseOverlay; editor's OpenPath handler closes the overlay, got {events:?}"
516        );
517    }
518
519    /// Selecting a note row updates the tracked `preview_path`; this is the
520    /// state the render-time diff compares against to detect stale previews
521    /// after an async reload.
522    #[tokio::test]
523    async fn refresh_preview_tracks_selected_path() {
524        let (tx, _rx) = unbounded_channel();
525        let path = VaultPath::note_path_from("/a.md");
526        let mut modal = make_modal_with(OneNoteSource { path: path.clone() }, tx.clone()).await;
527        modal.list.poll_until_idle().await;
528        assert_eq!(modal.preview_path, None, "no path tracked before refresh");
529
530        modal.refresh_preview_from_list();
531        assert_eq!(
532            modal.preview_path,
533            Some(path),
534            "preview_path should track the selected note"
535        );
536    }
537
538    /// Pressing Esc closes the modal.
539    #[tokio::test]
540    async fn esc_closes_modal() {
541        let (tx, mut rx) = unbounded_channel();
542        let mut modal = make_modal_with(
543            OneNoteSource {
544                path: VaultPath::note_path_from("/a.md"),
545            },
546            tx.clone(),
547        )
548        .await;
549        Overlay::handle_input(
550            &mut modal,
551            &InputEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
552            &tx,
553        );
554        let mut sent = false;
555        while let Ok(ev) = rx.try_recv() {
556            if matches!(ev, AppEvent::CloseOverlay) {
557                sent = true;
558            }
559        }
560        assert!(sent, "expected CloseOverlay on Esc");
561    }
562
563    /// Accepting a `?name` expansion in the Ctrl+K browser pins the saved-search
564    /// breadcrumb and runs the stored query. See `adr/0006`.
565    #[tokio::test(flavor = "multi_thread")]
566    async fn accepting_saved_search_pins_breadcrumb() {
567        let vault = temp_vault("modal-ss").await;
568        vault.validate_and_init().await.unwrap();
569        vault.save_search("todo-week", "#todo").await.unwrap();
570        let settings = AppSettings::default();
571        let (tx, _rx) = unbounded_channel();
572        let mut modal = NoteBrowserModal::new(
573            "test",
574            OneNoteSource {
575                path: VaultPath::note_path_from("/a.md"),
576            },
577            vault,
578            settings.key_bindings.clone(),
579            settings.icons(),
580            tx.clone(),
581        );
582
583        // Type a leading `?` and a prefix, draining the async popup between
584        // keystrokes so the suggestion lands before we accept.
585        for ch in ['?', 't', 'o'] {
586            Overlay::handle_input(
587                &mut modal,
588                &InputEvent::Key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)),
589                &tx,
590            );
591            for _ in 0..30 {
592                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
593                modal.list.poll();
594            }
595        }
596        Overlay::handle_input(
597            &mut modal,
598            &InputEvent::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
599            &tx,
600        );
601
602        assert_eq!(modal.query_text(), "#todo");
603        assert_eq!(
604            modal.saved_search_breadcrumb().as_deref(),
605            Some("todo-week")
606        );
607    }
608}