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::{Line, Span};
11use ratatui::widgets::{Block, Borders, ListItem, Paragraph};
12
13use kimun_core::{OrderBy, OrderField, with_order_directive};
14
15use crate::components::autocomplete::AutocompleteMode;
16use crate::components::event_state::EventState;
17use crate::components::events::{AppEvent, AppTx};
18use crate::components::file_list::{SortField, SortOrder};
19use crate::components::query_vars::{query_has_variables, query_is_unresolvable, resolve_query};
20use crate::components::saved_search_breadcrumb::SavedSearchBreadcrumb;
21use crate::components::search_list::{
22 Emit, KeyReaction, RowSource, SearchList, SearchMouse, SearchRow, VaultSuggestions,
23};
24use crate::keys::KeyBindings;
25use crate::keys::action_shortcuts::ActionShortcuts;
26use crate::keys::key_combo::KeyCombo;
27use crate::settings::icons::Icons;
28use crate::settings::themes::Theme;
29
30const DEFAULT_QUERY: &str = "<{note}";
33const DEFAULT_QUERY_LONG: &str = "lk:{note}";
36
37fn is_default_query(query: &str) -> bool {
42 let expanded = kimun_core::expand_bare_note_prefixes(
43 &kimun_core::strip_order_directive(query),
44 crate::components::query_vars::VAR_NOTE,
45 );
46 expanded == DEFAULT_QUERY || expanded == DEFAULT_QUERY_LONG
47}
48
49#[derive(Debug, Clone)]
55pub struct BacklinkEntry {
56 pub path: VaultPath,
57 pub title: String,
58 pub filename: String,
59 pub context: String,
61 pub full_text: Option<String>,
63}
64
65impl SearchRow for BacklinkEntry {
66 fn to_list_item(&self, theme: &Theme, _icons: &Icons, selected: bool) -> ListItem<'static> {
67 let fg = theme.fg.to_ratatui();
68 let fg_muted = theme.fg_muted.to_ratatui();
69 let bg = theme.bg_panel.to_ratatui();
70 let title_style = if selected {
71 Style::default()
72 .fg(theme.fg_selected.to_ratatui())
73 .bg(theme.bg_selected.to_ratatui())
74 .add_modifier(Modifier::BOLD)
75 } else {
76 Style::default().fg(fg).bg(bg)
77 };
78 let title_display = if self.title.is_empty() {
79 &self.filename
80 } else {
81 &self.title
82 };
83 ListItem::new(Line::from(vec![
84 Span::styled(format!(" {} ", title_display), title_style),
85 Span::styled(
86 format!(" {}", self.filename),
87 Style::default().fg(fg_muted).bg(if selected {
88 theme.bg_selected.to_ratatui()
89 } else {
90 bg
91 }),
92 ),
93 ]))
94 }
95
96 fn match_text(&self) -> Option<&str> {
97 Some(&self.filename)
98 }
99
100 fn visual_height(&self) -> u16 {
101 1
102 }
103}
104
105struct BacklinkSource {
116 vault: Arc<NoteVault>,
117 current_note: Arc<Mutex<VaultPath>>,
118}
119
120#[async_trait]
121impl RowSource<BacklinkEntry> for BacklinkSource {
122 async fn load(&self, query: &str, emit: Emit<BacklinkEntry>) {
123 let note = self.current_note.lock().unwrap().clone();
125 if query_is_unresolvable(query, Some(¬e)) {
131 emit.replace(Vec::new());
132 return;
133 }
134 let q = resolve_query(query, Some(¬e));
135 let mut entries = load_query(&self.vault, &q).await;
136 if kimun_core::SearchTerms::from_query_string(query)
143 .order_by
144 .is_empty()
145 {
146 entries.sort_by_key(|e| e.filename.to_lowercase());
147 }
148 emit.replace(entries);
149 }
150}
151
152#[derive(Clone, Copy, PartialEq)]
157enum ExpandState {
158 Collapsed,
159 Context,
160 Full,
161}
162
163#[derive(Clone, Copy)]
174struct ContentScroll {
175 anchored: bool,
179 offset: usize,
181 max: usize,
183}
184
185impl ContentScroll {
186 fn new() -> Self {
187 Self {
188 anchored: true,
189 offset: 0,
190 max: 0,
191 }
192 }
193
194 fn reset(&mut self) {
196 *self = Self::new();
197 }
198
199 fn re_anchor(&mut self) {
202 self.anchored = true;
203 }
204
205 fn scroll_up(&mut self) {
209 if self.offset > 0 {
210 self.offset -= 1;
211 self.anchored = false;
212 }
213 }
214
215 fn scroll_down(&mut self) {
220 if self.offset < self.max {
221 self.offset += 1;
222 self.anchored = false;
223 }
224 }
225
226 fn set_max(&mut self, max: usize) {
229 self.max = max;
230 self.offset = self.offset.min(max);
231 }
232
233 fn anchor_to(&mut self, offset: usize) {
236 if self.anchored {
237 self.offset = offset.min(self.max);
238 }
239 }
240}
241
242pub struct QueryPanel {
247 list: SearchList<BacklinkEntry>,
250 current_note: Arc<Mutex<VaultPath>>,
253 saved_search: SavedSearchBreadcrumb,
257 expand: ExpandState,
261 expand_path: Option<VaultPath>,
264 scroll: ContentScroll,
268 full_header_rect: Rect,
272 key_bindings: KeyBindings,
273 redraw_tx: Arc<Mutex<Option<AppTx>>>,
278 follow_link_combos: Vec<KeyCombo>,
280 order_cache: (SortField, SortOrder),
285 order_cache_query: String,
286 is_default_cache: bool,
290 needles_cache: Vec<String>,
296 needles_cache_key: (String, VaultPath),
297}
298
299impl QueryPanel {
300 pub fn new(vault: Arc<NoteVault>, key_bindings: KeyBindings) -> Self {
301 let icons = Icons::new(false);
302 let current_note = Arc::new(Mutex::new(VaultPath::empty()));
303 let redraw_tx: Arc<Mutex<Option<AppTx>>> = Arc::new(Mutex::new(None));
307 let redraw: Arc<dyn Fn() + Send + Sync> = {
308 let slot = redraw_tx.clone();
309 Arc::new(move || {
310 if let Some(tx) = slot.lock().unwrap().as_ref() {
311 let _ = tx.send(AppEvent::Redraw);
312 }
313 })
314 };
315 let source = BacklinkSource {
316 vault: vault.clone(),
317 current_note: current_note.clone(),
318 };
319 let combos = |action: &ActionShortcuts| -> Vec<KeyCombo> {
320 key_bindings
321 .to_hashmap()
322 .get(action)
323 .cloned()
324 .unwrap_or_default()
325 };
326 let follow_link_combos = combos(&ActionShortcuts::FollowLink);
327
328 let mut intercept = Vec::new();
329 intercept.extend(follow_link_combos.iter().cloned());
330
331 let list = SearchList::builder(source, redraw)
332 .initial_query(DEFAULT_QUERY)
333 .icons(icons.clone())
334 .autocomplete(
335 Arc::new(VaultSuggestions {
336 vault: vault.clone(),
337 }),
338 AutocompleteMode::SearchQuery,
339 )
340 .intercept(intercept)
341 .build();
342
343 Self {
344 list,
345 current_note,
346 saved_search: SavedSearchBreadcrumb::default(),
347 expand: ExpandState::Collapsed,
348 expand_path: None,
349 scroll: ContentScroll::new(),
350 full_header_rect: Rect::default(),
351 key_bindings,
352 redraw_tx,
353 follow_link_combos,
354 order_cache: (SortField::Name, SortOrder::Ascending),
356 order_cache_query: String::new(),
357 is_default_cache: true,
360 needles_cache: Vec::new(),
361 needles_cache_key: (String::new(), VaultPath::empty()),
364 }
365 }
366
367 pub fn active_query(&self) -> &str {
370 self.list.query()
371 }
372
373 pub fn set_active_query(&mut self, q: String) {
374 self.list.set_query(q);
375 self.reset_expand();
376 }
377
378 pub fn saved_search_breadcrumb(&self) -> Option<String> {
381 self.saved_search.label(self.list.query())
382 }
383
384 pub fn saved_search_name(&self) -> Option<&str> {
388 self.saved_search.name()
389 }
390
391 pub fn repin_saved_search(&mut self, name: String, query: &str) {
395 self.saved_search.set(Some(name), query);
396 }
397
398 fn query_is_blank(&self) -> bool {
403 let q = self.list.query();
404 q.trim().is_empty() || is_default_query(q)
405 }
406
407 pub fn apply_query(&mut self, query: String, name: Option<String>, tx: AppTx) {
411 self.ensure_redraw_tx(&tx);
412 self.set_active_query(query.clone());
413 self.saved_search.set(name, &query);
414 }
415
416 fn current_note(&self) -> VaultPath {
419 self.current_note.lock().unwrap().clone()
420 }
421
422 fn ensure_redraw_tx(&self, tx: &AppTx) {
425 let mut slot = self.redraw_tx.lock().unwrap();
426 if slot.is_none() {
427 *slot = Some(tx.clone());
428 }
429 }
430
431 fn cached_needles(&mut self) -> &[String] {
436 let note = self.current_note();
437 if self.needles_cache_key.0 != self.list.query() || self.needles_cache_key.1 != note {
438 self.needles_cache = query_needles(&resolve_query(self.list.query(), Some(¬e)));
439 self.needles_cache_key = (self.list.query().to_string(), note);
440 }
441 &self.needles_cache
442 }
443
444 fn is_full_expanded(&self) -> bool {
447 self.list.selected_row().is_some() && self.expand == ExpandState::Full
448 }
449
450 pub fn is_empty(&self) -> bool {
451 self.list.rows().is_empty()
452 }
453
454 pub fn selected_path(&self) -> Option<&VaultPath> {
455 self.list.selected_row().map(|e| &e.path)
456 }
457
458 fn clear_content_regions(&mut self) {
465 self.list.set_content_rect(Rect::default());
466 self.full_header_rect = Rect::default();
467 }
468
469 fn reset_expand(&mut self) {
470 self.expand = ExpandState::Collapsed;
471 self.expand_path = None;
472 self.scroll.reset();
473 self.clear_content_regions();
474 }
475
476 fn sync_expand_anchor(&mut self) {
481 let sel = self.list.selected_row().map(|e| e.path.clone());
482 if sel != self.expand_path {
483 if self.expand != ExpandState::Context || sel.is_none() {
484 self.expand = ExpandState::Collapsed;
485 }
486 self.expand_path = sel;
487 self.scroll.reset();
488 self.clear_content_regions();
489 }
490 }
491
492 pub fn set_note(&mut self, note_path: VaultPath, tx: AppTx) {
497 self.ensure_redraw_tx(&tx);
498 *self.current_note.lock().unwrap() = note_path;
499 if query_has_variables(self.list.query()) {
500 self.list.reload();
501 self.reset_expand();
502 }
503 }
504
505 pub fn current_order(&self) -> (SortField, SortOrder) {
510 let st = kimun_core::SearchTerms::from_query_string(self.list.query());
511 match st.order_by.first() {
512 Some(OrderBy::Title { asc }) => (
513 SortField::Title,
514 if *asc {
515 SortOrder::Ascending
516 } else {
517 SortOrder::Descending
518 },
519 ),
520 Some(OrderBy::FileName { asc }) => (
521 SortField::Name,
522 if *asc {
523 SortOrder::Ascending
524 } else {
525 SortOrder::Descending
526 },
527 ),
528 None => (SortField::Name, SortOrder::Ascending),
529 }
530 }
531
532 pub fn apply_sort(&mut self, field: SortField, order: SortOrder, tx: &AppTx) {
535 self.ensure_redraw_tx(tx);
536 let order_field = match field {
537 SortField::Name => OrderField::FileName,
538 SortField::Title => OrderField::Title,
539 };
540 let asc = matches!(order, SortOrder::Ascending);
541 let rewritten = with_order_directive(self.list.query(), order_field, asc);
542 self.list.set_query(rewritten);
543 self.reset_expand();
547 }
548
549 pub fn handle_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
552 self.ensure_redraw_tx(tx);
553 self.sync_expand_anchor();
554
555 if self.is_full_expanded() && matches!(key.code, KeyCode::Up | KeyCode::Down) {
558 self.scroll_content(key);
559 return EventState::Consumed;
560 }
561 let prev_query = self.list.query().to_string();
565 match self.list.handle_key(key) {
566 KeyReaction::Intercepted(c) if self.follow_link_combos.contains(&c) => {
567 if let Some(path) = self.selected_path().cloned() {
568 tx.send(AppEvent::OpenPath(path)).ok();
569 }
570 EventState::Consumed
571 }
572 KeyReaction::Consumed => {
573 let accepted = self.list.take_accepted_saved_search();
577 let blank = self.query_is_blank();
578 self.saved_search
579 .on_query_consumed(accepted, self.list.query(), blank);
580 if self.list.query() != prev_query {
585 self.scroll.re_anchor();
586 }
587 self.sync_expand_anchor();
588 EventState::Consumed
589 }
590 KeyReaction::Submit => {
591 self.toggle_expand();
594 EventState::Consumed
595 }
596 KeyReaction::Cancel => EventState::NotConsumed,
598 KeyReaction::Unhandled => EventState::NotConsumed,
599 KeyReaction::Intercepted(_) => EventState::Consumed,
600 }
601 }
602
603 pub fn handle_mouse(
612 &mut self,
613 mouse: &ratatui::crossterm::event::MouseEvent,
614 tx: &AppTx,
615 ) -> EventState {
616 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
617 use ratatui::layout::Position;
618 self.ensure_redraw_tx(tx);
619 let was_full = self.is_full_expanded();
624 self.sync_expand_anchor();
625 if was_full {
632 match mouse.kind {
633 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {}
635 MouseEventKind::Down(MouseButton::Left)
639 if self.full_header_rect.contains(Position {
640 x: mouse.column,
641 y: mouse.row,
642 }) =>
643 {
644 self.list.close_autocomplete();
645 self.toggle_expand();
646 return EventState::Consumed;
647 }
648 _ => {
649 self.list.close_autocomplete();
650 return EventState::Consumed;
651 }
652 }
653 }
654 match self.list.handle_mouse(mouse) {
655 SearchMouse::ContentScrollUp => {
656 self.scroll.scroll_up();
657 EventState::Consumed
658 }
659 SearchMouse::ContentScrollDown => {
660 self.scroll.scroll_down();
661 EventState::Consumed
662 }
663 SearchMouse::Activated(_) => {
664 self.toggle_expand();
665 EventState::Consumed
666 }
667 SearchMouse::Selected(_) | SearchMouse::Scrolled => {
668 self.sync_expand_anchor();
669 EventState::Consumed
670 }
671 SearchMouse::None => EventState::NotConsumed,
672 }
673 }
674
675 fn scroll_content(&mut self, key: &KeyEvent) {
676 match key.code {
677 KeyCode::Up => self.scroll.scroll_up(),
678 KeyCode::Down => self.scroll.scroll_down(),
679 _ => {}
680 }
681 }
682
683 fn toggle_expand(&mut self) {
684 if self.list.selected_row().is_none() {
685 return;
686 }
687 self.expand_path = self.list.selected_row().map(|e| e.path.clone());
688 match self.expand {
689 ExpandState::Collapsed => {
690 self.expand = ExpandState::Context;
691 self.scroll.re_anchor();
692 }
693 ExpandState::Context => {
694 self.scroll.reset();
695 self.expand = ExpandState::Full;
696 }
697 ExpandState::Full => {
698 self.scroll.reset();
699 self.expand = ExpandState::Collapsed;
700 }
701 }
702 self.clear_content_regions();
703 }
704
705 pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
706 [
707 (ActionShortcuts::FocusSidebar, "\u{2190} editor"),
708 (ActionShortcuts::FollowLink, "open note"),
709 (ActionShortcuts::SaveCurrentQuery, "save query"),
710 (ActionShortcuts::OpenSavedSearches, "searches"),
711 (ActionShortcuts::OpenSortDialog, "sort"),
712 ]
713 .iter()
714 .filter_map(|(action, label)| {
715 self.key_bindings
716 .first_combo_for(action)
717 .map(|k| (k, label.to_string()))
718 })
719 .collect()
720 }
721
722 pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
725 self.list.poll();
726 self.sync_expand_anchor();
727 self.list.set_panel_rect(rect);
730 self.list.set_content_rect(Rect::default());
735 self.full_header_rect = Rect::default();
736
737 let border_style = theme.border_style(focused);
738 let fg_muted = theme.fg_muted.to_ratatui();
739 let bg = theme.bg_panel.to_ratatui();
740
741 let count = self.list.visible_rows().len();
742 if self.list.query() != self.order_cache_query {
745 self.order_cache = self.current_order();
746 self.is_default_cache = is_default_query(self.list.query());
747 self.order_cache_query = self.list.query().to_string();
748 }
749 let (sort_field, sort_order) = self.order_cache;
750 let sort_indicator = format!("{}{}", sort_field.label(), sort_order.label());
751 let title = if self.is_default_cache {
758 format!("Backlinks ({}) {}", count, sort_indicator)
759 } else {
760 format!("Query ({}) {}", count, sort_indicator)
761 };
762
763 let outer = Block::default()
764 .title(title)
765 .borders(Borders::ALL)
766 .border_style(border_style)
767 .style(theme.panel_style());
768 let outer_inner = outer.inner(rect);
769 f.render_widget(outer, rect);
770
771 let rows = Layout::default()
773 .direction(Direction::Vertical)
774 .constraints([Constraint::Length(3), Constraint::Min(0)])
775 .split(outer_inner);
776 let search_title = self.saved_search.border_title(self.list.query(), " Query");
779 let search_block = Block::default()
780 .title(search_title)
781 .borders(Borders::ALL)
782 .border_style(border_style)
783 .style(theme.panel_style());
784 let search_inner = search_block.inner(rows[0]);
785 f.render_widget(search_block, rows[0]);
786 self.list.render_query(f, search_inner, theme, focused);
787
788 let inner = rows[1];
789
790 if self.list.is_loading() {
791 f.render_widget(
792 Paragraph::new(" Loading...").style(Style::default().fg(fg_muted).bg(bg)),
793 inner,
794 );
795 self.list.render_autocomplete(f, rect, theme);
796 return;
797 }
798
799 if self.list.visible_rows().is_empty() {
800 f.render_widget(
801 Paragraph::new(" No results").style(Style::default().fg(fg_muted).bg(bg)),
802 inner,
803 );
804 self.list.render_autocomplete(f, rect, theme);
805 return;
806 }
807
808 let selected_state = self.expand;
809
810 if selected_state == ExpandState::Full {
814 self.list.set_content_rect(rect);
815 if let Some(entry) = self.list.selected_row() {
816 let entry = entry.clone();
817 let text = entry.full_text.as_deref().unwrap_or(&entry.context);
818
819 let title_display = if entry.title.is_empty() {
821 &entry.filename
822 } else {
823 &entry.title
824 };
825
826 let parts = Layout::default()
827 .direction(Direction::Vertical)
828 .constraints([
829 Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
833 .split(inner);
834
835 self.full_header_rect = parts[0];
838 f.render_widget(
839 Paragraph::new(Line::from(vec![
840 Span::styled(
841 format!("\u{25BC} {} ", title_display),
842 Style::default()
843 .fg(theme.fg_selected.to_ratatui())
844 .bg(bg)
845 .add_modifier(Modifier::BOLD),
846 ),
847 Span::styled(
848 format!(" {}", entry.filename),
849 Style::default().fg(fg_muted).bg(bg),
850 ),
851 ]))
852 .style(Style::default().bg(bg)),
853 parts[0],
854 );
855
856 f.render_widget(
858 Paragraph::new("\u{2500}".repeat(parts[1].width as usize))
859 .style(Style::default().fg(fg_muted).bg(bg)),
860 parts[1],
861 );
862
863 let indent = 2usize;
865 let wrap_width = parts[2].width.saturating_sub(indent as u16 + 1) as usize;
866 let needles = self.cached_needles();
867
868 let mut lines = Vec::new();
869 for line in text.lines() {
870 let wrapped = wrap_line(line, wrap_width);
871 for wline in wrapped {
872 let spans = highlight_needles(&wline, needles, fg_muted, bg, theme);
873 let mut indented =
874 vec![Span::styled(" ".repeat(indent), Style::default().bg(bg))];
875 indented.extend(spans);
876 lines.push(Line::from(indented));
877 }
878 }
879
880 let total_lines = lines.len();
881 let viewport = parts[2].height as usize;
882 self.scroll.set_max(total_lines.saturating_sub(viewport));
883
884 f.render_widget(
885 Paragraph::new(lines)
886 .scroll((self.scroll.offset as u16, 0))
887 .style(Style::default().bg(bg)),
888 parts[2],
889 );
890 }
891 self.list.render_autocomplete(f, rect, theme);
892 return;
893 }
894
895 let has_context = selected_state == ExpandState::Context;
897
898 let (list_area, divider_area, content_area) = if has_context {
899 let max_list = inner.height / 2;
900 let list_height = (count as u16).min(max_list).max(1);
901 let areas = Layout::default()
902 .direction(Direction::Vertical)
903 .constraints([
904 Constraint::Length(list_height),
905 Constraint::Length(1),
906 Constraint::Min(0),
907 ])
908 .split(inner);
909 (areas[0], Some(areas[1]), Some(areas[2]))
910 } else {
911 (inner, None, None)
912 };
913
914 self.list.render(f, list_area, theme, focused);
917 self.list.set_list_rect(list_area);
918
919 if let Some(div) = divider_area {
921 f.render_widget(
922 Paragraph::new("\u{2500}".repeat(div.width as usize))
923 .style(Style::default().fg(fg_muted).bg(bg)),
924 div,
925 );
926 }
927
928 if let Some(area) = content_area
931 && selected_state == ExpandState::Context
932 && let Some(entry) = self.list.selected_row()
933 {
934 let entry = entry.clone();
935 let text = entry.full_text.as_deref().unwrap_or(&entry.context);
936 let indent = 2usize;
937 let wrap_width = area.width.saturating_sub(indent as u16 + 1) as usize;
938 let anchored = self.scroll.anchored;
942 let needles = self.cached_needles();
943
944 let mut lines = Vec::new();
945
946 let mut link_line: Option<usize> = None;
950
951 for line in text.lines() {
952 let wrapped = wrap_line(line, wrap_width);
953 for wline in wrapped {
954 if anchored
955 && link_line.is_none()
956 && needles
957 .iter()
958 .any(|n| !n.is_empty() && find_case_insensitive(&wline, n).is_some())
959 {
960 link_line = Some(lines.len());
961 }
962 let spans = highlight_needles(&wline, needles, fg_muted, bg, theme);
963 let mut indented =
964 vec![Span::styled(" ".repeat(indent), Style::default().bg(bg))];
965 indented.extend(spans);
966 lines.push(Line::from(indented));
967 }
968 }
969
970 let viewport = area.height as usize;
976 let total = lines.len();
977 self.scroll.set_max(total.saturating_sub(viewport));
978 let link_pos = link_line.unwrap_or(0);
979 let lines_after_link = total.saturating_sub(link_pos);
980 self.scroll.anchor_to(if lines_after_link <= viewport {
981 self.scroll.max
984 } else {
985 link_pos.saturating_sub(2)
988 });
989
990 f.render_widget(
991 Paragraph::new(lines)
992 .scroll((self.scroll.offset as u16, 0))
993 .style(Style::default().bg(bg)),
994 area,
995 );
996 self.list.set_content_rect(area);
1000 }
1001
1002 self.list.render_autocomplete(f, rect, theme);
1003 }
1004}
1005
1006async fn load_query(vault: &NoteVault, query: &str) -> Vec<BacklinkEntry> {
1013 let needles = query_needles(query);
1014 let results = vault.search_notes(query).await.unwrap_or_default();
1015 let mut entries = Vec::with_capacity(results.len());
1016 for (entry_data, content_data) in results {
1017 let text = vault
1018 .get_note_text(&entry_data.path)
1019 .await
1020 .unwrap_or_default();
1021 let context = extract_context_multi(&text, &needles);
1022 let (_p, filename) = entry_data.path.get_parent_path();
1023 entries.push(BacklinkEntry {
1024 path: entry_data.path,
1025 title: content_data.title,
1026 filename,
1027 context,
1028 full_text: Some(text),
1029 });
1030 }
1031 entries
1032}
1033
1034fn split_paragraphs(text: &str) -> Vec<String> {
1037 let mut paragraphs = Vec::new();
1038 let mut current: Vec<&str> = Vec::new();
1039
1040 for line in text.lines() {
1041 if line.trim().is_empty() {
1042 if !current.is_empty() {
1043 paragraphs.push(current.join("\n"));
1044 current.clear();
1045 }
1046 } else {
1047 current.push(line);
1048 }
1049 }
1050 if !current.is_empty() {
1051 paragraphs.push(current.join("\n"));
1052 }
1053
1054 paragraphs
1055}
1056
1057fn wrap_line(line: &str, max_width: usize) -> Vec<String> {
1065 if max_width == 0 || line.chars().count() <= max_width {
1066 return vec![line.to_string()];
1067 }
1068
1069 let mut result = Vec::new();
1070 let mut remaining = line;
1071
1072 while remaining.chars().count() > max_width {
1073 let byte_limit = remaining
1075 .char_indices()
1076 .nth(max_width)
1077 .map(|(i, _)| i)
1078 .unwrap_or(remaining.len());
1079
1080 let break_at = remaining[..byte_limit]
1082 .rfind(' ')
1083 .map(|i| i + 1) .unwrap_or(byte_limit); result.push(remaining[..break_at].trim_end().to_string());
1086 remaining = &remaining[break_at..];
1087 }
1088 if !remaining.is_empty() {
1089 result.push(remaining.to_string());
1090 }
1091 result
1092}
1093
1094fn find_case_insensitive(haystack: &str, needle: &str) -> Option<(usize, usize)> {
1099 let needle_chars: Vec<char> = needle.chars().collect();
1100 if needle_chars.is_empty() {
1101 return None;
1102 }
1103 let hay_indices: Vec<(usize, char)> = haystack.char_indices().collect();
1104 'outer: for start_idx in 0..hay_indices.len() {
1105 if start_idx + needle_chars.len() > hay_indices.len() {
1106 break;
1107 }
1108 for (j, &nc) in needle_chars.iter().enumerate() {
1109 let hc = hay_indices[start_idx + j].1;
1110 let mut h_lower = hc.to_lowercase();
1112 let mut n_lower = nc.to_lowercase();
1113 if h_lower.next() != n_lower.next() {
1114 continue 'outer;
1115 }
1116 }
1117 let byte_start = hay_indices[start_idx].0;
1119 let byte_end = if start_idx + needle_chars.len() < hay_indices.len() {
1120 hay_indices[start_idx + needle_chars.len()].0
1121 } else {
1122 haystack.len()
1123 };
1124 return Some((byte_start, byte_end));
1125 }
1126 None
1127}
1128
1129fn extract_context_multi(text: &str, needles: &[String]) -> String {
1132 let lowered: Vec<String> = needles.iter().map(|n| n.to_lowercase()).collect();
1133 for para in &split_paragraphs(text) {
1134 let lower = para.to_lowercase();
1135 if lowered.iter().any(|n| !n.is_empty() && lower.contains(n)) {
1136 return para.clone();
1137 }
1138 }
1139 text.lines()
1140 .find(|l| !l.trim().is_empty())
1141 .unwrap_or("")
1142 .to_string()
1143}
1144
1145fn highlight_needles(
1147 line: &str,
1148 needles: &[String],
1149 fg_muted: ratatui::style::Color,
1150 bg: ratatui::style::Color,
1151 theme: &Theme,
1152) -> Vec<Span<'static>> {
1153 let normal = Style::default().fg(fg_muted).bg(bg);
1154 let bold = Style::default()
1155 .fg(theme.accent.to_ratatui())
1156 .bg(bg)
1157 .add_modifier(Modifier::BOLD);
1158 let mut best: Option<(usize, usize)> = None;
1159 for needle in needles {
1160 if needle.is_empty() {
1161 continue;
1162 }
1163 if let Some((s, e)) = find_case_insensitive(line, needle)
1164 && (best.is_none() || s < best.unwrap().0)
1165 {
1166 best = Some((s, e));
1167 }
1168 }
1169 let Some((start, end)) = best else {
1170 return vec![Span::styled(line.to_string(), normal)];
1171 };
1172 let mut spans = Vec::new();
1173 if start > 0 {
1174 spans.push(Span::styled(line[..start].to_string(), normal));
1175 }
1176 spans.push(Span::styled(line[start..end].to_string(), bold));
1177 if end < line.len() {
1178 spans.push(Span::styled(line[end..].to_string(), normal));
1179 }
1180 spans
1181}
1182
1183fn query_needles(query: &str) -> Vec<String> {
1186 let st = kimun_core::SearchTerms::from_query_string(query);
1187 let mut needles = st.terms.clone();
1188 needles.extend(st.links.clone());
1189 needles.extend(st.forward_links.clone());
1190 needles
1191}
1192
1193#[cfg(test)]
1198mod tests {
1199 use super::*;
1200
1201 #[test]
1202 fn wrap_line_fits_within_width() {
1203 let result = wrap_line("short", 20);
1204 assert_eq!(result, vec!["short"]);
1205 }
1206
1207 #[test]
1208 fn wrap_line_breaks_at_word_boundary() {
1209 let result = wrap_line("hello world foo bar", 12);
1210 assert_eq!(result, vec!["hello world", "foo bar"]);
1211 }
1212
1213 #[test]
1214 fn wrap_line_hard_breaks_long_word() {
1215 let result = wrap_line("abcdefghij", 5);
1216 assert_eq!(result, vec!["abcde", "fghij"]);
1217 }
1218
1219 #[test]
1220 fn wrap_line_handles_multibyte_chars() {
1221 let result = wrap_line("日本語テスト", 3);
1223 assert_eq!(result, vec!["日本語", "テスト"]);
1224 }
1225
1226 #[test]
1227 fn wrap_line_empty_string() {
1228 let result = wrap_line("", 10);
1229 assert_eq!(result, vec![""]);
1230 }
1231
1232 #[test]
1233 fn extract_context_matches_any_needle() {
1234 let text = "# Title\n\nIntro line.\n\nA paragraph mentioning widget here.\n";
1235 let result = extract_context_multi(text, &["widget".to_string()]);
1236 assert!(result.contains("widget"));
1237 }
1238
1239 #[test]
1240 fn highlight_needles_highlights_first_match() {
1241 let spans = highlight_needles(
1242 "see widget and gadget",
1243 &["gadget".to_string()],
1244 ratatui::style::Color::Gray,
1245 ratatui::style::Color::Black,
1246 &crate::settings::themes::Theme::default(),
1247 );
1248 assert!(
1249 spans
1250 .iter()
1251 .any(|s| s.content.contains("gadget")
1252 && s.style.add_modifier.contains(Modifier::BOLD))
1253 );
1254 }
1255
1256 #[test]
1257 fn default_query_recognized_in_all_spellings() {
1258 assert!(is_default_query(DEFAULT_QUERY));
1262 assert!(is_default_query("<"));
1263 assert!(is_default_query("lk:"));
1264 assert!(is_default_query("< or:title"));
1265 assert!(is_default_query("<{note} -or:file"));
1266 assert!(!is_default_query("<projects"));
1267 assert!(!is_default_query(">"));
1268 assert!(!is_default_query(""));
1269 }
1270
1271 #[test]
1272 fn query_needles_extracts_terms_and_links() {
1273 let n = query_needles("widget <spec");
1274 assert!(n.iter().any(|x| x == "widget"));
1275 assert!(n.iter().any(|x| x == "spec"));
1276 }
1277
1278 #[test]
1279 fn query_needles_extracts_forward_links() {
1280 let n = query_needles(">spec");
1283 assert!(n.iter().any(|x| x == "spec"));
1284 }
1285
1286 #[tokio::test]
1287 async fn query_panel_load_query_lists_matches() {
1288 let vault = crate::test_support::temp_vault("qp").await;
1289 vault.validate_and_init().await.unwrap();
1290 vault
1291 .create_note(&VaultPath::note_path_from("/a.md"), "alpha #todo")
1292 .await
1293 .unwrap();
1294 vault
1295 .create_note(&VaultPath::note_path_from("/b.md"), "beta")
1296 .await
1297 .unwrap();
1298 let entries = load_query(&vault, "#todo").await;
1299 assert_eq!(entries.len(), 1);
1300 assert!(entries[0].filename.contains("a"));
1301 }
1302
1303 fn make_panel(vault: Arc<NoteVault>) -> QueryPanel {
1304 let kb = crate::settings::AppSettings::default().key_bindings.clone();
1305 QueryPanel::new(vault, kb)
1306 }
1307
1308 #[tokio::test]
1311 async fn cached_needles_track_query_and_note() {
1312 let vault = crate::test_support::temp_vault("qp_needles").await;
1313 vault.validate_and_init().await.unwrap();
1314 let mut panel = make_panel(vault);
1315
1316 *panel.current_note.lock().unwrap() = VaultPath::note_path_from("spec");
1318 assert!(panel.cached_needles().iter().any(|n| n == "spec"));
1319
1320 *panel.current_note.lock().unwrap() = VaultPath::note_path_from("other");
1322 assert!(panel.cached_needles().iter().any(|n| n == "other"));
1323
1324 panel.list.set_query("widget".to_string());
1326 let needles = panel.cached_needles();
1327 assert!(needles.iter().any(|n| n == "widget"));
1328 assert!(!needles.iter().any(|n| n == "other"));
1329 }
1330
1331 async fn settle(panel: &mut QueryPanel) {
1337 for _ in 0..100 {
1338 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1339 panel.list.poll();
1340 if !panel.list.is_loading() {
1341 break;
1342 }
1343 }
1344 }
1345
1346 #[tokio::test(flavor = "multi_thread")]
1347 async fn apply_sort_rewrites_query_order_directive() {
1348 let vault = crate::test_support::temp_vault("qp-sort").await;
1349 vault.validate_and_init().await.unwrap();
1350 let mut panel = make_panel(vault);
1351 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1352 panel.set_active_query("widget".to_string());
1353
1354 panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1355 assert_eq!(panel.active_query(), "widget or:title");
1356
1357 panel.apply_sort(SortField::Name, SortOrder::Descending, &tx);
1358 assert_eq!(panel.active_query(), "widget -or:file");
1359 }
1360
1361 #[tokio::test(flavor = "multi_thread")]
1365 async fn directiveless_query_is_name_ascending() {
1366 let vault = crate::test_support::temp_vault("qp-defaultorder").await;
1367 vault.validate_and_init().await.unwrap();
1368 for name in ["/charlie.md", "/alpha.md", "/bravo.md"] {
1370 vault
1371 .create_note(&VaultPath::note_path_from(name), "widget")
1372 .await
1373 .unwrap();
1374 }
1375 let mut panel = make_panel(vault);
1376 panel.set_active_query("widget".to_string()); settle(&mut panel).await;
1378
1379 let names: Vec<String> = panel
1380 .list
1381 .visible_rows()
1382 .iter()
1383 .map(|e| e.filename.clone())
1384 .collect();
1385 let mut sorted = names.clone();
1386 sorted.sort();
1387 assert_eq!(names, sorted, "directive-less query must be name-ascending");
1388 }
1389
1390 #[tokio::test(flavor = "multi_thread")]
1393 async fn accepting_saved_search_pins_breadcrumb() {
1394 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1395 let vault = crate::test_support::temp_vault("qp-ss-accept").await;
1396 vault.validate_and_init().await.unwrap();
1397 vault.save_search("todo-week", "#todo").await.unwrap();
1398 let mut panel = make_panel(vault);
1399 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1400
1401 panel.set_active_query(String::new());
1405 for ch in ['?', 't', 'o'] {
1406 panel.handle_key(&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
1407 for _ in 0..30 {
1408 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1409 panel.list.poll();
1410 }
1411 }
1412 panel.handle_key(&KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &tx);
1413
1414 assert_eq!(panel.active_query(), "#todo");
1415 assert_eq!(
1416 panel.saved_search_breadcrumb().as_deref(),
1417 Some("todo-week")
1418 );
1419 }
1420
1421 #[tokio::test(flavor = "multi_thread")]
1424 async fn editing_expanded_query_keeps_breadcrumb_marked_edited() {
1425 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1426 let vault = crate::test_support::temp_vault("qp-ss-edit").await;
1427 vault.validate_and_init().await.unwrap();
1428 let mut panel = make_panel(vault);
1429 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1430 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1431 assert_eq!(panel.saved_search_breadcrumb().as_deref(), Some("todo"));
1432
1433 panel.handle_key(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
1435 assert_eq!(panel.active_query(), "#todox");
1436 assert_eq!(
1437 panel.saved_search_breadcrumb().as_deref(),
1438 Some("todo • edited")
1439 );
1440 }
1441
1442 #[tokio::test(flavor = "multi_thread")]
1445 async fn emptying_field_clears_breadcrumb() {
1446 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1447 let vault = crate::test_support::temp_vault("qp-ss-empty").await;
1448 vault.validate_and_init().await.unwrap();
1449 let mut panel = make_panel(vault);
1450 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1451 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1452
1453 for _ in 0.."#todo".len() {
1455 panel.handle_key(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), &tx);
1456 }
1457 assert_eq!(panel.active_query(), "");
1458 assert_eq!(panel.saved_search_breadcrumb(), None);
1459 }
1460
1461 #[tokio::test(flavor = "multi_thread")]
1462 async fn apply_query_pins_breadcrumb() {
1463 let vault = crate::test_support::temp_vault("qp-name").await;
1464 vault.validate_and_init().await.unwrap();
1465 let mut panel = make_panel(vault);
1466 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1467 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1468 assert_eq!(panel.saved_search_breadcrumb().as_deref(), Some("todo"));
1469 }
1470
1471 #[tokio::test(flavor = "multi_thread")]
1475 async fn apply_sort_marks_saved_search_breadcrumb_edited() {
1476 let vault = crate::test_support::temp_vault("qp-sort-name").await;
1477 vault.validate_and_init().await.unwrap();
1478 let mut panel = make_panel(vault);
1479 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1480 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1481
1482 panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1483 assert_eq!(panel.active_query(), "#todo or:title");
1484 assert_eq!(
1485 panel.saved_search_breadcrumb().as_deref(),
1486 Some("todo • edited"),
1487 "sorting diverges from the stored query, so the breadcrumb is edited"
1488 );
1489 }
1490
1491 #[tokio::test(flavor = "multi_thread")]
1494 async fn repin_after_save_adopts_saved_identity() {
1495 let vault = crate::test_support::temp_vault("qp-repin").await;
1496 vault.validate_and_init().await.unwrap();
1497 let mut panel = make_panel(vault);
1498 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1499 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1500
1501 panel.set_active_query("#todo and #urgent".to_string());
1502 assert_eq!(
1503 panel.saved_search_breadcrumb().as_deref(),
1504 Some("todo • edited")
1505 );
1506
1507 panel.repin_saved_search("urgent-todos".to_string(), "#todo and #urgent");
1508 assert_eq!(
1509 panel.saved_search_breadcrumb().as_deref(),
1510 Some("urgent-todos"),
1511 "after a save the saved identity is the provenance — no edited marker"
1512 );
1513 }
1514
1515 #[tokio::test(flavor = "multi_thread")]
1519 async fn apply_sort_updates_visible_input_bar() {
1520 let vault = crate::test_support::temp_vault("qp-bar").await;
1521 vault.validate_and_init().await.unwrap();
1522 let mut panel = make_panel(vault);
1523 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1524 panel.set_active_query("widget".to_string());
1525 assert_eq!(
1526 panel.list.input_value(),
1527 "widget",
1528 "set_active_query syncs the bar"
1529 );
1530
1531 panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1532 assert_eq!(panel.active_query(), "widget or:title");
1533 assert_eq!(
1534 panel.list.input_value(),
1535 "widget or:title",
1536 "the input bar must reflect the rewritten query"
1537 );
1538 }
1539
1540 #[tokio::test(flavor = "multi_thread")]
1541 async fn current_order_reads_query_directive() {
1542 let vault = crate::test_support::temp_vault("qp-order").await;
1543 vault.validate_and_init().await.unwrap();
1544 let mut panel = make_panel(vault);
1545 panel.set_active_query("widget -or:title".to_string());
1546 assert_eq!(
1547 panel.current_order(),
1548 (SortField::Title, SortOrder::Descending)
1549 );
1550 panel.set_active_query("widget".to_string());
1551 assert_eq!(
1552 panel.current_order(),
1553 (SortField::Name, SortOrder::Ascending)
1554 );
1555 }
1556
1557 #[tokio::test(flavor = "multi_thread")]
1561 async fn context_preview_wheel_scrolls_preview_not_list() {
1562 use ratatui::Terminal;
1563 use ratatui::backend::TestBackend;
1564 use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
1565
1566 let vault = crate::test_support::temp_vault("qp-preview-wheel").await;
1567 vault.validate_and_init().await.unwrap();
1568 let mut body = String::from("#todo first line\n");
1571 for i in 0..40 {
1572 body.push_str(&format!("line {}\n", i));
1573 }
1574 vault
1575 .create_note(&VaultPath::note_path_from("/long.md"), &body)
1576 .await
1577 .unwrap();
1578 let mut panel = make_panel(vault);
1579 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1580 panel.set_active_query("#todo".to_string());
1581 settle(&mut panel).await;
1582 assert!(panel.list.selected_row().is_some());
1583
1584 panel.toggle_expand();
1587 assert!(panel.expand == ExpandState::Context);
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(), "preview rect recorded");
1595 assert_eq!(panel.scroll.offset, 0, "auto-anchor at the top needle");
1596 assert!(panel.scroll.max > 0, "content overflows viewport");
1597
1598 let wheel = move |y: u16| MouseEvent {
1599 kind: MouseEventKind::ScrollDown,
1600 column: preview.x + 1,
1601 row: y,
1602 modifiers: KeyModifiers::NONE,
1603 };
1604
1605 let over_list = wheel(preview.y.saturating_sub(3));
1607 panel.handle_mouse(&over_list, &tx);
1608 assert_eq!(panel.scroll.offset, 0, "list wheel must not move preview");
1609 assert!(panel.scroll.anchored, "anchor stays armed");
1610
1611 let over_preview = wheel(preview.y + 1);
1613 panel.handle_mouse(&over_preview, &tx);
1614 assert_eq!(panel.scroll.offset, 1, "preview wheel scrolls content");
1615 assert!(!panel.scroll.anchored, "user owns the scroll now");
1616
1617 terminal
1619 .draw(|f| panel.render(f, f.area(), &theme, true))
1620 .unwrap();
1621 assert_eq!(panel.scroll.offset, 1);
1622
1623 let up = MouseEvent {
1625 kind: MouseEventKind::ScrollUp,
1626 column: preview.x + 1,
1627 row: preview.y + 1,
1628 modifiers: KeyModifiers::NONE,
1629 };
1630 panel.handle_mouse(&up, &tx);
1631 panel.handle_mouse(&up, &tx);
1632 assert_eq!(panel.scroll.offset, 0);
1633 }
1634
1635 #[tokio::test(flavor = "multi_thread")]
1639 async fn noop_preview_wheel_keeps_autoscroll_armed() {
1640 use ratatui::Terminal;
1641 use ratatui::backend::TestBackend;
1642 use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
1643
1644 let vault = crate::test_support::temp_vault("qp-noop-wheel").await;
1645 vault.validate_and_init().await.unwrap();
1646 vault
1648 .create_note(&VaultPath::note_path_from("/short.md"), "#todo only line")
1649 .await
1650 .unwrap();
1651 let mut panel = make_panel(vault);
1652 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1653 panel.set_active_query("#todo".to_string());
1654 settle(&mut panel).await;
1655 panel.toggle_expand();
1656 let theme = crate::settings::themes::Theme::default();
1657 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1658 terminal
1659 .draw(|f| panel.render(f, f.area(), &theme, true))
1660 .unwrap();
1661 assert_eq!(panel.scroll.max, 0, "content fits the viewport");
1662
1663 let preview = panel.list.content_rect();
1664 let down = MouseEvent {
1665 kind: MouseEventKind::ScrollDown,
1666 column: preview.x + 1,
1667 row: preview.y + 1,
1668 modifiers: KeyModifiers::NONE,
1669 };
1670 panel.handle_mouse(&down, &tx);
1671 assert!(
1672 panel.scroll.anchored,
1673 "no-op wheel tick must not disarm the auto-anchor"
1674 );
1675 }
1676
1677 #[tokio::test(flavor = "multi_thread")]
1681 async fn query_keystroke_rearms_preview_autoscroll() {
1682 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1683
1684 let vault = crate::test_support::temp_vault("qp-rearm").await;
1685 vault.validate_and_init().await.unwrap();
1686 let mut body = String::from("#todo first line\n");
1687 for i in 0..40 {
1688 body.push_str(&format!("line {}\n", i));
1689 }
1690 vault
1691 .create_note(&VaultPath::note_path_from("/long.md"), &body)
1692 .await
1693 .unwrap();
1694 let mut panel = make_panel(vault);
1695 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1696 panel.set_active_query("#todo".to_string());
1697 settle(&mut panel).await;
1698 panel.toggle_expand();
1699 panel.scroll.anchored = false;
1701
1702 panel.handle_key(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
1703 assert_eq!(panel.active_query(), "#todox");
1704 assert!(
1705 panel.scroll.anchored,
1706 "a query edit must re-arm the preview auto-anchor"
1707 );
1708 }
1709
1710 #[tokio::test(flavor = "multi_thread")]
1714 async fn preview_wheel_closes_autocomplete_popup() {
1715 use ratatui::Terminal;
1716 use ratatui::backend::TestBackend;
1717 use ratatui::crossterm::event::{
1718 KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind,
1719 };
1720
1721 let vault = crate::test_support::temp_vault("qp-wheel-popup").await;
1722 vault.validate_and_init().await.unwrap();
1723 let mut body = String::from("#todo first line\n");
1724 for i in 0..40 {
1725 body.push_str(&format!("line {}\n", i));
1726 }
1727 vault
1728 .create_note(&VaultPath::note_path_from("/long.md"), &body)
1729 .await
1730 .unwrap();
1731 let mut panel = make_panel(vault);
1732 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1733 panel.set_active_query("#todo".to_string());
1734 settle(&mut panel).await;
1735 panel.toggle_expand();
1736 let theme = crate::settings::themes::Theme::default();
1737 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1738 terminal
1739 .draw(|f| panel.render(f, f.area(), &theme, true))
1740 .unwrap();
1741 let preview = panel.list.content_rect();
1742 assert!(!preview.is_empty());
1743
1744 for ch in [' ', '#'] {
1747 panel.handle_key(&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
1748 for _ in 0..30 {
1749 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1750 panel.list.poll();
1751 }
1752 }
1753 assert!(panel.list.autocomplete_is_open(), "popup open after `#`");
1754
1755 let wheel = MouseEvent {
1756 kind: MouseEventKind::ScrollDown,
1757 column: preview.x + 1,
1758 row: preview.y + 1,
1759 modifiers: KeyModifiers::NONE,
1760 };
1761 panel.handle_mouse(&wheel, &tx);
1762 assert!(
1763 !panel.list.autocomplete_is_open(),
1764 "wheel over the preview must dismiss the popup"
1765 );
1766 }
1767
1768 #[tokio::test(flavor = "multi_thread")]
1772 async fn full_expand_header_click_collapses() {
1773 use ratatui::Terminal;
1774 use ratatui::backend::TestBackend;
1775 use ratatui::crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
1776
1777 let vault = crate::test_support::temp_vault("qp-header-click").await;
1778 vault.validate_and_init().await.unwrap();
1779 vault
1780 .create_note(&VaultPath::note_path_from("/long.md"), "#todo body")
1781 .await
1782 .unwrap();
1783 let mut panel = make_panel(vault);
1784 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1785 panel.set_active_query("#todo".to_string());
1786 settle(&mut panel).await;
1787 panel.toggle_expand();
1789 panel.toggle_expand();
1790 assert!(panel.is_full_expanded());
1791 let theme = crate::settings::themes::Theme::default();
1792 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1793 terminal
1794 .draw(|f| panel.render(f, f.area(), &theme, true))
1795 .unwrap();
1796 let header = panel.full_header_rect;
1797 assert!(!header.is_empty(), "header rect recorded in full mode");
1798
1799 let click = |x: u16, y: u16| MouseEvent {
1800 kind: MouseEventKind::Down(MouseButton::Left),
1801 column: x,
1802 row: y,
1803 modifiers: KeyModifiers::NONE,
1804 };
1805
1806 panel.handle_mouse(&click(header.x + 1, header.y + 3), &tx);
1808 assert!(panel.is_full_expanded(), "content click must not collapse");
1809
1810 panel.handle_mouse(&click(header.x + 1, header.y), &tx);
1812 assert!(!panel.is_full_expanded());
1813 assert!(panel.expand == ExpandState::Collapsed);
1814 }
1815
1816 #[tokio::test(flavor = "multi_thread")]
1821 async fn toggling_expand_clears_stale_content_regions() {
1822 use ratatui::Terminal;
1823 use ratatui::backend::TestBackend;
1824
1825 let vault = crate::test_support::temp_vault("qp-stale-regions").await;
1826 vault.validate_and_init().await.unwrap();
1827 vault
1828 .create_note(&VaultPath::note_path_from("/long.md"), "#todo body")
1829 .await
1830 .unwrap();
1831 let mut panel = make_panel(vault);
1832 panel.set_active_query("#todo".to_string());
1833 settle(&mut panel).await;
1834 let theme = crate::settings::themes::Theme::default();
1835 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1836
1837 panel.toggle_expand();
1839 panel.toggle_expand();
1840 terminal
1841 .draw(|f| panel.render(f, f.area(), &theme, true))
1842 .unwrap();
1843 assert!(!panel.list.content_rect().is_empty());
1844 assert!(!panel.full_header_rect.is_empty());
1845
1846 panel.toggle_expand();
1849 assert!(
1850 panel.list.content_rect().is_empty(),
1851 "stale content rect must not survive a state change"
1852 );
1853 assert!(
1854 panel.full_header_rect.is_empty(),
1855 "stale header rect must not survive a state change"
1856 );
1857 }
1858
1859 #[tokio::test(flavor = "multi_thread")]
1865 async fn static_query_survives_navigation() {
1866 let vault = crate::test_support::temp_vault("nav-static").await;
1867 vault.validate_and_init().await.unwrap();
1868 vault
1869 .create_note(&VaultPath::note_path_from("/a.md"), "alpha #todo")
1870 .await
1871 .unwrap();
1872 let mut panel = make_panel(vault);
1873 panel.set_active_query("#todo".to_string());
1874 settle(&mut panel).await;
1875 assert_eq!(panel.list.visible_rows().len(), 1);
1876
1877 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1878 panel.set_note(VaultPath::note_path_from("x.md"), tx);
1879
1880 assert_eq!(panel.active_query(), "#todo");
1883 assert!(!panel.list.is_loading());
1884 settle(&mut panel).await;
1885 assert_eq!(panel.list.visible_rows().len(), 1); }
1887
1888 #[tokio::test(flavor = "multi_thread")]
1891 async fn note_variable_query_reruns_on_navigation() {
1892 let vault = crate::test_support::temp_vault("nav-var").await;
1893 vault.validate_and_init().await.unwrap();
1894 vault
1897 .create_note(&VaultPath::note_path_from("/target.md"), "I am the target")
1898 .await
1899 .unwrap();
1900 vault
1901 .create_note(&VaultPath::note_path_from("/linker.md"), "see [[target]]")
1902 .await
1903 .unwrap();
1904 let mut panel = make_panel(vault);
1905 assert_eq!(panel.active_query(), DEFAULT_QUERY);
1906
1907 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1908 panel.set_note(VaultPath::note_path_from("/target.md"), tx);
1909 settle(&mut panel).await;
1910
1911 assert!(
1913 panel
1914 .list
1915 .visible_rows()
1916 .iter()
1917 .any(|e| e.filename.contains("linker")),
1918 "expected linker as a backlink, got {:?}",
1919 panel
1920 .list
1921 .visible_rows()
1922 .iter()
1923 .map(|e| e.filename.clone())
1924 .collect::<Vec<_>>()
1925 );
1926 }
1927
1928 #[tokio::test(flavor = "multi_thread")]
1930 async fn note_variable_query_changes_with_note() {
1931 let vault = crate::test_support::temp_vault("nav-var2").await;
1932 vault.validate_and_init().await.unwrap();
1933 vault
1934 .create_note(&VaultPath::note_path_from("/a.md"), "I am a")
1935 .await
1936 .unwrap();
1937 vault
1938 .create_note(&VaultPath::note_path_from("/b.md"), "I am b")
1939 .await
1940 .unwrap();
1941 vault
1942 .create_note(&VaultPath::note_path_from("/links_a.md"), "see [[a]]")
1943 .await
1944 .unwrap();
1945 let mut panel = make_panel(vault);
1946 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1947
1948 panel.set_note(VaultPath::note_path_from("/a.md"), tx.clone());
1949 settle(&mut panel).await;
1950 assert!(
1951 panel
1952 .list
1953 .visible_rows()
1954 .iter()
1955 .any(|e| e.filename.contains("links_a"))
1956 );
1957
1958 panel.set_note(VaultPath::note_path_from("/b.md"), tx);
1959 settle(&mut panel).await;
1960 assert!(
1961 !panel
1962 .list
1963 .visible_rows()
1964 .iter()
1965 .any(|e| e.filename.contains("links_a")),
1966 "b has no backlinks, expected empty"
1967 );
1968 }
1969}