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