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