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
30pub struct NoteBrowserModal {
39 title: String,
40 list: SearchList<FileListEntry>,
41 vault: Arc<NoteVault>,
42 tx: AppTx,
43 preview_text: String,
44 preview_task: Option<tokio::task::JoinHandle<()>>,
46 preview_rx: Option<Receiver<String>>,
47 preview_path: Option<VaultPath>,
51 key_bindings: KeyBindings,
53 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 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 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 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 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 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 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 #[cfg(test)]
239 fn saved_search_breadcrumb(&self) -> Option<String> {
240 self.saved_search.label(self.list.query())
241 }
242
243 #[cfg(test)]
247 pub(super) fn query_text(&self) -> &str {
248 self.list.query()
249 }
250}
251
252impl 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::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 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 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 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 let columns = Layout::default()
355 .direction(Direction::Horizontal)
356 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
357 .split(rows[1]);
358
359 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 self.list.set_panel_rect(popup_rect);
372
373 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 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 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
425pub(super) fn format_journal_date(date: NaiveDate) -> String {
430 date.format("%A, %B %-d, %Y").to_string()
431}
432
433#[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 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 #[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 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 #[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 #[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 #[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 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 assert_eq!(Overlay::saved_search_provenance(&modal), Some("todo-week"));
621 }
622}