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