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, FileOp};
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::FileOp(FileOp::ShowMenu(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").absolute()));
1003 }
1004
1005 #[tokio::test]
1008 async fn cached_needles_track_query_and_note() {
1009 let vault = crate::test_support::temp_vault("qp_needles").await;
1010 vault.validate_and_init().await.unwrap();
1011 let mut panel = make_panel(vault);
1012 panel.list.set_query(DEFAULT_QUERY);
1013
1014 *panel.current_note.lock().unwrap() = VaultPath::note_path_from("spec");
1016 assert!(panel.cached_needles().iter().any(|n| n == "spec"));
1017
1018 *panel.current_note.lock().unwrap() = VaultPath::note_path_from("other");
1020 assert!(panel.cached_needles().iter().any(|n| n == "other"));
1021
1022 panel.list.set_query("widget".to_string());
1024 let needles = panel.cached_needles();
1025 assert!(needles.iter().any(|n| n == "widget"));
1026 assert!(!needles.iter().any(|n| n == "other"));
1027
1028 panel.list.set_query("#todo".to_string());
1031 assert!(
1032 panel.cached_needles().iter().any(|n| n == "#todo"),
1033 "preview needles must include labels"
1034 );
1035 }
1036
1037 async fn settle(panel: &mut QueryPanel) {
1043 for _ in 0..100 {
1044 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1045 panel.list.poll();
1046 if !panel.list.is_loading() {
1047 break;
1048 }
1049 }
1050 }
1051
1052 #[tokio::test(flavor = "multi_thread")]
1053 async fn apply_sort_rewrites_query_order_directive() {
1054 let vault = crate::test_support::temp_vault("qp-sort").await;
1055 vault.validate_and_init().await.unwrap();
1056 let mut panel = make_panel(vault);
1057 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1058 panel.set_active_query("widget".to_string());
1059
1060 panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1061 assert_eq!(panel.active_query(), "widget or:title");
1062
1063 panel.apply_sort(SortField::Name, SortOrder::Descending, &tx);
1064 assert_eq!(panel.active_query(), "widget -or:file");
1065 }
1066
1067 #[tokio::test(flavor = "multi_thread")]
1071 async fn directiveless_query_is_name_ascending() {
1072 let vault = crate::test_support::temp_vault("qp-defaultorder").await;
1073 vault.validate_and_init().await.unwrap();
1074 for name in ["/charlie.md", "/alpha.md", "/bravo.md"] {
1076 vault
1077 .create_note(&VaultPath::note_path_from(name), "widget")
1078 .await
1079 .unwrap();
1080 }
1081 let mut panel = make_panel(vault);
1082 panel.set_active_query("widget".to_string()); settle(&mut panel).await;
1084
1085 let names: Vec<String> = panel
1086 .list
1087 .visible_rows()
1088 .iter()
1089 .map(|e| e.filename.clone())
1090 .collect();
1091 let mut sorted = names.clone();
1092 sorted.sort();
1093 assert_eq!(names, sorted, "directive-less query must be name-ascending");
1094 }
1095
1096 #[tokio::test(flavor = "multi_thread")]
1099 async fn accepting_saved_search_pins_breadcrumb() {
1100 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1101 let vault = crate::test_support::temp_vault("qp-ss-accept").await;
1102 vault.validate_and_init().await.unwrap();
1103 vault.save_search("todo-week", "#todo").await.unwrap();
1104 let mut panel = make_panel(vault);
1105 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1106
1107 panel.set_active_query(String::new());
1111 for ch in ['?', 't', 'o'] {
1112 panel.handle_key(&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
1113 for _ in 0..30 {
1114 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1115 panel.list.poll();
1116 }
1117 }
1118 panel.handle_key(&KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &tx);
1119
1120 assert_eq!(panel.active_query(), "#todo");
1121 assert_eq!(
1122 panel.saved_search_breadcrumb().as_deref(),
1123 Some("todo-week")
1124 );
1125 }
1126
1127 #[tokio::test(flavor = "multi_thread")]
1130 async fn editing_expanded_query_keeps_breadcrumb_marked_edited() {
1131 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1132 let vault = crate::test_support::temp_vault("qp-ss-edit").await;
1133 vault.validate_and_init().await.unwrap();
1134 let mut panel = make_panel(vault);
1135 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1136 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1137 assert_eq!(panel.saved_search_breadcrumb().as_deref(), Some("todo"));
1138
1139 panel.handle_key(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
1141 assert_eq!(panel.active_query(), "#todox");
1142 assert_eq!(
1143 panel.saved_search_breadcrumb().as_deref(),
1144 Some("todo • edited")
1145 );
1146 }
1147
1148 #[tokio::test(flavor = "multi_thread")]
1151 async fn emptying_field_clears_breadcrumb() {
1152 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1153 let vault = crate::test_support::temp_vault("qp-ss-empty").await;
1154 vault.validate_and_init().await.unwrap();
1155 let mut panel = make_panel(vault);
1156 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1157 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1158
1159 for _ in 0.."#todo".len() {
1161 panel.handle_key(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), &tx);
1162 }
1163 assert_eq!(panel.active_query(), "");
1164 assert_eq!(panel.saved_search_breadcrumb(), None);
1165 }
1166
1167 #[tokio::test(flavor = "multi_thread")]
1168 async fn apply_query_pins_breadcrumb() {
1169 let vault = crate::test_support::temp_vault("qp-name").await;
1170 vault.validate_and_init().await.unwrap();
1171 let mut panel = make_panel(vault);
1172 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1173 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1174 assert_eq!(panel.saved_search_breadcrumb().as_deref(), Some("todo"));
1175 }
1176
1177 #[tokio::test(flavor = "multi_thread")]
1181 async fn apply_sort_marks_saved_search_breadcrumb_edited() {
1182 let vault = crate::test_support::temp_vault("qp-sort-name").await;
1183 vault.validate_and_init().await.unwrap();
1184 let mut panel = make_panel(vault);
1185 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1186 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1187
1188 panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1189 assert_eq!(panel.active_query(), "#todo or:title");
1190 assert_eq!(
1191 panel.saved_search_breadcrumb().as_deref(),
1192 Some("todo • edited"),
1193 "sorting diverges from the stored query, so the breadcrumb is edited"
1194 );
1195 }
1196
1197 #[tokio::test(flavor = "multi_thread")]
1200 async fn repin_after_save_adopts_saved_identity() {
1201 let vault = crate::test_support::temp_vault("qp-repin").await;
1202 vault.validate_and_init().await.unwrap();
1203 let mut panel = make_panel(vault);
1204 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1205 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1206
1207 panel.set_active_query("#todo and #urgent".to_string());
1208 assert_eq!(
1209 panel.saved_search_breadcrumb().as_deref(),
1210 Some("todo • edited")
1211 );
1212
1213 panel.repin_saved_search("urgent-todos".to_string(), "#todo and #urgent");
1214 assert_eq!(
1215 panel.saved_search_breadcrumb().as_deref(),
1216 Some("urgent-todos"),
1217 "after a save the saved identity is the provenance — no edited marker"
1218 );
1219 }
1220
1221 #[tokio::test(flavor = "multi_thread")]
1225 async fn apply_sort_updates_visible_input_bar() {
1226 let vault = crate::test_support::temp_vault("qp-bar").await;
1227 vault.validate_and_init().await.unwrap();
1228 let mut panel = make_panel(vault);
1229 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1230 panel.set_active_query("widget".to_string());
1231 assert_eq!(
1232 panel.list.input_value(),
1233 "widget",
1234 "set_active_query syncs the bar"
1235 );
1236
1237 panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1238 assert_eq!(panel.active_query(), "widget or:title");
1239 assert_eq!(
1240 panel.list.input_value(),
1241 "widget or:title",
1242 "the input bar must reflect the rewritten query"
1243 );
1244 }
1245
1246 #[tokio::test(flavor = "multi_thread")]
1247 async fn current_order_reads_query_directive() {
1248 let vault = crate::test_support::temp_vault("qp-order").await;
1249 vault.validate_and_init().await.unwrap();
1250 let mut panel = make_panel(vault);
1251 panel.set_active_query("widget -or:title".to_string());
1252 assert_eq!(
1253 panel.current_order(),
1254 (SortField::Title, SortOrder::Descending)
1255 );
1256 panel.set_active_query("widget".to_string());
1257 assert_eq!(
1258 panel.current_order(),
1259 (SortField::Name, SortOrder::Ascending)
1260 );
1261 }
1262
1263 #[tokio::test(flavor = "multi_thread")]
1267 async fn context_preview_wheel_scrolls_preview_not_list() {
1268 use ratatui::Terminal;
1269 use ratatui::backend::TestBackend;
1270 use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
1271
1272 let vault = crate::test_support::temp_vault("qp-preview-wheel").await;
1273 vault.validate_and_init().await.unwrap();
1274 let mut body = String::from("#todo first line\n");
1277 for i in 0..40 {
1278 body.push_str(&format!("line {}\n", i));
1279 }
1280 vault
1281 .create_note(&VaultPath::note_path_from("/long.md"), &body)
1282 .await
1283 .unwrap();
1284 let mut panel = make_panel(vault);
1285 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1286 panel.set_active_query("#todo".to_string());
1287 settle(&mut panel).await;
1288 assert!(panel.list.selected_row().is_some());
1289
1290 panel.toggle_expand();
1293 assert!(panel.preview.is_context());
1294 let theme = crate::settings::themes::Theme::default();
1295 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1296 terminal
1297 .draw(|f| panel.render(f, f.area(), &theme, true))
1298 .unwrap();
1299 let preview = panel.list.content_rect();
1300 assert!(!preview.is_empty(), "preview rect recorded");
1301 assert_eq!(
1302 panel.preview.scroll_offset(),
1303 0,
1304 "auto-anchor at the top needle"
1305 );
1306 assert!(panel.preview.scroll_max() > 0, "content overflows viewport");
1307
1308 let wheel = move |y: u16| MouseEvent {
1309 kind: MouseEventKind::ScrollDown,
1310 column: preview.x + 1,
1311 row: y,
1312 modifiers: KeyModifiers::NONE,
1313 };
1314
1315 let over_list = wheel(preview.y.saturating_sub(3));
1317 panel.handle_mouse(&over_list, &tx);
1318 assert_eq!(
1319 panel.preview.scroll_offset(),
1320 0,
1321 "list wheel must not move preview"
1322 );
1323 assert!(panel.preview.is_anchored(), "anchor stays armed");
1324
1325 let over_preview = wheel(preview.y + 1);
1327 panel.handle_mouse(&over_preview, &tx);
1328 assert_eq!(
1329 panel.preview.scroll_offset(),
1330 1,
1331 "preview wheel scrolls content"
1332 );
1333 assert!(!panel.preview.is_anchored(), "user owns the scroll now");
1334
1335 terminal
1337 .draw(|f| panel.render(f, f.area(), &theme, true))
1338 .unwrap();
1339 assert_eq!(panel.preview.scroll_offset(), 1);
1340
1341 let up = MouseEvent {
1343 kind: MouseEventKind::ScrollUp,
1344 column: preview.x + 1,
1345 row: preview.y + 1,
1346 modifiers: KeyModifiers::NONE,
1347 };
1348 panel.handle_mouse(&up, &tx);
1349 panel.handle_mouse(&up, &tx);
1350 assert_eq!(panel.preview.scroll_offset(), 0);
1351 }
1352
1353 #[tokio::test(flavor = "multi_thread")]
1357 async fn noop_preview_wheel_keeps_autoscroll_armed() {
1358 use ratatui::Terminal;
1359 use ratatui::backend::TestBackend;
1360 use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
1361
1362 let vault = crate::test_support::temp_vault("qp-noop-wheel").await;
1363 vault.validate_and_init().await.unwrap();
1364 vault
1366 .create_note(&VaultPath::note_path_from("/short.md"), "#todo only line")
1367 .await
1368 .unwrap();
1369 let mut panel = make_panel(vault);
1370 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1371 panel.set_active_query("#todo".to_string());
1372 settle(&mut panel).await;
1373 panel.toggle_expand();
1374 let theme = crate::settings::themes::Theme::default();
1375 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1376 terminal
1377 .draw(|f| panel.render(f, f.area(), &theme, true))
1378 .unwrap();
1379 assert_eq!(panel.preview.scroll_max(), 0, "content fits the viewport");
1380
1381 let preview = panel.list.content_rect();
1382 let down = MouseEvent {
1383 kind: MouseEventKind::ScrollDown,
1384 column: preview.x + 1,
1385 row: preview.y + 1,
1386 modifiers: KeyModifiers::NONE,
1387 };
1388 panel.handle_mouse(&down, &tx);
1389 assert!(
1390 panel.preview.is_anchored(),
1391 "no-op wheel tick must not disarm the auto-anchor"
1392 );
1393 }
1394
1395 #[tokio::test(flavor = "multi_thread")]
1399 async fn query_keystroke_rearms_preview_autoscroll() {
1400 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1401
1402 let vault = crate::test_support::temp_vault("qp-rearm").await;
1403 vault.validate_and_init().await.unwrap();
1404 let mut body = String::from("#todo first line\n");
1405 for i in 0..40 {
1406 body.push_str(&format!("line {}\n", i));
1407 }
1408 vault
1409 .create_note(&VaultPath::note_path_from("/long.md"), &body)
1410 .await
1411 .unwrap();
1412 let mut panel = make_panel(vault);
1413 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1414 panel.set_active_query("#todo".to_string());
1415 settle(&mut panel).await;
1416 panel.toggle_expand();
1417 panel.preview.force_user_scrolled();
1419
1420 panel.handle_key(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
1421 assert_eq!(panel.active_query(), "#todox");
1422 assert!(
1423 panel.preview.is_anchored(),
1424 "a query edit must re-arm the preview auto-anchor"
1425 );
1426 }
1427
1428 #[tokio::test(flavor = "multi_thread")]
1432 async fn preview_wheel_closes_autocomplete_popup() {
1433 use ratatui::Terminal;
1434 use ratatui::backend::TestBackend;
1435 use ratatui::crossterm::event::{
1436 KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind,
1437 };
1438
1439 let vault = crate::test_support::temp_vault("qp-wheel-popup").await;
1440 vault.validate_and_init().await.unwrap();
1441 let mut body = String::from("#todo first line\n");
1442 for i in 0..40 {
1443 body.push_str(&format!("line {}\n", i));
1444 }
1445 vault
1446 .create_note(&VaultPath::note_path_from("/long.md"), &body)
1447 .await
1448 .unwrap();
1449 let mut panel = make_panel(vault);
1450 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1451 panel.set_active_query("#todo".to_string());
1452 settle(&mut panel).await;
1453 panel.toggle_expand();
1454 let theme = crate::settings::themes::Theme::default();
1455 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1456 terminal
1457 .draw(|f| panel.render(f, f.area(), &theme, true))
1458 .unwrap();
1459 let preview = panel.list.content_rect();
1460 assert!(!preview.is_empty());
1461
1462 for ch in [' ', '#'] {
1465 panel.handle_key(&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
1466 for _ in 0..30 {
1467 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1468 panel.list.poll();
1469 }
1470 }
1471 assert!(panel.list.autocomplete_is_open(), "popup open after `#`");
1472
1473 let wheel = MouseEvent {
1474 kind: MouseEventKind::ScrollDown,
1475 column: preview.x + 1,
1476 row: preview.y + 1,
1477 modifiers: KeyModifiers::NONE,
1478 };
1479 panel.handle_mouse(&wheel, &tx);
1480 assert!(
1481 !panel.list.autocomplete_is_open(),
1482 "wheel over the preview must dismiss the popup"
1483 );
1484 }
1485
1486 #[tokio::test(flavor = "multi_thread")]
1490 async fn full_expand_header_click_collapses() {
1491 use ratatui::Terminal;
1492 use ratatui::backend::TestBackend;
1493 use ratatui::crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
1494
1495 let vault = crate::test_support::temp_vault("qp-header-click").await;
1496 vault.validate_and_init().await.unwrap();
1497 vault
1498 .create_note(&VaultPath::note_path_from("/long.md"), "#todo body")
1499 .await
1500 .unwrap();
1501 let mut panel = make_panel(vault);
1502 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1503 panel.set_active_query("#todo".to_string());
1504 settle(&mut panel).await;
1505 panel.toggle_expand();
1507 panel.toggle_expand();
1508 assert!(panel.is_full_expanded());
1509 let theme = crate::settings::themes::Theme::default();
1510 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1511 terminal
1512 .draw(|f| panel.render(f, f.area(), &theme, true))
1513 .unwrap();
1514 let header = panel.preview.full_header_rect();
1515 assert!(!header.is_empty(), "header rect recorded in full mode");
1516
1517 let click = |x: u16, y: u16| MouseEvent {
1518 kind: MouseEventKind::Down(MouseButton::Left),
1519 column: x,
1520 row: y,
1521 modifiers: KeyModifiers::NONE,
1522 };
1523
1524 panel.handle_mouse(&click(header.x + 1, header.y + 3), &tx);
1526 assert!(panel.is_full_expanded(), "content click must not collapse");
1527
1528 panel.handle_mouse(&click(header.x + 1, header.y), &tx);
1530 assert!(!panel.is_full_expanded());
1531 assert!(panel.preview.is_collapsed());
1532 }
1533
1534 #[tokio::test(flavor = "multi_thread")]
1539 async fn toggling_expand_clears_stale_content_regions() {
1540 use ratatui::Terminal;
1541 use ratatui::backend::TestBackend;
1542
1543 let vault = crate::test_support::temp_vault("qp-stale-regions").await;
1544 vault.validate_and_init().await.unwrap();
1545 vault
1546 .create_note(&VaultPath::note_path_from("/long.md"), "#todo body")
1547 .await
1548 .unwrap();
1549 let mut panel = make_panel(vault);
1550 panel.set_active_query("#todo".to_string());
1551 settle(&mut panel).await;
1552 let theme = crate::settings::themes::Theme::default();
1553 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1554
1555 panel.toggle_expand();
1557 panel.toggle_expand();
1558 terminal
1559 .draw(|f| panel.render(f, f.area(), &theme, true))
1560 .unwrap();
1561 assert!(!panel.list.content_rect().is_empty());
1562 assert!(!panel.preview.full_header_rect().is_empty());
1563
1564 panel.toggle_expand();
1567 assert!(
1568 panel.list.content_rect().is_empty(),
1569 "stale content rect must not survive a state change"
1570 );
1571 assert!(
1572 panel.preview.full_header_rect().is_empty(),
1573 "stale header rect must not survive a state change"
1574 );
1575 }
1576
1577 #[tokio::test(flavor = "multi_thread")]
1583 async fn static_query_survives_navigation() {
1584 let vault = crate::test_support::temp_vault("nav-static").await;
1585 vault.validate_and_init().await.unwrap();
1586 vault
1587 .create_note(&VaultPath::note_path_from("/a.md"), "alpha #todo")
1588 .await
1589 .unwrap();
1590 let mut panel = make_panel(vault);
1591 panel.set_active_query("#todo".to_string());
1592 settle(&mut panel).await;
1593 assert_eq!(panel.list.visible_rows().len(), 1);
1594
1595 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1596 panel.set_note(VaultPath::note_path_from("x.md"), tx);
1597
1598 assert_eq!(panel.active_query(), "#todo");
1601 assert!(!panel.list.is_loading());
1602 settle(&mut panel).await;
1603 assert_eq!(panel.list.visible_rows().len(), 1); }
1605
1606 #[tokio::test(flavor = "multi_thread")]
1609 async fn note_variable_query_reruns_on_navigation() {
1610 let vault = crate::test_support::temp_vault("nav-var").await;
1611 vault.validate_and_init().await.unwrap();
1612 vault
1615 .create_note(&VaultPath::note_path_from("/target.md"), "I am the target")
1616 .await
1617 .unwrap();
1618 vault
1619 .create_note(&VaultPath::note_path_from("/linker.md"), "see [[target]]")
1620 .await
1621 .unwrap();
1622 let mut panel = make_panel(vault);
1623 assert_eq!(panel.active_query(), "");
1626 panel.list.set_query(DEFAULT_QUERY);
1627
1628 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1629 panel.set_note(VaultPath::note_path_from("/target.md"), tx);
1630 settle(&mut panel).await;
1631
1632 assert!(
1634 panel
1635 .list
1636 .visible_rows()
1637 .iter()
1638 .any(|e| e.filename.contains("linker")),
1639 "expected linker as a backlink, got {:?}",
1640 panel
1641 .list
1642 .visible_rows()
1643 .iter()
1644 .map(|e| e.filename.clone())
1645 .collect::<Vec<_>>()
1646 );
1647 }
1648
1649 #[tokio::test(flavor = "multi_thread")]
1651 async fn note_variable_query_changes_with_note() {
1652 let vault = crate::test_support::temp_vault("nav-var2").await;
1653 vault.validate_and_init().await.unwrap();
1654 vault
1655 .create_note(&VaultPath::note_path_from("/a.md"), "I am a")
1656 .await
1657 .unwrap();
1658 vault
1659 .create_note(&VaultPath::note_path_from("/b.md"), "I am b")
1660 .await
1661 .unwrap();
1662 vault
1663 .create_note(&VaultPath::note_path_from("/links_a.md"), "see [[a]]")
1664 .await
1665 .unwrap();
1666 let mut panel = make_panel(vault);
1667 panel.list.set_query(DEFAULT_QUERY);
1668 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1669
1670 panel.set_note(VaultPath::note_path_from("/a.md"), tx.clone());
1671 settle(&mut panel).await;
1672 assert!(
1673 panel
1674 .list
1675 .visible_rows()
1676 .iter()
1677 .any(|e| e.filename.contains("links_a"))
1678 );
1679
1680 panel.set_note(VaultPath::note_path_from("/b.md"), tx);
1681 settle(&mut panel).await;
1682 assert!(
1683 !panel
1684 .list
1685 .visible_rows()
1686 .iter()
1687 .any(|e| e.filename.contains("links_a")),
1688 "b has no backlinks, expected empty"
1689 );
1690 }
1691}