1use std::sync::{Arc, Mutex};
2
3use async_trait::async_trait;
4use kimun_core::NoteVault;
5use kimun_core::nfs::VaultPath;
6use ratatui::Frame;
7use ratatui::crossterm::event::{KeyCode, KeyEvent};
8use ratatui::layout::{Constraint, Direction, Layout, Rect};
9use ratatui::style::{Modifier, Style};
10use ratatui::text::Span;
11use ratatui::widgets::{Block, Borders, ListItem, Paragraph};
12
13use crate::components::autocomplete::AutocompleteMode;
14use crate::components::event_state::EventState;
15use crate::components::events::{AppEvent, AppTx, FileOp, SortTarget};
16use crate::components::file_list::{SortField, SortOrder};
17use crate::components::preview_pane::{Highlight, PreviewPane};
18use crate::components::query_vars::{QueryContext, query_has_variables, resolve_query};
19use crate::components::saved_search_breadcrumb::SavedSearchBreadcrumb;
20use crate::components::search_list::{
21 Emit, Focus, KeyReaction, ResolvingRowSource, RowSource, SearchList, SearchMouse, SearchRow,
22 Unresolvable, VaultSuggestions,
23};
24use crate::components::sortable::{
25 SortState, SortableList, order_of_query, query_with_sort, sort_chip_line,
26};
27use crate::keys::KeyBindings;
28use crate::keys::action_shortcuts::ActionShortcuts;
29use crate::keys::key_combo::KeyCombo;
30use crate::settings::icons::Icons;
31use crate::settings::themes::Theme;
32
33const DEFAULT_QUERY: &str = "<{note}";
37const DEFAULT_QUERY_LONG: &str = "lk:{note}";
40
41fn is_default_query(query: &str) -> bool {
46 let expanded = kimun_core::expand_bare_note_prefixes(
47 &kimun_core::strip_order_directive(query),
48 crate::components::query_vars::VAR_NOTE,
49 );
50 expanded == DEFAULT_QUERY || expanded == DEFAULT_QUERY_LONG
51}
52
53#[derive(Debug, Clone)]
59pub struct BacklinkEntry {
60 pub path: VaultPath,
61 pub title: String,
62 pub filename: String,
63 pub context: String,
65 pub full_text: Option<String>,
67}
68
69impl SearchRow for BacklinkEntry {
70 fn to_list_item(&self, theme: &Theme, icons: &Icons, selected: bool) -> ListItem<'static> {
71 let title_display = if self.title.is_empty() {
72 &self.filename
73 } else {
74 &self.title
75 };
76 let title_style = if selected {
77 Style::default()
78 .fg(theme.selection_fg.to_ratatui())
79 .bg(theme.selection_bg.to_ratatui())
80 .add_modifier(Modifier::BOLD)
81 } else {
82 Style::default()
83 .fg(theme.fg.to_ratatui())
84 .bg(theme.bg_panel.to_ratatui())
85 };
86 crate::components::rich_row::RichRow::new(icons.note, title_display.clone())
87 .title_style(title_style)
88 .meta(self.filename.clone())
89 .into_list_item(theme)
90 }
91
92 fn match_text(&self) -> Option<&str> {
93 Some(&self.filename)
94 }
95
96 fn visual_height(&self) -> u16 {
97 1
98 }
99
100 fn yank_target(&self) -> Option<crate::components::search_list::YankTarget> {
101 Some(crate::components::search_list::YankTarget::path(
102 self.path.to_string(),
103 ))
104 }
105}
106
107struct BacklinkSource {
118 vault: Arc<NoteVault>,
119}
120
121#[async_trait]
122impl RowSource<BacklinkEntry> for BacklinkSource {
123 async fn load(&self, query: &str, emit: Emit<BacklinkEntry>) {
124 let mut entries = load_query(&self.vault, query).await;
125 if kimun_core::SearchTerms::from_query_string(query)
132 .order_by
133 .is_empty()
134 {
135 entries.sort_by_key(|e| e.filename.to_lowercase());
136 }
137 emit.replace(entries);
138 }
139}
140
141pub struct QueryPanel {
146 list: SearchList<BacklinkEntry>,
149 current_note: Arc<Mutex<VaultPath>>,
152 saved_search: SavedSearchBreadcrumb,
156 preview: PreviewPane,
161 sort_chip: crate::components::clickable::BorderChip,
164 syntax_chip: crate::components::clickable::BorderChip,
166 key_bindings: KeyBindings,
167 redraw_tx: Arc<Mutex<Option<AppTx>>>,
172 follow_link_combos: Vec<KeyCombo>,
174 order_cache: (SortField, SortOrder),
179 order_cache_query: String,
180 is_default_cache: bool,
184 needles_cache: Vec<String>,
190 needles_cache_key: (String, VaultPath),
191}
192
193impl QueryPanel {
194 pub fn new(vault: Arc<NoteVault>, key_bindings: KeyBindings, icons: Icons) -> Self {
195 let current_note = Arc::new(Mutex::new(VaultPath::empty()));
196 let redraw_tx: Arc<Mutex<Option<AppTx>>> = Arc::new(Mutex::new(None));
200 let redraw: Arc<dyn Fn() + Send + Sync> = {
201 let slot = redraw_tx.clone();
202 Arc::new(move || {
203 if let Some(tx) = slot.lock().unwrap().as_ref() {
204 let _ = tx.send(AppEvent::Redraw);
205 }
206 })
207 };
208 let source = ResolvingRowSource::new(
212 Arc::new(BacklinkSource {
213 vault: vault.clone(),
214 }),
215 {
216 let note = current_note.clone();
217 move || QueryContext::with_note(Some(note.lock().unwrap().clone()))
218 },
219 Unresolvable::Empty,
220 );
221 let combos = |action: &ActionShortcuts| -> Vec<KeyCombo> {
222 key_bindings
223 .to_hashmap()
224 .get(action)
225 .cloned()
226 .unwrap_or_default()
227 };
228 let follow_link_combos = combos(&ActionShortcuts::FollowLink);
229
230 let mut intercept = Vec::new();
231 intercept.extend(follow_link_combos.iter().cloned());
232
233 let list = SearchList::builder(source, redraw)
234 .highlight_query()
235 .yank_combos_from(&key_bindings)
236 .icons(icons.clone())
237 .autocomplete(
238 Arc::new(VaultSuggestions {
239 vault: vault.clone(),
240 }),
241 AutocompleteMode::SearchQuery,
242 )
243 .intercept(intercept)
244 .list_verb('l')
248 .list_verb('h')
249 .list_verb('o')
250 .list_verb('y')
251 .build();
252
253 Self {
254 list,
255 current_note,
256 saved_search: SavedSearchBreadcrumb::default(),
257 preview: PreviewPane::new(),
258 sort_chip: Default::default(),
259 syntax_chip: Default::default(),
260 key_bindings,
261 redraw_tx,
262 follow_link_combos,
263 order_cache: (SortField::Name, SortOrder::Ascending),
265 order_cache_query: String::new(),
266 is_default_cache: false,
269 needles_cache: Vec::new(),
270 needles_cache_key: (String::new(), VaultPath::empty()),
271 }
272 }
273
274 pub fn active_query(&self) -> &str {
277 self.list.query()
278 }
279
280 fn emphasis(&self) -> Option<Vec<String>> {
284 let resolved = resolve_query(self.list.query(), &self.query_ctx());
285 let needles = crate::components::query_highlight::emphasis_needles(&resolved);
286 (!needles.is_empty()).then_some(needles)
287 }
288
289 pub fn result_count(&self) -> usize {
291 self.list.match_count()
292 }
293
294 pub fn set_active_query(&mut self, q: String) {
295 self.list.set_query(q);
296 self.reset_expand();
297 }
298
299 pub fn saved_search_breadcrumb(&self) -> Option<String> {
302 self.saved_search.label(self.list.query())
303 }
304
305 pub fn saved_search_name(&self) -> Option<&str> {
309 self.saved_search.name()
310 }
311
312 pub fn repin_saved_search(&mut self, name: String, query: &str) {
316 self.saved_search.set(Some(name), query);
317 }
318
319 fn query_is_blank(&self) -> bool {
324 let q = self.list.query();
325 q.trim().is_empty() || is_default_query(q)
326 }
327
328 pub fn apply_query(&mut self, query: String, name: Option<String>, tx: AppTx) {
332 self.ensure_redraw_tx(&tx);
333 self.set_active_query(query.clone());
334 self.saved_search.set(name, &query);
335 }
336
337 fn current_note(&self) -> VaultPath {
340 self.current_note.lock().unwrap().clone()
341 }
342
343 fn query_ctx(&self) -> QueryContext {
347 QueryContext::with_note(Some(self.current_note()))
348 }
349
350 fn ensure_redraw_tx(&self, tx: &AppTx) {
353 let mut slot = self.redraw_tx.lock().unwrap();
354 if slot.is_none() {
355 *slot = Some(tx.clone());
356 }
357 }
358
359 fn cached_needles(&mut self) -> &[String] {
364 let note = self.current_note();
365 if self.needles_cache_key.0 != self.list.query() || self.needles_cache_key.1 != note {
366 let resolved = resolve_query(self.list.query(), &self.query_ctx());
367 self.needles_cache = crate::components::query_highlight::emphasis_needles(&resolved);
371 self.needles_cache_key = (self.list.query().to_string(), note);
372 }
373 &self.needles_cache
374 }
375
376 fn is_full_expanded(&self) -> bool {
379 self.list.selected_row().is_some() && self.preview.is_full()
380 }
381
382 pub fn is_empty(&self) -> bool {
383 self.list.rows().is_empty()
384 }
385
386 pub fn selected_path(&self) -> Option<&VaultPath> {
387 self.list.selected_row().map(|e| &e.path)
388 }
389
390 fn reset_expand(&mut self) {
391 self.preview.reset();
392 self.list.set_content_rect(Rect::default());
393 }
394
395 fn sync_expand_anchor(&mut self) {
399 let sel = self.list.selected_row().map(|e| e.path.clone());
400 if self.preview.sync(sel) {
401 self.list.set_content_rect(Rect::default());
402 }
403 }
404
405 pub fn set_note(&mut self, note_path: VaultPath, tx: AppTx) {
410 self.ensure_redraw_tx(&tx);
411 *self.current_note.lock().unwrap() = note_path;
412 if query_has_variables(self.list.query()) {
413 self.list.reload();
414 self.reset_expand();
415 }
416 }
417
418 pub fn current_order(&self) -> (SortField, SortOrder) {
423 order_of_query(self.list.query())
424 }
425
426 pub fn apply_sort(&mut self, field: SortField, order: SortOrder, tx: &AppTx) {
429 let Some(rewritten) = query_with_sort(self.list.query(), &field, order) else {
430 return;
431 };
432 self.ensure_redraw_tx(tx);
433 self.list.set_query(rewritten);
434 self.reset_expand();
438 }
439
440 pub fn handle_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
443 self.ensure_redraw_tx(tx);
444 self.sync_expand_anchor();
445
446 if self.is_full_expanded() && matches!(key.code, KeyCode::Up | KeyCode::Down) {
449 self.scroll_content(key);
450 return EventState::Consumed;
451 }
452 if key.code == KeyCode::Enter
457 && key
458 .modifiers
459 .contains(ratatui::crossterm::event::KeyModifiers::CONTROL)
460 {
461 if let Some(path) = self.selected_path().cloned() {
462 tx.send(AppEvent::OpenPath {
463 path,
464 emphasis: self.emphasis(),
465 })
466 .ok();
467 }
468 return EventState::Consumed;
469 }
470 let prev_query = self.list.query().to_string();
478 match self.list.handle_key(key) {
479 KeyReaction::Intercepted(c) if self.follow_link_combos.contains(&c) => {
480 if let Some(path) = self.selected_path().cloned() {
481 tx.send(AppEvent::OpenPath {
482 path,
483 emphasis: self.emphasis(),
484 })
485 .ok();
486 }
487 EventState::Consumed
488 }
489 KeyReaction::Consumed => {
490 self.after_query_edit(&prev_query);
491 EventState::Consumed
492 }
493 KeyReaction::Submit => {
494 self.toggle_expand();
497 EventState::Consumed
498 }
499 KeyReaction::ListVerb(c) => {
503 match c {
504 'l' => self.toggle_expand(),
505 'h' => self.collapse_expand(),
506 'o' => self.open_selected(tx),
507 'y' => self.yank_selected_row(tx),
508 _ => {}
509 }
510 self.sync_expand_anchor();
511 EventState::Consumed
512 }
513 KeyReaction::Cancel => EventState::NotConsumed,
516 KeyReaction::Unhandled => EventState::NotConsumed,
517 KeyReaction::Intercepted(_) => EventState::Consumed,
518 KeyReaction::Yank(target) => {
519 crate::components::yank_row(target, tx);
520 EventState::Consumed
521 }
522 }
523 }
524
525 pub fn handle_mouse(
534 &mut self,
535 mouse: &ratatui::crossterm::event::MouseEvent,
536 tx: &AppTx,
537 ) -> EventState {
538 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
539 use ratatui::layout::Position;
540 self.ensure_redraw_tx(tx);
541 let on_popup = self.list.popup_contains(mouse.column, mouse.row);
544 if !on_popup && self.syntax_chip.hit(mouse) {
546 self.list.close_autocomplete();
547 tx.send(AppEvent::OpenQueryHelp).ok();
548 return EventState::Consumed;
549 }
550 if !on_popup && self.sort_chip.hit(mouse) {
553 self.list.close_autocomplete();
554 tx.send(AppEvent::OpenSortDialog(SortTarget::Query)).ok();
555 return EventState::Consumed;
556 }
557 let was_full = self.is_full_expanded();
562 self.sync_expand_anchor();
563 if was_full {
570 match mouse.kind {
571 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {}
575 MouseEventKind::Down(MouseButton::Left)
576 if self.list.input_contains(mouse.column, mouse.row)
577 || self.list.popup_contains(mouse.column, mouse.row) => {}
578 MouseEventKind::Down(MouseButton::Left)
582 if self.preview.full_header_rect().contains(Position {
583 x: mouse.column,
584 y: mouse.row,
585 }) =>
586 {
587 self.list.close_autocomplete();
588 self.toggle_expand();
589 return EventState::Consumed;
590 }
591 MouseEventKind::Down(_) => {
593 self.list.close_autocomplete();
594 return EventState::Consumed;
595 }
596 _ => return EventState::Consumed,
597 }
598 }
599 let prev_query = self.list.query().to_string();
600 match self.list.handle_mouse(mouse) {
601 SearchMouse::Autocomplete { edited: true } => {
602 self.after_query_edit(&prev_query);
603 EventState::Consumed
604 }
605 SearchMouse::Autocomplete { edited: false } => EventState::Consumed,
606 SearchMouse::ContentScrollUp => {
607 self.preview.scroll_up();
608 EventState::Consumed
609 }
610 SearchMouse::ContentScrollDown => {
611 self.preview.scroll_down();
612 EventState::Consumed
613 }
614 SearchMouse::Activated(_) => {
617 self.toggle_expand();
618 EventState::Consumed
619 }
620 SearchMouse::DoubleClicked { .. } => {
621 self.open_selected(tx);
622 EventState::Consumed
623 }
624 SearchMouse::Context(_) => {
626 if let Some(path) = self.selected_path().cloned() {
627 tx.send(AppEvent::FileOp(FileOp::ShowMenu(path))).ok();
628 }
629 EventState::Consumed
630 }
631 SearchMouse::Selected(_) | SearchMouse::Scrolled => {
632 self.sync_expand_anchor();
633 EventState::Consumed
634 }
635 SearchMouse::InputFocused => EventState::Consumed,
636 SearchMouse::None => EventState::NotConsumed,
637 }
638 }
639
640 fn after_query_edit(&mut self, prev_query: &str) {
642 let accepted = self.list.take_accepted_saved_search();
645 let blank = self.query_is_blank();
646 self.saved_search
647 .on_query_consumed(accepted, self.list.query(), blank);
648 if self.list.query() != prev_query {
653 self.preview.re_anchor();
654 }
655 self.sync_expand_anchor();
656 }
657
658 fn yank_selected_row(&self, tx: &AppTx) {
662 crate::components::yank_row(self.list.selected_row().and_then(|r| r.yank_target()), tx);
663 }
664
665 fn scroll_content(&mut self, key: &KeyEvent) {
666 match key.code {
667 KeyCode::Up => self.preview.scroll_up(),
668 KeyCode::Down => self.preview.scroll_down(),
669 _ => {}
670 }
671 }
672
673 fn toggle_expand(&mut self) {
674 let sel = self.list.selected_row().map(|e| e.path.clone());
675 if sel.is_none() {
676 return;
677 }
678 self.preview.toggle(sel);
679 self.list.set_content_rect(Rect::default());
680 }
681
682 fn collapse_expand(&mut self) {
685 let sel = self.list.selected_row().map(|e| e.path.clone());
686 if sel.is_none() {
687 return;
688 }
689 self.preview.collapse_step(sel);
690 self.list.set_content_rect(Rect::default());
691 }
692
693 fn open_selected(&self, tx: &AppTx) {
696 if let Some(path) = self.selected_path().cloned() {
697 tx.send(AppEvent::OpenPath {
698 path,
699 emphasis: self.emphasis(),
700 })
701 .ok();
702 }
703 }
704
705 pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
706 if self.list.focus() == Focus::List {
709 return vec![
710 ("j/k".to_string(), "navigate".to_string()),
711 ("h/l".to_string(), "preview".to_string()),
712 ("o".to_string(), "open".to_string()),
713 ("y".to_string(), "yank".to_string()),
714 ("i".to_string(), "filter".to_string()),
715 ("Esc".to_string(), "\u{2190} editor".to_string()),
716 ];
717 }
718 crate::components::hints::hints_for(
719 &self.key_bindings,
720 &[
721 (ActionShortcuts::FocusSidebar, "\u{2190} editor"),
722 (ActionShortcuts::FollowLink, "open note"),
723 (ActionShortcuts::SaveCurrentQuery, "save query"),
724 (ActionShortcuts::OpenSavedSearches, "searches"),
725 (ActionShortcuts::OpenSortDialog, "sort"),
726 ],
727 )
728 }
729
730 pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
733 self.list.poll();
734 self.sync_expand_anchor();
735 self.list.set_panel_rect(rect);
738 self.list.set_content_rect(Rect::default());
743 self.preview.clear_header();
744
745 let border_style = theme.border_style(focused);
746 let gray = theme.gray.to_ratatui();
747 let bg = theme.bg_panel.to_ratatui();
748
749 let count = self.list.visible_rows().len();
750 if self.list.query() != self.order_cache_query {
753 self.order_cache = self.current_order();
754 self.is_default_cache = is_default_query(self.list.query());
755 self.order_cache_query = self.list.query().to_string();
756 }
757 let (sort_field, sort_order) = &self.order_cache;
758 let querying = !self.list.query().trim().is_empty();
765 let title = if !querying {
766 "Find".to_string()
767 } else if self.is_default_cache {
768 format!("Backlinks ({count})")
769 } else {
770 format!("Query ({count})")
771 };
772 let outer = Block::default()
773 .title(title)
774 .borders(Borders::ALL)
775 .border_style(border_style)
776 .style(theme.panel_style());
777 let outer_inner = outer.inner(rect);
778 f.render_widget(outer, rect);
779
780 let rows = Layout::default()
782 .direction(Direction::Vertical)
783 .constraints([Constraint::Length(3), Constraint::Min(0)])
784 .split(outer_inner);
785 let search_title = self.saved_search.border_title(self.list.query(), " Query");
788 let mut search_block = Block::default()
789 .title(search_title)
790 .borders(Borders::ALL)
791 .border_style(border_style)
792 .style(theme.panel_style());
793 if let Some(reason) = crate::components::query_highlight::error_reason(self.list.query()) {
796 search_block = search_block.title(
797 ratatui::text::Line::from(ratatui::text::Span::styled(
798 format!(" ⚠ {reason} "),
799 Style::default().fg(theme.red.to_ratatui()),
800 ))
801 .right_aligned(),
802 );
803 }
804 use crate::components::clickable::Edge;
809 if querying {
810 search_block = self.sort_chip.place(
811 search_block,
812 rows[0],
813 Edge::Top,
814 sort_chip_line(sort_field, *sort_order, theme),
815 );
816 } else {
817 self.sort_chip.hide();
818 }
819 search_block = self.syntax_chip.place(
822 search_block,
823 rows[0],
824 Edge::Bottom,
825 ratatui::text::Line::from(vec![
826 ratatui::text::Span::styled("[F1]", theme.action()),
827 ratatui::text::Span::styled(
828 " Syntax ",
829 Style::default().fg(theme.gray.to_ratatui()),
830 ),
831 ]),
832 );
833 let search_inner = search_block.inner(rows[0]);
834 f.render_widget(search_block, rows[0]);
835 self.list.render_query(f, search_inner, theme, focused);
836
837 let inner = rows[1];
838
839 if self.list.is_loading() {
840 f.render_widget(
841 Paragraph::new(" Loading...").style(Style::default().fg(gray).bg(bg)),
842 inner,
843 );
844 self.list.render_autocomplete(f, rect, theme);
845 return;
846 }
847
848 if self.list.visible_rows().is_empty() {
849 f.render_widget(
850 Paragraph::new(" No results").style(Style::default().fg(gray).bg(bg)),
851 inner,
852 );
853 self.list.render_autocomplete(f, rect, theme);
854 return;
855 }
856
857 if self.preview.is_full() {
861 self.list.set_content_rect(rect);
862 if let Some(entry) = self.list.selected_row() {
863 let entry = entry.clone();
864 let text = entry
865 .full_text
866 .clone()
867 .unwrap_or_else(|| entry.context.clone());
868 let needles = self.cached_needles().to_vec();
869 self.preview.render_full(
870 f,
871 inner,
872 &entry.title,
873 &entry.filename,
874 &text,
875 Highlight::Needles(&needles),
876 theme,
877 );
878 }
879 self.list.render_autocomplete(f, rect, theme);
880 return;
881 }
882
883 let has_context = self.preview.is_context();
885
886 let (list_area, divider_area, content_area) = if has_context {
887 let max_list = inner.height / 2;
888 let list_height = (count as u16).min(max_list).max(1);
889 let areas = Layout::default()
890 .direction(Direction::Vertical)
891 .constraints([
892 Constraint::Length(list_height),
893 Constraint::Length(1),
894 Constraint::Min(0),
895 ])
896 .split(inner);
897 (areas[0], Some(areas[1]), Some(areas[2]))
898 } else {
899 (inner, None, None)
900 };
901
902 if self.list.query().trim().is_empty() {
905 let dim = Style::default().fg(theme.gray.to_ratatui());
909 let key = Style::default().fg(theme.yellow.to_ratatui());
910 let lines = vec![
911 ratatui::text::Line::from(Span::styled("type to search the vault", dim)),
912 ratatui::text::Line::default(),
913 ratatui::text::Line::from(vec![
914 Span::styled(" #tag ", key),
915 Span::styled("label", dim),
916 ]),
917 ratatui::text::Line::from(vec![
918 Span::styled(" < > ", key),
919 Span::styled("backlinks · links", dim),
920 ]),
921 ratatui::text::Line::from(vec![
922 Span::styled(" \"phrase\" ", key),
923 Span::styled("exact match", dim),
924 ]),
925 ratatui::text::Line::from(vec![
926 Span::styled(" =date ", key),
927 Span::styled("modified", dim),
928 ]),
929 ratatui::text::Line::from(vec![
930 Span::styled(" ?name ", key),
931 Span::styled("saved search", dim),
932 ]),
933 ];
934 f.render_widget(ratatui::widgets::Paragraph::new(lines), list_area);
935 } else {
936 self.list.render(f, list_area, theme, focused);
937 }
938 self.list.set_list_rect(list_area);
939
940 if let Some(div) = divider_area {
942 f.render_widget(
943 Paragraph::new("\u{2500}".repeat(div.width as usize))
944 .style(Style::default().fg(gray).bg(bg)),
945 div,
946 );
947 }
948
949 if let Some(area) = content_area
952 && self.preview.is_context()
953 && let Some(entry) = self.list.selected_row()
954 {
955 let entry = entry.clone();
956 let text = entry
957 .full_text
958 .clone()
959 .unwrap_or_else(|| entry.context.clone());
960 let needles = self.cached_needles().to_vec();
961 self.preview
962 .render_context(f, area, &text, Highlight::Needles(&needles), theme);
963 self.list.set_content_rect(area);
967 }
968
969 self.list.render_autocomplete(f, rect, theme);
970 }
971}
972
973async fn load_query(vault: &NoteVault, query: &str) -> Vec<BacklinkEntry> {
980 let needles = crate::components::query_highlight::emphasis_needles(query);
981 let results = vault.search_notes(query).await.unwrap_or_default();
982 let mut entries = Vec::with_capacity(results.len());
983 for (entry_data, content_data) in results {
984 let text = vault
985 .get_note_text(&entry_data.path)
986 .await
987 .unwrap_or_default();
988 let context = extract_context_multi(&text, &needles);
989 let (_p, filename) = entry_data.path.get_parent_path();
990 entries.push(BacklinkEntry {
991 path: entry_data.path,
992 title: content_data.title,
993 filename,
994 context,
995 full_text: Some(text),
996 });
997 }
998 entries
999}
1000
1001fn split_paragraphs(text: &str) -> Vec<String> {
1004 let mut paragraphs = Vec::new();
1005 let mut current: Vec<&str> = Vec::new();
1006
1007 for line in text.lines() {
1008 if line.trim().is_empty() {
1009 if !current.is_empty() {
1010 paragraphs.push(current.join("\n"));
1011 current.clear();
1012 }
1013 } else {
1014 current.push(line);
1015 }
1016 }
1017 if !current.is_empty() {
1018 paragraphs.push(current.join("\n"));
1019 }
1020
1021 paragraphs
1022}
1023
1024fn extract_context_multi(text: &str, needles: &[String]) -> String {
1031 let lowered: Vec<String> = needles.iter().map(|n| n.to_lowercase()).collect();
1032 for para in &split_paragraphs(text) {
1033 let lower = para.to_lowercase();
1034 if lowered.iter().any(|n| !n.is_empty() && lower.contains(n)) {
1035 return para.clone();
1036 }
1037 }
1038 text.lines()
1039 .find(|l| !l.trim().is_empty())
1040 .unwrap_or("")
1041 .to_string()
1042}
1043
1044impl SortableList for QueryPanel {
1045 fn sort_state(&self) -> SortState {
1046 let (field, order) = self.current_order();
1047 SortState {
1048 field,
1049 order,
1050 group_dirs: None,
1051 }
1052 }
1053
1054 fn apply_sort(&mut self, state: &SortState, tx: &AppTx) {
1055 QueryPanel::apply_sort(self, state.field.clone(), state.order, tx);
1056 }
1057
1058 fn allows_property(&self) -> bool {
1059 true
1060 }
1061}
1062
1063#[cfg(test)]
1068mod tests {
1069 use super::*;
1070
1071 #[test]
1072 fn extract_context_matches_any_needle() {
1073 let text = "# Title\n\nIntro line.\n\nA paragraph mentioning widget here.\n";
1074 let result = extract_context_multi(text, &["widget".to_string()]);
1075 assert!(result.contains("widget"));
1076 }
1077
1078 #[test]
1079 fn default_query_recognized_in_all_spellings() {
1080 assert!(is_default_query(DEFAULT_QUERY));
1084 assert!(is_default_query("<"));
1085 assert!(is_default_query("lk:"));
1086 assert!(is_default_query("< or:title"));
1087 assert!(is_default_query("<{note} -or:file"));
1088 assert!(!is_default_query("<projects"));
1089 assert!(!is_default_query(">"));
1090 assert!(!is_default_query(""));
1091 }
1092
1093 #[tokio::test]
1094 async fn query_panel_load_query_lists_matches() {
1095 let vault = crate::test_support::temp_vault("qp").await;
1096 vault.validate_and_init().await.unwrap();
1097 vault
1098 .create_note(&VaultPath::note_path_from("/a.md"), "alpha #todo")
1099 .await
1100 .unwrap();
1101 vault
1102 .create_note(&VaultPath::note_path_from("/b.md"), "beta")
1103 .await
1104 .unwrap();
1105 let entries = load_query(&vault, "#todo").await;
1106 assert_eq!(entries.len(), 1);
1107 assert!(entries[0].filename.contains("a"));
1108 }
1109
1110 fn make_panel(vault: Arc<NoteVault>) -> QueryPanel {
1111 let kb = crate::settings::AppSettings::default().key_bindings.clone();
1112 QueryPanel::new(vault, kb, Icons::new(false))
1113 }
1114
1115 #[tokio::test(flavor = "multi_thread")]
1118 async fn ctrl_enter_opens_selected_result() {
1119 let vault = crate::test_support::temp_vault("qp-ctrl-enter").await;
1120 vault.validate_and_init().await.unwrap();
1121 vault
1122 .save_note(&VaultPath::note_path_from("target"), "the note body")
1123 .await
1124 .unwrap();
1125 let mut panel = make_panel(vault);
1126 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1127
1128 panel.apply_query("target".to_string(), None, tx.clone());
1130 for _ in 0..50 {
1131 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1132 panel.list.poll();
1133 }
1134 assert!(
1135 panel.selected_path().is_some(),
1136 "result loaded and selected"
1137 );
1138
1139 panel.handle_key(
1140 &KeyEvent::new(
1141 KeyCode::Enter,
1142 ratatui::crossterm::event::KeyModifiers::CONTROL,
1143 ),
1144 &tx,
1145 );
1146
1147 let mut opened = None;
1148 while let Ok(ev) = rx.try_recv() {
1149 if let AppEvent::OpenPath { path, .. } = ev {
1150 opened = Some(path);
1151 }
1152 }
1153 assert_eq!(opened, Some(VaultPath::note_path_from("target").absolute()));
1155 }
1156
1157 #[tokio::test(flavor = "multi_thread")]
1160 async fn ctrl_y_yanks_selected_path() {
1161 use ratatui::crossterm::event::KeyModifiers;
1162 let vault = crate::test_support::temp_vault("qp-ctrl-y").await;
1163 vault.validate_and_init().await.unwrap();
1164 vault
1165 .save_note(&VaultPath::note_path_from("target"), "the note body")
1166 .await
1167 .unwrap();
1168 let mut panel = make_panel(vault);
1169 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1170 panel.apply_query("target".to_string(), None, tx.clone());
1171 settle(&mut panel).await;
1172 assert!(panel.selected_path().is_some(), "result selected");
1173
1174 let st = panel.handle_key(
1175 &KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL),
1176 &tx,
1177 );
1178 assert_eq!(st, EventState::Consumed);
1179 let mut flashed = false;
1180 while let Ok(ev) = rx.try_recv() {
1181 if matches!(ev, AppEvent::FlashMessage(_)) {
1182 flashed = true;
1183 }
1184 }
1185 assert!(
1186 flashed,
1187 "Ctrl+Y emits a flash message (ok or clipboard error)"
1188 );
1189 }
1190
1191 #[tokio::test(flavor = "multi_thread")]
1195 async fn plain_letters_stay_query_text_in_find() {
1196 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
1197 let vault = crate::test_support::temp_vault("qp-letters").await;
1198 vault.validate_and_init().await.unwrap();
1199 let mut panel = make_panel(vault);
1200 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1201 panel.set_active_query(String::new());
1202 for ch in ['l', 'h', 'o', 'y'] {
1203 panel.handle_key(&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
1204 }
1205 assert_eq!(panel.active_query(), "lhoy", "letters edit the query");
1206 assert!(
1207 panel.preview.is_collapsed(),
1208 "letters must not cycle the preview in FIND"
1209 );
1210 }
1211
1212 #[tokio::test]
1215 async fn cached_needles_track_query_and_note() {
1216 let vault = crate::test_support::temp_vault("qp_needles").await;
1217 vault.validate_and_init().await.unwrap();
1218 let mut panel = make_panel(vault);
1219 panel.list.set_query(DEFAULT_QUERY);
1220
1221 *panel.current_note.lock().unwrap() = VaultPath::note_path_from("spec");
1223 assert!(panel.cached_needles().iter().any(|n| n == "spec"));
1224
1225 *panel.current_note.lock().unwrap() = VaultPath::note_path_from("other");
1227 assert!(panel.cached_needles().iter().any(|n| n == "other"));
1228
1229 panel.list.set_query("widget".to_string());
1231 let needles = panel.cached_needles();
1232 assert!(needles.iter().any(|n| n == "widget"));
1233 assert!(!needles.iter().any(|n| n == "other"));
1234
1235 panel.list.set_query("#todo".to_string());
1238 assert!(
1239 panel.cached_needles().iter().any(|n| n == "#todo"),
1240 "preview needles must include labels"
1241 );
1242 }
1243
1244 async fn settle(panel: &mut QueryPanel) {
1250 for _ in 0..100 {
1251 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1252 panel.list.poll();
1253 if !panel.list.is_loading() {
1254 break;
1255 }
1256 }
1257 }
1258
1259 #[tokio::test(flavor = "multi_thread")]
1260 async fn apply_sort_rewrites_query_order_directive() {
1261 let vault = crate::test_support::temp_vault("qp-sort").await;
1262 vault.validate_and_init().await.unwrap();
1263 let mut panel = make_panel(vault);
1264 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1265 panel.set_active_query("widget".to_string());
1266
1267 panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1268 assert_eq!(panel.active_query(), "widget or:title");
1269
1270 panel.apply_sort(SortField::Name, SortOrder::Descending, &tx);
1271 assert_eq!(panel.active_query(), "widget -or:file");
1272 }
1273
1274 #[tokio::test(flavor = "multi_thread")]
1278 async fn directiveless_query_is_name_ascending() {
1279 let vault = crate::test_support::temp_vault("qp-defaultorder").await;
1280 vault.validate_and_init().await.unwrap();
1281 for name in ["/charlie.md", "/alpha.md", "/bravo.md"] {
1283 vault
1284 .create_note(&VaultPath::note_path_from(name), "widget")
1285 .await
1286 .unwrap();
1287 }
1288 let mut panel = make_panel(vault);
1289 panel.set_active_query("widget".to_string()); settle(&mut panel).await;
1291
1292 let names: Vec<String> = panel
1293 .list
1294 .visible_rows()
1295 .iter()
1296 .map(|e| e.filename.clone())
1297 .collect();
1298 let mut sorted = names.clone();
1299 sorted.sort();
1300 assert_eq!(names, sorted, "directive-less query must be name-ascending");
1301 }
1302
1303 #[tokio::test(flavor = "multi_thread")]
1306 async fn accepting_saved_search_pins_breadcrumb() {
1307 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1308 let vault = crate::test_support::temp_vault("qp-ss-accept").await;
1309 vault.validate_and_init().await.unwrap();
1310 vault.save_search("todo-week", "#todo").await.unwrap();
1311 let mut panel = make_panel(vault);
1312 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1313
1314 panel.set_active_query(String::new());
1318 for ch in ['?', 't', 'o'] {
1319 panel.handle_key(&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
1320 for _ in 0..30 {
1321 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1322 panel.list.poll();
1323 }
1324 }
1325 panel.handle_key(&KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &tx);
1326
1327 assert_eq!(panel.active_query(), "#todo");
1328 assert_eq!(
1329 panel.saved_search_breadcrumb().as_deref(),
1330 Some("todo-week")
1331 );
1332 }
1333
1334 #[tokio::test(flavor = "multi_thread")]
1337 async fn editing_expanded_query_keeps_breadcrumb_marked_edited() {
1338 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1339 let vault = crate::test_support::temp_vault("qp-ss-edit").await;
1340 vault.validate_and_init().await.unwrap();
1341 let mut panel = make_panel(vault);
1342 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1343 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1344 assert_eq!(panel.saved_search_breadcrumb().as_deref(), Some("todo"));
1345
1346 panel.handle_key(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
1348 assert_eq!(panel.active_query(), "#todox");
1349 assert_eq!(
1350 panel.saved_search_breadcrumb().as_deref(),
1351 Some("todo • edited")
1352 );
1353 }
1354
1355 #[tokio::test(flavor = "multi_thread")]
1358 async fn emptying_field_clears_breadcrumb() {
1359 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1360 let vault = crate::test_support::temp_vault("qp-ss-empty").await;
1361 vault.validate_and_init().await.unwrap();
1362 let mut panel = make_panel(vault);
1363 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1364 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1365
1366 for _ in 0.."#todo".len() {
1368 panel.handle_key(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), &tx);
1369 }
1370 assert_eq!(panel.active_query(), "");
1371 assert_eq!(panel.saved_search_breadcrumb(), None);
1372 }
1373
1374 #[tokio::test(flavor = "multi_thread")]
1375 async fn apply_query_pins_breadcrumb() {
1376 let vault = crate::test_support::temp_vault("qp-name").await;
1377 vault.validate_and_init().await.unwrap();
1378 let mut panel = make_panel(vault);
1379 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1380 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1381 assert_eq!(panel.saved_search_breadcrumb().as_deref(), Some("todo"));
1382 }
1383
1384 #[tokio::test(flavor = "multi_thread")]
1388 async fn apply_sort_marks_saved_search_breadcrumb_edited() {
1389 let vault = crate::test_support::temp_vault("qp-sort-name").await;
1390 vault.validate_and_init().await.unwrap();
1391 let mut panel = make_panel(vault);
1392 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1393 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1394
1395 panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1396 assert_eq!(panel.active_query(), "#todo or:title");
1397 assert_eq!(
1398 panel.saved_search_breadcrumb().as_deref(),
1399 Some("todo • edited"),
1400 "sorting diverges from the stored query, so the breadcrumb is edited"
1401 );
1402 }
1403
1404 #[tokio::test(flavor = "multi_thread")]
1407 async fn repin_after_save_adopts_saved_identity() {
1408 let vault = crate::test_support::temp_vault("qp-repin").await;
1409 vault.validate_and_init().await.unwrap();
1410 let mut panel = make_panel(vault);
1411 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1412 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1413
1414 panel.set_active_query("#todo and #urgent".to_string());
1415 assert_eq!(
1416 panel.saved_search_breadcrumb().as_deref(),
1417 Some("todo • edited")
1418 );
1419
1420 panel.repin_saved_search("urgent-todos".to_string(), "#todo and #urgent");
1421 assert_eq!(
1422 panel.saved_search_breadcrumb().as_deref(),
1423 Some("urgent-todos"),
1424 "after a save the saved identity is the provenance — no edited marker"
1425 );
1426 }
1427
1428 #[tokio::test(flavor = "multi_thread")]
1432 async fn apply_sort_updates_visible_input_bar() {
1433 let vault = crate::test_support::temp_vault("qp-bar").await;
1434 vault.validate_and_init().await.unwrap();
1435 let mut panel = make_panel(vault);
1436 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1437 panel.set_active_query("widget".to_string());
1438 assert_eq!(
1439 panel.list.input_value(),
1440 "widget",
1441 "set_active_query syncs the bar"
1442 );
1443
1444 panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1445 assert_eq!(panel.active_query(), "widget or:title");
1446 assert_eq!(
1447 panel.list.input_value(),
1448 "widget or:title",
1449 "the input bar must reflect the rewritten query"
1450 );
1451 }
1452
1453 #[tokio::test(flavor = "multi_thread")]
1454 async fn current_order_reads_query_directive() {
1455 let vault = crate::test_support::temp_vault("qp-order").await;
1456 vault.validate_and_init().await.unwrap();
1457 let mut panel = make_panel(vault);
1458 panel.set_active_query("widget -or:title".to_string());
1459 assert_eq!(
1460 panel.current_order(),
1461 (SortField::Title, SortOrder::Descending)
1462 );
1463 panel.set_active_query("widget".to_string());
1464 assert_eq!(
1465 panel.current_order(),
1466 (SortField::Name, SortOrder::Ascending)
1467 );
1468 }
1469
1470 #[tokio::test(flavor = "multi_thread")]
1471 async fn current_order_reports_a_property_sort() {
1472 let vault = crate::test_support::temp_vault("qp-prop-order").await;
1473 vault.validate_and_init().await.unwrap();
1474 let mut panel = make_panel(vault);
1475 panel.set_active_query("#work -^%due".to_string());
1476 assert_eq!(
1477 panel.current_order(),
1478 (SortField::Property("due".into()), SortOrder::Descending)
1479 );
1480 }
1481
1482 #[tokio::test(flavor = "multi_thread")]
1483 async fn apply_sort_ignores_empty_property_key() {
1484 let vault = crate::test_support::temp_vault("qp-prop-empty").await;
1485 vault.validate_and_init().await.unwrap();
1486 let mut panel = make_panel(vault);
1487 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1488 panel.set_active_query("x -or:title".to_string());
1489 panel.apply_sort(
1490 SortField::Property(String::new()),
1491 SortOrder::Ascending,
1492 &tx,
1493 );
1494 panel.apply_sort(SortField::Property(" ".into()), SortOrder::Ascending, &tx);
1495 assert_eq!(panel.active_query(), "x -or:title");
1496 }
1497
1498 #[tokio::test(flavor = "multi_thread")]
1499 async fn property_sort_survives_order_toggle() {
1500 let vault = crate::test_support::temp_vault("qp-prop-toggle").await;
1501 vault.validate_and_init().await.unwrap();
1502 let mut panel = make_panel(vault);
1503 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1504 panel.set_active_query("x ^%\"due date\"".to_string());
1505 let (field, order) = panel.current_order();
1506 panel.apply_sort(field, order.toggle(), &tx);
1507 assert_eq!(panel.active_query(), "x -or:prop:\"due date\"");
1508 panel.apply_sort(SortField::Name, SortOrder::Ascending, &tx);
1509 assert_eq!(panel.active_query(), "x or:file");
1510 }
1511
1512 #[tokio::test(flavor = "multi_thread")]
1516 async fn context_preview_wheel_scrolls_preview_not_list() {
1517 use ratatui::Terminal;
1518 use ratatui::backend::TestBackend;
1519 use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
1520
1521 let vault = crate::test_support::temp_vault("qp-preview-wheel").await;
1522 vault.validate_and_init().await.unwrap();
1523 let mut body = String::from("#todo first line\n");
1526 for i in 0..40 {
1527 body.push_str(&format!("line {}\n", i));
1528 }
1529 vault
1530 .create_note(&VaultPath::note_path_from("/long.md"), &body)
1531 .await
1532 .unwrap();
1533 let mut panel = make_panel(vault);
1534 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1535 panel.set_active_query("#todo".to_string());
1536 settle(&mut panel).await;
1537 assert!(panel.list.selected_row().is_some());
1538
1539 panel.toggle_expand();
1542 assert!(panel.preview.is_context());
1543 let theme = crate::settings::themes::Theme::default();
1544 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1545 terminal
1546 .draw(|f| panel.render(f, f.area(), &theme, true))
1547 .unwrap();
1548 let preview = panel.list.content_rect();
1549 assert!(!preview.is_empty(), "preview rect recorded");
1550 assert_eq!(
1551 panel.preview.scroll_offset(),
1552 0,
1553 "auto-anchor at the top needle"
1554 );
1555 assert!(panel.preview.scroll_max() > 0, "content overflows viewport");
1556
1557 let wheel = move |y: u16| MouseEvent {
1558 kind: MouseEventKind::ScrollDown,
1559 column: preview.x + 1,
1560 row: y,
1561 modifiers: KeyModifiers::NONE,
1562 };
1563
1564 let over_list = wheel(preview.y.saturating_sub(3));
1566 panel.handle_mouse(&over_list, &tx);
1567 assert_eq!(
1568 panel.preview.scroll_offset(),
1569 0,
1570 "list wheel must not move preview"
1571 );
1572 assert!(panel.preview.is_anchored(), "anchor stays armed");
1573
1574 let over_preview = wheel(preview.y + 1);
1576 panel.handle_mouse(&over_preview, &tx);
1577 assert_eq!(
1578 panel.preview.scroll_offset(),
1579 1,
1580 "preview wheel scrolls content"
1581 );
1582 assert!(!panel.preview.is_anchored(), "user owns the scroll now");
1583
1584 terminal
1586 .draw(|f| panel.render(f, f.area(), &theme, true))
1587 .unwrap();
1588 assert_eq!(panel.preview.scroll_offset(), 1);
1589
1590 let up = MouseEvent {
1592 kind: MouseEventKind::ScrollUp,
1593 column: preview.x + 1,
1594 row: preview.y + 1,
1595 modifiers: KeyModifiers::NONE,
1596 };
1597 panel.handle_mouse(&up, &tx);
1598 panel.handle_mouse(&up, &tx);
1599 assert_eq!(panel.preview.scroll_offset(), 0);
1600 }
1601
1602 #[tokio::test(flavor = "multi_thread")]
1606 async fn noop_preview_wheel_keeps_autoscroll_armed() {
1607 use ratatui::Terminal;
1608 use ratatui::backend::TestBackend;
1609 use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
1610
1611 let vault = crate::test_support::temp_vault("qp-noop-wheel").await;
1612 vault.validate_and_init().await.unwrap();
1613 vault
1615 .create_note(&VaultPath::note_path_from("/short.md"), "#todo only line")
1616 .await
1617 .unwrap();
1618 let mut panel = make_panel(vault);
1619 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1620 panel.set_active_query("#todo".to_string());
1621 settle(&mut panel).await;
1622 panel.toggle_expand();
1623 let theme = crate::settings::themes::Theme::default();
1624 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1625 terminal
1626 .draw(|f| panel.render(f, f.area(), &theme, true))
1627 .unwrap();
1628 assert_eq!(panel.preview.scroll_max(), 0, "content fits the viewport");
1629
1630 let preview = panel.list.content_rect();
1631 let down = MouseEvent {
1632 kind: MouseEventKind::ScrollDown,
1633 column: preview.x + 1,
1634 row: preview.y + 1,
1635 modifiers: KeyModifiers::NONE,
1636 };
1637 panel.handle_mouse(&down, &tx);
1638 assert!(
1639 panel.preview.is_anchored(),
1640 "no-op wheel tick must not disarm the auto-anchor"
1641 );
1642 }
1643
1644 #[tokio::test(flavor = "multi_thread")]
1648 async fn query_keystroke_rearms_preview_autoscroll() {
1649 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1650
1651 let vault = crate::test_support::temp_vault("qp-rearm").await;
1652 vault.validate_and_init().await.unwrap();
1653 let mut body = String::from("#todo first line\n");
1654 for i in 0..40 {
1655 body.push_str(&format!("line {}\n", i));
1656 }
1657 vault
1658 .create_note(&VaultPath::note_path_from("/long.md"), &body)
1659 .await
1660 .unwrap();
1661 let mut panel = make_panel(vault);
1662 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1663 panel.set_active_query("#todo".to_string());
1664 settle(&mut panel).await;
1665 panel.toggle_expand();
1666 panel.preview.force_user_scrolled();
1668
1669 panel.handle_key(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
1670 assert_eq!(panel.active_query(), "#todox");
1671 assert!(
1672 panel.preview.is_anchored(),
1673 "a query edit must re-arm the preview auto-anchor"
1674 );
1675 }
1676
1677 #[tokio::test(flavor = "multi_thread")]
1681 async fn preview_wheel_closes_autocomplete_popup() {
1682 use ratatui::Terminal;
1683 use ratatui::backend::TestBackend;
1684 use ratatui::crossterm::event::{
1685 KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind,
1686 };
1687
1688 let vault = crate::test_support::temp_vault("qp-wheel-popup").await;
1689 vault.validate_and_init().await.unwrap();
1690 let mut body = String::from("#todo first line\n");
1691 for i in 0..40 {
1692 body.push_str(&format!("line {}\n", i));
1693 }
1694 vault
1695 .create_note(&VaultPath::note_path_from("/long.md"), &body)
1696 .await
1697 .unwrap();
1698 let mut panel = make_panel(vault);
1699 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1700 panel.set_active_query("#todo".to_string());
1701 settle(&mut panel).await;
1702 panel.toggle_expand();
1703 let theme = crate::settings::themes::Theme::default();
1704 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1705 terminal
1706 .draw(|f| panel.render(f, f.area(), &theme, true))
1707 .unwrap();
1708 let preview = panel.list.content_rect();
1709 assert!(!preview.is_empty());
1710
1711 for ch in [' ', '#'] {
1714 panel.handle_key(&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
1715 for _ in 0..30 {
1716 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1717 panel.list.poll();
1718 }
1719 }
1720 assert!(panel.list.autocomplete_is_open(), "popup open after `#`");
1721
1722 let wheel = MouseEvent {
1723 kind: MouseEventKind::ScrollDown,
1724 column: preview.x + 1,
1725 row: preview.y + 1,
1726 modifiers: KeyModifiers::NONE,
1727 };
1728 panel.handle_mouse(&wheel, &tx);
1729 assert!(
1730 !panel.list.autocomplete_is_open(),
1731 "wheel over the preview must dismiss the popup"
1732 );
1733 }
1734
1735 #[tokio::test(flavor = "multi_thread")]
1738 async fn double_click_on_a_result_opens_it() {
1739 use ratatui::Terminal;
1740 use ratatui::backend::TestBackend;
1741
1742 let vault = crate::test_support::temp_vault("qp-double-click").await;
1743 vault.validate_and_init().await.unwrap();
1744 vault
1745 .create_note(&VaultPath::note_path_from("/hit.md"), "#todo body")
1746 .await
1747 .unwrap();
1748 let mut panel = make_panel(vault);
1749 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1750 panel.set_active_query("#todo".to_string());
1751 settle(&mut panel).await;
1752 let theme = crate::settings::themes::Theme::default();
1753 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1754 terminal
1755 .draw(|f| panel.render(f, f.area(), &theme, true))
1756 .unwrap();
1757 let (x, y) = crate::test_support::find_text(terminal.backend().buffer(), "hit")
1758 .expect("result row drawn");
1759 let press = match crate::test_support::mouse_down_at(x, y) {
1760 crate::components::events::InputEvent::Mouse(m) => m,
1761 _ => unreachable!(),
1762 };
1763 panel.handle_mouse(&press, &tx);
1764 panel.handle_mouse(&press, &tx);
1765 let opened = crate::test_support::drain(&mut rx)
1766 .into_iter()
1767 .any(|e| matches!(e, AppEvent::OpenPath { .. }));
1768 assert!(opened, "a double-click opens the result");
1769 assert!(
1770 panel.preview.is_collapsed(),
1771 "and does not toggle the preview"
1772 );
1773 }
1774
1775 #[tokio::test(flavor = "multi_thread")]
1779 async fn full_expand_header_click_collapses() {
1780 use ratatui::Terminal;
1781 use ratatui::backend::TestBackend;
1782 use ratatui::crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
1783
1784 let vault = crate::test_support::temp_vault("qp-header-click").await;
1785 vault.validate_and_init().await.unwrap();
1786 vault
1787 .create_note(&VaultPath::note_path_from("/long.md"), "#todo body")
1788 .await
1789 .unwrap();
1790 let mut panel = make_panel(vault);
1791 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1792 panel.set_active_query("#todo".to_string());
1793 settle(&mut panel).await;
1794 panel.toggle_expand();
1796 panel.toggle_expand();
1797 assert!(panel.is_full_expanded());
1798 let theme = crate::settings::themes::Theme::default();
1799 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1800 terminal
1801 .draw(|f| panel.render(f, f.area(), &theme, true))
1802 .unwrap();
1803 let header = panel.preview.full_header_rect();
1804 assert!(!header.is_empty(), "header rect recorded in full mode");
1805
1806 let click = |x: u16, y: u16| MouseEvent {
1807 kind: MouseEventKind::Down(MouseButton::Left),
1808 column: x,
1809 row: y,
1810 modifiers: KeyModifiers::NONE,
1811 };
1812
1813 panel.handle_mouse(&click(header.x + 1, header.y + 3), &tx);
1815 assert!(panel.is_full_expanded(), "content click must not collapse");
1816
1817 panel.handle_mouse(&click(header.x + 1, header.y), &tx);
1819 assert!(!panel.is_full_expanded());
1820 assert!(panel.preview.is_collapsed());
1821 }
1822
1823 #[tokio::test(flavor = "multi_thread")]
1830 async fn full_preview_anchors_scroll_to_first_needle_match() {
1831 use ratatui::Terminal;
1832 use ratatui::backend::TestBackend;
1833
1834 let vault = crate::test_support::temp_vault("qp-full-anchor").await;
1835 vault.validate_and_init().await.unwrap();
1836 let mut body = String::new();
1839 for i in 0..8 {
1840 body.push_str(&format!("line{i}\n"));
1841 }
1842 body.push_str("#todo widget line\n");
1843 for i in 0..8 {
1844 body.push_str(&format!("tail{i}\n"));
1845 }
1846 vault
1847 .create_note(&VaultPath::note_path_from("/long.md"), &body)
1848 .await
1849 .unwrap();
1850 let mut panel = make_panel(vault);
1851 panel.set_active_query("#todo".to_string());
1852 settle(&mut panel).await;
1853 assert!(panel.list.selected_row().is_some());
1854
1855 panel.toggle_expand();
1857 panel.toggle_expand();
1858 assert!(panel.is_full_expanded());
1859
1860 let theme = crate::settings::themes::Theme::default();
1861 let mut terminal = Terminal::new(TestBackend::new(40, 6)).unwrap();
1862 terminal
1863 .draw(|f| panel.render(f, f.area(), &theme, true))
1864 .unwrap();
1865
1866 assert!(
1867 panel.preview.scroll_offset() > 0,
1868 "Full preview anchors on the first needle match, offset={}",
1869 panel.preview.scroll_offset()
1870 );
1871 }
1872
1873 #[tokio::test(flavor = "multi_thread")]
1878 async fn toggling_expand_clears_stale_content_regions() {
1879 use ratatui::Terminal;
1880 use ratatui::backend::TestBackend;
1881
1882 let vault = crate::test_support::temp_vault("qp-stale-regions").await;
1883 vault.validate_and_init().await.unwrap();
1884 vault
1885 .create_note(&VaultPath::note_path_from("/long.md"), "#todo body")
1886 .await
1887 .unwrap();
1888 let mut panel = make_panel(vault);
1889 panel.set_active_query("#todo".to_string());
1890 settle(&mut panel).await;
1891 let theme = crate::settings::themes::Theme::default();
1892 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1893
1894 panel.toggle_expand();
1896 panel.toggle_expand();
1897 terminal
1898 .draw(|f| panel.render(f, f.area(), &theme, true))
1899 .unwrap();
1900 assert!(!panel.list.content_rect().is_empty());
1901 assert!(!panel.preview.full_header_rect().is_empty());
1902
1903 panel.toggle_expand();
1906 assert!(
1907 panel.list.content_rect().is_empty(),
1908 "stale content rect must not survive a state change"
1909 );
1910 assert!(
1911 panel.preview.full_header_rect().is_empty(),
1912 "stale header rect must not survive a state change"
1913 );
1914 }
1915
1916 #[tokio::test(flavor = "multi_thread")]
1922 async fn static_query_survives_navigation() {
1923 let vault = crate::test_support::temp_vault("nav-static").await;
1924 vault.validate_and_init().await.unwrap();
1925 vault
1926 .create_note(&VaultPath::note_path_from("/a.md"), "alpha #todo")
1927 .await
1928 .unwrap();
1929 let mut panel = make_panel(vault);
1930 panel.set_active_query("#todo".to_string());
1931 settle(&mut panel).await;
1932 assert_eq!(panel.list.visible_rows().len(), 1);
1933
1934 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1935 panel.set_note(VaultPath::note_path_from("x.md"), tx);
1936
1937 assert_eq!(panel.active_query(), "#todo");
1940 assert!(!panel.list.is_loading());
1941 settle(&mut panel).await;
1942 assert_eq!(panel.list.visible_rows().len(), 1); }
1944
1945 #[tokio::test(flavor = "multi_thread")]
1948 async fn note_variable_query_reruns_on_navigation() {
1949 let vault = crate::test_support::temp_vault("nav-var").await;
1950 vault.validate_and_init().await.unwrap();
1951 vault
1954 .create_note(&VaultPath::note_path_from("/target.md"), "I am the target")
1955 .await
1956 .unwrap();
1957 vault
1958 .create_note(&VaultPath::note_path_from("/linker.md"), "see [[target]]")
1959 .await
1960 .unwrap();
1961 let mut panel = make_panel(vault);
1962 assert_eq!(panel.active_query(), "");
1965 panel.list.set_query(DEFAULT_QUERY);
1966
1967 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1968 panel.set_note(VaultPath::note_path_from("/target.md"), tx);
1969 settle(&mut panel).await;
1970
1971 assert!(
1973 panel
1974 .list
1975 .visible_rows()
1976 .iter()
1977 .any(|e| e.filename.contains("linker")),
1978 "expected linker as a backlink, got {:?}",
1979 panel
1980 .list
1981 .visible_rows()
1982 .iter()
1983 .map(|e| e.filename.clone())
1984 .collect::<Vec<_>>()
1985 );
1986 }
1987
1988 #[tokio::test(flavor = "multi_thread")]
1990 async fn note_variable_query_changes_with_note() {
1991 let vault = crate::test_support::temp_vault("nav-var2").await;
1992 vault.validate_and_init().await.unwrap();
1993 vault
1994 .create_note(&VaultPath::note_path_from("/a.md"), "I am a")
1995 .await
1996 .unwrap();
1997 vault
1998 .create_note(&VaultPath::note_path_from("/b.md"), "I am b")
1999 .await
2000 .unwrap();
2001 vault
2002 .create_note(&VaultPath::note_path_from("/links_a.md"), "see [[a]]")
2003 .await
2004 .unwrap();
2005 let mut panel = make_panel(vault);
2006 panel.list.set_query(DEFAULT_QUERY);
2007 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2008
2009 panel.set_note(VaultPath::note_path_from("/a.md"), tx.clone());
2010 settle(&mut panel).await;
2011 assert!(
2012 panel
2013 .list
2014 .visible_rows()
2015 .iter()
2016 .any(|e| e.filename.contains("links_a"))
2017 );
2018
2019 panel.set_note(VaultPath::note_path_from("/b.md"), tx);
2020 settle(&mut panel).await;
2021 assert!(
2022 !panel
2023 .list
2024 .visible_rows()
2025 .iter()
2026 .any(|e| e.filename.contains("links_a")),
2027 "b has no backlinks, expected empty"
2028 );
2029 }
2030}