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, InputEvent, redraw_callback};
15use crate::components::file_list::FileListEntry;
16use crate::components::overlay::{Overlay, OverlayKind};
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}
73
74impl NoteBrowserModal {
75 pub fn new(
76 title: impl Into<String>,
77 scope: BrowserScope,
78 provider: impl RowSource<FileListEntry>,
79 vault: Arc<NoteVault>,
80 key_bindings: KeyBindings,
81 icons: Icons,
82 tx: AppTx,
83 ) -> Self {
84 Self::new_with_query(
85 title,
86 scope,
87 provider,
88 vault,
89 key_bindings,
90 icons,
91 tx,
92 String::new(),
93 )
94 }
95
96 #[allow(clippy::too_many_arguments)]
102 pub fn with_initial_query<S: Into<String>>(
103 title: impl Into<String>,
104 scope: BrowserScope,
105 provider: impl RowSource<FileListEntry>,
106 vault: Arc<NoteVault>,
107 key_bindings: KeyBindings,
108 icons: Icons,
109 tx: AppTx,
110 query: S,
111 ) -> Self {
112 Self::new_with_query(
113 title,
114 scope,
115 provider,
116 vault,
117 key_bindings,
118 icons,
119 tx,
120 query.into(),
121 )
122 }
123
124 #[allow(clippy::too_many_arguments)]
125 fn new_with_query(
126 title: impl Into<String>,
127 scope: BrowserScope,
128 provider: impl RowSource<FileListEntry>,
129 vault: Arc<NoteVault>,
130 key_bindings: KeyBindings,
131 icons: Icons,
132 tx: AppTx,
133 initial_query: String,
134 ) -> Self {
135 let prefix_glyph = match scope {
136 BrowserScope::Query => icons.rail_find,
137 BrowserScope::Files => icons.rail_files,
138 };
139 let mut builder = SearchList::builder(provider, redraw_callback(tx.clone()))
140 .initial_query(initial_query)
141 .icons(icons)
142 .autocomplete(
143 Arc::new(VaultSuggestions {
144 vault: vault.clone(),
145 }),
146 AutocompleteMode::SearchQuery,
147 );
148 if scope == BrowserScope::Query {
149 builder = builder.highlight_query();
150 }
151 let list = builder.build();
152 let mut modal = Self {
153 scope,
154 prefix_glyph,
155 title: title.into(),
156 list,
157 vault,
158 tx,
159 preview_text: String::new(),
160 preview_task: None,
161 preview_rx: None,
162 preview_path: None,
163 key_bindings,
164 saved_search: SavedSearchBreadcrumb::default(),
165 };
166 modal.refresh_preview(None);
167 modal
168 }
169
170 fn preview_needles(&self) -> Vec<String> {
174 if self.scope != BrowserScope::Query {
175 return Vec::new();
176 }
177 crate::components::query_highlight::emphasis_needles(self.list.query())
178 }
179
180 fn emphasis(&self) -> Option<Vec<String>> {
183 let needles = self.preview_needles();
184 (!needles.is_empty()).then_some(needles)
185 }
186
187 fn schedule_preview(&mut self, path: VaultPath) {
190 if let Some(handle) = self.preview_task.take() {
191 handle.abort();
192 }
193 let vault = Arc::clone(&self.vault);
194 let tx = self.tx.clone();
195 let (result_tx, result_rx) = std::sync::mpsc::channel();
196 self.preview_rx = Some(result_rx);
197
198 let handle = tokio::spawn(async move {
199 let text = vault.get_note_text(&path).await.unwrap_or_default();
200 result_tx.send(text).ok();
201 tx.send(AppEvent::Redraw).ok();
202 });
203 self.preview_task = Some(handle);
204 }
205
206 fn poll_preview(&mut self) {
207 let Some(rx) = &self.preview_rx else { return };
208 match rx.try_recv() {
209 Ok(text) => {
210 self.preview_text = text;
211 self.preview_rx = None;
212 self.preview_task = None;
213 }
214 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
215 self.preview_rx = None;
216 }
217 Err(std::sync::mpsc::TryRecvError::Empty) => {}
218 }
219 }
220
221 fn refresh_preview(&mut self, selected: Option<&FileListEntry>) {
224 let maybe_path = selected.and_then(|e| match e {
225 FileListEntry::Note { path, .. } => Some(path.clone()),
226 _ => None,
227 });
228 if let Some(path) = maybe_path {
229 self.schedule_preview(path);
230 } else {
231 self.preview_text.clear();
232 if let Some(h) = self.preview_task.take() {
233 h.abort();
234 }
235 }
236 }
237
238 fn selected_note_path(&self) -> Option<VaultPath> {
241 self.list.selected_row().and_then(|e| match e {
242 FileListEntry::Note { path, .. } => Some(path.clone()),
243 _ => None,
244 })
245 }
246
247 fn refresh_preview_from_list(&mut self) {
249 let path = self.selected_note_path();
250 self.preview_path = path.clone();
251 match path {
252 Some(path) => self.schedule_preview(path),
253 None => {
254 self.preview_text.clear();
255 if let Some(h) = self.preview_task.take() {
256 h.abort();
257 }
258 }
259 }
260 }
261
262 fn open_selected(&self, tx: &AppTx) {
267 let Some(entry) = self.list.selected_row() else {
268 return;
269 };
270 if let FileListEntry::CreateNote { path, .. } = entry {
271 let path = path.clone();
272 let vault = Arc::clone(&self.vault);
273 let tx = tx.clone();
274 tokio::spawn(async move {
275 vault.load_or_create_note(&path, None).await.ok();
276 tx.send(AppEvent::open(path)).ok();
277 });
278 return;
279 }
280 let path = entry.path().clone();
281 tx.send(AppEvent::OpenPath {
282 path,
283 emphasis: self.emphasis(),
284 })
285 .ok();
286 }
287
288 #[cfg(test)]
291 fn saved_search_breadcrumb(&self) -> Option<String> {
292 self.saved_search.label(self.list.query())
293 }
294
295 #[cfg(test)]
299 pub(super) fn query_text(&self) -> &str {
300 self.list.query()
301 }
302}
303
304impl Overlay for NoteBrowserModal {
309 fn kind(&self) -> OverlayKind {
310 OverlayKind::NoteBrowser
311 }
312
313 fn query(&self) -> Option<&str> {
314 Some(self.list.query())
315 }
316
317 fn saved_search_provenance(&self) -> Option<&str> {
318 self.saved_search.name()
319 }
320
321 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
322 match event {
323 InputEvent::Mouse(mouse) => match self.list.handle_mouse(mouse) {
324 SearchMouse::Activated(_) => {
325 self.open_selected(tx);
326 EventState::Consumed
327 }
328 SearchMouse::Context(_) | SearchMouse::Selected(_) | SearchMouse::Scrolled => {
329 self.refresh_preview_from_list();
330 EventState::Consumed
331 }
332 SearchMouse::ContentScrollUp | SearchMouse::ContentScrollDown => {
335 EventState::Consumed
336 }
337 SearchMouse::None => EventState::NotConsumed,
338 },
339 InputEvent::Key(key) => match self.list.handle_key(key) {
340 KeyReaction::Submit => {
341 self.open_selected(tx);
342 EventState::Consumed
343 }
344 KeyReaction::Cancel => {
345 tx.send(AppEvent::CloseOverlay).ok();
346 EventState::Consumed
347 }
348 KeyReaction::Consumed => {
349 let accepted = self.list.take_accepted_saved_search();
353 let blank = self.list.query().trim().is_empty();
354 self.saved_search
355 .on_query_consumed(accepted, self.list.query(), blank);
356 self.refresh_preview_from_list();
357 EventState::Consumed
358 }
359 KeyReaction::Intercepted(_) | KeyReaction::Unhandled => EventState::NotConsumed,
360 },
361 _ => EventState::NotConsumed,
362 }
363 }
364
365 fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
366 self.poll_preview();
367
368 let popup_rect = crate::components::centered_rect(75, 75, area);
369
370 let modal_style = Style::default()
372 .fg(theme.fg.to_ratatui())
373 .bg(theme.bg_hard.to_ratatui());
374 let title = format!(" {} ", self.title);
375 let inner = modal_chrome(
376 f,
377 popup_rect,
378 theme,
379 ModalSpec {
380 title: Some(&title),
381 bg: ModalBg::Hard,
382 ..Default::default()
383 },
384 );
385
386 let rows = Layout::default()
387 .direction(Direction::Vertical)
388 .constraints([
389 Constraint::Length(3),
390 Constraint::Min(0),
391 Constraint::Length(1),
392 ])
393 .split(inner);
394
395 let search_title = self
399 .saved_search
400 .border_title(self.list.query(), " Search ");
401 let result_count = self.list.match_count();
402 let search_block = Block::default()
403 .title(search_title)
404 .title(
405 ratatui::text::Line::from(ratatui::text::Span::styled(
406 format!(" {result_count} results "),
407 Style::default().fg(theme.gray.to_ratatui()),
408 ))
409 .right_aligned(),
410 )
411 .borders(Borders::ALL)
412 .border_style(theme.border_style(true))
413 .style(modal_style);
414 let search_inner = search_block.inner(rows[0]);
415 f.render_widget(search_block, rows[0]);
416 let prefix = format!("{} ", self.prefix_glyph);
418 let prefix_w = unicode_width::UnicodeWidthStr::width(prefix.as_str()) as u16;
419 f.render_widget(
420 Paragraph::new(prefix).style(
421 Style::default()
422 .fg(theme.yellow.to_ratatui())
423 .bg(theme.bg_hard.to_ratatui()),
424 ),
425 Rect {
426 width: prefix_w.min(search_inner.width),
427 ..search_inner
428 },
429 );
430 let input_rect = Rect {
431 x: search_inner.x.saturating_add(prefix_w),
432 width: search_inner.width.saturating_sub(prefix_w),
433 ..search_inner
434 };
435 self.list.render_query(f, input_rect, theme, true);
436
437 let columns = Layout::default()
439 .direction(Direction::Horizontal)
440 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
441 .split(rows[1]);
442
443 let list_block = Block::default()
447 .borders(Borders::ALL)
448 .border_style(theme.border_style(false))
449 .style(modal_style);
450 let list_inner = list_block.inner(columns[0]);
451 f.render_widget(list_block, columns[0]);
452 self.list.render(f, list_inner, theme, false);
453 self.list.set_list_rect(list_inner);
454 self.list.set_panel_rect(popup_rect);
456
457 if self.selected_note_path() != self.preview_path {
462 self.refresh_preview_from_list();
463 }
464
465 let needles = self.preview_needles();
468 let match_count = count_matches(&self.preview_text, &needles);
469 let preview_title = match (&self.preview_path, match_count) {
470 (Some(path), Some(n)) => {
471 format!(" {} · {} matches ", path.get_name(), n)
472 }
473 (Some(path), None) => format!(" {} ", path.get_name()),
474 (None, _) => " Preview ".to_string(),
475 };
476 let preview_block = Block::default()
477 .title(preview_title)
478 .borders(Borders::ALL)
479 .border_style(theme.border_style(false))
480 .style(modal_style);
481 let preview_inner = preview_block.inner(columns[1]);
482 f.render_widget(preview_block, columns[1]);
483 f.render_widget(
484 Paragraph::new(highlight_matches(
485 &self.preview_text,
486 &needles,
487 theme,
488 modal_style,
489 )),
490 preview_inner,
491 );
492
493 f.render_widget(
495 Paragraph::new("↑↓: navigate | Enter: open | Esc: close")
496 .style(Style::default().fg(theme.fg_secondary.to_ratatui())),
497 rows[2],
498 );
499
500 self.list.render_autocomplete(f, popup_rect, theme);
503 }
504
505 fn hint_shortcuts(&self) -> Vec<(String, String)> {
506 let mut hints = vec![
507 ("↑↓".to_string(), "navigate".to_string()),
508 ("Enter".to_string(), "open".to_string()),
509 ("Esc".to_string(), "close".to_string()),
510 ];
511 if let Some(k) = self
512 .key_bindings
513 .first_combo_for(&ActionShortcuts::SaveCurrentQuery)
514 {
515 hints.push((k, "save query".to_string()));
516 }
517 hints
518 }
519}
520
521pub(super) fn format_journal_date(date: NaiveDate) -> String {
526 date.format("%A, %B %-d, %Y").to_string()
527}
528
529fn count_matches(text: &str, needles: &[String]) -> Option<usize> {
537 if needles.is_empty() {
538 return None;
539 }
540 let lower = text.to_lowercase();
541 Some(
542 needles
543 .iter()
544 .map(|n| lower.match_indices(n.as_str()).count())
545 .sum(),
546 )
547}
548
549fn highlight_matches<'a>(
553 text: &'a str,
554 needles: &[String],
555 theme: &Theme,
556 base: Style,
557) -> ratatui::text::Text<'a> {
558 use ratatui::text::{Line, Span};
559 if needles.is_empty() {
560 return ratatui::text::Text::styled(text, base);
561 }
562 let emphasis = base.patch(
563 Style::default()
564 .fg(theme.color_search_match.to_ratatui())
565 .add_modifier(ratatui::style::Modifier::BOLD),
566 );
567 let mut lines = Vec::new();
568 for line in text.lines() {
569 let lower = line.to_lowercase();
570 if lower.len() != line.len() {
571 lines.push(Line::styled(line, base));
572 continue;
573 }
574 let mut ranges: Vec<(usize, usize)> = needles
576 .iter()
577 .flat_map(|n| {
578 lower
579 .match_indices(n.as_str())
580 .map(|(i, m)| (i, i + m.len()))
581 })
582 .collect();
583 ranges.sort_unstable_by_key(|(s, e)| (*s, std::cmp::Reverse(*e)));
586 ranges.dedup();
587 let mut spans = Vec::new();
588 let mut pos = 0;
589 for (start, end) in ranges {
590 if start < pos {
591 continue; }
593 if !line.is_char_boundary(start) || !line.is_char_boundary(end) {
597 continue;
598 }
599 if start > pos {
600 spans.push(Span::styled(&line[pos..start], base));
601 }
602 spans.push(Span::styled(&line[start..end], emphasis));
603 pos = end;
604 }
605 if pos < line.len() {
606 spans.push(Span::styled(&line[pos..], base));
607 }
608 lines.push(Line::from(spans));
609 }
610 ratatui::text::Text::from(lines)
611}
612
613#[cfg(test)]
614mod tests {
615 use super::*;
616 use crate::components::search_list::{Emit, RowSource};
617 use crate::settings::AppSettings;
618 use crate::test_support::temp_vault;
619 use async_trait::async_trait;
620 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
621 use tokio::sync::mpsc::unbounded_channel;
622
623 struct OneNoteSource {
626 path: VaultPath,
627 }
628
629 #[async_trait]
630 impl RowSource<FileListEntry> for OneNoteSource {
631 async fn load(&self, _query: &str, emit: Emit<FileListEntry>) {
632 emit.replace(vec![FileListEntry::Note {
633 path: self.path.clone(),
634 title: "Note".to_string(),
635 filename: self.path.to_string(),
636 journal_date: None,
637 }]);
638 }
639 }
640
641 async fn make_modal_with(source: impl RowSource<FileListEntry>, tx: AppTx) -> NoteBrowserModal {
642 let vault = temp_vault("modal").await;
643 let settings = AppSettings::default();
644 NoteBrowserModal::new(
645 "test",
646 BrowserScope::Query,
647 source,
648 vault,
649 settings.key_bindings.clone(),
650 settings.icons(),
651 tx,
652 )
653 }
654
655 #[tokio::test]
656 async fn modal_constructed_with_initial_query_prefills_input() {
657 let vault = temp_vault("modal_iq").await;
658 let settings = AppSettings::default();
659 let (tx, _rx) = unbounded_channel();
660 let modal = NoteBrowserModal::with_initial_query(
661 "test",
662 BrowserScope::Query,
663 OneNoteSource {
664 path: VaultPath::note_path_from("/a.md"),
665 },
666 vault,
667 settings.key_bindings.clone(),
668 settings.icons(),
669 tx,
670 "#important",
671 );
672 assert_eq!(modal.query_text(), "#important");
673 }
674
675 #[tokio::test]
679 async fn submit_opens_selected_note() {
680 let (tx, mut rx) = unbounded_channel();
681 let path = VaultPath::note_path_from("/a.md");
682 let mut modal = make_modal_with(OneNoteSource { path: path.clone() }, tx.clone()).await;
683 modal.list.poll_until_idle().await;
685
686 Overlay::handle_input(
687 &mut modal,
688 &InputEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
689 &tx,
690 );
691
692 let mut events = Vec::new();
693 while let Ok(ev) = rx.try_recv() {
694 events.push(ev);
695 }
696 assert!(
697 events
698 .iter()
699 .any(|e| matches!(e, AppEvent::OpenPath { path: p, .. } if *p == path)),
700 "expected OpenPath, got {events:?}"
701 );
702 assert!(
703 !events.iter().any(|e| matches!(e, AppEvent::CloseOverlay)),
704 "select must not emit CloseOverlay; editor's OpenPath handler closes the overlay, got {events:?}"
705 );
706 }
707
708 #[tokio::test]
712 async fn refresh_preview_tracks_selected_path() {
713 let (tx, _rx) = unbounded_channel();
714 let path = VaultPath::note_path_from("/a.md");
715 let mut modal = make_modal_with(OneNoteSource { path: path.clone() }, tx.clone()).await;
716 modal.list.poll_until_idle().await;
717 assert_eq!(modal.preview_path, None, "no path tracked before refresh");
718
719 modal.refresh_preview_from_list();
720 assert_eq!(
721 modal.preview_path,
722 Some(path),
723 "preview_path should track the selected note"
724 );
725 }
726
727 #[tokio::test]
729 async fn esc_closes_modal() {
730 let (tx, mut rx) = unbounded_channel();
731 let mut modal = make_modal_with(
732 OneNoteSource {
733 path: VaultPath::note_path_from("/a.md"),
734 },
735 tx.clone(),
736 )
737 .await;
738 Overlay::handle_input(
739 &mut modal,
740 &InputEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
741 &tx,
742 );
743 let mut sent = false;
744 while let Ok(ev) = rx.try_recv() {
745 if matches!(ev, AppEvent::CloseOverlay) {
746 sent = true;
747 }
748 }
749 assert!(sent, "expected CloseOverlay on Esc");
750 }
751
752 #[tokio::test(flavor = "multi_thread")]
755 async fn accepting_saved_search_pins_breadcrumb() {
756 let vault = temp_vault("modal-ss").await;
757 vault.validate_and_init().await.unwrap();
758 vault.save_search("todo-week", "#todo").await.unwrap();
759 let settings = AppSettings::default();
760 let (tx, _rx) = unbounded_channel();
761 let mut modal = NoteBrowserModal::new(
762 "test",
763 BrowserScope::Query,
764 OneNoteSource {
765 path: VaultPath::note_path_from("/a.md"),
766 },
767 vault,
768 settings.key_bindings.clone(),
769 settings.icons(),
770 tx.clone(),
771 );
772
773 for ch in ['?', 't', 'o'] {
776 Overlay::handle_input(
777 &mut modal,
778 &InputEvent::Key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)),
779 &tx,
780 );
781 for _ in 0..30 {
782 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
783 modal.list.poll();
784 }
785 }
786 Overlay::handle_input(
787 &mut modal,
788 &InputEvent::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
789 &tx,
790 );
791
792 assert_eq!(modal.query_text(), "#todo");
793 assert_eq!(
794 modal.saved_search_breadcrumb().as_deref(),
795 Some("todo-week")
796 );
797 assert_eq!(Overlay::saved_search_provenance(&modal), Some("todo-week"));
800 }
801}