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 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 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 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 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 let columns = Layout::default()
346 .direction(Direction::Horizontal)
347 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
348 .split(rows[1]);
349
350 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 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 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 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
414pub(super) fn format_journal_date(date: NaiveDate) -> String {
419 date.format("%A, %B %-d, %Y").to_string()
420}
421
422#[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 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 #[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 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 #[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 #[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 #[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 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}