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::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 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 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 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 let columns = Layout::default()
350 .direction(Direction::Horizontal)
351 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
352 .split(rows[1]);
353
354 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 self.list.set_panel_rect(popup_rect);
367
368 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 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 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
420pub(super) fn format_journal_date(date: NaiveDate) -> String {
425 date.format("%A, %B %-d, %Y").to_string()
426}
427
428#[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 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 #[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 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 #[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 #[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 #[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 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 assert_eq!(Overlay::saved_search_provenance(&modal), Some("todo-week"));
616 }
617}