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::{
18 FileListEntry, PropertyValues, SortField, SortOrder, entry_order,
19};
20use crate::components::overlay::{Overlay, OverlayKind, OverlayMsg};
21use crate::components::panel::{ModalBg, ModalSpec, modal_chrome};
22use crate::components::preview_highlight;
23use crate::components::saved_search_breadcrumb::SavedSearchBreadcrumb;
24use crate::components::search_list::{
25 KeyReaction, RowSource, SearchList, SearchMouse, VaultSuggestions,
26};
27use crate::components::sortable::{
28 PropertySort, SortState, SortableList, is_blank_property, order_of_query, query_with_sort,
29};
30use crate::keys::KeyBindings;
31use crate::keys::action_shortcuts::ActionShortcuts;
32use crate::settings::icons::Icons;
33use crate::settings::themes::Theme;
34
35pub mod file_finder_provider;
36pub mod link_results_provider;
37pub mod search_provider;
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
50pub enum BrowserScope {
51 Query,
54 Files,
56}
57
58pub struct NoteBrowserModal {
59 scope: BrowserScope,
60 prefix_glyph: &'static str,
62 title: String,
63 list: SearchList<FileListEntry>,
64 vault: Arc<NoteVault>,
65 tx: AppTx,
66 preview_text: String,
67 preview_task: Option<tokio::task::JoinHandle<()>>,
69 preview_rx: Option<Receiver<String>>,
70 preview_path: Option<VaultPath>,
74 key_bindings: KeyBindings,
76 saved_search: SavedSearchBreadcrumb,
80 sort_chip: crate::components::clickable::BorderChip,
83 hints: crate::components::hint_row::HintRow,
86 error: Option<String>,
89 row_sort: Option<RowSort>,
93}
94
95#[derive(Default)]
98struct RowSort {
99 state: Option<SortState>,
100 property: PropertySort,
101}
102
103impl NoteBrowserModal {
104 pub fn new(
105 title: impl Into<String>,
106 scope: BrowserScope,
107 provider: impl RowSource<FileListEntry>,
108 vault: Arc<NoteVault>,
109 key_bindings: KeyBindings,
110 icons: Icons,
111 tx: AppTx,
112 ) -> Self {
113 Self::new_with_query(
114 title,
115 scope,
116 provider,
117 vault,
118 key_bindings,
119 icons,
120 tx,
121 String::new(),
122 )
123 }
124
125 #[allow(clippy::too_many_arguments)]
131 pub fn with_initial_query<S: Into<String>>(
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 query: S,
140 ) -> Self {
141 Self::new_with_query(
142 title,
143 scope,
144 provider,
145 vault,
146 key_bindings,
147 icons,
148 tx,
149 query.into(),
150 )
151 }
152
153 #[allow(clippy::too_many_arguments)]
154 fn new_with_query(
155 title: impl Into<String>,
156 scope: BrowserScope,
157 provider: impl RowSource<FileListEntry>,
158 vault: Arc<NoteVault>,
159 key_bindings: KeyBindings,
160 icons: Icons,
161 tx: AppTx,
162 initial_query: String,
163 ) -> Self {
164 let prefix_glyph = match scope {
165 BrowserScope::Query => icons.rail_find,
166 BrowserScope::Files => icons.rail_files,
167 };
168 let mut builder = SearchList::builder(provider, redraw_callback(tx.clone()))
169 .initial_query(initial_query)
170 .yank_combos_from(&key_bindings)
171 .icons(icons)
172 .autocomplete(
173 Arc::new(VaultSuggestions {
174 vault: vault.clone(),
175 }),
176 AutocompleteMode::SearchQuery,
177 );
178 if scope == BrowserScope::Query {
179 builder = builder.highlight_query();
180 }
181 let list = builder.build();
182 let mut modal = Self {
183 scope,
184 prefix_glyph,
185 title: title.into(),
186 list,
187 vault,
188 tx,
189 preview_text: String::new(),
190 preview_task: None,
191 preview_rx: None,
192 preview_path: None,
193 key_bindings,
194 saved_search: SavedSearchBreadcrumb::default(),
195 sort_chip: Default::default(),
196 hints: browser_hints(scope),
197 error: None,
198 row_sort: None,
199 };
200 modal.refresh_preview(None);
201 modal
202 }
203
204 pub fn with_row_sort(mut self) -> Self {
208 if self.scope == BrowserScope::Files {
209 self.row_sort = Some(RowSort::default());
210 }
211 self
212 }
213
214 fn reorder_rows(&mut self) {
217 let Some(RowSort {
218 state: Some(state),
219 property,
220 }) = &self.row_sort
221 else {
222 return;
223 };
224 if property.is_awaiting(&state.field) {
225 return;
226 }
227 let values = property.values_for_field(&state.field);
228 let order = entry_order(state.field.clone(), state.order, false, values);
229 self.list.set_order(Some(order));
230 self.refresh_preview_from_list();
231 }
232
233 pub(crate) fn on_property_sort_values(&mut self, key: &str, values: PropertyValues) -> bool {
236 let accepted = self
237 .row_sort
238 .as_mut()
239 .is_some_and(|rs| rs.property.receive(key, values));
240 if accepted {
241 self.reorder_rows();
242 }
243 accepted
244 }
245
246 fn poll_row_sort(&mut self) {
249 if let Some((key, values)) = self.row_sort.as_mut().and_then(|rs| rs.property.poll()) {
250 self.on_property_sort_values(&key, values);
251 }
252 }
253
254 #[cfg(test)]
255 fn row_sort_pending(&self) -> bool {
256 self.row_sort
257 .as_ref()
258 .is_some_and(|rs| rs.property.is_pending())
259 }
260
261 fn preview_needles(&self) -> Vec<String> {
265 if self.scope != BrowserScope::Query {
266 return Vec::new();
267 }
268 crate::components::query_highlight::emphasis_needles(self.list.query())
269 }
270
271 fn emphasis(&self) -> Option<Vec<String>> {
274 let needles = self.preview_needles();
275 (!needles.is_empty()).then_some(needles)
276 }
277
278 fn schedule_preview(&mut self, path: VaultPath) {
281 if let Some(handle) = self.preview_task.take() {
282 handle.abort();
283 }
284 let vault = Arc::clone(&self.vault);
285 let tx = self.tx.clone();
286 let (result_tx, result_rx) = std::sync::mpsc::channel();
287 self.preview_rx = Some(result_rx);
288
289 let handle = tokio::spawn(async move {
290 let text = vault.get_note_text(&path).await.unwrap_or_default();
291 result_tx.send(text).ok();
292 tx.send(AppEvent::Redraw).ok();
293 });
294 self.preview_task = Some(handle);
295 }
296
297 fn poll_preview(&mut self) {
298 let Some(rx) = &self.preview_rx else { return };
299 match rx.try_recv() {
300 Ok(text) => {
301 self.preview_text = text;
302 self.preview_rx = None;
303 self.preview_task = None;
304 }
305 Err(std::sync::mpsc::TryRecvError::Disconnected) => {
306 self.preview_rx = None;
307 }
308 Err(std::sync::mpsc::TryRecvError::Empty) => {}
309 }
310 }
311
312 fn refresh_preview(&mut self, selected: Option<&FileListEntry>) {
315 let maybe_path = selected.and_then(|e| match e {
316 FileListEntry::Note { path, .. } => Some(path.clone()),
317 _ => None,
318 });
319 if let Some(path) = maybe_path {
320 self.schedule_preview(path);
321 } else {
322 self.preview_text.clear();
323 if let Some(h) = self.preview_task.take() {
324 h.abort();
325 }
326 }
327 }
328
329 fn selected_note_path(&self) -> Option<VaultPath> {
332 self.list.selected_row().and_then(|e| match e {
333 FileListEntry::Note { path, .. } => Some(path.clone()),
334 _ => None,
335 })
336 }
337
338 fn refresh_preview_from_list(&mut self) {
340 let path = self.selected_note_path();
341 self.preview_path = path.clone();
342 match path {
343 Some(path) => self.schedule_preview(path),
344 None => {
345 self.preview_text.clear();
346 if let Some(h) = self.preview_task.take() {
347 h.abort();
348 }
349 }
350 }
351 }
352
353 fn open_selected(&self, tx: &AppTx) {
358 let Some(entry) = self.list.selected_row() else {
359 return;
360 };
361 if let FileListEntry::CreateNote { path, .. } = entry {
362 let path = path.clone();
363 let vault = Arc::clone(&self.vault);
364 let tx = tx.clone();
365 tokio::spawn(async move {
366 match vault.load_or_create_note(&path, None).await {
367 Ok((_, created)) => tx.announce_and_open(path, created),
368 Err(e) => {
369 tx.send(AppEvent::OverlayData(OverlayData::Error(e.to_string())))
370 .ok();
371 }
372 }
373 });
374 return;
375 }
376 let path = entry.path().clone();
377 tx.send(AppEvent::OpenPath {
378 path,
379 emphasis: self.emphasis(),
380 })
381 .ok();
382 }
383
384 fn is_sortable(&self) -> bool {
385 self.scope == BrowserScope::Query || self.row_sort.is_some()
386 }
387
388 #[cfg(test)]
391 fn saved_search_breadcrumb(&self) -> Option<String> {
392 self.saved_search.label(self.list.query())
393 }
394
395 #[cfg(test)]
399 pub(super) fn query_text(&self) -> &str {
400 self.list.query()
401 }
402}
403
404impl SortableList for NoteBrowserModal {
409 fn sort_state(&self) -> SortState {
413 if let Some(rs) = &self.row_sort {
414 return rs.state.clone().unwrap_or(SortState {
415 field: SortField::Name,
416 order: SortOrder::Ascending,
417 group_dirs: None,
418 });
419 }
420 let (field, order) = order_of_query(self.list.query());
421 SortState {
422 field,
423 order,
424 group_dirs: None,
425 }
426 }
427
428 fn apply_sort(&mut self, state: &SortState, tx: &AppTx) {
435 if let Some(rs) = &mut self.row_sort {
436 if is_blank_property(&state.field) {
437 return;
438 }
439 rs.state = Some(state.clone());
440 rs.property.sync(&self.vault, &state.field, tx);
441 self.reorder_rows();
442 return;
443 }
444 if let Some(rewritten) = query_with_sort(self.list.query(), &state.field, state.order) {
445 self.list.set_query(rewritten);
446 self.refresh_preview_from_list();
447 }
448 }
449
450 fn allows_property(&self) -> bool {
451 true
452 }
453
454 fn is_unsorted(&self) -> bool {
458 match &self.row_sort {
459 Some(rs) => rs.state.is_none(),
460 None => self.scope == BrowserScope::Query && self.list.query().trim().is_empty(),
461 }
462 }
463}
464
465impl NoteBrowserModal {
470 fn after_query_edit(&mut self) {
475 let accepted = self.list.take_accepted_saved_search();
476 let blank = self.list.query().trim().is_empty();
477 self.saved_search
478 .on_query_consumed(accepted, self.list.query(), blank);
479 self.refresh_preview_from_list();
480 }
481}
482
483impl Overlay for NoteBrowserModal {
484 fn kind(&self) -> OverlayKind {
485 OverlayKind::NoteBrowser
486 }
487
488 fn query(&self) -> Option<&str> {
489 Some(self.list.query())
490 }
491
492 fn saved_search_provenance(&self) -> Option<&str> {
493 self.saved_search.name()
494 }
495
496 fn as_sortable(&self) -> Option<&dyn SortableList> {
500 self.is_sortable().then_some(self as &dyn SortableList)
501 }
502
503 fn takes_query_syntax(&self) -> bool {
504 self.scope == BrowserScope::Query
505 }
506
507 fn as_sortable_mut(&mut self) -> Option<&mut dyn SortableList> {
508 if self.is_sortable() { Some(self) } else { None }
509 }
510
511 fn handle_input(&mut self, event: &InputEvent, tx: &AppTx) -> EventState {
512 match event {
513 InputEvent::Mouse(mouse) if let Some(chip) = self.chip_at(mouse) => {
514 self.click_chip(chip, tx)
515 }
516 InputEvent::Mouse(mouse) => match self.list.handle_mouse(mouse) {
517 SearchMouse::Activated(_) | SearchMouse::DoubleClicked { repeat: false, .. } => {
518 self.open_selected(tx);
519 EventState::Consumed
520 }
521 SearchMouse::Context(_) | SearchMouse::Selected(_) | SearchMouse::Scrolled => {
522 self.refresh_preview_from_list();
523 EventState::Consumed
524 }
525 SearchMouse::ContentScrollUp | SearchMouse::ContentScrollDown => {
528 EventState::Consumed
529 }
530 SearchMouse::Autocomplete { edited: true } => {
531 self.after_query_edit();
532 EventState::Consumed
533 }
534 SearchMouse::InputFocused
537 | SearchMouse::Autocomplete { edited: false }
538 | SearchMouse::DoubleClicked { repeat: true, .. } => EventState::Consumed,
539 SearchMouse::None => EventState::NotConsumed,
540 },
541 InputEvent::Key(key) => {
542 self.error = None;
544 match self.list.handle_key(key) {
545 KeyReaction::Submit => {
546 self.open_selected(tx);
547 EventState::Consumed
548 }
549 KeyReaction::Cancel => {
550 tx.send(AppEvent::CloseOverlay).ok();
551 EventState::Consumed
552 }
553 KeyReaction::Consumed => {
554 self.after_query_edit();
555 EventState::Consumed
556 }
557 KeyReaction::Yank(target) => {
558 crate::components::yank_row(target, tx);
559 EventState::Consumed
560 }
561 KeyReaction::Intercepted(_)
562 | KeyReaction::ListVerb(_)
563 | KeyReaction::Unhandled => EventState::NotConsumed,
564 }
565 }
566 _ => EventState::NotConsumed,
567 }
568 }
569
570 fn handle_data(
571 &mut self,
572 data: &OverlayData,
573 _vault: &Arc<NoteVault>,
574 _tx: &AppTx,
575 ) -> OverlayMsg {
576 if let OverlayData::Error(text) = data {
579 self.error = Some(text.clone());
580 OverlayMsg::Consumed
581 } else {
582 OverlayMsg::NotConsumed
583 }
584 }
585
586 fn render(&mut self, f: &mut Frame, area: Rect, theme: &Theme) {
587 self.poll_preview();
588 self.poll_row_sort();
589
590 let popup_rect = crate::components::centered_rect(75, 75, area);
591
592 let modal_style = Style::default()
594 .fg(theme.fg.to_ratatui())
595 .bg(theme.bg_hard.to_ratatui());
596 let title = format!(" {} ", self.title);
597 let inner = modal_chrome(
598 f,
599 popup_rect,
600 theme,
601 ModalSpec {
602 title: Some(&title),
603 bg: ModalBg::Hard,
604 ..Default::default()
605 },
606 );
607
608 let rows = Layout::default()
609 .direction(Direction::Vertical)
610 .constraints([
611 Constraint::Length(3),
612 Constraint::Min(0),
613 Constraint::Length(1),
614 ])
615 .split(inner);
616
617 let search_title = self
621 .saved_search
622 .border_title(self.list.query(), " Search ");
623 let result_count = self.list.match_count();
624 let mut search_block = Block::default().title(search_title).title(
625 ratatui::text::Line::from(ratatui::text::Span::styled(
626 format!(" {result_count} results "),
627 Style::default().fg(theme.gray.to_ratatui()),
628 ))
629 .right_aligned(),
630 );
631 if self.is_sortable() {
635 let line = if self.is_unsorted() {
636 ratatui::text::Line::from(ratatui::text::Span::styled(" Unsorted ", theme.action()))
637 } else {
638 let state = self.sort_state();
639 crate::components::sortable::sort_chip_line(&state.field, state.order, theme)
640 };
641 search_block = self.sort_chip.place(
642 search_block,
643 rows[0],
644 crate::components::clickable::Edge::Top,
645 line,
646 );
647 } else {
648 self.sort_chip.hide();
649 }
650 let search_block = search_block
651 .borders(Borders::ALL)
652 .border_style(theme.border_style(true))
653 .style(modal_style);
654 let search_inner = search_block.inner(rows[0]);
655 f.render_widget(search_block, rows[0]);
656 let prefix = format!("{} ", self.prefix_glyph);
658 let prefix_w = unicode_width::UnicodeWidthStr::width(prefix.as_str()) as u16;
659 f.render_widget(
660 Paragraph::new(prefix).style(
661 Style::default()
662 .fg(theme.yellow.to_ratatui())
663 .bg(theme.bg_hard.to_ratatui()),
664 ),
665 Rect {
666 width: prefix_w.min(search_inner.width),
667 ..search_inner
668 },
669 );
670 let input_rect = Rect {
671 x: search_inner.x.saturating_add(prefix_w),
672 width: search_inner.width.saturating_sub(prefix_w),
673 ..search_inner
674 };
675 self.list.render_query(f, input_rect, theme, true);
676
677 let columns = Layout::default()
679 .direction(Direction::Horizontal)
680 .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
681 .split(rows[1]);
682
683 let list_block = Block::default()
687 .borders(Borders::ALL)
688 .border_style(theme.border_style(false))
689 .style(modal_style);
690 let list_inner = list_block.inner(columns[0]);
691 f.render_widget(list_block, columns[0]);
692 self.list.render(f, list_inner, theme, false);
693 self.list.set_list_rect(list_inner);
694 self.list.set_panel_rect(popup_rect);
696
697 if self.selected_note_path() != self.preview_path {
702 self.refresh_preview_from_list();
703 }
704
705 let needles = self.preview_needles();
708 let match_count = count_matches(&self.preview_text, &needles);
709 let preview_title = match (&self.preview_path, match_count) {
710 (Some(path), Some(n)) => {
711 format!(" {} · {} matches ", path.get_name(), n)
712 }
713 (Some(path), None) => format!(" {} ", path.get_name()),
714 (None, _) => " Preview ".to_string(),
715 };
716 let preview_block = Block::default()
717 .title(preview_title)
718 .borders(Borders::ALL)
719 .border_style(theme.border_style(false))
720 .style(modal_style);
721 let preview_inner = preview_block.inner(columns[1]);
722 f.render_widget(preview_block, columns[1]);
723 f.render_widget(
724 Paragraph::new(highlight_matches(
725 &self.preview_text,
726 &needles,
727 theme,
728 modal_style,
729 )),
730 preview_inner,
731 );
732
733 match &self.error {
735 Some(err) => {
736 self.hints.hide();
737 f.render_widget(
738 Paragraph::new(format!("⚠ {err}"))
739 .style(Style::default().fg(theme.red.to_ratatui())),
740 rows[2],
741 )
742 }
743 None => self.hints.render(
744 f,
745 rows[2],
746 Style::default().fg(theme.fg_secondary.to_ratatui()),
747 theme,
748 ),
749 }
750
751 self.list.render_autocomplete(f, popup_rect, theme);
754 }
755
756 fn hint_shortcuts(&self) -> Vec<(String, String)> {
757 let mut hints = vec![
758 ("↑↓".to_string(), "navigate".to_string()),
759 ("Enter".to_string(), "open".to_string()),
760 ("Esc".to_string(), "close".to_string()),
761 ];
762 if let Some(k) = self
763 .key_bindings
764 .first_combo_for(&ActionShortcuts::SaveCurrentQuery)
765 {
766 hints.push((k, "save query".to_string()));
767 }
768 hints
769 }
770}
771
772#[derive(Debug, Clone, Copy, PartialEq, Eq)]
778enum BrowserChip {
779 Key(ratatui::crossterm::event::KeyEvent),
781 Syntax,
784 Sort,
787}
788
789impl NoteBrowserModal {
790 fn click_chip(&mut self, chip: BrowserChip, tx: &AppTx) -> EventState {
793 let event = match chip {
794 BrowserChip::Key(key) => return self.handle_input(&InputEvent::Key(key), tx),
795 BrowserChip::Syntax => AppEvent::OpenQueryHelp,
796 BrowserChip::Sort => {
797 AppEvent::OpenSortDialog(crate::components::events::SortTarget::Browser)
798 }
799 };
800 self.list.close_autocomplete();
801 tx.send(event).ok();
802 EventState::Consumed
803 }
804
805 fn chip_at(&self, m: &ratatui::crossterm::event::MouseEvent) -> Option<BrowserChip> {
808 if self.list.popup_contains(m.column, m.row) {
809 return None;
810 }
811 if let Some(key) = self.hints.hit(m) {
812 return Some(if key.code == ratatui::crossterm::event::KeyCode::F(1) {
813 BrowserChip::Syntax
814 } else {
815 BrowserChip::Key(key)
816 });
817 }
818 self.sort_chip.hit(m).then_some(BrowserChip::Sort)
819 }
820}
821
822fn browser_hints(scope: BrowserScope) -> crate::components::hint_row::HintRow {
825 use ratatui::crossterm::event::KeyCode;
826 let mut chips = vec![
827 (KeyCode::Null, "↑↓", "Navigate"),
828 (KeyCode::Enter, "⏎", "Open"),
829 (KeyCode::Esc, "Esc", "Close"),
830 ];
831 if scope == BrowserScope::Query {
832 chips.push((KeyCode::F(1), "F1", "Query syntax"));
833 }
834 crate::components::hint_row::HintRow::new(&chips).with_indent(0)
835}
836
837pub(crate) fn format_journal_date(date: NaiveDate) -> String {
838 date.format("%A, %B %-d, %Y").to_string()
839}
840
841fn count_matches(text: &str, needles: &[String]) -> Option<usize> {
851 if needles.is_empty() {
852 return None;
853 }
854 Some(preview_highlight::match_ranges(text, needles).len())
855}
856
857fn highlight_matches<'a>(
861 text: &'a str,
862 needles: &[String],
863 theme: &Theme,
864 base: Style,
865) -> ratatui::text::Text<'a> {
866 use ratatui::text::{Line, Span};
867 if needles.is_empty() {
868 return ratatui::text::Text::styled(text, base);
869 }
870 let emphasis = base.patch(
871 Style::default()
872 .fg(theme.color_search_match.to_ratatui())
873 .add_modifier(ratatui::style::Modifier::BOLD),
874 );
875 let mut lines = Vec::new();
876 for line in text.lines() {
877 let ranges = preview_highlight::match_ranges(line, needles);
878 if ranges.is_empty() {
879 lines.push(Line::styled(line, base));
880 continue;
881 }
882 let spans = preview_highlight::style_ranges(line, &ranges, |s, hit| {
884 Span::styled(s, if hit { emphasis } else { base })
885 });
886 lines.push(Line::from(spans));
887 }
888 ratatui::text::Text::from(lines)
889}
890
891#[cfg(test)]
892mod tests {
893 use super::*;
894 use crate::components::file_list::{SortField, SortOrder};
895 use crate::components::search_list::{Emit, RowSource};
896 use crate::components::sortable::{SortState, SortableList};
897 use crate::settings::AppSettings;
898 use crate::test_support::temp_vault;
899 use async_trait::async_trait;
900 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
901 use tokio::sync::mpsc::unbounded_channel;
902
903 #[test]
904 fn count_matches_matches_highlighted_ranges() {
905 assert_eq!(count_matches("anything", &[]), None);
907 let needles = vec!["foo".to_string(), "foobar".to_string()];
910 assert_eq!(count_matches("foobar", &needles), Some(1));
911 assert_eq!(count_matches("foo and foo", &["foo".to_string()]), Some(2));
913 }
914
915 struct OneNoteSource {
918 path: VaultPath,
919 }
920
921 #[async_trait]
922 impl RowSource<FileListEntry> for OneNoteSource {
923 async fn load(&self, _query: &str, emit: Emit<FileListEntry>) {
924 emit.replace(vec![FileListEntry::Note {
925 path: self.path.clone(),
926 title: "Note".to_string(),
927 filename: self.path.to_string(),
928 journal_date: None,
929 is_open: false,
930 }]);
931 }
932 }
933
934 async fn make_modal_with(source: impl RowSource<FileListEntry>, tx: AppTx) -> NoteBrowserModal {
935 let vault = temp_vault("modal").await;
936 let settings = AppSettings::default();
937 NoteBrowserModal::new(
938 "test",
939 BrowserScope::Query,
940 source,
941 vault,
942 settings.key_bindings.clone(),
943 settings.icons(),
944 tx,
945 )
946 }
947
948 #[tokio::test]
949 async fn dialog_error_surfaces_then_clears_on_keystroke() {
950 let (tx, _rx) = unbounded_channel();
951 let path = VaultPath::note_path_from("/a.md");
952 let mut modal = make_modal_with(OneNoteSource { path }, tx.clone()).await;
953 let vault = temp_vault("modal_err").await;
954
955 let consumed = modal.handle_data(&OverlayData::Error("boom".to_string()), &vault, &tx);
956 assert!(matches!(consumed, OverlayMsg::Consumed));
957 assert_eq!(modal.error.as_deref(), Some("boom"));
958
959 modal.handle_input(
961 &InputEvent::Key(KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE)),
962 &tx,
963 );
964 assert_eq!(modal.error, None, "keystroke should clear the error");
965 }
966
967 #[tokio::test]
968 async fn modal_constructed_with_initial_query_prefills_input() {
969 let vault = temp_vault("modal_iq").await;
970 let settings = AppSettings::default();
971 let (tx, _rx) = unbounded_channel();
972 let modal = NoteBrowserModal::with_initial_query(
973 "test",
974 BrowserScope::Query,
975 OneNoteSource {
976 path: VaultPath::note_path_from("/a.md"),
977 },
978 vault,
979 settings.key_bindings.clone(),
980 settings.icons(),
981 tx,
982 "#important",
983 );
984 assert_eq!(modal.query_text(), "#important");
985 }
986
987 #[tokio::test]
991 async fn submit_opens_selected_note() {
992 let (tx, mut rx) = unbounded_channel();
993 let path = VaultPath::note_path_from("/a.md");
994 let mut modal = make_modal_with(OneNoteSource { path: path.clone() }, tx.clone()).await;
995 modal.list.poll_until_idle().await;
997
998 Overlay::handle_input(
999 &mut modal,
1000 &InputEvent::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
1001 &tx,
1002 );
1003
1004 let mut events = Vec::new();
1005 while let Ok(ev) = rx.try_recv() {
1006 events.push(ev);
1007 }
1008 assert!(
1009 events
1010 .iter()
1011 .any(|e| matches!(e, AppEvent::OpenPath { path: p, .. } if *p == path)),
1012 "expected OpenPath, got {events:?}"
1013 );
1014 assert!(
1015 !events.iter().any(|e| matches!(e, AppEvent::CloseOverlay)),
1016 "select must not emit CloseOverlay; editor's OpenPath handler closes the overlay, got {events:?}"
1017 );
1018 }
1019
1020 #[tokio::test]
1024 async fn refresh_preview_tracks_selected_path() {
1025 let (tx, _rx) = unbounded_channel();
1026 let path = VaultPath::note_path_from("/a.md");
1027 let mut modal = make_modal_with(OneNoteSource { path: path.clone() }, tx.clone()).await;
1028 modal.list.poll_until_idle().await;
1029 assert_eq!(modal.preview_path, None, "no path tracked before refresh");
1030
1031 modal.refresh_preview_from_list();
1032 assert_eq!(
1033 modal.preview_path,
1034 Some(path),
1035 "preview_path should track the selected note"
1036 );
1037 }
1038
1039 #[tokio::test]
1041 async fn esc_closes_modal() {
1042 let (tx, mut rx) = unbounded_channel();
1043 let mut modal = make_modal_with(
1044 OneNoteSource {
1045 path: VaultPath::note_path_from("/a.md"),
1046 },
1047 tx.clone(),
1048 )
1049 .await;
1050 Overlay::handle_input(
1051 &mut modal,
1052 &InputEvent::Key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE)),
1053 &tx,
1054 );
1055 let mut sent = false;
1056 while let Ok(ev) = rx.try_recv() {
1057 if matches!(ev, AppEvent::CloseOverlay) {
1058 sent = true;
1059 }
1060 }
1061 assert!(sent, "expected CloseOverlay on Esc");
1062 }
1063
1064 #[tokio::test(flavor = "multi_thread")]
1067 async fn accepting_saved_search_pins_breadcrumb() {
1068 let vault = temp_vault("modal-ss").await;
1069 vault.validate_and_init().await.unwrap();
1070 vault.save_search("todo-week", "#todo").await.unwrap();
1071 let settings = AppSettings::default();
1072 let (tx, _rx) = unbounded_channel();
1073 let mut modal = NoteBrowserModal::new(
1074 "test",
1075 BrowserScope::Query,
1076 OneNoteSource {
1077 path: VaultPath::note_path_from("/a.md"),
1078 },
1079 vault,
1080 settings.key_bindings.clone(),
1081 settings.icons(),
1082 tx.clone(),
1083 );
1084
1085 for ch in ['?', 't', 'o'] {
1088 Overlay::handle_input(
1089 &mut modal,
1090 &InputEvent::Key(KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE)),
1091 &tx,
1092 );
1093 for _ in 0..30 {
1094 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1095 modal.list.poll();
1096 }
1097 }
1098 Overlay::handle_input(
1099 &mut modal,
1100 &InputEvent::Key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)),
1101 &tx,
1102 );
1103
1104 assert_eq!(modal.query_text(), "#todo");
1105 assert_eq!(
1106 modal.saved_search_breadcrumb().as_deref(),
1107 Some("todo-week")
1108 );
1109 assert_eq!(Overlay::saved_search_provenance(&modal), Some("todo-week"));
1112 }
1113
1114 #[tokio::test]
1119 async fn an_error_line_hides_the_hint_chips() {
1120 use ratatui::{Terminal, backend::TestBackend};
1121 let (mut modal, _tx) = search_modal(BrowserScope::Query, "#x").await;
1122 let theme = crate::settings::themes::Theme::default();
1123 let mut t = Terminal::new(TestBackend::new(100, 30)).unwrap();
1124 t.draw(|f| modal.render(f, f.area(), &theme)).unwrap();
1125 let (x, y) = crate::test_support::find_text(t.backend().buffer(), "[Esc] Close")
1126 .expect("chips drawn");
1127 modal.error = Some("could not create".into());
1128 t.draw(|f| modal.render(f, f.area(), &theme)).unwrap();
1129 let (tx, mut rx) = unbounded_channel();
1130 modal.handle_input(&crate::test_support::mouse_down_at(x, y), &tx);
1131 assert!(
1132 !crate::test_support::drain(&mut rx)
1133 .iter()
1134 .any(|e| matches!(e, AppEvent::CloseOverlay)),
1135 "the hidden [Esc] chip did not fire"
1136 );
1137 }
1138
1139 async fn search_modal(scope: BrowserScope, query: &str) -> (NoteBrowserModal, AppTx) {
1140 let vault = temp_vault("modal_sort").await;
1141 let settings = AppSettings::default();
1142 let (tx, _rx) = unbounded_channel();
1143 let modal = NoteBrowserModal::with_initial_query(
1144 "test",
1145 scope,
1146 OneNoteSource {
1147 path: VaultPath::note_path_from("/a.md"),
1148 },
1149 vault,
1150 settings.key_bindings.clone(),
1151 settings.icons(),
1152 tx.clone(),
1153 query,
1154 );
1155 (modal, tx)
1156 }
1157
1158 #[tokio::test]
1159 async fn apply_sort_rewrites_the_query_order_directive() {
1160 let (mut modal, tx) = search_modal(BrowserScope::Query, "#work").await;
1161 let state = SortState {
1162 field: SortField::Property("due".into()),
1163 order: SortOrder::Descending,
1164 group_dirs: None,
1165 };
1166 modal.apply_sort(&state, &tx);
1167 assert_eq!(modal.query_text(), "#work -or:prop:due");
1168 assert_eq!(modal.sort_state(), state, "sort_state reads it back");
1169 }
1170
1171 #[tokio::test]
1174 async fn title_sort_reorders_the_recent_notes_in_place() {
1175 use crate::components::note_browser::search_provider::resolving_search_source;
1176 let vault = temp_vault("modal_sort_recents").await;
1177 vault.validate_and_init().await.unwrap();
1178 for (file, body) in [
1179 ("a", "# Charlie\nx"),
1180 ("b", "# Alpha\nx"),
1181 ("c", "# Bravo\nx"),
1182 ] {
1183 vault
1184 .create_note(&VaultPath::note_path_from(file), body)
1185 .await
1186 .unwrap();
1187 }
1188 vault
1189 .create_note(&VaultPath::note_path_from("other"), "# Aaa\nx")
1190 .await
1191 .unwrap();
1192 let recents: Vec<VaultPath> = ["c", "a", "b"]
1193 .into_iter()
1194 .map(VaultPath::note_path_from)
1195 .collect();
1196 let settings = AppSettings::default();
1197 let (tx, _rx) = unbounded_channel();
1198 let mut modal = NoteBrowserModal::new(
1199 "test",
1200 BrowserScope::Query,
1201 resolving_search_source(vault.clone(), recents, None),
1202 vault,
1203 settings.key_bindings.clone(),
1204 settings.icons(),
1205 tx.clone(),
1206 );
1207 let listed = |m: &NoteBrowserModal| -> Vec<String> {
1208 m.list
1209 .visible_rows()
1210 .iter()
1211 .filter_map(|r| match r {
1212 FileListEntry::Note { path, .. } => Some(path.get_clean_name()),
1213 _ => None,
1214 })
1215 .collect()
1216 };
1217 modal.list.poll_until_idle().await;
1218 assert_eq!(listed(&modal), ["c", "a", "b"]);
1219
1220 modal.apply_sort(
1221 &SortState {
1222 field: SortField::Title,
1223 order: SortOrder::Ascending,
1224 group_dirs: None,
1225 },
1226 &tx,
1227 );
1228 modal.list.poll_until_idle().await;
1229 assert_eq!(modal.query_text(), "or:title");
1230 assert_eq!(listed(&modal), ["b", "c", "a"], "same set, by title");
1231 }
1232
1233 #[tokio::test]
1234 async fn apply_sort_ignores_an_empty_property_key() {
1235 let (mut modal, tx) = search_modal(BrowserScope::Query, "x -or:title").await;
1236 for key in ["", " "] {
1237 let state = SortState {
1238 field: SortField::Property(key.into()),
1239 order: SortOrder::Ascending,
1240 group_dirs: None,
1241 };
1242 modal.apply_sort(&state, &tx);
1243 }
1244 assert_eq!(modal.query_text(), "x -or:title");
1245 }
1246
1247 #[tokio::test]
1248 async fn sort_state_defaults_to_name_ascending() {
1249 let (modal, _tx) = search_modal(BrowserScope::Query, "#work").await;
1250 assert_eq!(
1251 modal.sort_state(),
1252 SortState {
1253 field: SortField::Name,
1254 order: SortOrder::Ascending,
1255 group_dirs: None,
1256 }
1257 );
1258 assert!(modal.allows_property());
1259 }
1260
1261 #[tokio::test]
1264 async fn the_search_browser_and_the_file_finder_are_sortable() {
1265 let (mut search, _) = search_modal(BrowserScope::Query, "").await;
1266 let (mut links, _) = search_modal(BrowserScope::Files, "").await;
1267 let (finder, _) = search_modal(BrowserScope::Files, "").await;
1268 let mut finder = finder.with_row_sort();
1269 assert!(search.as_sortable().is_some());
1270 assert!(search.as_sortable_mut().is_some());
1271 assert!(links.as_sortable().is_none());
1272 assert!(links.as_sortable_mut().is_none());
1273 assert!(finder.as_sortable().is_some());
1274 assert!(finder.as_sortable_mut().is_some());
1275 assert!(finder.allows_property());
1276 }
1277
1278 #[tokio::test]
1281 async fn apply_sort_marks_the_saved_search_breadcrumb_edited() {
1282 let (mut modal, tx) = search_modal(BrowserScope::Query, "#todo").await;
1283 modal.saved_search.set(Some("todo".into()), "#todo");
1284 assert_eq!(modal.saved_search_breadcrumb().as_deref(), Some("todo"));
1285 modal.apply_sort(
1286 &SortState {
1287 field: SortField::Title,
1288 order: SortOrder::Ascending,
1289 group_dirs: None,
1290 },
1291 &tx,
1292 );
1293 assert_eq!(
1294 modal.saved_search_breadcrumb().as_deref(),
1295 Some("todo • edited")
1296 );
1297 }
1298
1299 async fn finder_modal(prefix: &str) -> (NoteBrowserModal, AppTx) {
1301 use crate::components::note_browser::file_finder_provider::FileFinderProvider;
1302 let vault = temp_vault(prefix).await;
1303 vault.validate_and_init().await.unwrap();
1304 for (name, body) in [
1305 ("alpha", "---\nrank: 3\n---\nbody"),
1306 ("bravo", "---\nrank: 1\n---\nbody"),
1307 ("charlie", "body"),
1308 ] {
1309 vault
1310 .create_note(&VaultPath::note_path_from(name), body)
1311 .await
1312 .unwrap();
1313 }
1314 let settings = AppSettings::default();
1315 let (tx, _rx) = unbounded_channel();
1316 let provider = FileFinderProvider::new(vault.clone(), VaultPath::root());
1317 let mut modal = NoteBrowserModal::new(
1318 "Find Note",
1319 BrowserScope::Files,
1320 provider,
1321 vault,
1322 settings.key_bindings.clone(),
1323 settings.icons(),
1324 tx.clone(),
1325 )
1326 .with_row_sort();
1327 modal.list.poll_until_idle().await;
1328 (modal, tx)
1329 }
1330
1331 fn finder_names(modal: &NoteBrowserModal) -> Vec<String> {
1332 modal
1333 .list
1334 .visible_rows()
1335 .iter()
1336 .map(|r| r.path().get_name())
1337 .collect()
1338 }
1339
1340 fn sort(field: SortField, order: SortOrder) -> SortState {
1341 SortState {
1342 field,
1343 order,
1344 group_dirs: None,
1345 }
1346 }
1347
1348 async fn poll_finder_values(modal: &mut NoteBrowserModal) {
1350 for _ in 0..100 {
1351 modal.poll_row_sort();
1352 if !modal.row_sort_pending() {
1353 return;
1354 }
1355 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1356 }
1357 panic!("property values never arrived");
1358 }
1359
1360 #[tokio::test(flavor = "multi_thread")]
1363 async fn file_finder_sorts_rows_by_name_and_property() {
1364 let (mut modal, tx) = finder_modal("finder-sort").await;
1365 modal.apply_sort(&sort(SortField::Name, SortOrder::Descending), &tx);
1366 assert_eq!(finder_names(&modal), ["charlie.md", "bravo.md", "alpha.md"]);
1367 assert_eq!(
1368 modal.sort_state(),
1369 sort(SortField::Name, SortOrder::Descending)
1370 );
1371
1372 let rank = SortField::Property("rank".into());
1373 modal.apply_sort(&sort(rank.clone(), SortOrder::Ascending), &tx);
1374 assert_eq!(
1375 finder_names(&modal),
1376 ["charlie.md", "bravo.md", "alpha.md"],
1377 "current order kept until the values arrive"
1378 );
1379 poll_finder_values(&mut modal).await;
1380 assert_eq!(finder_names(&modal), ["bravo.md", "alpha.md", "charlie.md"]);
1381 modal.apply_sort(&sort(rank.clone(), SortOrder::Descending), &tx);
1382 assert_eq!(finder_names(&modal), ["alpha.md", "bravo.md", "charlie.md"]);
1383 assert_eq!(modal.sort_state(), sort(rank, SortOrder::Descending));
1384 }
1385
1386 #[tokio::test(flavor = "multi_thread")]
1388 async fn file_finder_ignores_stale_property_values() {
1389 use kimun_core::PropertySortValue::Number;
1390 let (mut modal, tx) = finder_modal("finder-stale").await;
1391 modal.apply_sort(&sort(SortField::Name, SortOrder::Ascending), &tx);
1392 modal.apply_sort(
1393 &sort(SortField::Property("rank".into()), SortOrder::Ascending),
1394 &tx,
1395 );
1396 let stale = Arc::new(std::collections::HashMap::from([(
1397 VaultPath::note_path_from("/charlie"),
1398 Number(0.0),
1399 )]));
1400 assert!(!modal.on_property_sort_values("other", stale));
1401 assert_eq!(finder_names(&modal), ["alpha.md", "bravo.md", "charlie.md"]);
1402 }
1403
1404 #[tokio::test(flavor = "multi_thread")]
1407 async fn file_finder_is_unsorted_until_a_sort_is_picked() {
1408 let (mut modal, tx) = finder_modal("finder-unsorted").await;
1409 assert!(modal.is_unsorted());
1410 modal.apply_sort(&sort(SortField::Name, SortOrder::Ascending), &tx);
1411 assert!(!modal.is_unsorted());
1412 }
1413
1414 #[tokio::test]
1417 async fn search_browser_is_unsorted_only_on_the_recents() {
1418 let (empty, _) = search_modal(BrowserScope::Query, "").await;
1419 assert!(empty.is_unsorted());
1420 let (term, _) = search_modal(BrowserScope::Query, "#work").await;
1421 assert!(!term.is_unsorted());
1422 let (sorted, _) = search_modal(BrowserScope::Query, "or:title").await;
1423 assert!(!sorted.is_unsorted());
1424 }
1425
1426 #[tokio::test(flavor = "multi_thread")]
1429 async fn file_finder_order_toggles_reuse_the_property_values() {
1430 let (mut modal, tx) = finder_modal("finder-prop-cache").await;
1431 let rank = SortField::Property("rank".into());
1432 modal.apply_sort(&sort(rank.clone(), SortOrder::Ascending), &tx);
1433 poll_finder_values(&mut modal).await;
1434 let fetches = |m: &NoteBrowserModal| m.row_sort.as_ref().unwrap().property.fetches;
1435 assert_eq!(fetches(&modal), 1);
1436 modal.apply_sort(&sort(rank.clone(), SortOrder::Descending), &tx);
1437 modal.apply_sort(&sort(rank, SortOrder::Ascending), &tx);
1438 assert_eq!(fetches(&modal), 1, "order toggles: no refetch");
1439 assert!(!modal.row_sort_pending());
1440 assert_eq!(finder_names(&modal), ["bravo.md", "alpha.md", "charlie.md"]);
1441 modal.apply_sort(
1442 &sort(SortField::Property("due".into()), SortOrder::Ascending),
1443 &tx,
1444 );
1445 assert_eq!(fetches(&modal), 2, "a new key fetches");
1446 modal.apply_sort(&sort(SortField::Title, SortOrder::Ascending), &tx);
1447 modal.apply_sort(
1448 &sort(SortField::Property("due".into()), SortOrder::Ascending),
1449 &tx,
1450 );
1451 assert_eq!(fetches(&modal), 3, "back to the key after Title refetches");
1452 }
1453}