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, Paragraph};
11
12use crate::components::autocomplete::AutocompleteMode;
13use crate::components::event_state::EventState;
14use crate::components::events::{AppEvent, AppTx, AppTxExt, InputEvent, redraw_callback};
15use crate::components::file_list::FileListEntry;
16use crate::components::overlay::{Overlay, OverlayKind, OverlayMsg};
17use crate::components::panel::{ModalBg, ModalSpec, modal_chrome};
18use crate::components::preview_highlight;
19use crate::components::saved_search_breadcrumb::SavedSearchBreadcrumb;
20use crate::components::search_list::{
21 KeyReaction, RowSource, SearchList, SearchMouse, VaultSuggestions,
22};
23use crate::keys::KeyBindings;
24use crate::keys::action_shortcuts::ActionShortcuts;
25use crate::settings::icons::Icons;
26use crate::settings::themes::Theme;
27
28pub mod file_finder_provider;
29pub mod link_results_provider;
30pub mod search_provider;
31
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum BrowserScope {
44 Query,
47 Files,
49}
50
51pub struct NoteBrowserModal {
52 scope: BrowserScope,
53 prefix_glyph: &'static str,
55 title: String,
56 list: SearchList<FileListEntry>,
57 vault: Arc<NoteVault>,
58 tx: AppTx,
59 preview_text: String,
60 preview_task: Option<tokio::task::JoinHandle<()>>,
62 preview_rx: Option<Receiver<String>>,
63 preview_path: Option<VaultPath>,
67 key_bindings: KeyBindings,
69 saved_search: SavedSearchBreadcrumb,
73 error: Option<String>,
76}
77
78impl NoteBrowserModal {
79 pub fn new(
80 title: impl Into<String>,
81 scope: BrowserScope,
82 provider: impl RowSource<FileListEntry>,
83 vault: Arc<NoteVault>,
84 key_bindings: KeyBindings,
85 icons: Icons,
86 tx: AppTx,
87 ) -> Self {
88 Self::new_with_query(
89 title,
90 scope,
91 provider,
92 vault,
93 key_bindings,
94 icons,
95 tx,
96 String::new(),
97 )
98 }
99
100 #[allow(clippy::too_many_arguments)]
106 pub fn with_initial_query<S: Into<String>>(
107 title: impl Into<String>,
108 scope: BrowserScope,
109 provider: impl RowSource<FileListEntry>,
110 vault: Arc<NoteVault>,
111 key_bindings: KeyBindings,
112 icons: Icons,
113 tx: AppTx,
114 query: S,
115 ) -> Self {
116 Self::new_with_query(
117 title,
118 scope,
119 provider,
120 vault,
121 key_bindings,
122 icons,
123 tx,
124 query.into(),
125 )
126 }
127
128 #[allow(clippy::too_many_arguments)]
129 fn new_with_query(
130 title: impl Into<String>,
131 scope: BrowserScope,
132 provider: impl RowSource<FileListEntry>,
133 vault: Arc<NoteVault>,
134 key_bindings: KeyBindings,
135 icons: Icons,
136 tx: AppTx,
137 initial_query: String,
138 ) -> Self {
139 let prefix_glyph = match scope {
140 BrowserScope::Query => icons.rail_find,
141 BrowserScope::Files => icons.rail_files,
142 };
143 let mut builder = SearchList::builder(provider, redraw_callback(tx.clone()))
144 .initial_query(initial_query)
145 .icons(icons)
146 .autocomplete(
147 Arc::new(VaultSuggestions {
148 vault: vault.clone(),
149 }),
150 AutocompleteMode::SearchQuery,
151 );
152 if scope == BrowserScope::Query {
153 builder = builder.highlight_query();
154 }
155 let list = builder.build();
156 let mut modal = Self {
157 scope,
158 prefix_glyph,
159 title: title.into(),
160 list,
161 vault,
162 tx,
163 preview_text: String::new(),
164 preview_task: None,
165 preview_rx: None,
166 preview_path: None,
167 key_bindings,
168 saved_search: SavedSearchBreadcrumb::default(),
169 error: None,
170 };
171 modal.refresh_preview(None);
172 modal
173 }
174
175 fn preview_needles(&self) -> Vec<String> {
179 if self.scope != BrowserScope::Query {
180 return Vec::new();
181 }
182 crate::components::query_highlight::emphasis_needles(self.list.query())
183 }
184
185 fn emphasis(&self) -> Option<Vec<String>> {
188 let needles = self.preview_needles();
189 (!needles.is_empty()).then_some(needles)
190 }
191
192 fn schedule_preview(&mut self, path: VaultPath) {
195 if let Some(handle) = self.preview_task.take() {
196 handle.abort();
197 }
198 let vault = Arc::clone(&self.vault);
199 let tx = self.tx.clone();
200 let (result_tx, result_rx) = std::sync::mpsc::channel();
201 self.preview_rx = Some(result_rx);
202
203 let handle = tokio::spawn(async move {
204 let text = vault.get_note_text(&path).await.unwrap_or_default();
205 result_tx.send(text).ok();
206 tx.send(AppEvent::Redraw).ok();
207 });
208 self.preview_task = Some(handle);
209 }
210
211 fn poll_preview(&mut self) {
212 let Some(rx) = &self.preview_rx else { return };
213 match rx.try_recv() {
214 Ok(text) => {
215 self.preview_text = text;
216 self.preview_rx = None;
217 self.preview_task = None;
218 }
219 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
220 self.preview_rx = None;
221 }
222 Err(std::sync::mpsc::TryRecvError::Empty) => {}
223 }
224 }
225
226 fn refresh_preview(&mut self, selected: Option<&FileListEntry>) {
229 let maybe_path = selected.and_then(|e| match e {
230 FileListEntry::Note { path, .. } => Some(path.clone()),
231 _ => None,
232 });
233 if let Some(path) = maybe_path {
234 self.schedule_preview(path);
235 } else {
236 self.preview_text.clear();
237 if let Some(h) = self.preview_task.take() {
238 h.abort();
239 }
240 }
241 }
242
243 fn selected_note_path(&self) -> Option<VaultPath> {
246 self.list.selected_row().and_then(|e| match e {
247 FileListEntry::Note { path, .. } => Some(path.clone()),
248 _ => None,
249 })
250 }
251
252 fn refresh_preview_from_list(&mut self) {
254 let path = self.selected_note_path();
255 self.preview_path = path.clone();
256 match path {
257 Some(path) => self.schedule_preview(path),
258 None => {
259 self.preview_text.clear();
260 if let Some(h) = self.preview_task.take() {
261 h.abort();
262 }
263 }
264 }
265 }
266
267 fn open_selected(&self, tx: &AppTx) {
272 let Some(entry) = self.list.selected_row() else {
273 return;
274 };
275 if let FileListEntry::CreateNote { path, .. } = entry {
276 let path = path.clone();
277 let vault = Arc::clone(&self.vault);
278 let tx = tx.clone();
279 tokio::spawn(async move {
280 match vault.load_or_create_note(&path, None).await {
281 Ok((_, created)) => tx.announce_and_open(path, created),
282 Err(e) => {
283 tx.send(AppEvent::DialogError(e.to_string())).ok();
284 }
285 }
286 });
287 return;
288 }
289 let path = entry.path().clone();
290 tx.send(AppEvent::OpenPath {
291 path,
292 emphasis: self.emphasis(),
293 })
294 .ok();
295 }
296
297 #[cfg(test)]
300 fn saved_search_breadcrumb(&self) -> Option<String> {
301 self.saved_search.label(self.list.query())
302 }
303
304 #[cfg(test)]
308 pub(super) fn query_text(&self) -> &str {
309 self.list.query()
310 }
311}
312
313impl Overlay for NoteBrowserModal {
318 fn kind(&self) -> OverlayKind {
319 OverlayKind::NoteBrowser
320 }
321
322 fn query(&self) -> Option<&str> {
323 Some(self.list.query())
324 }
325
326 fn saved_search_provenance(&self) -> Option<&str> {
327 self.saved_search.name()
328 }
329
330 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
331 match event {
332 InputEvent::Mouse(mouse) => match self.list.handle_mouse(mouse) {
333 SearchMouse::Activated(_) => {
334 self.open_selected(tx);
335 EventState::Consumed
336 }
337 SearchMouse::Context(_) | SearchMouse::Selected(_) | SearchMouse::Scrolled => {
338 self.refresh_preview_from_list();
339 EventState::Consumed
340 }
341 SearchMouse::ContentScrollUp | SearchMouse::ContentScrollDown => {
344 EventState::Consumed
345 }
346 SearchMouse::None => EventState::NotConsumed,
347 },
348 InputEvent::Key(key) => {
349 self.error = None;
351 match self.list.handle_key(key) {
352 KeyReaction::Submit => {
353 self.open_selected(tx);
354 EventState::Consumed
355 }
356 KeyReaction::Cancel => {
357 tx.send(AppEvent::CloseOverlay).ok();
358 EventState::Consumed
359 }
360 KeyReaction::Consumed => {
361 let accepted = self.list.take_accepted_saved_search();
365 let blank = self.list.query().trim().is_empty();
366 self.saved_search
367 .on_query_consumed(accepted, self.list.query(), blank);
368 self.refresh_preview_from_list();
369 EventState::Consumed
370 }
371 KeyReaction::Intercepted(_) | KeyReaction::Unhandled => EventState::NotConsumed,
372 }
373 }
374 _ => EventState::NotConsumed,
375 }
376 }
377
378 fn handle_app_message(
379 &mut self,
380 msg: &AppEvent,
381 _vault: &Arc<NoteVault>,
382 _tx: &AppTx,
383 ) -> OverlayMsg {
384 if let AppEvent::DialogError(text) = msg {
387 self.error = Some(text.clone());
388 OverlayMsg::Consumed
389 } else {
390 OverlayMsg::NotConsumed
391 }
392 }
393
394 fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
395 self.poll_preview();
396
397 let popup_rect = crate::components::centered_rect(75, 75, area);
398
399 let modal_style = Style::default()
401 .fg(theme.fg.to_ratatui())
402 .bg(theme.bg_hard.to_ratatui());
403 let title = format!(" {} ", self.title);
404 let inner = modal_chrome(
405 f,
406 popup_rect,
407 theme,
408 ModalSpec {
409 title: Some(&title),
410 bg: ModalBg::Hard,
411 ..Default::default()
412 },
413 );
414
415 let rows = Layout::default()
416 .direction(Direction::Vertical)
417 .constraints([
418 Constraint::Length(3),
419 Constraint::Min(0),
420 Constraint::Length(1),
421 ])
422 .split(inner);
423
424 let search_title = self
428 .saved_search
429 .border_title(self.list.query(), " Search ");
430 let result_count = self.list.match_count();
431 let search_block = Block::default()
432 .title(search_title)
433 .title(
434 ratatui::text::Line::from(ratatui::text::Span::styled(
435 format!(" {result_count} results "),
436 Style::default().fg(theme.gray.to_ratatui()),
437 ))
438 .right_aligned(),
439 )
440 .borders(Borders::ALL)
441 .border_style(theme.border_style(true))
442 .style(modal_style);
443 let search_inner = search_block.inner(rows[0]);
444 f.render_widget(search_block, rows[0]);
445 let prefix = format!("{} ", self.prefix_glyph);
447 let prefix_w = unicode_width::UnicodeWidthStr::width(prefix.as_str()) as u16;
448 f.render_widget(
449 Paragraph::new(prefix).style(
450 Style::default()
451 .fg(theme.yellow.to_ratatui())
452 .bg(theme.bg_hard.to_ratatui()),
453 ),
454 Rect {
455 width: prefix_w.min(search_inner.width),
456 ..search_inner
457 },
458 );
459 let input_rect = Rect {
460 x: search_inner.x.saturating_add(prefix_w),
461 width: search_inner.width.saturating_sub(prefix_w),
462 ..search_inner
463 };
464 self.list.render_query(f, input_rect, theme, true);
465
466 let columns = Layout::default()
468 .direction(Direction::Horizontal)
469 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
470 .split(rows[1]);
471
472 let list_block = Block::default()
476 .borders(Borders::ALL)
477 .border_style(theme.border_style(false))
478 .style(modal_style);
479 let list_inner = list_block.inner(columns[0]);
480 f.render_widget(list_block, columns[0]);
481 self.list.render(f, list_inner, theme, false);
482 self.list.set_list_rect(list_inner);
483 self.list.set_panel_rect(popup_rect);
485
486 if self.selected_note_path() != self.preview_path {
491 self.refresh_preview_from_list();
492 }
493
494 let needles = self.preview_needles();
497 let match_count = count_matches(&self.preview_text, &needles);
498 let preview_title = match (&self.preview_path, match_count) {
499 (Some(path), Some(n)) => {
500 format!(" {} · {} matches ", path.get_name(), n)
501 }
502 (Some(path), None) => format!(" {} ", path.get_name()),
503 (None, _) => " Preview ".to_string(),
504 };
505 let preview_block = Block::default()
506 .title(preview_title)
507 .borders(Borders::ALL)
508 .border_style(theme.border_style(false))
509 .style(modal_style);
510 let preview_inner = preview_block.inner(columns[1]);
511 f.render_widget(preview_block, columns[1]);
512 f.render_widget(
513 Paragraph::new(highlight_matches(
514 &self.preview_text,
515 &needles,
516 theme,
517 modal_style,
518 )),
519 preview_inner,
520 );
521
522 let hint = match &self.error {
524 Some(err) => Paragraph::new(format!("⚠ {err}"))
525 .style(Style::default().fg(theme.red.to_ratatui())),
526 None => Paragraph::new("↑↓: navigate | Enter: open | Esc: close")
527 .style(Style::default().fg(theme.fg_secondary.to_ratatui())),
528 };
529 f.render_widget(hint, rows[2]);
530
531 self.list.render_autocomplete(f, popup_rect, theme);
534 }
535
536 fn hint_shortcuts(&self) -> Vec<(String, String)> {
537 let mut hints = vec![
538 ("↑↓".to_string(), "navigate".to_string()),
539 ("Enter".to_string(), "open".to_string()),
540 ("Esc".to_string(), "close".to_string()),
541 ];
542 if let Some(k) = self
543 .key_bindings
544 .first_combo_for(&ActionShortcuts::SaveCurrentQuery)
545 {
546 hints.push((k, "save query".to_string()));
547 }
548 hints
549 }
550}
551
552pub(super) fn format_journal_date(date: NaiveDate) -> String {
557 date.format("%A, %B %-d, %Y").to_string()
558}
559
560fn count_matches(text: &str, needles: &[String]) -> Option<usize> {
570 if needles.is_empty() {
571 return None;
572 }
573 Some(preview_highlight::match_ranges(text, needles).len())
574}
575
576fn highlight_matches<'a>(
580 text: &'a str,
581 needles: &[String],
582 theme: &Theme,
583 base: Style,
584) -> ratatui::text::Text<'a> {
585 use ratatui::text::{Line, Span};
586 if needles.is_empty() {
587 return ratatui::text::Text::styled(text, base);
588 }
589 let emphasis = base.patch(
590 Style::default()
591 .fg(theme.color_search_match.to_ratatui())
592 .add_modifier(ratatui::style::Modifier::BOLD),
593 );
594 let mut lines = Vec::new();
595 for line in text.lines() {
596 let ranges = preview_highlight::match_ranges(line, needles);
597 if ranges.is_empty() {
598 lines.push(Line::styled(line, base));
599 continue;
600 }
601 let spans = preview_highlight::style_ranges(line, &ranges, |s, hit| {
603 Span::styled(s, if hit { emphasis } else { base })
604 });
605 lines.push(Line::from(spans));
606 }
607 ratatui::text::Text::from(lines)
608}
609
610#[cfg(test)]
611mod tests {
612 use super::*;
613 use crate::components::search_list::{Emit, RowSource};
614 use crate::settings::AppSettings;
615 use crate::test_support::temp_vault;
616 use async_trait::async_trait;
617 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
618 use tokio::sync::mpsc::unbounded_channel;
619
620 #[test]
621 fn count_matches_matches_highlighted_ranges() {
622 assert_eq!(count_matches("anything", &[]), None);
624 let needles = vec!["foo".to_string(), "foobar".to_string()];
627 assert_eq!(count_matches("foobar", &needles), Some(1));
628 assert_eq!(count_matches("foo and foo", &["foo".to_string()]), Some(2));
630 }
631
632 struct OneNoteSource {
635 path: VaultPath,
636 }
637
638 #[async_trait]
639 impl RowSource<FileListEntry> for OneNoteSource {
640 async fn load(&self, _query: &str, emit: Emit<FileListEntry>) {
641 emit.replace(vec![FileListEntry::Note {
642 path: self.path.clone(),
643 title: "Note".to_string(),
644 filename: self.path.to_string(),
645 journal_date: None,
646 is_open: false,
647 }]);
648 }
649 }
650
651 async fn make_modal_with(source: impl RowSource<FileListEntry>, tx: AppTx) -> NoteBrowserModal {
652 let vault = temp_vault("modal").await;
653 let settings = AppSettings::default();
654 NoteBrowserModal::new(
655 "test",
656 BrowserScope::Query,
657 source,
658 vault,
659 settings.key_bindings.clone(),
660 settings.icons(),
661 tx,
662 )
663 }
664
665 #[tokio::test]
666 async fn dialog_error_surfaces_then_clears_on_keystroke() {
667 let (tx, _rx) = unbounded_channel();
668 let path = VaultPath::note_path_from("/a.md");
669 let mut modal = make_modal_with(OneNoteSource { path }, tx.clone()).await;
670 let vault = temp_vault("modal_err").await;
671
672 let consumed =
673 modal.handle_app_message(&AppEvent::DialogError("boom".to_string()), &vault, &tx);
674 assert!(matches!(consumed, OverlayMsg::Consumed));
675 assert_eq!(modal.error.as_deref(), Some("boom"));
676
677 modal.handle_input(
679 &InputEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)),
680 &tx,
681 );
682 assert_eq!(modal.error, None, "keystroke should clear the error");
683 }
684
685 #[tokio::test]
686 async fn modal_constructed_with_initial_query_prefills_input() {
687 let vault = temp_vault("modal_iq").await;
688 let settings = AppSettings::default();
689 let (tx, _rx) = unbounded_channel();
690 let modal = NoteBrowserModal::with_initial_query(
691 "test",
692 BrowserScope::Query,
693 OneNoteSource {
694 path: VaultPath::note_path_from("/a.md"),
695 },
696 vault,
697 settings.key_bindings.clone(),
698 settings.icons(),
699 tx,
700 "#important",
701 );
702 assert_eq!(modal.query_text(), "#important");
703 }
704
705 #[tokio::test]
709 async fn submit_opens_selected_note() {
710 let (tx, mut rx) = unbounded_channel();
711 let path = VaultPath::note_path_from("/a.md");
712 let mut modal = make_modal_with(OneNoteSource { path: path.clone() }, tx.clone()).await;
713 modal.list.poll_until_idle().await;
715
716 Overlay::handle_input(
717 &mut modal,
718 &InputEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
719 &tx,
720 );
721
722 let mut events = Vec::new();
723 while let Ok(ev) = rx.try_recv() {
724 events.push(ev);
725 }
726 assert!(
727 events
728 .iter()
729 .any(|e| matches!(e, AppEvent::OpenPath { path: p, .. } if *p == path)),
730 "expected OpenPath, got {events:?}"
731 );
732 assert!(
733 !events.iter().any(|e| matches!(e, AppEvent::CloseOverlay)),
734 "select must not emit CloseOverlay; editor's OpenPath handler closes the overlay, got {events:?}"
735 );
736 }
737
738 #[tokio::test]
742 async fn refresh_preview_tracks_selected_path() {
743 let (tx, _rx) = unbounded_channel();
744 let path = VaultPath::note_path_from("/a.md");
745 let mut modal = make_modal_with(OneNoteSource { path: path.clone() }, tx.clone()).await;
746 modal.list.poll_until_idle().await;
747 assert_eq!(modal.preview_path, None, "no path tracked before refresh");
748
749 modal.refresh_preview_from_list();
750 assert_eq!(
751 modal.preview_path,
752 Some(path),
753 "preview_path should track the selected note"
754 );
755 }
756
757 #[tokio::test]
759 async fn esc_closes_modal() {
760 let (tx, mut rx) = unbounded_channel();
761 let mut modal = make_modal_with(
762 OneNoteSource {
763 path: VaultPath::note_path_from("/a.md"),
764 },
765 tx.clone(),
766 )
767 .await;
768 Overlay::handle_input(
769 &mut modal,
770 &InputEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
771 &tx,
772 );
773 let mut sent = false;
774 while let Ok(ev) = rx.try_recv() {
775 if matches!(ev, AppEvent::CloseOverlay) {
776 sent = true;
777 }
778 }
779 assert!(sent, "expected CloseOverlay on Esc");
780 }
781
782 #[tokio::test(flavor = "multi_thread")]
785 async fn accepting_saved_search_pins_breadcrumb() {
786 let vault = temp_vault("modal-ss").await;
787 vault.validate_and_init().await.unwrap();
788 vault.save_search("todo-week", "#todo").await.unwrap();
789 let settings = AppSettings::default();
790 let (tx, _rx) = unbounded_channel();
791 let mut modal = NoteBrowserModal::new(
792 "test",
793 BrowserScope::Query,
794 OneNoteSource {
795 path: VaultPath::note_path_from("/a.md"),
796 },
797 vault,
798 settings.key_bindings.clone(),
799 settings.icons(),
800 tx.clone(),
801 );
802
803 for ch in ['?', 't', 'o'] {
806 Overlay::handle_input(
807 &mut modal,
808 &InputEvent::Key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)),
809 &tx,
810 );
811 for _ in 0..30 {
812 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
813 modal.list.poll();
814 }
815 }
816 Overlay::handle_input(
817 &mut modal,
818 &InputEvent::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
819 &tx,
820 );
821
822 assert_eq!(modal.query_text(), "#todo");
823 assert_eq!(
824 modal.saved_search_breadcrumb().as_deref(),
825 Some("todo-week")
826 );
827 assert_eq!(Overlay::saved_search_provenance(&modal), Some("todo-week"));
830 }
831}