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