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 kimun_core::{OrderBy, OrderField, with_order_directive};
14
15use crate::components::autocomplete::AutocompleteMode;
16use crate::components::event_state::EventState;
17use crate::components::events::{AppEvent, AppTx};
18use crate::components::file_list::{SortField, SortOrder};
19use crate::components::preview_pane::PreviewPane;
20use crate::components::query_vars::{QueryContext, query_has_variables, resolve_query};
21use crate::components::saved_search_breadcrumb::SavedSearchBreadcrumb;
22use crate::components::search_list::{
23 Emit, KeyReaction, ResolvingRowSource, RowSource, SearchList, SearchMouse, SearchRow,
24 Unresolvable, VaultSuggestions,
25};
26use crate::keys::KeyBindings;
27use crate::keys::action_shortcuts::ActionShortcuts;
28use crate::keys::key_combo::KeyCombo;
29use crate::settings::icons::Icons;
30use crate::settings::themes::Theme;
31
32const DEFAULT_QUERY: &str = "<{note}";
36const DEFAULT_QUERY_LONG: &str = "lk:{note}";
39
40fn is_default_query(query: &str) -> bool {
45 let expanded = kimun_core::expand_bare_note_prefixes(
46 &kimun_core::strip_order_directive(query),
47 crate::components::query_vars::VAR_NOTE,
48 );
49 expanded == DEFAULT_QUERY || expanded == DEFAULT_QUERY_LONG
50}
51
52#[derive(Debug, Clone)]
58pub struct BacklinkEntry {
59 pub path: VaultPath,
60 pub title: String,
61 pub filename: String,
62 pub context: String,
64 pub full_text: Option<String>,
66}
67
68impl SearchRow for BacklinkEntry {
69 fn to_list_item(&self, theme: &Theme, icons: &Icons, selected: bool) -> ListItem<'static> {
70 let title_display = if self.title.is_empty() {
71 &self.filename
72 } else {
73 &self.title
74 };
75 let title_style = if selected {
76 Style::default()
77 .fg(theme.selection_fg.to_ratatui())
78 .bg(theme.selection_bg.to_ratatui())
79 .add_modifier(Modifier::BOLD)
80 } else {
81 Style::default()
82 .fg(theme.fg.to_ratatui())
83 .bg(theme.bg_panel.to_ratatui())
84 };
85 crate::components::rich_row::RichRow::new(icons.note, title_display.clone())
86 .title_style(title_style)
87 .meta(self.filename.clone())
88 .into_list_item(theme)
89 }
90
91 fn match_text(&self) -> Option<&str> {
92 Some(&self.filename)
93 }
94
95 fn visual_height(&self) -> u16 {
96 1
97 }
98}
99
100struct BacklinkSource {
111 vault: Arc<NoteVault>,
112}
113
114#[async_trait]
115impl RowSource<BacklinkEntry> for BacklinkSource {
116 async fn load(&self, query: &str, emit: Emit<BacklinkEntry>) {
117 let mut entries = load_query(&self.vault, query).await;
118 if kimun_core::SearchTerms::from_query_string(query)
125 .order_by
126 .is_empty()
127 {
128 entries.sort_by_key(|e| e.filename.to_lowercase());
129 }
130 emit.replace(entries);
131 }
132}
133
134pub struct QueryPanel {
139 list: SearchList<BacklinkEntry>,
142 current_note: Arc<Mutex<VaultPath>>,
145 saved_search: SavedSearchBreadcrumb,
149 preview: PreviewPane,
154 key_bindings: KeyBindings,
155 redraw_tx: Arc<Mutex<Option<AppTx>>>,
160 follow_link_combos: Vec<KeyCombo>,
162 order_cache: (SortField, SortOrder),
167 order_cache_query: String,
168 is_default_cache: bool,
172 needles_cache: Vec<String>,
178 needles_cache_key: (String, VaultPath),
179}
180
181impl QueryPanel {
182 pub fn new(vault: Arc<NoteVault>, key_bindings: KeyBindings, icons: Icons) -> Self {
183 let current_note = Arc::new(Mutex::new(VaultPath::empty()));
184 let redraw_tx: Arc<Mutex<Option<AppTx>>> = Arc::new(Mutex::new(None));
188 let redraw: Arc<dyn Fn() + Send + Sync> = {
189 let slot = redraw_tx.clone();
190 Arc::new(move || {
191 if let Some(tx) = slot.lock().unwrap().as_ref() {
192 let _ = tx.send(AppEvent::Redraw);
193 }
194 })
195 };
196 let source = ResolvingRowSource::new(
200 Arc::new(BacklinkSource {
201 vault: vault.clone(),
202 }),
203 {
204 let note = current_note.clone();
205 move || QueryContext::with_note(Some(note.lock().unwrap().clone()))
206 },
207 Unresolvable::Empty,
208 );
209 let combos = |action: &ActionShortcuts| -> Vec<KeyCombo> {
210 key_bindings
211 .to_hashmap()
212 .get(action)
213 .cloned()
214 .unwrap_or_default()
215 };
216 let follow_link_combos = combos(&ActionShortcuts::FollowLink);
217
218 let mut intercept = Vec::new();
219 intercept.extend(follow_link_combos.iter().cloned());
220
221 let list = SearchList::builder(source, redraw)
222 .highlight_query()
223 .icons(icons.clone())
224 .autocomplete(
225 Arc::new(VaultSuggestions {
226 vault: vault.clone(),
227 }),
228 AutocompleteMode::SearchQuery,
229 )
230 .intercept(intercept)
231 .build();
232
233 Self {
234 list,
235 current_note,
236 saved_search: SavedSearchBreadcrumb::default(),
237 preview: PreviewPane::new(),
238 key_bindings,
239 redraw_tx,
240 follow_link_combos,
241 order_cache: (SortField::Name, SortOrder::Ascending),
243 order_cache_query: String::new(),
244 is_default_cache: false,
247 needles_cache: Vec::new(),
248 needles_cache_key: (String::new(), VaultPath::empty()),
249 }
250 }
251
252 pub fn active_query(&self) -> &str {
255 self.list.query()
256 }
257
258 fn emphasis(&self) -> Option<Vec<String>> {
262 let resolved = resolve_query(self.list.query(), &self.query_ctx());
263 let needles = crate::components::query_highlight::emphasis_needles(&resolved);
264 (!needles.is_empty()).then_some(needles)
265 }
266
267 pub fn result_count(&self) -> usize {
269 self.list.match_count()
270 }
271
272 pub fn set_active_query(&mut self, q: String) {
273 self.list.set_query(q);
274 self.reset_expand();
275 }
276
277 pub fn saved_search_breadcrumb(&self) -> Option<String> {
280 self.saved_search.label(self.list.query())
281 }
282
283 pub fn saved_search_name(&self) -> Option<&str> {
287 self.saved_search.name()
288 }
289
290 pub fn repin_saved_search(&mut self, name: String, query: &str) {
294 self.saved_search.set(Some(name), query);
295 }
296
297 fn query_is_blank(&self) -> bool {
302 let q = self.list.query();
303 q.trim().is_empty() || is_default_query(q)
304 }
305
306 pub fn apply_query(&mut self, query: String, name: Option<String>, tx: AppTx) {
310 self.ensure_redraw_tx(&tx);
311 self.set_active_query(query.clone());
312 self.saved_search.set(name, &query);
313 }
314
315 fn current_note(&self) -> VaultPath {
318 self.current_note.lock().unwrap().clone()
319 }
320
321 fn query_ctx(&self) -> QueryContext {
325 QueryContext::with_note(Some(self.current_note()))
326 }
327
328 fn ensure_redraw_tx(&self, tx: &AppTx) {
331 let mut slot = self.redraw_tx.lock().unwrap();
332 if slot.is_none() {
333 *slot = Some(tx.clone());
334 }
335 }
336
337 fn cached_needles(&mut self) -> &[String] {
342 let note = self.current_note();
343 if self.needles_cache_key.0 != self.list.query() || self.needles_cache_key.1 != note {
344 let resolved = resolve_query(self.list.query(), &self.query_ctx());
345 self.needles_cache = crate::components::query_highlight::emphasis_needles(&resolved);
349 self.needles_cache_key = (self.list.query().to_string(), note);
350 }
351 &self.needles_cache
352 }
353
354 fn is_full_expanded(&self) -> bool {
357 self.list.selected_row().is_some() && self.preview.is_full()
358 }
359
360 pub fn is_empty(&self) -> bool {
361 self.list.rows().is_empty()
362 }
363
364 pub fn selected_path(&self) -> Option<&VaultPath> {
365 self.list.selected_row().map(|e| &e.path)
366 }
367
368 fn reset_expand(&mut self) {
369 self.preview.reset();
370 self.list.set_content_rect(Rect::default());
371 }
372
373 fn sync_expand_anchor(&mut self) {
377 let sel = self.list.selected_row().map(|e| e.path.clone());
378 if self.preview.sync(sel) {
379 self.list.set_content_rect(Rect::default());
380 }
381 }
382
383 pub fn set_note(&mut self, note_path: VaultPath, tx: AppTx) {
388 self.ensure_redraw_tx(&tx);
389 *self.current_note.lock().unwrap() = note_path;
390 if query_has_variables(self.list.query()) {
391 self.list.reload();
392 self.reset_expand();
393 }
394 }
395
396 pub fn current_order(&self) -> (SortField, SortOrder) {
401 let st = kimun_core::SearchTerms::from_query_string(self.list.query());
402 match st.order_by.first() {
403 Some(OrderBy::Title { asc }) => (
404 SortField::Title,
405 if *asc {
406 SortOrder::Ascending
407 } else {
408 SortOrder::Descending
409 },
410 ),
411 Some(OrderBy::FileName { asc }) => (
412 SortField::Name,
413 if *asc {
414 SortOrder::Ascending
415 } else {
416 SortOrder::Descending
417 },
418 ),
419 None => (SortField::Name, SortOrder::Ascending),
420 }
421 }
422
423 pub fn apply_sort(&mut self, field: SortField, order: SortOrder, tx: &AppTx) {
426 self.ensure_redraw_tx(tx);
427 let order_field = match field {
428 SortField::Name => OrderField::FileName,
429 SortField::Title => OrderField::Title,
430 };
431 let asc = matches!(order, SortOrder::Ascending);
432 let rewritten = with_order_directive(self.list.query(), order_field, asc);
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();
475 match self.list.handle_key(key) {
476 KeyReaction::Intercepted(c) if self.follow_link_combos.contains(&c) => {
477 if let Some(path) = self.selected_path().cloned() {
478 tx.send(AppEvent::OpenPath {
479 path,
480 emphasis: self.emphasis(),
481 })
482 .ok();
483 }
484 EventState::Consumed
485 }
486 KeyReaction::Consumed => {
487 let accepted = self.list.take_accepted_saved_search();
491 let blank = self.query_is_blank();
492 self.saved_search
493 .on_query_consumed(accepted, self.list.query(), blank);
494 if self.list.query() != prev_query {
499 self.preview.re_anchor();
500 }
501 self.sync_expand_anchor();
502 EventState::Consumed
503 }
504 KeyReaction::Submit => {
505 self.toggle_expand();
508 EventState::Consumed
509 }
510 KeyReaction::Cancel => EventState::NotConsumed,
512 KeyReaction::Unhandled => EventState::NotConsumed,
513 KeyReaction::Intercepted(_) => EventState::Consumed,
514 }
515 }
516
517 pub fn handle_mouse(
526 &mut self,
527 mouse: &ratatui::crossterm::event::MouseEvent,
528 tx: &AppTx,
529 ) -> EventState {
530 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
531 use ratatui::layout::Position;
532 self.ensure_redraw_tx(tx);
533 let was_full = self.is_full_expanded();
538 self.sync_expand_anchor();
539 if was_full {
546 match mouse.kind {
547 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {}
549 MouseEventKind::Down(MouseButton::Left)
553 if self.preview.full_header_rect().contains(Position {
554 x: mouse.column,
555 y: mouse.row,
556 }) =>
557 {
558 self.list.close_autocomplete();
559 self.toggle_expand();
560 return EventState::Consumed;
561 }
562 _ => {
563 self.list.close_autocomplete();
564 return EventState::Consumed;
565 }
566 }
567 }
568 match self.list.handle_mouse(mouse) {
569 SearchMouse::ContentScrollUp => {
570 self.preview.scroll_up();
571 EventState::Consumed
572 }
573 SearchMouse::ContentScrollDown => {
574 self.preview.scroll_down();
575 EventState::Consumed
576 }
577 SearchMouse::Activated(_) => {
578 self.toggle_expand();
579 EventState::Consumed
580 }
581 SearchMouse::Context(_) => {
583 if let Some(path) = self.selected_path().cloned() {
584 tx.send(AppEvent::ShowFileOpsMenu(path)).ok();
585 }
586 EventState::Consumed
587 }
588 SearchMouse::Selected(_) | SearchMouse::Scrolled => {
589 self.sync_expand_anchor();
590 EventState::Consumed
591 }
592 SearchMouse::None => EventState::NotConsumed,
593 }
594 }
595
596 fn scroll_content(&mut self, key: &KeyEvent) {
597 match key.code {
598 KeyCode::Up => self.preview.scroll_up(),
599 KeyCode::Down => self.preview.scroll_down(),
600 _ => {}
601 }
602 }
603
604 fn toggle_expand(&mut self) {
605 let sel = self.list.selected_row().map(|e| e.path.clone());
606 if sel.is_none() {
607 return;
608 }
609 self.preview.toggle(sel);
610 self.list.set_content_rect(Rect::default());
611 }
612
613 pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
614 crate::components::hints::hints_for(
615 &self.key_bindings,
616 &[
617 (ActionShortcuts::FocusSidebar, "\u{2190} editor"),
618 (ActionShortcuts::FollowLink, "open note"),
619 (ActionShortcuts::SaveCurrentQuery, "save query"),
620 (ActionShortcuts::OpenSavedSearches, "searches"),
621 (ActionShortcuts::OpenSortDialog, "sort"),
622 ],
623 )
624 }
625
626 pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
629 self.list.poll();
630 self.sync_expand_anchor();
631 self.list.set_panel_rect(rect);
634 self.list.set_content_rect(Rect::default());
639 self.preview.clear_header();
640
641 let border_style = theme.border_style(focused);
642 let gray = theme.gray.to_ratatui();
643 let bg = theme.bg_panel.to_ratatui();
644
645 let count = self.list.visible_rows().len();
646 if self.list.query() != self.order_cache_query {
649 self.order_cache = self.current_order();
650 self.is_default_cache = is_default_query(self.list.query());
651 self.order_cache_query = self.list.query().to_string();
652 }
653 let (sort_field, sort_order) = self.order_cache;
654 let sort_indicator = format!("{}{}", sort_field.label(), sort_order.label());
655 let title = if self.list.query().trim().is_empty() {
662 "Find".to_string()
663 } else if self.is_default_cache {
664 format!("Backlinks ({}) {}", count, sort_indicator)
665 } else {
666 format!("Query ({}) {}", count, sort_indicator)
667 };
668
669 let outer = Block::default()
670 .title(title)
671 .borders(Borders::ALL)
672 .border_style(border_style)
673 .style(theme.panel_style());
674 let outer_inner = outer.inner(rect);
675 f.render_widget(outer, rect);
676
677 let rows = Layout::default()
679 .direction(Direction::Vertical)
680 .constraints([Constraint::Length(3), Constraint::Min(0)])
681 .split(outer_inner);
682 let search_title = self.saved_search.border_title(self.list.query(), " Query");
685 let mut search_block = Block::default()
686 .title(search_title)
687 .borders(Borders::ALL)
688 .border_style(border_style)
689 .style(theme.panel_style());
690 if let Some(reason) = crate::components::query_highlight::error_reason(self.list.query()) {
693 search_block = search_block.title(
694 ratatui::text::Line::from(ratatui::text::Span::styled(
695 format!(" ⚠ {reason} "),
696 Style::default().fg(theme.red.to_ratatui()),
697 ))
698 .right_aligned(),
699 );
700 }
701 let search_inner = search_block.inner(rows[0]);
702 f.render_widget(search_block, rows[0]);
703 self.list.render_query(f, search_inner, theme, focused);
704
705 let inner = rows[1];
706
707 if self.list.is_loading() {
708 f.render_widget(
709 Paragraph::new(" Loading...").style(Style::default().fg(gray).bg(bg)),
710 inner,
711 );
712 self.list.render_autocomplete(f, rect, theme);
713 return;
714 }
715
716 if self.list.visible_rows().is_empty() {
717 f.render_widget(
718 Paragraph::new(" No results").style(Style::default().fg(gray).bg(bg)),
719 inner,
720 );
721 self.list.render_autocomplete(f, rect, theme);
722 return;
723 }
724
725 if self.preview.is_full() {
729 self.list.set_content_rect(rect);
730 if let Some(entry) = self.list.selected_row() {
731 let entry = entry.clone();
732 let text = entry
733 .full_text
734 .clone()
735 .unwrap_or_else(|| entry.context.clone());
736 let needles = self.cached_needles().to_vec();
737 self.preview.render_full(
738 f,
739 inner,
740 &entry.title,
741 &entry.filename,
742 &text,
743 &needles,
744 theme,
745 );
746 }
747 self.list.render_autocomplete(f, rect, theme);
748 return;
749 }
750
751 let has_context = self.preview.is_context();
753
754 let (list_area, divider_area, content_area) = if has_context {
755 let max_list = inner.height / 2;
756 let list_height = (count as u16).min(max_list).max(1);
757 let areas = Layout::default()
758 .direction(Direction::Vertical)
759 .constraints([
760 Constraint::Length(list_height),
761 Constraint::Length(1),
762 Constraint::Min(0),
763 ])
764 .split(inner);
765 (areas[0], Some(areas[1]), Some(areas[2]))
766 } else {
767 (inner, None, None)
768 };
769
770 if self.list.query().trim().is_empty() {
773 let dim = Style::default().fg(theme.gray.to_ratatui());
777 let key = Style::default().fg(theme.yellow.to_ratatui());
778 let lines = vec![
779 ratatui::text::Line::from(Span::styled("type to search the vault", dim)),
780 ratatui::text::Line::default(),
781 ratatui::text::Line::from(vec![
782 Span::styled(" #tag ", key),
783 Span::styled("label", dim),
784 ]),
785 ratatui::text::Line::from(vec![
786 Span::styled(" < > ", key),
787 Span::styled("backlinks · links", dim),
788 ]),
789 ratatui::text::Line::from(vec![
790 Span::styled(" \"phrase\" ", key),
791 Span::styled("exact match", dim),
792 ]),
793 ratatui::text::Line::from(vec![
794 Span::styled(" =date ", key),
795 Span::styled("modified", dim),
796 ]),
797 ratatui::text::Line::from(vec![
798 Span::styled(" ?name ", key),
799 Span::styled("saved search", dim),
800 ]),
801 ];
802 f.render_widget(ratatui::widgets::Paragraph::new(lines), list_area);
803 } else {
804 self.list.render(f, list_area, theme, focused);
805 }
806 self.list.set_list_rect(list_area);
807
808 if let Some(div) = divider_area {
810 f.render_widget(
811 Paragraph::new("\u{2500}".repeat(div.width as usize))
812 .style(Style::default().fg(gray).bg(bg)),
813 div,
814 );
815 }
816
817 if let Some(area) = content_area
820 && self.preview.is_context()
821 && let Some(entry) = self.list.selected_row()
822 {
823 let entry = entry.clone();
824 let text = entry
825 .full_text
826 .clone()
827 .unwrap_or_else(|| entry.context.clone());
828 let needles = self.cached_needles().to_vec();
829 self.preview.render_context(f, area, &text, &needles, theme);
830 self.list.set_content_rect(area);
834 }
835
836 self.list.render_autocomplete(f, rect, theme);
837 }
838}
839
840async fn load_query(vault: &NoteVault, query: &str) -> Vec<BacklinkEntry> {
847 let needles = crate::components::query_highlight::emphasis_needles(query);
848 let results = vault.search_notes(query).await.unwrap_or_default();
849 let mut entries = Vec::with_capacity(results.len());
850 for (entry_data, content_data) in results {
851 let text = vault
852 .get_note_text(&entry_data.path)
853 .await
854 .unwrap_or_default();
855 let context = extract_context_multi(&text, &needles);
856 let (_p, filename) = entry_data.path.get_parent_path();
857 entries.push(BacklinkEntry {
858 path: entry_data.path,
859 title: content_data.title,
860 filename,
861 context,
862 full_text: Some(text),
863 });
864 }
865 entries
866}
867
868fn split_paragraphs(text: &str) -> Vec<String> {
871 let mut paragraphs = Vec::new();
872 let mut current: Vec<&str> = Vec::new();
873
874 for line in text.lines() {
875 if line.trim().is_empty() {
876 if !current.is_empty() {
877 paragraphs.push(current.join("\n"));
878 current.clear();
879 }
880 } else {
881 current.push(line);
882 }
883 }
884 if !current.is_empty() {
885 paragraphs.push(current.join("\n"));
886 }
887
888 paragraphs
889}
890
891fn extract_context_multi(text: &str, needles: &[String]) -> String {
898 let lowered: Vec<String> = needles.iter().map(|n| n.to_lowercase()).collect();
899 for para in &split_paragraphs(text) {
900 let lower = para.to_lowercase();
901 if lowered.iter().any(|n| !n.is_empty() && lower.contains(n)) {
902 return para.clone();
903 }
904 }
905 text.lines()
906 .find(|l| !l.trim().is_empty())
907 .unwrap_or("")
908 .to_string()
909}
910
911#[cfg(test)]
916mod tests {
917 use super::*;
918
919 #[test]
920 fn extract_context_matches_any_needle() {
921 let text = "# Title\n\nIntro line.\n\nA paragraph mentioning widget here.\n";
922 let result = extract_context_multi(text, &["widget".to_string()]);
923 assert!(result.contains("widget"));
924 }
925
926 #[test]
927 fn default_query_recognized_in_all_spellings() {
928 assert!(is_default_query(DEFAULT_QUERY));
932 assert!(is_default_query("<"));
933 assert!(is_default_query("lk:"));
934 assert!(is_default_query("< or:title"));
935 assert!(is_default_query("<{note} -or:file"));
936 assert!(!is_default_query("<projects"));
937 assert!(!is_default_query(">"));
938 assert!(!is_default_query(""));
939 }
940
941 #[tokio::test]
942 async fn query_panel_load_query_lists_matches() {
943 let vault = crate::test_support::temp_vault("qp").await;
944 vault.validate_and_init().await.unwrap();
945 vault
946 .create_note(&VaultPath::note_path_from("/a.md"), "alpha #todo")
947 .await
948 .unwrap();
949 vault
950 .create_note(&VaultPath::note_path_from("/b.md"), "beta")
951 .await
952 .unwrap();
953 let entries = load_query(&vault, "#todo").await;
954 assert_eq!(entries.len(), 1);
955 assert!(entries[0].filename.contains("a"));
956 }
957
958 fn make_panel(vault: Arc<NoteVault>) -> QueryPanel {
959 let kb = crate::settings::AppSettings::default().key_bindings.clone();
960 QueryPanel::new(vault, kb, Icons::new(false))
961 }
962
963 #[tokio::test(flavor = "multi_thread")]
966 async fn ctrl_enter_opens_selected_result() {
967 let vault = crate::test_support::temp_vault("qp-ctrl-enter").await;
968 vault.validate_and_init().await.unwrap();
969 vault
970 .save_note(&VaultPath::note_path_from("target"), "the note body")
971 .await
972 .unwrap();
973 let mut panel = make_panel(vault);
974 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
975
976 panel.apply_query("target".to_string(), None, tx.clone());
978 for _ in 0..50 {
979 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
980 panel.list.poll();
981 }
982 assert!(
983 panel.selected_path().is_some(),
984 "result loaded and selected"
985 );
986
987 panel.handle_key(
988 &KeyEvent::new(
989 KeyCode::Enter,
990 ratatui::crossterm::event::KeyModifiers::CONTROL,
991 ),
992 &tx,
993 );
994
995 let mut opened = None;
996 while let Ok(ev) = rx.try_recv() {
997 if let AppEvent::OpenPath { path, .. } = ev {
998 opened = Some(path);
999 }
1000 }
1001 assert_eq!(opened, Some(VaultPath::note_path_from("target")));
1002 }
1003
1004 #[tokio::test]
1007 async fn cached_needles_track_query_and_note() {
1008 let vault = crate::test_support::temp_vault("qp_needles").await;
1009 vault.validate_and_init().await.unwrap();
1010 let mut panel = make_panel(vault);
1011 panel.list.set_query(DEFAULT_QUERY);
1012
1013 *panel.current_note.lock().unwrap() = VaultPath::note_path_from("spec");
1015 assert!(panel.cached_needles().iter().any(|n| n == "spec"));
1016
1017 *panel.current_note.lock().unwrap() = VaultPath::note_path_from("other");
1019 assert!(panel.cached_needles().iter().any(|n| n == "other"));
1020
1021 panel.list.set_query("widget".to_string());
1023 let needles = panel.cached_needles();
1024 assert!(needles.iter().any(|n| n == "widget"));
1025 assert!(!needles.iter().any(|n| n == "other"));
1026
1027 panel.list.set_query("#todo".to_string());
1030 assert!(
1031 panel.cached_needles().iter().any(|n| n == "#todo"),
1032 "preview needles must include labels"
1033 );
1034 }
1035
1036 async fn settle(panel: &mut QueryPanel) {
1042 for _ in 0..100 {
1043 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1044 panel.list.poll();
1045 if !panel.list.is_loading() {
1046 break;
1047 }
1048 }
1049 }
1050
1051 #[tokio::test(flavor = "multi_thread")]
1052 async fn apply_sort_rewrites_query_order_directive() {
1053 let vault = crate::test_support::temp_vault("qp-sort").await;
1054 vault.validate_and_init().await.unwrap();
1055 let mut panel = make_panel(vault);
1056 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1057 panel.set_active_query("widget".to_string());
1058
1059 panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1060 assert_eq!(panel.active_query(), "widget or:title");
1061
1062 panel.apply_sort(SortField::Name, SortOrder::Descending, &tx);
1063 assert_eq!(panel.active_query(), "widget -or:file");
1064 }
1065
1066 #[tokio::test(flavor = "multi_thread")]
1070 async fn directiveless_query_is_name_ascending() {
1071 let vault = crate::test_support::temp_vault("qp-defaultorder").await;
1072 vault.validate_and_init().await.unwrap();
1073 for name in ["/charlie.md", "/alpha.md", "/bravo.md"] {
1075 vault
1076 .create_note(&VaultPath::note_path_from(name), "widget")
1077 .await
1078 .unwrap();
1079 }
1080 let mut panel = make_panel(vault);
1081 panel.set_active_query("widget".to_string()); settle(&mut panel).await;
1083
1084 let names: Vec<String> = panel
1085 .list
1086 .visible_rows()
1087 .iter()
1088 .map(|e| e.filename.clone())
1089 .collect();
1090 let mut sorted = names.clone();
1091 sorted.sort();
1092 assert_eq!(names, sorted, "directive-less query must be name-ascending");
1093 }
1094
1095 #[tokio::test(flavor = "multi_thread")]
1098 async fn accepting_saved_search_pins_breadcrumb() {
1099 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1100 let vault = crate::test_support::temp_vault("qp-ss-accept").await;
1101 vault.validate_and_init().await.unwrap();
1102 vault.save_search("todo-week", "#todo").await.unwrap();
1103 let mut panel = make_panel(vault);
1104 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1105
1106 panel.set_active_query(String::new());
1110 for ch in ['?', 't', 'o'] {
1111 panel.handle_key(&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
1112 for _ in 0..30 {
1113 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1114 panel.list.poll();
1115 }
1116 }
1117 panel.handle_key(&KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &tx);
1118
1119 assert_eq!(panel.active_query(), "#todo");
1120 assert_eq!(
1121 panel.saved_search_breadcrumb().as_deref(),
1122 Some("todo-week")
1123 );
1124 }
1125
1126 #[tokio::test(flavor = "multi_thread")]
1129 async fn editing_expanded_query_keeps_breadcrumb_marked_edited() {
1130 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1131 let vault = crate::test_support::temp_vault("qp-ss-edit").await;
1132 vault.validate_and_init().await.unwrap();
1133 let mut panel = make_panel(vault);
1134 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1135 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1136 assert_eq!(panel.saved_search_breadcrumb().as_deref(), Some("todo"));
1137
1138 panel.handle_key(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
1140 assert_eq!(panel.active_query(), "#todox");
1141 assert_eq!(
1142 panel.saved_search_breadcrumb().as_deref(),
1143 Some("todo • edited")
1144 );
1145 }
1146
1147 #[tokio::test(flavor = "multi_thread")]
1150 async fn emptying_field_clears_breadcrumb() {
1151 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1152 let vault = crate::test_support::temp_vault("qp-ss-empty").await;
1153 vault.validate_and_init().await.unwrap();
1154 let mut panel = make_panel(vault);
1155 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1156 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1157
1158 for _ in 0.."#todo".len() {
1160 panel.handle_key(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), &tx);
1161 }
1162 assert_eq!(panel.active_query(), "");
1163 assert_eq!(panel.saved_search_breadcrumb(), None);
1164 }
1165
1166 #[tokio::test(flavor = "multi_thread")]
1167 async fn apply_query_pins_breadcrumb() {
1168 let vault = crate::test_support::temp_vault("qp-name").await;
1169 vault.validate_and_init().await.unwrap();
1170 let mut panel = make_panel(vault);
1171 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1172 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1173 assert_eq!(panel.saved_search_breadcrumb().as_deref(), Some("todo"));
1174 }
1175
1176 #[tokio::test(flavor = "multi_thread")]
1180 async fn apply_sort_marks_saved_search_breadcrumb_edited() {
1181 let vault = crate::test_support::temp_vault("qp-sort-name").await;
1182 vault.validate_and_init().await.unwrap();
1183 let mut panel = make_panel(vault);
1184 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1185 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1186
1187 panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1188 assert_eq!(panel.active_query(), "#todo or:title");
1189 assert_eq!(
1190 panel.saved_search_breadcrumb().as_deref(),
1191 Some("todo • edited"),
1192 "sorting diverges from the stored query, so the breadcrumb is edited"
1193 );
1194 }
1195
1196 #[tokio::test(flavor = "multi_thread")]
1199 async fn repin_after_save_adopts_saved_identity() {
1200 let vault = crate::test_support::temp_vault("qp-repin").await;
1201 vault.validate_and_init().await.unwrap();
1202 let mut panel = make_panel(vault);
1203 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1204 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1205
1206 panel.set_active_query("#todo and #urgent".to_string());
1207 assert_eq!(
1208 panel.saved_search_breadcrumb().as_deref(),
1209 Some("todo • edited")
1210 );
1211
1212 panel.repin_saved_search("urgent-todos".to_string(), "#todo and #urgent");
1213 assert_eq!(
1214 panel.saved_search_breadcrumb().as_deref(),
1215 Some("urgent-todos"),
1216 "after a save the saved identity is the provenance — no edited marker"
1217 );
1218 }
1219
1220 #[tokio::test(flavor = "multi_thread")]
1224 async fn apply_sort_updates_visible_input_bar() {
1225 let vault = crate::test_support::temp_vault("qp-bar").await;
1226 vault.validate_and_init().await.unwrap();
1227 let mut panel = make_panel(vault);
1228 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1229 panel.set_active_query("widget".to_string());
1230 assert_eq!(
1231 panel.list.input_value(),
1232 "widget",
1233 "set_active_query syncs the bar"
1234 );
1235
1236 panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1237 assert_eq!(panel.active_query(), "widget or:title");
1238 assert_eq!(
1239 panel.list.input_value(),
1240 "widget or:title",
1241 "the input bar must reflect the rewritten query"
1242 );
1243 }
1244
1245 #[tokio::test(flavor = "multi_thread")]
1246 async fn current_order_reads_query_directive() {
1247 let vault = crate::test_support::temp_vault("qp-order").await;
1248 vault.validate_and_init().await.unwrap();
1249 let mut panel = make_panel(vault);
1250 panel.set_active_query("widget -or:title".to_string());
1251 assert_eq!(
1252 panel.current_order(),
1253 (SortField::Title, SortOrder::Descending)
1254 );
1255 panel.set_active_query("widget".to_string());
1256 assert_eq!(
1257 panel.current_order(),
1258 (SortField::Name, SortOrder::Ascending)
1259 );
1260 }
1261
1262 #[tokio::test(flavor = "multi_thread")]
1266 async fn context_preview_wheel_scrolls_preview_not_list() {
1267 use ratatui::Terminal;
1268 use ratatui::backend::TestBackend;
1269 use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
1270
1271 let vault = crate::test_support::temp_vault("qp-preview-wheel").await;
1272 vault.validate_and_init().await.unwrap();
1273 let mut body = String::from("#todo first line\n");
1276 for i in 0..40 {
1277 body.push_str(&format!("line {}\n", i));
1278 }
1279 vault
1280 .create_note(&VaultPath::note_path_from("/long.md"), &body)
1281 .await
1282 .unwrap();
1283 let mut panel = make_panel(vault);
1284 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1285 panel.set_active_query("#todo".to_string());
1286 settle(&mut panel).await;
1287 assert!(panel.list.selected_row().is_some());
1288
1289 panel.toggle_expand();
1292 assert!(panel.preview.is_context());
1293 let theme = crate::settings::themes::Theme::default();
1294 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1295 terminal
1296 .draw(|f| panel.render(f, f.area(), &theme, true))
1297 .unwrap();
1298 let preview = panel.list.content_rect();
1299 assert!(!preview.is_empty(), "preview rect recorded");
1300 assert_eq!(
1301 panel.preview.scroll_offset(),
1302 0,
1303 "auto-anchor at the top needle"
1304 );
1305 assert!(panel.preview.scroll_max() > 0, "content overflows viewport");
1306
1307 let wheel = move |y: u16| MouseEvent {
1308 kind: MouseEventKind::ScrollDown,
1309 column: preview.x + 1,
1310 row: y,
1311 modifiers: KeyModifiers::NONE,
1312 };
1313
1314 let over_list = wheel(preview.y.saturating_sub(3));
1316 panel.handle_mouse(&over_list, &tx);
1317 assert_eq!(
1318 panel.preview.scroll_offset(),
1319 0,
1320 "list wheel must not move preview"
1321 );
1322 assert!(panel.preview.is_anchored(), "anchor stays armed");
1323
1324 let over_preview = wheel(preview.y + 1);
1326 panel.handle_mouse(&over_preview, &tx);
1327 assert_eq!(
1328 panel.preview.scroll_offset(),
1329 1,
1330 "preview wheel scrolls content"
1331 );
1332 assert!(!panel.preview.is_anchored(), "user owns the scroll now");
1333
1334 terminal
1336 .draw(|f| panel.render(f, f.area(), &theme, true))
1337 .unwrap();
1338 assert_eq!(panel.preview.scroll_offset(), 1);
1339
1340 let up = MouseEvent {
1342 kind: MouseEventKind::ScrollUp,
1343 column: preview.x + 1,
1344 row: preview.y + 1,
1345 modifiers: KeyModifiers::NONE,
1346 };
1347 panel.handle_mouse(&up, &tx);
1348 panel.handle_mouse(&up, &tx);
1349 assert_eq!(panel.preview.scroll_offset(), 0);
1350 }
1351
1352 #[tokio::test(flavor = "multi_thread")]
1356 async fn noop_preview_wheel_keeps_autoscroll_armed() {
1357 use ratatui::Terminal;
1358 use ratatui::backend::TestBackend;
1359 use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
1360
1361 let vault = crate::test_support::temp_vault("qp-noop-wheel").await;
1362 vault.validate_and_init().await.unwrap();
1363 vault
1365 .create_note(&VaultPath::note_path_from("/short.md"), "#todo only line")
1366 .await
1367 .unwrap();
1368 let mut panel = make_panel(vault);
1369 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1370 panel.set_active_query("#todo".to_string());
1371 settle(&mut panel).await;
1372 panel.toggle_expand();
1373 let theme = crate::settings::themes::Theme::default();
1374 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1375 terminal
1376 .draw(|f| panel.render(f, f.area(), &theme, true))
1377 .unwrap();
1378 assert_eq!(panel.preview.scroll_max(), 0, "content fits the viewport");
1379
1380 let preview = panel.list.content_rect();
1381 let down = MouseEvent {
1382 kind: MouseEventKind::ScrollDown,
1383 column: preview.x + 1,
1384 row: preview.y + 1,
1385 modifiers: KeyModifiers::NONE,
1386 };
1387 panel.handle_mouse(&down, &tx);
1388 assert!(
1389 panel.preview.is_anchored(),
1390 "no-op wheel tick must not disarm the auto-anchor"
1391 );
1392 }
1393
1394 #[tokio::test(flavor = "multi_thread")]
1398 async fn query_keystroke_rearms_preview_autoscroll() {
1399 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1400
1401 let vault = crate::test_support::temp_vault("qp-rearm").await;
1402 vault.validate_and_init().await.unwrap();
1403 let mut body = String::from("#todo first line\n");
1404 for i in 0..40 {
1405 body.push_str(&format!("line {}\n", i));
1406 }
1407 vault
1408 .create_note(&VaultPath::note_path_from("/long.md"), &body)
1409 .await
1410 .unwrap();
1411 let mut panel = make_panel(vault);
1412 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1413 panel.set_active_query("#todo".to_string());
1414 settle(&mut panel).await;
1415 panel.toggle_expand();
1416 panel.preview.force_user_scrolled();
1418
1419 panel.handle_key(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
1420 assert_eq!(panel.active_query(), "#todox");
1421 assert!(
1422 panel.preview.is_anchored(),
1423 "a query edit must re-arm the preview auto-anchor"
1424 );
1425 }
1426
1427 #[tokio::test(flavor = "multi_thread")]
1431 async fn preview_wheel_closes_autocomplete_popup() {
1432 use ratatui::Terminal;
1433 use ratatui::backend::TestBackend;
1434 use ratatui::crossterm::event::{
1435 KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind,
1436 };
1437
1438 let vault = crate::test_support::temp_vault("qp-wheel-popup").await;
1439 vault.validate_and_init().await.unwrap();
1440 let mut body = String::from("#todo first line\n");
1441 for i in 0..40 {
1442 body.push_str(&format!("line {}\n", i));
1443 }
1444 vault
1445 .create_note(&VaultPath::note_path_from("/long.md"), &body)
1446 .await
1447 .unwrap();
1448 let mut panel = make_panel(vault);
1449 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1450 panel.set_active_query("#todo".to_string());
1451 settle(&mut panel).await;
1452 panel.toggle_expand();
1453 let theme = crate::settings::themes::Theme::default();
1454 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1455 terminal
1456 .draw(|f| panel.render(f, f.area(), &theme, true))
1457 .unwrap();
1458 let preview = panel.list.content_rect();
1459 assert!(!preview.is_empty());
1460
1461 for ch in [' ', '#'] {
1464 panel.handle_key(&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
1465 for _ in 0..30 {
1466 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1467 panel.list.poll();
1468 }
1469 }
1470 assert!(panel.list.autocomplete_is_open(), "popup open after `#`");
1471
1472 let wheel = MouseEvent {
1473 kind: MouseEventKind::ScrollDown,
1474 column: preview.x + 1,
1475 row: preview.y + 1,
1476 modifiers: KeyModifiers::NONE,
1477 };
1478 panel.handle_mouse(&wheel, &tx);
1479 assert!(
1480 !panel.list.autocomplete_is_open(),
1481 "wheel over the preview must dismiss the popup"
1482 );
1483 }
1484
1485 #[tokio::test(flavor = "multi_thread")]
1489 async fn full_expand_header_click_collapses() {
1490 use ratatui::Terminal;
1491 use ratatui::backend::TestBackend;
1492 use ratatui::crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
1493
1494 let vault = crate::test_support::temp_vault("qp-header-click").await;
1495 vault.validate_and_init().await.unwrap();
1496 vault
1497 .create_note(&VaultPath::note_path_from("/long.md"), "#todo body")
1498 .await
1499 .unwrap();
1500 let mut panel = make_panel(vault);
1501 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1502 panel.set_active_query("#todo".to_string());
1503 settle(&mut panel).await;
1504 panel.toggle_expand();
1506 panel.toggle_expand();
1507 assert!(panel.is_full_expanded());
1508 let theme = crate::settings::themes::Theme::default();
1509 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1510 terminal
1511 .draw(|f| panel.render(f, f.area(), &theme, true))
1512 .unwrap();
1513 let header = panel.preview.full_header_rect();
1514 assert!(!header.is_empty(), "header rect recorded in full mode");
1515
1516 let click = |x: u16, y: u16| MouseEvent {
1517 kind: MouseEventKind::Down(MouseButton::Left),
1518 column: x,
1519 row: y,
1520 modifiers: KeyModifiers::NONE,
1521 };
1522
1523 panel.handle_mouse(&click(header.x + 1, header.y + 3), &tx);
1525 assert!(panel.is_full_expanded(), "content click must not collapse");
1526
1527 panel.handle_mouse(&click(header.x + 1, header.y), &tx);
1529 assert!(!panel.is_full_expanded());
1530 assert!(panel.preview.is_collapsed());
1531 }
1532
1533 #[tokio::test(flavor = "multi_thread")]
1538 async fn toggling_expand_clears_stale_content_regions() {
1539 use ratatui::Terminal;
1540 use ratatui::backend::TestBackend;
1541
1542 let vault = crate::test_support::temp_vault("qp-stale-regions").await;
1543 vault.validate_and_init().await.unwrap();
1544 vault
1545 .create_note(&VaultPath::note_path_from("/long.md"), "#todo body")
1546 .await
1547 .unwrap();
1548 let mut panel = make_panel(vault);
1549 panel.set_active_query("#todo".to_string());
1550 settle(&mut panel).await;
1551 let theme = crate::settings::themes::Theme::default();
1552 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1553
1554 panel.toggle_expand();
1556 panel.toggle_expand();
1557 terminal
1558 .draw(|f| panel.render(f, f.area(), &theme, true))
1559 .unwrap();
1560 assert!(!panel.list.content_rect().is_empty());
1561 assert!(!panel.preview.full_header_rect().is_empty());
1562
1563 panel.toggle_expand();
1566 assert!(
1567 panel.list.content_rect().is_empty(),
1568 "stale content rect must not survive a state change"
1569 );
1570 assert!(
1571 panel.preview.full_header_rect().is_empty(),
1572 "stale header rect must not survive a state change"
1573 );
1574 }
1575
1576 #[tokio::test(flavor = "multi_thread")]
1582 async fn static_query_survives_navigation() {
1583 let vault = crate::test_support::temp_vault("nav-static").await;
1584 vault.validate_and_init().await.unwrap();
1585 vault
1586 .create_note(&VaultPath::note_path_from("/a.md"), "alpha #todo")
1587 .await
1588 .unwrap();
1589 let mut panel = make_panel(vault);
1590 panel.set_active_query("#todo".to_string());
1591 settle(&mut panel).await;
1592 assert_eq!(panel.list.visible_rows().len(), 1);
1593
1594 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1595 panel.set_note(VaultPath::note_path_from("x.md"), tx);
1596
1597 assert_eq!(panel.active_query(), "#todo");
1600 assert!(!panel.list.is_loading());
1601 settle(&mut panel).await;
1602 assert_eq!(panel.list.visible_rows().len(), 1); }
1604
1605 #[tokio::test(flavor = "multi_thread")]
1608 async fn note_variable_query_reruns_on_navigation() {
1609 let vault = crate::test_support::temp_vault("nav-var").await;
1610 vault.validate_and_init().await.unwrap();
1611 vault
1614 .create_note(&VaultPath::note_path_from("/target.md"), "I am the target")
1615 .await
1616 .unwrap();
1617 vault
1618 .create_note(&VaultPath::note_path_from("/linker.md"), "see [[target]]")
1619 .await
1620 .unwrap();
1621 let mut panel = make_panel(vault);
1622 assert_eq!(panel.active_query(), "");
1625 panel.list.set_query(DEFAULT_QUERY);
1626
1627 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1628 panel.set_note(VaultPath::note_path_from("/target.md"), tx);
1629 settle(&mut panel).await;
1630
1631 assert!(
1633 panel
1634 .list
1635 .visible_rows()
1636 .iter()
1637 .any(|e| e.filename.contains("linker")),
1638 "expected linker as a backlink, got {:?}",
1639 panel
1640 .list
1641 .visible_rows()
1642 .iter()
1643 .map(|e| e.filename.clone())
1644 .collect::<Vec<_>>()
1645 );
1646 }
1647
1648 #[tokio::test(flavor = "multi_thread")]
1650 async fn note_variable_query_changes_with_note() {
1651 let vault = crate::test_support::temp_vault("nav-var2").await;
1652 vault.validate_and_init().await.unwrap();
1653 vault
1654 .create_note(&VaultPath::note_path_from("/a.md"), "I am a")
1655 .await
1656 .unwrap();
1657 vault
1658 .create_note(&VaultPath::note_path_from("/b.md"), "I am b")
1659 .await
1660 .unwrap();
1661 vault
1662 .create_note(&VaultPath::note_path_from("/links_a.md"), "see [[a]]")
1663 .await
1664 .unwrap();
1665 let mut panel = make_panel(vault);
1666 panel.list.set_query(DEFAULT_QUERY);
1667 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1668
1669 panel.set_note(VaultPath::note_path_from("/a.md"), tx.clone());
1670 settle(&mut panel).await;
1671 assert!(
1672 panel
1673 .list
1674 .visible_rows()
1675 .iter()
1676 .any(|e| e.filename.contains("links_a"))
1677 );
1678
1679 panel.set_note(VaultPath::note_path_from("/b.md"), tx);
1680 settle(&mut panel).await;
1681 assert!(
1682 !panel
1683 .list
1684 .visible_rows()
1685 .iter()
1686 .any(|e| e.filename.contains("links_a")),
1687 "b has no backlinks, expected empty"
1688 );
1689 }
1690}