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::{Highlight, 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, Focus, 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 fn yank_target(&self) -> Option<crate::components::search_list::YankTarget> {
100 Some(crate::components::search_list::YankTarget::path(
101 self.path.to_string(),
102 ))
103 }
104}
105
106struct BacklinkSource {
117 vault: Arc<NoteVault>,
118}
119
120#[async_trait]
121impl RowSource<BacklinkEntry> for BacklinkSource {
122 async fn load(&self, query: &str, emit: Emit<BacklinkEntry>) {
123 let mut entries = load_query(&self.vault, query).await;
124 if kimun_core::SearchTerms::from_query_string(query)
131 .order_by
132 .is_empty()
133 {
134 entries.sort_by_key(|e| e.filename.to_lowercase());
135 }
136 emit.replace(entries);
137 }
138}
139
140pub struct QueryPanel {
145 list: SearchList<BacklinkEntry>,
148 current_note: Arc<Mutex<VaultPath>>,
151 saved_search: SavedSearchBreadcrumb,
155 preview: PreviewPane,
160 key_bindings: KeyBindings,
161 redraw_tx: Arc<Mutex<Option<AppTx>>>,
166 follow_link_combos: Vec<KeyCombo>,
168 order_cache: (SortField, SortOrder),
173 order_cache_query: String,
174 is_default_cache: bool,
178 needles_cache: Vec<String>,
184 needles_cache_key: (String, VaultPath),
185}
186
187impl QueryPanel {
188 pub fn new(vault: Arc<NoteVault>, key_bindings: KeyBindings, icons: Icons) -> Self {
189 let current_note = Arc::new(Mutex::new(VaultPath::empty()));
190 let redraw_tx: Arc<Mutex<Option<AppTx>>> = Arc::new(Mutex::new(None));
194 let redraw: Arc<dyn Fn() + Send + Sync> = {
195 let slot = redraw_tx.clone();
196 Arc::new(move || {
197 if let Some(tx) = slot.lock().unwrap().as_ref() {
198 let _ = tx.send(AppEvent::Redraw);
199 }
200 })
201 };
202 let source = ResolvingRowSource::new(
206 Arc::new(BacklinkSource {
207 vault: vault.clone(),
208 }),
209 {
210 let note = current_note.clone();
211 move || QueryContext::with_note(Some(note.lock().unwrap().clone()))
212 },
213 Unresolvable::Empty,
214 );
215 let combos = |action: &ActionShortcuts| -> Vec<KeyCombo> {
216 key_bindings
217 .to_hashmap()
218 .get(action)
219 .cloned()
220 .unwrap_or_default()
221 };
222 let follow_link_combos = combos(&ActionShortcuts::FollowLink);
223
224 let mut intercept = Vec::new();
225 intercept.extend(follow_link_combos.iter().cloned());
226
227 let list = SearchList::builder(source, redraw)
228 .highlight_query()
229 .yank_combos_from(&key_bindings)
230 .icons(icons.clone())
231 .autocomplete(
232 Arc::new(VaultSuggestions {
233 vault: vault.clone(),
234 }),
235 AutocompleteMode::SearchQuery,
236 )
237 .intercept(intercept)
238 .list_verb('l')
242 .list_verb('h')
243 .list_verb('o')
244 .list_verb('y')
245 .build();
246
247 Self {
248 list,
249 current_note,
250 saved_search: SavedSearchBreadcrumb::default(),
251 preview: PreviewPane::new(),
252 key_bindings,
253 redraw_tx,
254 follow_link_combos,
255 order_cache: (SortField::Name, SortOrder::Ascending),
257 order_cache_query: String::new(),
258 is_default_cache: false,
261 needles_cache: Vec::new(),
262 needles_cache_key: (String::new(), VaultPath::empty()),
263 }
264 }
265
266 pub fn active_query(&self) -> &str {
269 self.list.query()
270 }
271
272 fn emphasis(&self) -> Option<Vec<String>> {
276 let resolved = resolve_query(self.list.query(), &self.query_ctx());
277 let needles = crate::components::query_highlight::emphasis_needles(&resolved);
278 (!needles.is_empty()).then_some(needles)
279 }
280
281 pub fn result_count(&self) -> usize {
283 self.list.match_count()
284 }
285
286 pub fn set_active_query(&mut self, q: String) {
287 self.list.set_query(q);
288 self.reset_expand();
289 }
290
291 pub fn saved_search_breadcrumb(&self) -> Option<String> {
294 self.saved_search.label(self.list.query())
295 }
296
297 pub fn saved_search_name(&self) -> Option<&str> {
301 self.saved_search.name()
302 }
303
304 pub fn repin_saved_search(&mut self, name: String, query: &str) {
308 self.saved_search.set(Some(name), query);
309 }
310
311 fn query_is_blank(&self) -> bool {
316 let q = self.list.query();
317 q.trim().is_empty() || is_default_query(q)
318 }
319
320 pub fn apply_query(&mut self, query: String, name: Option<String>, tx: AppTx) {
324 self.ensure_redraw_tx(&tx);
325 self.set_active_query(query.clone());
326 self.saved_search.set(name, &query);
327 }
328
329 fn current_note(&self) -> VaultPath {
332 self.current_note.lock().unwrap().clone()
333 }
334
335 fn query_ctx(&self) -> QueryContext {
339 QueryContext::with_note(Some(self.current_note()))
340 }
341
342 fn ensure_redraw_tx(&self, tx: &AppTx) {
345 let mut slot = self.redraw_tx.lock().unwrap();
346 if slot.is_none() {
347 *slot = Some(tx.clone());
348 }
349 }
350
351 fn cached_needles(&mut self) -> &[String] {
356 let note = self.current_note();
357 if self.needles_cache_key.0 != self.list.query() || self.needles_cache_key.1 != note {
358 let resolved = resolve_query(self.list.query(), &self.query_ctx());
359 self.needles_cache = crate::components::query_highlight::emphasis_needles(&resolved);
363 self.needles_cache_key = (self.list.query().to_string(), note);
364 }
365 &self.needles_cache
366 }
367
368 fn is_full_expanded(&self) -> bool {
371 self.list.selected_row().is_some() && self.preview.is_full()
372 }
373
374 pub fn is_empty(&self) -> bool {
375 self.list.rows().is_empty()
376 }
377
378 pub fn selected_path(&self) -> Option<&VaultPath> {
379 self.list.selected_row().map(|e| &e.path)
380 }
381
382 fn reset_expand(&mut self) {
383 self.preview.reset();
384 self.list.set_content_rect(Rect::default());
385 }
386
387 fn sync_expand_anchor(&mut self) {
391 let sel = self.list.selected_row().map(|e| e.path.clone());
392 if self.preview.sync(sel) {
393 self.list.set_content_rect(Rect::default());
394 }
395 }
396
397 pub fn set_note(&mut self, note_path: VaultPath, tx: AppTx) {
402 self.ensure_redraw_tx(&tx);
403 *self.current_note.lock().unwrap() = note_path;
404 if query_has_variables(self.list.query()) {
405 self.list.reload();
406 self.reset_expand();
407 }
408 }
409
410 pub fn current_order(&self) -> (SortField, SortOrder) {
415 let st = kimun_core::SearchTerms::from_query_string(self.list.query());
416 match st.order_by.first() {
417 Some(OrderBy::Title { asc }) => (
418 SortField::Title,
419 if *asc {
420 SortOrder::Ascending
421 } else {
422 SortOrder::Descending
423 },
424 ),
425 Some(OrderBy::FileName { asc }) => (
426 SortField::Name,
427 if *asc {
428 SortOrder::Ascending
429 } else {
430 SortOrder::Descending
431 },
432 ),
433 None => (SortField::Name, SortOrder::Ascending),
434 }
435 }
436
437 pub fn apply_sort(&mut self, field: SortField, order: SortOrder, tx: &AppTx) {
440 self.ensure_redraw_tx(tx);
441 let order_field = match field {
442 SortField::Name => OrderField::FileName,
443 SortField::Title => OrderField::Title,
444 };
445 let asc = matches!(order, SortOrder::Ascending);
446 let rewritten = with_order_directive(self.list.query(), order_field, asc);
447 self.list.set_query(rewritten);
448 self.reset_expand();
452 }
453
454 pub fn handle_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
457 self.ensure_redraw_tx(tx);
458 self.sync_expand_anchor();
459
460 if self.is_full_expanded() && matches!(key.code, KeyCode::Up | KeyCode::Down) {
463 self.scroll_content(key);
464 return EventState::Consumed;
465 }
466 if key.code == KeyCode::Enter
471 && key
472 .modifiers
473 .contains(ratatui::crossterm::event::KeyModifiers::CONTROL)
474 {
475 if let Some(path) = self.selected_path().cloned() {
476 tx.send(AppEvent::OpenPath {
477 path,
478 emphasis: self.emphasis(),
479 })
480 .ok();
481 }
482 return EventState::Consumed;
483 }
484 let prev_query = self.list.query().to_string();
492 match self.list.handle_key(key) {
493 KeyReaction::Intercepted(c) if self.follow_link_combos.contains(&c) => {
494 if let Some(path) = self.selected_path().cloned() {
495 tx.send(AppEvent::OpenPath {
496 path,
497 emphasis: self.emphasis(),
498 })
499 .ok();
500 }
501 EventState::Consumed
502 }
503 KeyReaction::Consumed => {
504 let accepted = self.list.take_accepted_saved_search();
508 let blank = self.query_is_blank();
509 self.saved_search
510 .on_query_consumed(accepted, self.list.query(), blank);
511 if self.list.query() != prev_query {
516 self.preview.re_anchor();
517 }
518 self.sync_expand_anchor();
519 EventState::Consumed
520 }
521 KeyReaction::Submit => {
522 self.toggle_expand();
525 EventState::Consumed
526 }
527 KeyReaction::ListVerb(c) => {
531 match c {
532 'l' => self.toggle_expand(),
533 'h' => self.collapse_expand(),
534 'o' => self.open_selected(tx),
535 'y' => self.yank_selected_row(tx),
536 _ => {}
537 }
538 self.sync_expand_anchor();
539 EventState::Consumed
540 }
541 KeyReaction::Cancel => EventState::NotConsumed,
544 KeyReaction::Unhandled => EventState::NotConsumed,
545 KeyReaction::Intercepted(_) => EventState::Consumed,
546 KeyReaction::Yank(target) => {
547 crate::components::yank_row(target, tx);
548 EventState::Consumed
549 }
550 }
551 }
552
553 pub fn handle_mouse(
562 &mut self,
563 mouse: &ratatui::crossterm::event::MouseEvent,
564 tx: &AppTx,
565 ) -> EventState {
566 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
567 use ratatui::layout::Position;
568 self.ensure_redraw_tx(tx);
569 let was_full = self.is_full_expanded();
574 self.sync_expand_anchor();
575 if was_full {
582 match mouse.kind {
583 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {}
585 MouseEventKind::Down(MouseButton::Left)
589 if self.preview.full_header_rect().contains(Position {
590 x: mouse.column,
591 y: mouse.row,
592 }) =>
593 {
594 self.list.close_autocomplete();
595 self.toggle_expand();
596 return EventState::Consumed;
597 }
598 _ => {
599 self.list.close_autocomplete();
600 return EventState::Consumed;
601 }
602 }
603 }
604 match self.list.handle_mouse(mouse) {
605 SearchMouse::ContentScrollUp => {
606 self.preview.scroll_up();
607 EventState::Consumed
608 }
609 SearchMouse::ContentScrollDown => {
610 self.preview.scroll_down();
611 EventState::Consumed
612 }
613 SearchMouse::Activated(_) => {
614 self.toggle_expand();
615 EventState::Consumed
616 }
617 SearchMouse::Context(_) => {
619 if let Some(path) = self.selected_path().cloned() {
620 tx.send(AppEvent::FileOp(FileOp::ShowMenu(path))).ok();
621 }
622 EventState::Consumed
623 }
624 SearchMouse::Selected(_) | SearchMouse::Scrolled => {
625 self.sync_expand_anchor();
626 EventState::Consumed
627 }
628 SearchMouse::None => EventState::NotConsumed,
629 }
630 }
631
632 fn yank_selected_row(&self, tx: &AppTx) {
636 crate::components::yank_row(self.list.selected_row().and_then(|r| r.yank_target()), tx);
637 }
638
639 fn scroll_content(&mut self, key: &KeyEvent) {
640 match key.code {
641 KeyCode::Up => self.preview.scroll_up(),
642 KeyCode::Down => self.preview.scroll_down(),
643 _ => {}
644 }
645 }
646
647 fn toggle_expand(&mut self) {
648 let sel = self.list.selected_row().map(|e| e.path.clone());
649 if sel.is_none() {
650 return;
651 }
652 self.preview.toggle(sel);
653 self.list.set_content_rect(Rect::default());
654 }
655
656 fn collapse_expand(&mut self) {
659 let sel = self.list.selected_row().map(|e| e.path.clone());
660 if sel.is_none() {
661 return;
662 }
663 self.preview.collapse_step(sel);
664 self.list.set_content_rect(Rect::default());
665 }
666
667 fn open_selected(&self, tx: &AppTx) {
670 if let Some(path) = self.selected_path().cloned() {
671 tx.send(AppEvent::OpenPath {
672 path,
673 emphasis: self.emphasis(),
674 })
675 .ok();
676 }
677 }
678
679 pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
680 if self.list.focus() == Focus::List {
683 return vec![
684 ("j/k".to_string(), "navigate".to_string()),
685 ("h/l".to_string(), "preview".to_string()),
686 ("o".to_string(), "open".to_string()),
687 ("y".to_string(), "yank".to_string()),
688 ("i".to_string(), "filter".to_string()),
689 ("Esc".to_string(), "\u{2190} editor".to_string()),
690 ];
691 }
692 crate::components::hints::hints_for(
693 &self.key_bindings,
694 &[
695 (ActionShortcuts::FocusSidebar, "\u{2190} editor"),
696 (ActionShortcuts::FollowLink, "open note"),
697 (ActionShortcuts::SaveCurrentQuery, "save query"),
698 (ActionShortcuts::OpenSavedSearches, "searches"),
699 (ActionShortcuts::OpenSortDialog, "sort"),
700 ],
701 )
702 }
703
704 pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
707 self.list.poll();
708 self.sync_expand_anchor();
709 self.list.set_panel_rect(rect);
712 self.list.set_content_rect(Rect::default());
717 self.preview.clear_header();
718
719 let border_style = theme.border_style(focused);
720 let gray = theme.gray.to_ratatui();
721 let bg = theme.bg_panel.to_ratatui();
722
723 let count = self.list.visible_rows().len();
724 if self.list.query() != self.order_cache_query {
727 self.order_cache = self.current_order();
728 self.is_default_cache = is_default_query(self.list.query());
729 self.order_cache_query = self.list.query().to_string();
730 }
731 let (sort_field, sort_order) = self.order_cache;
732 let sort_indicator = format!("{}{}", sort_field.label(), sort_order.label());
733 let title = if self.list.query().trim().is_empty() {
740 "Find".to_string()
741 } else if self.is_default_cache {
742 format!("Backlinks ({}) {}", count, sort_indicator)
743 } else {
744 format!("Query ({}) {}", count, sort_indicator)
745 };
746
747 let outer = Block::default()
748 .title(title)
749 .borders(Borders::ALL)
750 .border_style(border_style)
751 .style(theme.panel_style());
752 let outer_inner = outer.inner(rect);
753 f.render_widget(outer, rect);
754
755 let rows = Layout::default()
757 .direction(Direction::Vertical)
758 .constraints([Constraint::Length(3), Constraint::Min(0)])
759 .split(outer_inner);
760 let search_title = self.saved_search.border_title(self.list.query(), " Query");
763 let mut search_block = Block::default()
764 .title(search_title)
765 .borders(Borders::ALL)
766 .border_style(border_style)
767 .style(theme.panel_style());
768 if let Some(reason) = crate::components::query_highlight::error_reason(self.list.query()) {
771 search_block = search_block.title(
772 ratatui::text::Line::from(ratatui::text::Span::styled(
773 format!(" ⚠ {reason} "),
774 Style::default().fg(theme.red.to_ratatui()),
775 ))
776 .right_aligned(),
777 );
778 }
779 let search_inner = search_block.inner(rows[0]);
780 f.render_widget(search_block, rows[0]);
781 self.list.render_query(f, search_inner, theme, focused);
782
783 let inner = rows[1];
784
785 if self.list.is_loading() {
786 f.render_widget(
787 Paragraph::new(" Loading...").style(Style::default().fg(gray).bg(bg)),
788 inner,
789 );
790 self.list.render_autocomplete(f, rect, theme);
791 return;
792 }
793
794 if self.list.visible_rows().is_empty() {
795 f.render_widget(
796 Paragraph::new(" No results").style(Style::default().fg(gray).bg(bg)),
797 inner,
798 );
799 self.list.render_autocomplete(f, rect, theme);
800 return;
801 }
802
803 if self.preview.is_full() {
807 self.list.set_content_rect(rect);
808 if let Some(entry) = self.list.selected_row() {
809 let entry = entry.clone();
810 let text = entry
811 .full_text
812 .clone()
813 .unwrap_or_else(|| entry.context.clone());
814 let needles = self.cached_needles().to_vec();
815 self.preview.render_full(
816 f,
817 inner,
818 &entry.title,
819 &entry.filename,
820 &text,
821 Highlight::Needles(&needles),
822 theme,
823 );
824 }
825 self.list.render_autocomplete(f, rect, theme);
826 return;
827 }
828
829 let has_context = self.preview.is_context();
831
832 let (list_area, divider_area, content_area) = if has_context {
833 let max_list = inner.height / 2;
834 let list_height = (count as u16).min(max_list).max(1);
835 let areas = Layout::default()
836 .direction(Direction::Vertical)
837 .constraints([
838 Constraint::Length(list_height),
839 Constraint::Length(1),
840 Constraint::Min(0),
841 ])
842 .split(inner);
843 (areas[0], Some(areas[1]), Some(areas[2]))
844 } else {
845 (inner, None, None)
846 };
847
848 if self.list.query().trim().is_empty() {
851 let dim = Style::default().fg(theme.gray.to_ratatui());
855 let key = Style::default().fg(theme.yellow.to_ratatui());
856 let lines = vec![
857 ratatui::text::Line::from(Span::styled("type to search the vault", dim)),
858 ratatui::text::Line::default(),
859 ratatui::text::Line::from(vec![
860 Span::styled(" #tag ", key),
861 Span::styled("label", dim),
862 ]),
863 ratatui::text::Line::from(vec![
864 Span::styled(" < > ", key),
865 Span::styled("backlinks · links", dim),
866 ]),
867 ratatui::text::Line::from(vec![
868 Span::styled(" \"phrase\" ", key),
869 Span::styled("exact match", dim),
870 ]),
871 ratatui::text::Line::from(vec![
872 Span::styled(" =date ", key),
873 Span::styled("modified", dim),
874 ]),
875 ratatui::text::Line::from(vec![
876 Span::styled(" ?name ", key),
877 Span::styled("saved search", dim),
878 ]),
879 ];
880 f.render_widget(ratatui::widgets::Paragraph::new(lines), list_area);
881 } else {
882 self.list.render(f, list_area, theme, focused);
883 }
884 self.list.set_list_rect(list_area);
885
886 if let Some(div) = divider_area {
888 f.render_widget(
889 Paragraph::new("\u{2500}".repeat(div.width as usize))
890 .style(Style::default().fg(gray).bg(bg)),
891 div,
892 );
893 }
894
895 if let Some(area) = content_area
898 && self.preview.is_context()
899 && let Some(entry) = self.list.selected_row()
900 {
901 let entry = entry.clone();
902 let text = entry
903 .full_text
904 .clone()
905 .unwrap_or_else(|| entry.context.clone());
906 let needles = self.cached_needles().to_vec();
907 self.preview
908 .render_context(f, area, &text, Highlight::Needles(&needles), theme);
909 self.list.set_content_rect(area);
913 }
914
915 self.list.render_autocomplete(f, rect, theme);
916 }
917}
918
919async fn load_query(vault: &NoteVault, query: &str) -> Vec<BacklinkEntry> {
926 let needles = crate::components::query_highlight::emphasis_needles(query);
927 let results = vault.search_notes(query).await.unwrap_or_default();
928 let mut entries = Vec::with_capacity(results.len());
929 for (entry_data, content_data) in results {
930 let text = vault
931 .get_note_text(&entry_data.path)
932 .await
933 .unwrap_or_default();
934 let context = extract_context_multi(&text, &needles);
935 let (_p, filename) = entry_data.path.get_parent_path();
936 entries.push(BacklinkEntry {
937 path: entry_data.path,
938 title: content_data.title,
939 filename,
940 context,
941 full_text: Some(text),
942 });
943 }
944 entries
945}
946
947fn split_paragraphs(text: &str) -> Vec<String> {
950 let mut paragraphs = Vec::new();
951 let mut current: Vec<&str> = Vec::new();
952
953 for line in text.lines() {
954 if line.trim().is_empty() {
955 if !current.is_empty() {
956 paragraphs.push(current.join("\n"));
957 current.clear();
958 }
959 } else {
960 current.push(line);
961 }
962 }
963 if !current.is_empty() {
964 paragraphs.push(current.join("\n"));
965 }
966
967 paragraphs
968}
969
970fn extract_context_multi(text: &str, needles: &[String]) -> String {
977 let lowered: Vec<String> = needles.iter().map(|n| n.to_lowercase()).collect();
978 for para in &split_paragraphs(text) {
979 let lower = para.to_lowercase();
980 if lowered.iter().any(|n| !n.is_empty() && lower.contains(n)) {
981 return para.clone();
982 }
983 }
984 text.lines()
985 .find(|l| !l.trim().is_empty())
986 .unwrap_or("")
987 .to_string()
988}
989
990#[cfg(test)]
995mod tests {
996 use super::*;
997
998 #[test]
999 fn extract_context_matches_any_needle() {
1000 let text = "# Title\n\nIntro line.\n\nA paragraph mentioning widget here.\n";
1001 let result = extract_context_multi(text, &["widget".to_string()]);
1002 assert!(result.contains("widget"));
1003 }
1004
1005 #[test]
1006 fn default_query_recognized_in_all_spellings() {
1007 assert!(is_default_query(DEFAULT_QUERY));
1011 assert!(is_default_query("<"));
1012 assert!(is_default_query("lk:"));
1013 assert!(is_default_query("< or:title"));
1014 assert!(is_default_query("<{note} -or:file"));
1015 assert!(!is_default_query("<projects"));
1016 assert!(!is_default_query(">"));
1017 assert!(!is_default_query(""));
1018 }
1019
1020 #[tokio::test]
1021 async fn query_panel_load_query_lists_matches() {
1022 let vault = crate::test_support::temp_vault("qp").await;
1023 vault.validate_and_init().await.unwrap();
1024 vault
1025 .create_note(&VaultPath::note_path_from("/a.md"), "alpha #todo")
1026 .await
1027 .unwrap();
1028 vault
1029 .create_note(&VaultPath::note_path_from("/b.md"), "beta")
1030 .await
1031 .unwrap();
1032 let entries = load_query(&vault, "#todo").await;
1033 assert_eq!(entries.len(), 1);
1034 assert!(entries[0].filename.contains("a"));
1035 }
1036
1037 fn make_panel(vault: Arc<NoteVault>) -> QueryPanel {
1038 let kb = crate::settings::AppSettings::default().key_bindings.clone();
1039 QueryPanel::new(vault, kb, Icons::new(false))
1040 }
1041
1042 #[tokio::test(flavor = "multi_thread")]
1045 async fn ctrl_enter_opens_selected_result() {
1046 let vault = crate::test_support::temp_vault("qp-ctrl-enter").await;
1047 vault.validate_and_init().await.unwrap();
1048 vault
1049 .save_note(&VaultPath::note_path_from("target"), "the note body")
1050 .await
1051 .unwrap();
1052 let mut panel = make_panel(vault);
1053 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1054
1055 panel.apply_query("target".to_string(), None, tx.clone());
1057 for _ in 0..50 {
1058 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1059 panel.list.poll();
1060 }
1061 assert!(
1062 panel.selected_path().is_some(),
1063 "result loaded and selected"
1064 );
1065
1066 panel.handle_key(
1067 &KeyEvent::new(
1068 KeyCode::Enter,
1069 ratatui::crossterm::event::KeyModifiers::CONTROL,
1070 ),
1071 &tx,
1072 );
1073
1074 let mut opened = None;
1075 while let Ok(ev) = rx.try_recv() {
1076 if let AppEvent::OpenPath { path, .. } = ev {
1077 opened = Some(path);
1078 }
1079 }
1080 assert_eq!(opened, Some(VaultPath::note_path_from("target").absolute()));
1082 }
1083
1084 #[tokio::test(flavor = "multi_thread")]
1087 async fn ctrl_y_yanks_selected_path() {
1088 use ratatui::crossterm::event::KeyModifiers;
1089 let vault = crate::test_support::temp_vault("qp-ctrl-y").await;
1090 vault.validate_and_init().await.unwrap();
1091 vault
1092 .save_note(&VaultPath::note_path_from("target"), "the note body")
1093 .await
1094 .unwrap();
1095 let mut panel = make_panel(vault);
1096 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1097 panel.apply_query("target".to_string(), None, tx.clone());
1098 settle(&mut panel).await;
1099 assert!(panel.selected_path().is_some(), "result selected");
1100
1101 let st = panel.handle_key(
1102 &KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL),
1103 &tx,
1104 );
1105 assert_eq!(st, EventState::Consumed);
1106 let mut flashed = false;
1107 while let Ok(ev) = rx.try_recv() {
1108 if matches!(ev, AppEvent::FlashMessage(_)) {
1109 flashed = true;
1110 }
1111 }
1112 assert!(
1113 flashed,
1114 "Ctrl+Y emits a flash message (ok or clipboard error)"
1115 );
1116 }
1117
1118 #[tokio::test(flavor = "multi_thread")]
1122 async fn plain_letters_stay_query_text_in_find() {
1123 use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
1124 let vault = crate::test_support::temp_vault("qp-letters").await;
1125 vault.validate_and_init().await.unwrap();
1126 let mut panel = make_panel(vault);
1127 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1128 panel.set_active_query(String::new());
1129 for ch in ['l', 'h', 'o', 'y'] {
1130 panel.handle_key(&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
1131 }
1132 assert_eq!(panel.active_query(), "lhoy", "letters edit the query");
1133 assert!(
1134 panel.preview.is_collapsed(),
1135 "letters must not cycle the preview in FIND"
1136 );
1137 }
1138
1139 #[tokio::test]
1142 async fn cached_needles_track_query_and_note() {
1143 let vault = crate::test_support::temp_vault("qp_needles").await;
1144 vault.validate_and_init().await.unwrap();
1145 let mut panel = make_panel(vault);
1146 panel.list.set_query(DEFAULT_QUERY);
1147
1148 *panel.current_note.lock().unwrap() = VaultPath::note_path_from("spec");
1150 assert!(panel.cached_needles().iter().any(|n| n == "spec"));
1151
1152 *panel.current_note.lock().unwrap() = VaultPath::note_path_from("other");
1154 assert!(panel.cached_needles().iter().any(|n| n == "other"));
1155
1156 panel.list.set_query("widget".to_string());
1158 let needles = panel.cached_needles();
1159 assert!(needles.iter().any(|n| n == "widget"));
1160 assert!(!needles.iter().any(|n| n == "other"));
1161
1162 panel.list.set_query("#todo".to_string());
1165 assert!(
1166 panel.cached_needles().iter().any(|n| n == "#todo"),
1167 "preview needles must include labels"
1168 );
1169 }
1170
1171 async fn settle(panel: &mut QueryPanel) {
1177 for _ in 0..100 {
1178 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1179 panel.list.poll();
1180 if !panel.list.is_loading() {
1181 break;
1182 }
1183 }
1184 }
1185
1186 #[tokio::test(flavor = "multi_thread")]
1187 async fn apply_sort_rewrites_query_order_directive() {
1188 let vault = crate::test_support::temp_vault("qp-sort").await;
1189 vault.validate_and_init().await.unwrap();
1190 let mut panel = make_panel(vault);
1191 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1192 panel.set_active_query("widget".to_string());
1193
1194 panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1195 assert_eq!(panel.active_query(), "widget or:title");
1196
1197 panel.apply_sort(SortField::Name, SortOrder::Descending, &tx);
1198 assert_eq!(panel.active_query(), "widget -or:file");
1199 }
1200
1201 #[tokio::test(flavor = "multi_thread")]
1205 async fn directiveless_query_is_name_ascending() {
1206 let vault = crate::test_support::temp_vault("qp-defaultorder").await;
1207 vault.validate_and_init().await.unwrap();
1208 for name in ["/charlie.md", "/alpha.md", "/bravo.md"] {
1210 vault
1211 .create_note(&VaultPath::note_path_from(name), "widget")
1212 .await
1213 .unwrap();
1214 }
1215 let mut panel = make_panel(vault);
1216 panel.set_active_query("widget".to_string()); settle(&mut panel).await;
1218
1219 let names: Vec<String> = panel
1220 .list
1221 .visible_rows()
1222 .iter()
1223 .map(|e| e.filename.clone())
1224 .collect();
1225 let mut sorted = names.clone();
1226 sorted.sort();
1227 assert_eq!(names, sorted, "directive-less query must be name-ascending");
1228 }
1229
1230 #[tokio::test(flavor = "multi_thread")]
1233 async fn accepting_saved_search_pins_breadcrumb() {
1234 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1235 let vault = crate::test_support::temp_vault("qp-ss-accept").await;
1236 vault.validate_and_init().await.unwrap();
1237 vault.save_search("todo-week", "#todo").await.unwrap();
1238 let mut panel = make_panel(vault);
1239 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1240
1241 panel.set_active_query(String::new());
1245 for ch in ['?', 't', 'o'] {
1246 panel.handle_key(&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
1247 for _ in 0..30 {
1248 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1249 panel.list.poll();
1250 }
1251 }
1252 panel.handle_key(&KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &tx);
1253
1254 assert_eq!(panel.active_query(), "#todo");
1255 assert_eq!(
1256 panel.saved_search_breadcrumb().as_deref(),
1257 Some("todo-week")
1258 );
1259 }
1260
1261 #[tokio::test(flavor = "multi_thread")]
1264 async fn editing_expanded_query_keeps_breadcrumb_marked_edited() {
1265 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1266 let vault = crate::test_support::temp_vault("qp-ss-edit").await;
1267 vault.validate_and_init().await.unwrap();
1268 let mut panel = make_panel(vault);
1269 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1270 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1271 assert_eq!(panel.saved_search_breadcrumb().as_deref(), Some("todo"));
1272
1273 panel.handle_key(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
1275 assert_eq!(panel.active_query(), "#todox");
1276 assert_eq!(
1277 panel.saved_search_breadcrumb().as_deref(),
1278 Some("todo • edited")
1279 );
1280 }
1281
1282 #[tokio::test(flavor = "multi_thread")]
1285 async fn emptying_field_clears_breadcrumb() {
1286 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1287 let vault = crate::test_support::temp_vault("qp-ss-empty").await;
1288 vault.validate_and_init().await.unwrap();
1289 let mut panel = make_panel(vault);
1290 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1291 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1292
1293 for _ in 0.."#todo".len() {
1295 panel.handle_key(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), &tx);
1296 }
1297 assert_eq!(panel.active_query(), "");
1298 assert_eq!(panel.saved_search_breadcrumb(), None);
1299 }
1300
1301 #[tokio::test(flavor = "multi_thread")]
1302 async fn apply_query_pins_breadcrumb() {
1303 let vault = crate::test_support::temp_vault("qp-name").await;
1304 vault.validate_and_init().await.unwrap();
1305 let mut panel = make_panel(vault);
1306 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1307 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1308 assert_eq!(panel.saved_search_breadcrumb().as_deref(), Some("todo"));
1309 }
1310
1311 #[tokio::test(flavor = "multi_thread")]
1315 async fn apply_sort_marks_saved_search_breadcrumb_edited() {
1316 let vault = crate::test_support::temp_vault("qp-sort-name").await;
1317 vault.validate_and_init().await.unwrap();
1318 let mut panel = make_panel(vault);
1319 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1320 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1321
1322 panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1323 assert_eq!(panel.active_query(), "#todo or:title");
1324 assert_eq!(
1325 panel.saved_search_breadcrumb().as_deref(),
1326 Some("todo • edited"),
1327 "sorting diverges from the stored query, so the breadcrumb is edited"
1328 );
1329 }
1330
1331 #[tokio::test(flavor = "multi_thread")]
1334 async fn repin_after_save_adopts_saved_identity() {
1335 let vault = crate::test_support::temp_vault("qp-repin").await;
1336 vault.validate_and_init().await.unwrap();
1337 let mut panel = make_panel(vault);
1338 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1339 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1340
1341 panel.set_active_query("#todo and #urgent".to_string());
1342 assert_eq!(
1343 panel.saved_search_breadcrumb().as_deref(),
1344 Some("todo • edited")
1345 );
1346
1347 panel.repin_saved_search("urgent-todos".to_string(), "#todo and #urgent");
1348 assert_eq!(
1349 panel.saved_search_breadcrumb().as_deref(),
1350 Some("urgent-todos"),
1351 "after a save the saved identity is the provenance — no edited marker"
1352 );
1353 }
1354
1355 #[tokio::test(flavor = "multi_thread")]
1359 async fn apply_sort_updates_visible_input_bar() {
1360 let vault = crate::test_support::temp_vault("qp-bar").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.set_active_query("widget".to_string());
1365 assert_eq!(
1366 panel.list.input_value(),
1367 "widget",
1368 "set_active_query syncs the bar"
1369 );
1370
1371 panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1372 assert_eq!(panel.active_query(), "widget or:title");
1373 assert_eq!(
1374 panel.list.input_value(),
1375 "widget or:title",
1376 "the input bar must reflect the rewritten query"
1377 );
1378 }
1379
1380 #[tokio::test(flavor = "multi_thread")]
1381 async fn current_order_reads_query_directive() {
1382 let vault = crate::test_support::temp_vault("qp-order").await;
1383 vault.validate_and_init().await.unwrap();
1384 let mut panel = make_panel(vault);
1385 panel.set_active_query("widget -or:title".to_string());
1386 assert_eq!(
1387 panel.current_order(),
1388 (SortField::Title, SortOrder::Descending)
1389 );
1390 panel.set_active_query("widget".to_string());
1391 assert_eq!(
1392 panel.current_order(),
1393 (SortField::Name, SortOrder::Ascending)
1394 );
1395 }
1396
1397 #[tokio::test(flavor = "multi_thread")]
1401 async fn context_preview_wheel_scrolls_preview_not_list() {
1402 use ratatui::Terminal;
1403 use ratatui::backend::TestBackend;
1404 use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
1405
1406 let vault = crate::test_support::temp_vault("qp-preview-wheel").await;
1407 vault.validate_and_init().await.unwrap();
1408 let mut body = String::from("#todo first line\n");
1411 for i in 0..40 {
1412 body.push_str(&format!("line {}\n", i));
1413 }
1414 vault
1415 .create_note(&VaultPath::note_path_from("/long.md"), &body)
1416 .await
1417 .unwrap();
1418 let mut panel = make_panel(vault);
1419 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1420 panel.set_active_query("#todo".to_string());
1421 settle(&mut panel).await;
1422 assert!(panel.list.selected_row().is_some());
1423
1424 panel.toggle_expand();
1427 assert!(panel.preview.is_context());
1428 let theme = crate::settings::themes::Theme::default();
1429 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1430 terminal
1431 .draw(|f| panel.render(f, f.area(), &theme, true))
1432 .unwrap();
1433 let preview = panel.list.content_rect();
1434 assert!(!preview.is_empty(), "preview rect recorded");
1435 assert_eq!(
1436 panel.preview.scroll_offset(),
1437 0,
1438 "auto-anchor at the top needle"
1439 );
1440 assert!(panel.preview.scroll_max() > 0, "content overflows viewport");
1441
1442 let wheel = move |y: u16| MouseEvent {
1443 kind: MouseEventKind::ScrollDown,
1444 column: preview.x + 1,
1445 row: y,
1446 modifiers: KeyModifiers::NONE,
1447 };
1448
1449 let over_list = wheel(preview.y.saturating_sub(3));
1451 panel.handle_mouse(&over_list, &tx);
1452 assert_eq!(
1453 panel.preview.scroll_offset(),
1454 0,
1455 "list wheel must not move preview"
1456 );
1457 assert!(panel.preview.is_anchored(), "anchor stays armed");
1458
1459 let over_preview = wheel(preview.y + 1);
1461 panel.handle_mouse(&over_preview, &tx);
1462 assert_eq!(
1463 panel.preview.scroll_offset(),
1464 1,
1465 "preview wheel scrolls content"
1466 );
1467 assert!(!panel.preview.is_anchored(), "user owns the scroll now");
1468
1469 terminal
1471 .draw(|f| panel.render(f, f.area(), &theme, true))
1472 .unwrap();
1473 assert_eq!(panel.preview.scroll_offset(), 1);
1474
1475 let up = MouseEvent {
1477 kind: MouseEventKind::ScrollUp,
1478 column: preview.x + 1,
1479 row: preview.y + 1,
1480 modifiers: KeyModifiers::NONE,
1481 };
1482 panel.handle_mouse(&up, &tx);
1483 panel.handle_mouse(&up, &tx);
1484 assert_eq!(panel.preview.scroll_offset(), 0);
1485 }
1486
1487 #[tokio::test(flavor = "multi_thread")]
1491 async fn noop_preview_wheel_keeps_autoscroll_armed() {
1492 use ratatui::Terminal;
1493 use ratatui::backend::TestBackend;
1494 use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
1495
1496 let vault = crate::test_support::temp_vault("qp-noop-wheel").await;
1497 vault.validate_and_init().await.unwrap();
1498 vault
1500 .create_note(&VaultPath::note_path_from("/short.md"), "#todo only line")
1501 .await
1502 .unwrap();
1503 let mut panel = make_panel(vault);
1504 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1505 panel.set_active_query("#todo".to_string());
1506 settle(&mut panel).await;
1507 panel.toggle_expand();
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 assert_eq!(panel.preview.scroll_max(), 0, "content fits the viewport");
1514
1515 let preview = panel.list.content_rect();
1516 let down = MouseEvent {
1517 kind: MouseEventKind::ScrollDown,
1518 column: preview.x + 1,
1519 row: preview.y + 1,
1520 modifiers: KeyModifiers::NONE,
1521 };
1522 panel.handle_mouse(&down, &tx);
1523 assert!(
1524 panel.preview.is_anchored(),
1525 "no-op wheel tick must not disarm the auto-anchor"
1526 );
1527 }
1528
1529 #[tokio::test(flavor = "multi_thread")]
1533 async fn query_keystroke_rearms_preview_autoscroll() {
1534 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1535
1536 let vault = crate::test_support::temp_vault("qp-rearm").await;
1537 vault.validate_and_init().await.unwrap();
1538 let mut body = String::from("#todo first line\n");
1539 for i in 0..40 {
1540 body.push_str(&format!("line {}\n", i));
1541 }
1542 vault
1543 .create_note(&VaultPath::note_path_from("/long.md"), &body)
1544 .await
1545 .unwrap();
1546 let mut panel = make_panel(vault);
1547 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1548 panel.set_active_query("#todo".to_string());
1549 settle(&mut panel).await;
1550 panel.toggle_expand();
1551 panel.preview.force_user_scrolled();
1553
1554 panel.handle_key(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
1555 assert_eq!(panel.active_query(), "#todox");
1556 assert!(
1557 panel.preview.is_anchored(),
1558 "a query edit must re-arm the preview auto-anchor"
1559 );
1560 }
1561
1562 #[tokio::test(flavor = "multi_thread")]
1566 async fn preview_wheel_closes_autocomplete_popup() {
1567 use ratatui::Terminal;
1568 use ratatui::backend::TestBackend;
1569 use ratatui::crossterm::event::{
1570 KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind,
1571 };
1572
1573 let vault = crate::test_support::temp_vault("qp-wheel-popup").await;
1574 vault.validate_and_init().await.unwrap();
1575 let mut body = String::from("#todo first line\n");
1576 for i in 0..40 {
1577 body.push_str(&format!("line {}\n", i));
1578 }
1579 vault
1580 .create_note(&VaultPath::note_path_from("/long.md"), &body)
1581 .await
1582 .unwrap();
1583 let mut panel = make_panel(vault);
1584 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1585 panel.set_active_query("#todo".to_string());
1586 settle(&mut panel).await;
1587 panel.toggle_expand();
1588 let theme = crate::settings::themes::Theme::default();
1589 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1590 terminal
1591 .draw(|f| panel.render(f, f.area(), &theme, true))
1592 .unwrap();
1593 let preview = panel.list.content_rect();
1594 assert!(!preview.is_empty());
1595
1596 for ch in [' ', '#'] {
1599 panel.handle_key(&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
1600 for _ in 0..30 {
1601 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1602 panel.list.poll();
1603 }
1604 }
1605 assert!(panel.list.autocomplete_is_open(), "popup open after `#`");
1606
1607 let wheel = MouseEvent {
1608 kind: MouseEventKind::ScrollDown,
1609 column: preview.x + 1,
1610 row: preview.y + 1,
1611 modifiers: KeyModifiers::NONE,
1612 };
1613 panel.handle_mouse(&wheel, &tx);
1614 assert!(
1615 !panel.list.autocomplete_is_open(),
1616 "wheel over the preview must dismiss the popup"
1617 );
1618 }
1619
1620 #[tokio::test(flavor = "multi_thread")]
1624 async fn full_expand_header_click_collapses() {
1625 use ratatui::Terminal;
1626 use ratatui::backend::TestBackend;
1627 use ratatui::crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
1628
1629 let vault = crate::test_support::temp_vault("qp-header-click").await;
1630 vault.validate_and_init().await.unwrap();
1631 vault
1632 .create_note(&VaultPath::note_path_from("/long.md"), "#todo body")
1633 .await
1634 .unwrap();
1635 let mut panel = make_panel(vault);
1636 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1637 panel.set_active_query("#todo".to_string());
1638 settle(&mut panel).await;
1639 panel.toggle_expand();
1641 panel.toggle_expand();
1642 assert!(panel.is_full_expanded());
1643 let theme = crate::settings::themes::Theme::default();
1644 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1645 terminal
1646 .draw(|f| panel.render(f, f.area(), &theme, true))
1647 .unwrap();
1648 let header = panel.preview.full_header_rect();
1649 assert!(!header.is_empty(), "header rect recorded in full mode");
1650
1651 let click = |x: u16, y: u16| MouseEvent {
1652 kind: MouseEventKind::Down(MouseButton::Left),
1653 column: x,
1654 row: y,
1655 modifiers: KeyModifiers::NONE,
1656 };
1657
1658 panel.handle_mouse(&click(header.x + 1, header.y + 3), &tx);
1660 assert!(panel.is_full_expanded(), "content click must not collapse");
1661
1662 panel.handle_mouse(&click(header.x + 1, header.y), &tx);
1664 assert!(!panel.is_full_expanded());
1665 assert!(panel.preview.is_collapsed());
1666 }
1667
1668 #[tokio::test(flavor = "multi_thread")]
1675 async fn full_preview_anchors_scroll_to_first_needle_match() {
1676 use ratatui::Terminal;
1677 use ratatui::backend::TestBackend;
1678
1679 let vault = crate::test_support::temp_vault("qp-full-anchor").await;
1680 vault.validate_and_init().await.unwrap();
1681 let mut body = String::new();
1684 for i in 0..8 {
1685 body.push_str(&format!("line{i}\n"));
1686 }
1687 body.push_str("#todo widget line\n");
1688 for i in 0..8 {
1689 body.push_str(&format!("tail{i}\n"));
1690 }
1691 vault
1692 .create_note(&VaultPath::note_path_from("/long.md"), &body)
1693 .await
1694 .unwrap();
1695 let mut panel = make_panel(vault);
1696 panel.set_active_query("#todo".to_string());
1697 settle(&mut panel).await;
1698 assert!(panel.list.selected_row().is_some());
1699
1700 panel.toggle_expand();
1702 panel.toggle_expand();
1703 assert!(panel.is_full_expanded());
1704
1705 let theme = crate::settings::themes::Theme::default();
1706 let mut terminal = Terminal::new(TestBackend::new(40, 6)).unwrap();
1707 terminal
1708 .draw(|f| panel.render(f, f.area(), &theme, true))
1709 .unwrap();
1710
1711 assert!(
1712 panel.preview.scroll_offset() > 0,
1713 "Full preview anchors on the first needle match, offset={}",
1714 panel.preview.scroll_offset()
1715 );
1716 }
1717
1718 #[tokio::test(flavor = "multi_thread")]
1723 async fn toggling_expand_clears_stale_content_regions() {
1724 use ratatui::Terminal;
1725 use ratatui::backend::TestBackend;
1726
1727 let vault = crate::test_support::temp_vault("qp-stale-regions").await;
1728 vault.validate_and_init().await.unwrap();
1729 vault
1730 .create_note(&VaultPath::note_path_from("/long.md"), "#todo body")
1731 .await
1732 .unwrap();
1733 let mut panel = make_panel(vault);
1734 panel.set_active_query("#todo".to_string());
1735 settle(&mut panel).await;
1736 let theme = crate::settings::themes::Theme::default();
1737 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1738
1739 panel.toggle_expand();
1741 panel.toggle_expand();
1742 terminal
1743 .draw(|f| panel.render(f, f.area(), &theme, true))
1744 .unwrap();
1745 assert!(!panel.list.content_rect().is_empty());
1746 assert!(!panel.preview.full_header_rect().is_empty());
1747
1748 panel.toggle_expand();
1751 assert!(
1752 panel.list.content_rect().is_empty(),
1753 "stale content rect must not survive a state change"
1754 );
1755 assert!(
1756 panel.preview.full_header_rect().is_empty(),
1757 "stale header rect must not survive a state change"
1758 );
1759 }
1760
1761 #[tokio::test(flavor = "multi_thread")]
1767 async fn static_query_survives_navigation() {
1768 let vault = crate::test_support::temp_vault("nav-static").await;
1769 vault.validate_and_init().await.unwrap();
1770 vault
1771 .create_note(&VaultPath::note_path_from("/a.md"), "alpha #todo")
1772 .await
1773 .unwrap();
1774 let mut panel = make_panel(vault);
1775 panel.set_active_query("#todo".to_string());
1776 settle(&mut panel).await;
1777 assert_eq!(panel.list.visible_rows().len(), 1);
1778
1779 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1780 panel.set_note(VaultPath::note_path_from("x.md"), tx);
1781
1782 assert_eq!(panel.active_query(), "#todo");
1785 assert!(!panel.list.is_loading());
1786 settle(&mut panel).await;
1787 assert_eq!(panel.list.visible_rows().len(), 1); }
1789
1790 #[tokio::test(flavor = "multi_thread")]
1793 async fn note_variable_query_reruns_on_navigation() {
1794 let vault = crate::test_support::temp_vault("nav-var").await;
1795 vault.validate_and_init().await.unwrap();
1796 vault
1799 .create_note(&VaultPath::note_path_from("/target.md"), "I am the target")
1800 .await
1801 .unwrap();
1802 vault
1803 .create_note(&VaultPath::note_path_from("/linker.md"), "see [[target]]")
1804 .await
1805 .unwrap();
1806 let mut panel = make_panel(vault);
1807 assert_eq!(panel.active_query(), "");
1810 panel.list.set_query(DEFAULT_QUERY);
1811
1812 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1813 panel.set_note(VaultPath::note_path_from("/target.md"), tx);
1814 settle(&mut panel).await;
1815
1816 assert!(
1818 panel
1819 .list
1820 .visible_rows()
1821 .iter()
1822 .any(|e| e.filename.contains("linker")),
1823 "expected linker as a backlink, got {:?}",
1824 panel
1825 .list
1826 .visible_rows()
1827 .iter()
1828 .map(|e| e.filename.clone())
1829 .collect::<Vec<_>>()
1830 );
1831 }
1832
1833 #[tokio::test(flavor = "multi_thread")]
1835 async fn note_variable_query_changes_with_note() {
1836 let vault = crate::test_support::temp_vault("nav-var2").await;
1837 vault.validate_and_init().await.unwrap();
1838 vault
1839 .create_note(&VaultPath::note_path_from("/a.md"), "I am a")
1840 .await
1841 .unwrap();
1842 vault
1843 .create_note(&VaultPath::note_path_from("/b.md"), "I am b")
1844 .await
1845 .unwrap();
1846 vault
1847 .create_note(&VaultPath::note_path_from("/links_a.md"), "see [[a]]")
1848 .await
1849 .unwrap();
1850 let mut panel = make_panel(vault);
1851 panel.list.set_query(DEFAULT_QUERY);
1852 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1853
1854 panel.set_note(VaultPath::note_path_from("/a.md"), tx.clone());
1855 settle(&mut panel).await;
1856 assert!(
1857 panel
1858 .list
1859 .visible_rows()
1860 .iter()
1861 .any(|e| e.filename.contains("links_a"))
1862 );
1863
1864 panel.set_note(VaultPath::note_path_from("/b.md"), tx);
1865 settle(&mut panel).await;
1866 assert!(
1867 !panel
1868 .list
1869 .visible_rows()
1870 .iter()
1871 .any(|e| e.filename.contains("links_a")),
1872 "b has no backlinks, expected empty"
1873 );
1874 }
1875}