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 .yank_combos_from(&key_bindings)
148 .icons(icons)
149 .autocomplete(
150 Arc::new(VaultSuggestions {
151 vault: vault.clone(),
152 }),
153 AutocompleteMode::SearchQuery,
154 );
155 if scope == BrowserScope::Query {
156 builder = builder.highlight_query();
157 }
158 let list = builder.build();
159 let mut modal = Self {
160 scope,
161 prefix_glyph,
162 title: title.into(),
163 list,
164 vault,
165 tx,
166 preview_text: String::new(),
167 preview_task: None,
168 preview_rx: None,
169 preview_path: None,
170 key_bindings,
171 saved_search: SavedSearchBreadcrumb::default(),
172 error: None,
173 };
174 modal.refresh_preview(None);
175 modal
176 }
177
178 fn preview_needles(&self) -> Vec<String> {
182 if self.scope != BrowserScope::Query {
183 return Vec::new();
184 }
185 crate::components::query_highlight::emphasis_needles(self.list.query())
186 }
187
188 fn emphasis(&self) -> Option<Vec<String>> {
191 let needles = self.preview_needles();
192 (!needles.is_empty()).then_some(needles)
193 }
194
195 fn schedule_preview(&mut self, path: VaultPath) {
198 if let Some(handle) = self.preview_task.take() {
199 handle.abort();
200 }
201 let vault = Arc::clone(&self.vault);
202 let tx = self.tx.clone();
203 let (result_tx, result_rx) = std::sync::mpsc::channel();
204 self.preview_rx = Some(result_rx);
205
206 let handle = tokio::spawn(async move {
207 let text = vault.get_note_text(&path).await.unwrap_or_default();
208 result_tx.send(text).ok();
209 tx.send(AppEvent::Redraw).ok();
210 });
211 self.preview_task = Some(handle);
212 }
213
214 fn poll_preview(&mut self) {
215 let Some(rx) = &self.preview_rx else { return };
216 match rx.try_recv() {
217 Ok(text) => {
218 self.preview_text = text;
219 self.preview_rx = None;
220 self.preview_task = None;
221 }
222 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
223 self.preview_rx = None;
224 }
225 Err(std::sync::mpsc::TryRecvError::Empty) => {}
226 }
227 }
228
229 fn refresh_preview(&mut self, selected: Option<&FileListEntry>) {
232 let maybe_path = selected.and_then(|e| match e {
233 FileListEntry::Note { path, .. } => Some(path.clone()),
234 _ => None,
235 });
236 if let Some(path) = maybe_path {
237 self.schedule_preview(path);
238 } else {
239 self.preview_text.clear();
240 if let Some(h) = self.preview_task.take() {
241 h.abort();
242 }
243 }
244 }
245
246 fn selected_note_path(&self) -> Option<VaultPath> {
249 self.list.selected_row().and_then(|e| match e {
250 FileListEntry::Note { path, .. } => Some(path.clone()),
251 _ => None,
252 })
253 }
254
255 fn refresh_preview_from_list(&mut self) {
257 let path = self.selected_note_path();
258 self.preview_path = path.clone();
259 match path {
260 Some(path) => self.schedule_preview(path),
261 None => {
262 self.preview_text.clear();
263 if let Some(h) = self.preview_task.take() {
264 h.abort();
265 }
266 }
267 }
268 }
269
270 fn open_selected(&self, tx: &AppTx) {
275 let Some(entry) = self.list.selected_row() else {
276 return;
277 };
278 if let FileListEntry::CreateNote { path, .. } = entry {
279 let path = path.clone();
280 let vault = Arc::clone(&self.vault);
281 let tx = tx.clone();
282 tokio::spawn(async move {
283 match vault.load_or_create_note(&path, None).await {
284 Ok((_, created)) => tx.announce_and_open(path, created),
285 Err(e) => {
286 tx.send(AppEvent::OverlayData(OverlayData::Error(e.to_string())))
287 .ok();
288 }
289 }
290 });
291 return;
292 }
293 let path = entry.path().clone();
294 tx.send(AppEvent::OpenPath {
295 path,
296 emphasis: self.emphasis(),
297 })
298 .ok();
299 }
300
301 #[cfg(test)]
304 fn saved_search_breadcrumb(&self) -> Option<String> {
305 self.saved_search.label(self.list.query())
306 }
307
308 #[cfg(test)]
312 pub(super) fn query_text(&self) -> &str {
313 self.list.query()
314 }
315}
316
317impl Overlay for NoteBrowserModal {
322 fn kind(&self) -> OverlayKind {
323 OverlayKind::NoteBrowser
324 }
325
326 fn query(&self) -> Option<&str> {
327 Some(self.list.query())
328 }
329
330 fn saved_search_provenance(&self) -> Option<&str> {
331 self.saved_search.name()
332 }
333
334 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
335 match event {
336 InputEvent::Mouse(mouse) => match self.list.handle_mouse(mouse) {
337 SearchMouse::Activated(_) => {
338 self.open_selected(tx);
339 EventState::Consumed
340 }
341 SearchMouse::Context(_) | SearchMouse::Selected(_) | SearchMouse::Scrolled => {
342 self.refresh_preview_from_list();
343 EventState::Consumed
344 }
345 SearchMouse::ContentScrollUp | SearchMouse::ContentScrollDown => {
348 EventState::Consumed
349 }
350 SearchMouse::None => EventState::NotConsumed,
351 },
352 InputEvent::Key(key) => {
353 self.error = None;
355 match self.list.handle_key(key) {
356 KeyReaction::Submit => {
357 self.open_selected(tx);
358 EventState::Consumed
359 }
360 KeyReaction::Cancel => {
361 tx.send(AppEvent::CloseOverlay).ok();
362 EventState::Consumed
363 }
364 KeyReaction::Consumed => {
365 let accepted = self.list.take_accepted_saved_search();
369 let blank = self.list.query().trim().is_empty();
370 self.saved_search
371 .on_query_consumed(accepted, self.list.query(), blank);
372 self.refresh_preview_from_list();
373 EventState::Consumed
374 }
375 KeyReaction::Yank(target) => {
376 crate::components::yank_row(target, tx);
377 EventState::Consumed
378 }
379 KeyReaction::Intercepted(_)
380 | KeyReaction::ListVerb(_)
381 | KeyReaction::Unhandled => EventState::NotConsumed,
382 }
383 }
384 _ => EventState::NotConsumed,
385 }
386 }
387
388 fn handle_data(
389 &mut self,
390 data: &OverlayData,
391 _vault: &Arc<NoteVault>,
392 _tx: &AppTx,
393 ) -> OverlayMsg {
394 if let OverlayData::Error(text) = data {
397 self.error = Some(text.clone());
398 OverlayMsg::Consumed
399 } else {
400 OverlayMsg::NotConsumed
401 }
402 }
403
404 fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
405 self.poll_preview();
406
407 let popup_rect = crate::components::centered_rect(75, 75, area);
408
409 let modal_style = Style::default()
411 .fg(theme.fg.to_ratatui())
412 .bg(theme.bg_hard.to_ratatui());
413 let title = format!(" {} ", self.title);
414 let inner = modal_chrome(
415 f,
416 popup_rect,
417 theme,
418 ModalSpec {
419 title: Some(&title),
420 bg: ModalBg::Hard,
421 ..Default::default()
422 },
423 );
424
425 let rows = Layout::default()
426 .direction(Direction::Vertical)
427 .constraints([
428 Constraint::Length(3),
429 Constraint::Min(0),
430 Constraint::Length(1),
431 ])
432 .split(inner);
433
434 let search_title = self
438 .saved_search
439 .border_title(self.list.query(), " Search ");
440 let result_count = self.list.match_count();
441 let search_block = Block::default()
442 .title(search_title)
443 .title(
444 ratatui::text::Line::from(ratatui::text::Span::styled(
445 format!(" {result_count} results "),
446 Style::default().fg(theme.gray.to_ratatui()),
447 ))
448 .right_aligned(),
449 )
450 .borders(Borders::ALL)
451 .border_style(theme.border_style(true))
452 .style(modal_style);
453 let search_inner = search_block.inner(rows[0]);
454 f.render_widget(search_block, rows[0]);
455 let prefix = format!("{} ", self.prefix_glyph);
457 let prefix_w = unicode_width::UnicodeWidthStr::width(prefix.as_str()) as u16;
458 f.render_widget(
459 Paragraph::new(prefix).style(
460 Style::default()
461 .fg(theme.yellow.to_ratatui())
462 .bg(theme.bg_hard.to_ratatui()),
463 ),
464 Rect {
465 width: prefix_w.min(search_inner.width),
466 ..search_inner
467 },
468 );
469 let input_rect = Rect {
470 x: search_inner.x.saturating_add(prefix_w),
471 width: search_inner.width.saturating_sub(prefix_w),
472 ..search_inner
473 };
474 self.list.render_query(f, input_rect, theme, true);
475
476 let columns = Layout::default()
478 .direction(Direction::Horizontal)
479 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
480 .split(rows[1]);
481
482 let list_block = Block::default()
486 .borders(Borders::ALL)
487 .border_style(theme.border_style(false))
488 .style(modal_style);
489 let list_inner = list_block.inner(columns[0]);
490 f.render_widget(list_block, columns[0]);
491 self.list.render(f, list_inner, theme, false);
492 self.list.set_list_rect(list_inner);
493 self.list.set_panel_rect(popup_rect);
495
496 if self.selected_note_path() != self.preview_path {
501 self.refresh_preview_from_list();
502 }
503
504 let needles = self.preview_needles();
507 let match_count = count_matches(&self.preview_text, &needles);
508 let preview_title = match (&self.preview_path, match_count) {
509 (Some(path), Some(n)) => {
510 format!(" {} · {} matches ", path.get_name(), n)
511 }
512 (Some(path), None) => format!(" {} ", path.get_name()),
513 (None, _) => " Preview ".to_string(),
514 };
515 let preview_block = Block::default()
516 .title(preview_title)
517 .borders(Borders::ALL)
518 .border_style(theme.border_style(false))
519 .style(modal_style);
520 let preview_inner = preview_block.inner(columns[1]);
521 f.render_widget(preview_block, columns[1]);
522 f.render_widget(
523 Paragraph::new(highlight_matches(
524 &self.preview_text,
525 &needles,
526 theme,
527 modal_style,
528 )),
529 preview_inner,
530 );
531
532 let hint = match &self.error {
534 Some(err) => Paragraph::new(format!("⚠ {err}"))
535 .style(Style::default().fg(theme.red.to_ratatui())),
536 None => Paragraph::new("↑↓: navigate | Enter: open | Esc: close")
537 .style(Style::default().fg(theme.fg_secondary.to_ratatui())),
538 };
539 f.render_widget(hint, rows[2]);
540
541 self.list.render_autocomplete(f, popup_rect, theme);
544 }
545
546 fn hint_shortcuts(&self) -> Vec<(String, String)> {
547 let mut hints = vec![
548 ("↑↓".to_string(), "navigate".to_string()),
549 ("Enter".to_string(), "open".to_string()),
550 ("Esc".to_string(), "close".to_string()),
551 ];
552 if let Some(k) = self
553 .key_bindings
554 .first_combo_for(&ActionShortcuts::SaveCurrentQuery)
555 {
556 hints.push((k, "save query".to_string()));
557 }
558 hints
559 }
560}
561
562pub(crate) fn format_journal_date(date: NaiveDate) -> String {
567 date.format("%A, %B %-d, %Y").to_string()
568}
569
570fn count_matches(text: &str, needles: &[String]) -> Option<usize> {
580 if needles.is_empty() {
581 return None;
582 }
583 Some(preview_highlight::match_ranges(text, needles).len())
584}
585
586fn highlight_matches<'a>(
590 text: &'a str,
591 needles: &[String],
592 theme: &Theme,
593 base: Style,
594) -> ratatui::text::Text<'a> {
595 use ratatui::text::{Line, Span};
596 if needles.is_empty() {
597 return ratatui::text::Text::styled(text, base);
598 }
599 let emphasis = base.patch(
600 Style::default()
601 .fg(theme.color_search_match.to_ratatui())
602 .add_modifier(ratatui::style::Modifier::BOLD),
603 );
604 let mut lines = Vec::new();
605 for line in text.lines() {
606 let ranges = preview_highlight::match_ranges(line, needles);
607 if ranges.is_empty() {
608 lines.push(Line::styled(line, base));
609 continue;
610 }
611 let spans = preview_highlight::style_ranges(line, &ranges, |s, hit| {
613 Span::styled(s, if hit { emphasis } else { base })
614 });
615 lines.push(Line::from(spans));
616 }
617 ratatui::text::Text::from(lines)
618}
619
620#[cfg(test)]
621mod tests {
622 use super::*;
623 use crate::components::search_list::{Emit, RowSource};
624 use crate::settings::AppSettings;
625 use crate::test_support::temp_vault;
626 use async_trait::async_trait;
627 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
628 use tokio::sync::mpsc::unbounded_channel;
629
630 #[test]
631 fn count_matches_matches_highlighted_ranges() {
632 assert_eq!(count_matches("anything", &[]), None);
634 let needles = vec!["foo".to_string(), "foobar".to_string()];
637 assert_eq!(count_matches("foobar", &needles), Some(1));
638 assert_eq!(count_matches("foo and foo", &["foo".to_string()]), Some(2));
640 }
641
642 struct OneNoteSource {
645 path: VaultPath,
646 }
647
648 #[async_trait]
649 impl RowSource<FileListEntry> for OneNoteSource {
650 async fn load(&self, _query: &str, emit: Emit<FileListEntry>) {
651 emit.replace(vec![FileListEntry::Note {
652 path: self.path.clone(),
653 title: "Note".to_string(),
654 filename: self.path.to_string(),
655 journal_date: None,
656 is_open: false,
657 }]);
658 }
659 }
660
661 async fn make_modal_with(source: impl RowSource<FileListEntry>, tx: AppTx) -> NoteBrowserModal {
662 let vault = temp_vault("modal").await;
663 let settings = AppSettings::default();
664 NoteBrowserModal::new(
665 "test",
666 BrowserScope::Query,
667 source,
668 vault,
669 settings.key_bindings.clone(),
670 settings.icons(),
671 tx,
672 )
673 }
674
675 #[tokio::test]
676 async fn dialog_error_surfaces_then_clears_on_keystroke() {
677 let (tx, _rx) = unbounded_channel();
678 let path = VaultPath::note_path_from("/a.md");
679 let mut modal = make_modal_with(OneNoteSource { path }, tx.clone()).await;
680 let vault = temp_vault("modal_err").await;
681
682 let consumed = modal.handle_data(&OverlayData::Error("boom".to_string()), &vault, &tx);
683 assert!(matches!(consumed, OverlayMsg::Consumed));
684 assert_eq!(modal.error.as_deref(), Some("boom"));
685
686 modal.handle_input(
688 &InputEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)),
689 &tx,
690 );
691 assert_eq!(modal.error, None, "keystroke should clear the error");
692 }
693
694 #[tokio::test]
695 async fn modal_constructed_with_initial_query_prefills_input() {
696 let vault = temp_vault("modal_iq").await;
697 let settings = AppSettings::default();
698 let (tx, _rx) = unbounded_channel();
699 let modal = NoteBrowserModal::with_initial_query(
700 "test",
701 BrowserScope::Query,
702 OneNoteSource {
703 path: VaultPath::note_path_from("/a.md"),
704 },
705 vault,
706 settings.key_bindings.clone(),
707 settings.icons(),
708 tx,
709 "#important",
710 );
711 assert_eq!(modal.query_text(), "#important");
712 }
713
714 #[tokio::test]
718 async fn submit_opens_selected_note() {
719 let (tx, mut rx) = unbounded_channel();
720 let path = VaultPath::note_path_from("/a.md");
721 let mut modal = make_modal_with(OneNoteSource { path: path.clone() }, tx.clone()).await;
722 modal.list.poll_until_idle().await;
724
725 Overlay::handle_input(
726 &mut modal,
727 &InputEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
728 &tx,
729 );
730
731 let mut events = Vec::new();
732 while let Ok(ev) = rx.try_recv() {
733 events.push(ev);
734 }
735 assert!(
736 events
737 .iter()
738 .any(|e| matches!(e, AppEvent::OpenPath { path: p, .. } if *p == path)),
739 "expected OpenPath, got {events:?}"
740 );
741 assert!(
742 !events.iter().any(|e| matches!(e, AppEvent::CloseOverlay)),
743 "select must not emit CloseOverlay; editor's OpenPath handler closes the overlay, got {events:?}"
744 );
745 }
746
747 #[tokio::test]
751 async fn refresh_preview_tracks_selected_path() {
752 let (tx, _rx) = unbounded_channel();
753 let path = VaultPath::note_path_from("/a.md");
754 let mut modal = make_modal_with(OneNoteSource { path: path.clone() }, tx.clone()).await;
755 modal.list.poll_until_idle().await;
756 assert_eq!(modal.preview_path, None, "no path tracked before refresh");
757
758 modal.refresh_preview_from_list();
759 assert_eq!(
760 modal.preview_path,
761 Some(path),
762 "preview_path should track the selected note"
763 );
764 }
765
766 #[tokio::test]
768 async fn esc_closes_modal() {
769 let (tx, mut rx) = unbounded_channel();
770 let mut modal = make_modal_with(
771 OneNoteSource {
772 path: VaultPath::note_path_from("/a.md"),
773 },
774 tx.clone(),
775 )
776 .await;
777 Overlay::handle_input(
778 &mut modal,
779 &InputEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
780 &tx,
781 );
782 let mut sent = false;
783 while let Ok(ev) = rx.try_recv() {
784 if matches!(ev, AppEvent::CloseOverlay) {
785 sent = true;
786 }
787 }
788 assert!(sent, "expected CloseOverlay on Esc");
789 }
790
791 #[tokio::test(flavor = "multi_thread")]
794 async fn accepting_saved_search_pins_breadcrumb() {
795 let vault = temp_vault("modal-ss").await;
796 vault.validate_and_init().await.unwrap();
797 vault.save_search("todo-week", "#todo").await.unwrap();
798 let settings = AppSettings::default();
799 let (tx, _rx) = unbounded_channel();
800 let mut modal = NoteBrowserModal::new(
801 "test",
802 BrowserScope::Query,
803 OneNoteSource {
804 path: VaultPath::note_path_from("/a.md"),
805 },
806 vault,
807 settings.key_bindings.clone(),
808 settings.icons(),
809 tx.clone(),
810 );
811
812 for ch in ['?', 't', 'o'] {
815 Overlay::handle_input(
816 &mut modal,
817 &InputEvent::Key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)),
818 &tx,
819 );
820 for _ in 0..30 {
821 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
822 modal.list.poll();
823 }
824 }
825 Overlay::handle_input(
826 &mut modal,
827 &InputEvent::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
828 &tx,
829 );
830
831 assert_eq!(modal.query_text(), "#todo");
832 assert_eq!(
833 modal.saved_search_breadcrumb().as_deref(),
834 Some("todo-week")
835 );
836 assert_eq!(Overlay::saved_search_provenance(&modal), Some("todo-week"));
839 }
840}