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}";
34const DEFAULT_QUERY_LONG: &str = "lk:{note}";
37
38fn is_default_query(query: &str) -> bool {
43 let expanded = kimun_core::expand_bare_note_prefixes(
44 &kimun_core::strip_order_directive(query),
45 crate::components::query_vars::VAR_NOTE,
46 );
47 expanded == DEFAULT_QUERY || expanded == DEFAULT_QUERY_LONG
48}
49
50#[derive(Debug, Clone)]
56pub struct BacklinkEntry {
57 pub path: VaultPath,
58 pub title: String,
59 pub filename: String,
60 pub context: String,
62 pub full_text: Option<String>,
64}
65
66impl SearchRow for BacklinkEntry {
67 fn to_list_item(&self, theme: &Theme, icons: &Icons, selected: bool) -> ListItem<'static> {
68 let title_display = if self.title.is_empty() {
69 &self.filename
70 } else {
71 &self.title
72 };
73 let title_style = if selected {
74 Style::default()
75 .fg(theme.selection_fg.to_ratatui())
76 .bg(theme.selection_bg.to_ratatui())
77 .add_modifier(Modifier::BOLD)
78 } else {
79 Style::default()
80 .fg(theme.fg.to_ratatui())
81 .bg(theme.bg_panel.to_ratatui())
82 };
83 crate::components::rich_row::RichRow::new(icons.note, title_display.clone())
84 .title_style(title_style)
85 .meta(self.filename.clone())
86 .into_list_item(theme)
87 }
88
89 fn match_text(&self) -> Option<&str> {
90 Some(&self.filename)
91 }
92
93 fn visual_height(&self) -> u16 {
94 1
95 }
96}
97
98struct BacklinkSource {
109 vault: Arc<NoteVault>,
110 current_note: Arc<Mutex<VaultPath>>,
111}
112
113#[async_trait]
114impl RowSource<BacklinkEntry> for BacklinkSource {
115 async fn load(&self, query: &str, emit: Emit<BacklinkEntry>) {
116 let note = self.current_note.lock().unwrap().clone();
118 if query_is_unresolvable(query, Some(¬e)) {
124 emit.replace(Vec::new());
125 return;
126 }
127 let q = resolve_query(query, Some(¬e));
128 let mut entries = load_query(&self.vault, &q).await;
129 if kimun_core::SearchTerms::from_query_string(query)
136 .order_by
137 .is_empty()
138 {
139 entries.sort_by_key(|e| e.filename.to_lowercase());
140 }
141 emit.replace(entries);
142 }
143}
144
145#[derive(Clone, Copy, PartialEq)]
150enum ExpandState {
151 Collapsed,
152 Context,
153 Full,
154}
155
156#[derive(Clone, Copy)]
167struct ContentScroll {
168 anchored: bool,
172 offset: usize,
174 max: usize,
176}
177
178impl ContentScroll {
179 fn new() -> Self {
180 Self {
181 anchored: true,
182 offset: 0,
183 max: 0,
184 }
185 }
186
187 fn reset(&mut self) {
189 *self = Self::new();
190 }
191
192 fn re_anchor(&mut self) {
195 self.anchored = true;
196 }
197
198 fn scroll_up(&mut self) {
202 if self.offset > 0 {
203 self.offset -= 1;
204 self.anchored = false;
205 }
206 }
207
208 fn scroll_down(&mut self) {
213 if self.offset < self.max {
214 self.offset += 1;
215 self.anchored = false;
216 }
217 }
218
219 fn set_max(&mut self, max: usize) {
222 self.max = max;
223 self.offset = self.offset.min(max);
224 }
225
226 fn anchor_to(&mut self, offset: usize) {
229 if self.anchored {
230 self.offset = offset.min(self.max);
231 }
232 }
233}
234
235pub struct QueryPanel {
240 list: SearchList<BacklinkEntry>,
243 current_note: Arc<Mutex<VaultPath>>,
246 saved_search: SavedSearchBreadcrumb,
250 expand: ExpandState,
254 expand_path: Option<VaultPath>,
257 scroll: ContentScroll,
261 full_header_rect: Rect,
265 key_bindings: KeyBindings,
266 redraw_tx: Arc<Mutex<Option<AppTx>>>,
271 follow_link_combos: Vec<KeyCombo>,
273 order_cache: (SortField, SortOrder),
278 order_cache_query: String,
279 is_default_cache: bool,
283 needles_cache: Vec<String>,
289 needles_cache_key: (String, VaultPath),
290}
291
292impl QueryPanel {
293 pub fn new(vault: Arc<NoteVault>, key_bindings: KeyBindings, icons: Icons) -> Self {
294 let current_note = Arc::new(Mutex::new(VaultPath::empty()));
295 let redraw_tx: Arc<Mutex<Option<AppTx>>> = Arc::new(Mutex::new(None));
299 let redraw: Arc<dyn Fn() + Send + Sync> = {
300 let slot = redraw_tx.clone();
301 Arc::new(move || {
302 if let Some(tx) = slot.lock().unwrap().as_ref() {
303 let _ = tx.send(AppEvent::Redraw);
304 }
305 })
306 };
307 let source = BacklinkSource {
308 vault: vault.clone(),
309 current_note: current_note.clone(),
310 };
311 let combos = |action: &ActionShortcuts| -> Vec<KeyCombo> {
312 key_bindings
313 .to_hashmap()
314 .get(action)
315 .cloned()
316 .unwrap_or_default()
317 };
318 let follow_link_combos = combos(&ActionShortcuts::FollowLink);
319
320 let mut intercept = Vec::new();
321 intercept.extend(follow_link_combos.iter().cloned());
322
323 let list = SearchList::builder(source, redraw)
324 .highlight_query()
325 .icons(icons.clone())
326 .autocomplete(
327 Arc::new(VaultSuggestions {
328 vault: vault.clone(),
329 }),
330 AutocompleteMode::SearchQuery,
331 )
332 .intercept(intercept)
333 .build();
334
335 Self {
336 list,
337 current_note,
338 saved_search: SavedSearchBreadcrumb::default(),
339 expand: ExpandState::Collapsed,
340 expand_path: None,
341 scroll: ContentScroll::new(),
342 full_header_rect: Rect::default(),
343 key_bindings,
344 redraw_tx,
345 follow_link_combos,
346 order_cache: (SortField::Name, SortOrder::Ascending),
348 order_cache_query: String::new(),
349 is_default_cache: false,
352 needles_cache: Vec::new(),
353 needles_cache_key: (String::new(), VaultPath::empty()),
354 }
355 }
356
357 pub fn active_query(&self) -> &str {
360 self.list.query()
361 }
362
363 fn emphasis(&self) -> Option<Vec<String>> {
367 let resolved = resolve_query(self.list.query(), Some(&self.current_note()));
368 let needles = crate::components::query_highlight::emphasis_needles(&resolved);
369 (!needles.is_empty()).then_some(needles)
370 }
371
372 pub fn result_count(&self) -> usize {
374 self.list.match_count()
375 }
376
377 pub fn set_active_query(&mut self, q: String) {
378 self.list.set_query(q);
379 self.reset_expand();
380 }
381
382 pub fn saved_search_breadcrumb(&self) -> Option<String> {
385 self.saved_search.label(self.list.query())
386 }
387
388 pub fn saved_search_name(&self) -> Option<&str> {
392 self.saved_search.name()
393 }
394
395 pub fn repin_saved_search(&mut self, name: String, query: &str) {
399 self.saved_search.set(Some(name), query);
400 }
401
402 fn query_is_blank(&self) -> bool {
407 let q = self.list.query();
408 q.trim().is_empty() || is_default_query(q)
409 }
410
411 pub fn apply_query(&mut self, query: String, name: Option<String>, tx: AppTx) {
415 self.ensure_redraw_tx(&tx);
416 self.set_active_query(query.clone());
417 self.saved_search.set(name, &query);
418 }
419
420 fn current_note(&self) -> VaultPath {
423 self.current_note.lock().unwrap().clone()
424 }
425
426 fn ensure_redraw_tx(&self, tx: &AppTx) {
429 let mut slot = self.redraw_tx.lock().unwrap();
430 if slot.is_none() {
431 *slot = Some(tx.clone());
432 }
433 }
434
435 fn cached_needles(&mut self) -> &[String] {
440 let note = self.current_note();
441 if self.needles_cache_key.0 != self.list.query() || self.needles_cache_key.1 != note {
442 self.needles_cache = query_needles(&resolve_query(self.list.query(), Some(¬e)));
443 self.needles_cache_key = (self.list.query().to_string(), note);
444 }
445 &self.needles_cache
446 }
447
448 fn is_full_expanded(&self) -> bool {
451 self.list.selected_row().is_some() && self.expand == ExpandState::Full
452 }
453
454 pub fn is_empty(&self) -> bool {
455 self.list.rows().is_empty()
456 }
457
458 pub fn selected_path(&self) -> Option<&VaultPath> {
459 self.list.selected_row().map(|e| &e.path)
460 }
461
462 fn clear_content_regions(&mut self) {
469 self.list.set_content_rect(Rect::default());
470 self.full_header_rect = Rect::default();
471 }
472
473 fn reset_expand(&mut self) {
474 self.expand = ExpandState::Collapsed;
475 self.expand_path = None;
476 self.scroll.reset();
477 self.clear_content_regions();
478 }
479
480 fn sync_expand_anchor(&mut self) {
485 let sel = self.list.selected_row().map(|e| e.path.clone());
486 if sel != self.expand_path {
487 if self.expand != ExpandState::Context || sel.is_none() {
488 self.expand = ExpandState::Collapsed;
489 }
490 self.expand_path = sel;
491 self.scroll.reset();
492 self.clear_content_regions();
493 }
494 }
495
496 pub fn set_note(&mut self, note_path: VaultPath, tx: AppTx) {
501 self.ensure_redraw_tx(&tx);
502 *self.current_note.lock().unwrap() = note_path;
503 if query_has_variables(self.list.query()) {
504 self.list.reload();
505 self.reset_expand();
506 }
507 }
508
509 pub fn current_order(&self) -> (SortField, SortOrder) {
514 let st = kimun_core::SearchTerms::from_query_string(self.list.query());
515 match st.order_by.first() {
516 Some(OrderBy::Title { asc }) => (
517 SortField::Title,
518 if *asc {
519 SortOrder::Ascending
520 } else {
521 SortOrder::Descending
522 },
523 ),
524 Some(OrderBy::FileName { asc }) => (
525 SortField::Name,
526 if *asc {
527 SortOrder::Ascending
528 } else {
529 SortOrder::Descending
530 },
531 ),
532 None => (SortField::Name, SortOrder::Ascending),
533 }
534 }
535
536 pub fn apply_sort(&mut self, field: SortField, order: SortOrder, tx: &AppTx) {
539 self.ensure_redraw_tx(tx);
540 let order_field = match field {
541 SortField::Name => OrderField::FileName,
542 SortField::Title => OrderField::Title,
543 };
544 let asc = matches!(order, SortOrder::Ascending);
545 let rewritten = with_order_directive(self.list.query(), order_field, asc);
546 self.list.set_query(rewritten);
547 self.reset_expand();
551 }
552
553 pub fn handle_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
556 self.ensure_redraw_tx(tx);
557 self.sync_expand_anchor();
558
559 if self.is_full_expanded() && matches!(key.code, KeyCode::Up | KeyCode::Down) {
562 self.scroll_content(key);
563 return EventState::Consumed;
564 }
565 if key.code == KeyCode::Enter
570 && key
571 .modifiers
572 .contains(ratatui::crossterm::event::KeyModifiers::CONTROL)
573 {
574 if let Some(path) = self.selected_path().cloned() {
575 tx.send(AppEvent::OpenPath {
576 path,
577 emphasis: self.emphasis(),
578 })
579 .ok();
580 }
581 return EventState::Consumed;
582 }
583 let prev_query = self.list.query().to_string();
588 match self.list.handle_key(key) {
589 KeyReaction::Intercepted(c) if self.follow_link_combos.contains(&c) => {
590 if let Some(path) = self.selected_path().cloned() {
591 tx.send(AppEvent::OpenPath {
592 path,
593 emphasis: self.emphasis(),
594 })
595 .ok();
596 }
597 EventState::Consumed
598 }
599 KeyReaction::Consumed => {
600 let accepted = self.list.take_accepted_saved_search();
604 let blank = self.query_is_blank();
605 self.saved_search
606 .on_query_consumed(accepted, self.list.query(), blank);
607 if self.list.query() != prev_query {
612 self.scroll.re_anchor();
613 }
614 self.sync_expand_anchor();
615 EventState::Consumed
616 }
617 KeyReaction::Submit => {
618 self.toggle_expand();
621 EventState::Consumed
622 }
623 KeyReaction::Cancel => EventState::NotConsumed,
625 KeyReaction::Unhandled => EventState::NotConsumed,
626 KeyReaction::Intercepted(_) => EventState::Consumed,
627 }
628 }
629
630 pub fn handle_mouse(
639 &mut self,
640 mouse: &ratatui::crossterm::event::MouseEvent,
641 tx: &AppTx,
642 ) -> EventState {
643 use ratatui::crossterm::event::{MouseButton, MouseEventKind};
644 use ratatui::layout::Position;
645 self.ensure_redraw_tx(tx);
646 let was_full = self.is_full_expanded();
651 self.sync_expand_anchor();
652 if was_full {
659 match mouse.kind {
660 MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {}
662 MouseEventKind::Down(MouseButton::Left)
666 if self.full_header_rect.contains(Position {
667 x: mouse.column,
668 y: mouse.row,
669 }) =>
670 {
671 self.list.close_autocomplete();
672 self.toggle_expand();
673 return EventState::Consumed;
674 }
675 _ => {
676 self.list.close_autocomplete();
677 return EventState::Consumed;
678 }
679 }
680 }
681 match self.list.handle_mouse(mouse) {
682 SearchMouse::ContentScrollUp => {
683 self.scroll.scroll_up();
684 EventState::Consumed
685 }
686 SearchMouse::ContentScrollDown => {
687 self.scroll.scroll_down();
688 EventState::Consumed
689 }
690 SearchMouse::Activated(_) => {
691 self.toggle_expand();
692 EventState::Consumed
693 }
694 SearchMouse::Context(_) => {
696 if let Some(path) = self.selected_path().cloned() {
697 tx.send(AppEvent::ShowFileOpsMenu(path)).ok();
698 }
699 EventState::Consumed
700 }
701 SearchMouse::Selected(_) | SearchMouse::Scrolled => {
702 self.sync_expand_anchor();
703 EventState::Consumed
704 }
705 SearchMouse::None => EventState::NotConsumed,
706 }
707 }
708
709 fn scroll_content(&mut self, key: &KeyEvent) {
710 match key.code {
711 KeyCode::Up => self.scroll.scroll_up(),
712 KeyCode::Down => self.scroll.scroll_down(),
713 _ => {}
714 }
715 }
716
717 fn toggle_expand(&mut self) {
718 if self.list.selected_row().is_none() {
719 return;
720 }
721 self.expand_path = self.list.selected_row().map(|e| e.path.clone());
722 match self.expand {
723 ExpandState::Collapsed => {
724 self.expand = ExpandState::Context;
725 self.scroll.re_anchor();
726 }
727 ExpandState::Context => {
728 self.scroll.reset();
729 self.expand = ExpandState::Full;
730 }
731 ExpandState::Full => {
732 self.scroll.reset();
733 self.expand = ExpandState::Collapsed;
734 }
735 }
736 self.clear_content_regions();
737 }
738
739 pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
740 crate::components::hints::hints_for(
741 &self.key_bindings,
742 &[
743 (ActionShortcuts::FocusSidebar, "\u{2190} editor"),
744 (ActionShortcuts::FollowLink, "open note"),
745 (ActionShortcuts::SaveCurrentQuery, "save query"),
746 (ActionShortcuts::OpenSavedSearches, "searches"),
747 (ActionShortcuts::OpenSortDialog, "sort"),
748 ],
749 )
750 }
751
752 pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
755 self.list.poll();
756 self.sync_expand_anchor();
757 self.list.set_panel_rect(rect);
760 self.list.set_content_rect(Rect::default());
765 self.full_header_rect = Rect::default();
766
767 let border_style = theme.border_style(focused);
768 let gray = theme.gray.to_ratatui();
769 let bg = theme.bg_panel.to_ratatui();
770
771 let count = self.list.visible_rows().len();
772 if self.list.query() != self.order_cache_query {
775 self.order_cache = self.current_order();
776 self.is_default_cache = is_default_query(self.list.query());
777 self.order_cache_query = self.list.query().to_string();
778 }
779 let (sort_field, sort_order) = self.order_cache;
780 let sort_indicator = format!("{}{}", sort_field.label(), sort_order.label());
781 let title = if self.list.query().trim().is_empty() {
788 "Find".to_string()
789 } else if self.is_default_cache {
790 format!("Backlinks ({}) {}", count, sort_indicator)
791 } else {
792 format!("Query ({}) {}", count, sort_indicator)
793 };
794
795 let outer = Block::default()
796 .title(title)
797 .borders(Borders::ALL)
798 .border_style(border_style)
799 .style(theme.panel_style());
800 let outer_inner = outer.inner(rect);
801 f.render_widget(outer, rect);
802
803 let rows = Layout::default()
805 .direction(Direction::Vertical)
806 .constraints([Constraint::Length(3), Constraint::Min(0)])
807 .split(outer_inner);
808 let search_title = self.saved_search.border_title(self.list.query(), " Query");
811 let mut search_block = Block::default()
812 .title(search_title)
813 .borders(Borders::ALL)
814 .border_style(border_style)
815 .style(theme.panel_style());
816 if let Some(reason) = crate::components::query_highlight::error_reason(self.list.query()) {
819 search_block = search_block.title(
820 ratatui::text::Line::from(ratatui::text::Span::styled(
821 format!(" ⚠ {reason} "),
822 Style::default().fg(theme.red.to_ratatui()),
823 ))
824 .right_aligned(),
825 );
826 }
827 let search_inner = search_block.inner(rows[0]);
828 f.render_widget(search_block, rows[0]);
829 self.list.render_query(f, search_inner, theme, focused);
830
831 let inner = rows[1];
832
833 if self.list.is_loading() {
834 f.render_widget(
835 Paragraph::new(" Loading...").style(Style::default().fg(gray).bg(bg)),
836 inner,
837 );
838 self.list.render_autocomplete(f, rect, theme);
839 return;
840 }
841
842 if self.list.visible_rows().is_empty() {
843 f.render_widget(
844 Paragraph::new(" No results").style(Style::default().fg(gray).bg(bg)),
845 inner,
846 );
847 self.list.render_autocomplete(f, rect, theme);
848 return;
849 }
850
851 let selected_state = self.expand;
852
853 if selected_state == ExpandState::Full {
857 self.list.set_content_rect(rect);
858 if let Some(entry) = self.list.selected_row() {
859 let entry = entry.clone();
860 let text = entry.full_text.as_deref().unwrap_or(&entry.context);
861
862 let title_display = if entry.title.is_empty() {
864 &entry.filename
865 } else {
866 &entry.title
867 };
868
869 let parts = Layout::default()
870 .direction(Direction::Vertical)
871 .constraints([
872 Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
876 .split(inner);
877
878 self.full_header_rect = parts[0];
881 f.render_widget(
882 Paragraph::new(Line::from(vec![
883 Span::styled(
884 format!("\u{25BC} {} ", title_display),
885 Style::default()
886 .fg(theme.selection_fg.to_ratatui())
887 .bg(bg)
888 .add_modifier(Modifier::BOLD),
889 ),
890 Span::styled(
891 format!(" {}", entry.filename),
892 Style::default().fg(gray).bg(bg),
893 ),
894 ]))
895 .style(Style::default().bg(bg)),
896 parts[0],
897 );
898
899 f.render_widget(
901 Paragraph::new("\u{2500}".repeat(parts[1].width as usize))
902 .style(Style::default().fg(gray).bg(bg)),
903 parts[1],
904 );
905
906 let indent = 2usize;
908 let wrap_width = parts[2].width.saturating_sub(indent as u16 + 1) as usize;
909 let needles = self.cached_needles();
910
911 let mut lines = Vec::new();
912 for line in text.lines() {
913 let wrapped = wrap_line(line, wrap_width);
914 for wline in wrapped {
915 let spans = highlight_needles(&wline, needles, gray, bg, theme);
916 let mut indented =
917 vec![Span::styled(" ".repeat(indent), Style::default().bg(bg))];
918 indented.extend(spans);
919 lines.push(Line::from(indented));
920 }
921 }
922
923 let total_lines = lines.len();
924 let viewport = parts[2].height as usize;
925 self.scroll.set_max(total_lines.saturating_sub(viewport));
926
927 f.render_widget(
928 Paragraph::new(lines)
929 .scroll((self.scroll.offset as u16, 0))
930 .style(Style::default().bg(bg)),
931 parts[2],
932 );
933 }
934 self.list.render_autocomplete(f, rect, theme);
935 return;
936 }
937
938 let has_context = selected_state == ExpandState::Context;
940
941 let (list_area, divider_area, content_area) = if has_context {
942 let max_list = inner.height / 2;
943 let list_height = (count as u16).min(max_list).max(1);
944 let areas = Layout::default()
945 .direction(Direction::Vertical)
946 .constraints([
947 Constraint::Length(list_height),
948 Constraint::Length(1),
949 Constraint::Min(0),
950 ])
951 .split(inner);
952 (areas[0], Some(areas[1]), Some(areas[2]))
953 } else {
954 (inner, None, None)
955 };
956
957 if self.list.query().trim().is_empty() {
960 let dim = Style::default().fg(theme.gray.to_ratatui());
964 let key = Style::default().fg(theme.yellow.to_ratatui());
965 let lines = vec![
966 ratatui::text::Line::from(Span::styled("type to search the vault", dim)),
967 ratatui::text::Line::default(),
968 ratatui::text::Line::from(vec![
969 Span::styled(" #tag ", key),
970 Span::styled("label", dim),
971 ]),
972 ratatui::text::Line::from(vec![
973 Span::styled(" < > ", key),
974 Span::styled("backlinks · links", dim),
975 ]),
976 ratatui::text::Line::from(vec![
977 Span::styled(" \"phrase\" ", key),
978 Span::styled("exact match", dim),
979 ]),
980 ratatui::text::Line::from(vec![
981 Span::styled(" =date ", key),
982 Span::styled("modified", dim),
983 ]),
984 ratatui::text::Line::from(vec![
985 Span::styled(" ?name ", key),
986 Span::styled("saved search", dim),
987 ]),
988 ];
989 f.render_widget(ratatui::widgets::Paragraph::new(lines), list_area);
990 } else {
991 self.list.render(f, list_area, theme, focused);
992 }
993 self.list.set_list_rect(list_area);
994
995 if let Some(div) = divider_area {
997 f.render_widget(
998 Paragraph::new("\u{2500}".repeat(div.width as usize))
999 .style(Style::default().fg(gray).bg(bg)),
1000 div,
1001 );
1002 }
1003
1004 if let Some(area) = content_area
1007 && selected_state == ExpandState::Context
1008 && let Some(entry) = self.list.selected_row()
1009 {
1010 let entry = entry.clone();
1011 let text = entry.full_text.as_deref().unwrap_or(&entry.context);
1012 let indent = 2usize;
1013 let wrap_width = area.width.saturating_sub(indent as u16 + 1) as usize;
1014 let anchored = self.scroll.anchored;
1018 let needles = self.cached_needles();
1019
1020 let mut lines = Vec::new();
1021
1022 let mut link_line: Option<usize> = None;
1026
1027 for line in text.lines() {
1028 let wrapped = wrap_line(line, wrap_width);
1029 for wline in wrapped {
1030 if anchored
1031 && link_line.is_none()
1032 && needles
1033 .iter()
1034 .any(|n| !n.is_empty() && find_case_insensitive(&wline, n).is_some())
1035 {
1036 link_line = Some(lines.len());
1037 }
1038 let spans = highlight_needles(&wline, needles, gray, bg, theme);
1039 let mut indented =
1040 vec![Span::styled(" ".repeat(indent), Style::default().bg(bg))];
1041 indented.extend(spans);
1042 lines.push(Line::from(indented));
1043 }
1044 }
1045
1046 let viewport = area.height as usize;
1052 let total = lines.len();
1053 self.scroll.set_max(total.saturating_sub(viewport));
1054 let link_pos = link_line.unwrap_or(0);
1055 let lines_after_link = total.saturating_sub(link_pos);
1056 self.scroll.anchor_to(if lines_after_link <= viewport {
1057 self.scroll.max
1060 } else {
1061 link_pos.saturating_sub(2)
1064 });
1065
1066 f.render_widget(
1067 Paragraph::new(lines)
1068 .scroll((self.scroll.offset as u16, 0))
1069 .style(Style::default().bg(bg)),
1070 area,
1071 );
1072 self.list.set_content_rect(area);
1076 }
1077
1078 self.list.render_autocomplete(f, rect, theme);
1079 }
1080}
1081
1082async fn load_query(vault: &NoteVault, query: &str) -> Vec<BacklinkEntry> {
1089 let needles = query_needles(query);
1090 let results = vault.search_notes(query).await.unwrap_or_default();
1091 let mut entries = Vec::with_capacity(results.len());
1092 for (entry_data, content_data) in results {
1093 let text = vault
1094 .get_note_text(&entry_data.path)
1095 .await
1096 .unwrap_or_default();
1097 let context = extract_context_multi(&text, &needles);
1098 let (_p, filename) = entry_data.path.get_parent_path();
1099 entries.push(BacklinkEntry {
1100 path: entry_data.path,
1101 title: content_data.title,
1102 filename,
1103 context,
1104 full_text: Some(text),
1105 });
1106 }
1107 entries
1108}
1109
1110fn split_paragraphs(text: &str) -> Vec<String> {
1113 let mut paragraphs = Vec::new();
1114 let mut current: Vec<&str> = Vec::new();
1115
1116 for line in text.lines() {
1117 if line.trim().is_empty() {
1118 if !current.is_empty() {
1119 paragraphs.push(current.join("\n"));
1120 current.clear();
1121 }
1122 } else {
1123 current.push(line);
1124 }
1125 }
1126 if !current.is_empty() {
1127 paragraphs.push(current.join("\n"));
1128 }
1129
1130 paragraphs
1131}
1132
1133fn wrap_line(line: &str, max_width: usize) -> Vec<String> {
1141 if max_width == 0 || line.chars().count() <= max_width {
1142 return vec![line.to_string()];
1143 }
1144
1145 let mut result = Vec::new();
1146 let mut remaining = line;
1147
1148 while remaining.chars().count() > max_width {
1149 let byte_limit = remaining
1151 .char_indices()
1152 .nth(max_width)
1153 .map(|(i, _)| i)
1154 .unwrap_or(remaining.len());
1155
1156 let break_at = remaining[..byte_limit]
1158 .rfind(' ')
1159 .map(|i| i + 1) .unwrap_or(byte_limit); result.push(remaining[..break_at].trim_end().to_string());
1162 remaining = &remaining[break_at..];
1163 }
1164 if !remaining.is_empty() {
1165 result.push(remaining.to_string());
1166 }
1167 result
1168}
1169
1170fn find_case_insensitive(haystack: &str, needle: &str) -> Option<(usize, usize)> {
1175 let needle_chars: Vec<char> = needle.chars().collect();
1176 if needle_chars.is_empty() {
1177 return None;
1178 }
1179 let hay_indices: Vec<(usize, char)> = haystack.char_indices().collect();
1180 'outer: for start_idx in 0..hay_indices.len() {
1181 if start_idx + needle_chars.len() > hay_indices.len() {
1182 break;
1183 }
1184 for (j, &nc) in needle_chars.iter().enumerate() {
1185 let hc = hay_indices[start_idx + j].1;
1186 let mut h_lower = hc.to_lowercase();
1188 let mut n_lower = nc.to_lowercase();
1189 if h_lower.next() != n_lower.next() {
1190 continue 'outer;
1191 }
1192 }
1193 let byte_start = hay_indices[start_idx].0;
1195 let byte_end = if start_idx + needle_chars.len() < hay_indices.len() {
1196 hay_indices[start_idx + needle_chars.len()].0
1197 } else {
1198 haystack.len()
1199 };
1200 return Some((byte_start, byte_end));
1201 }
1202 None
1203}
1204
1205fn extract_context_multi(text: &str, needles: &[String]) -> String {
1208 let lowered: Vec<String> = needles.iter().map(|n| n.to_lowercase()).collect();
1209 for para in &split_paragraphs(text) {
1210 let lower = para.to_lowercase();
1211 if lowered.iter().any(|n| !n.is_empty() && lower.contains(n)) {
1212 return para.clone();
1213 }
1214 }
1215 text.lines()
1216 .find(|l| !l.trim().is_empty())
1217 .unwrap_or("")
1218 .to_string()
1219}
1220
1221fn highlight_needles(
1223 line: &str,
1224 needles: &[String],
1225 gray: ratatui::style::Color,
1226 bg: ratatui::style::Color,
1227 theme: &Theme,
1228) -> Vec<Span<'static>> {
1229 let normal = Style::default().fg(gray).bg(bg);
1230 let bold = Style::default()
1231 .fg(theme.accent.to_ratatui())
1232 .bg(bg)
1233 .add_modifier(Modifier::BOLD);
1234 let mut best: Option<(usize, usize)> = None;
1235 for needle in needles {
1236 if needle.is_empty() {
1237 continue;
1238 }
1239 if let Some((s, e)) = find_case_insensitive(line, needle)
1240 && (best.is_none() || s < best.unwrap().0)
1241 {
1242 best = Some((s, e));
1243 }
1244 }
1245 let Some((start, end)) = best else {
1246 return vec![Span::styled(line.to_string(), normal)];
1247 };
1248 let mut spans = Vec::new();
1249 if start > 0 {
1250 spans.push(Span::styled(line[..start].to_string(), normal));
1251 }
1252 spans.push(Span::styled(line[start..end].to_string(), bold));
1253 if end < line.len() {
1254 spans.push(Span::styled(line[end..].to_string(), normal));
1255 }
1256 spans
1257}
1258
1259fn query_needles(query: &str) -> Vec<String> {
1262 let st = kimun_core::SearchTerms::from_query_string(query);
1263 let mut needles = st.terms.clone();
1264 needles.extend(st.links.clone());
1265 needles.extend(st.forward_links.clone());
1266 needles
1267}
1268
1269#[cfg(test)]
1274mod tests {
1275 use super::*;
1276
1277 #[test]
1278 fn wrap_line_fits_within_width() {
1279 let result = wrap_line("short", 20);
1280 assert_eq!(result, vec!["short"]);
1281 }
1282
1283 #[test]
1284 fn wrap_line_breaks_at_word_boundary() {
1285 let result = wrap_line("hello world foo bar", 12);
1286 assert_eq!(result, vec!["hello world", "foo bar"]);
1287 }
1288
1289 #[test]
1290 fn wrap_line_hard_breaks_long_word() {
1291 let result = wrap_line("abcdefghij", 5);
1292 assert_eq!(result, vec!["abcde", "fghij"]);
1293 }
1294
1295 #[test]
1296 fn wrap_line_handles_multibyte_chars() {
1297 let result = wrap_line("日本語テスト", 3);
1299 assert_eq!(result, vec!["日本語", "テスト"]);
1300 }
1301
1302 #[test]
1303 fn wrap_line_empty_string() {
1304 let result = wrap_line("", 10);
1305 assert_eq!(result, vec![""]);
1306 }
1307
1308 #[test]
1309 fn extract_context_matches_any_needle() {
1310 let text = "# Title\n\nIntro line.\n\nA paragraph mentioning widget here.\n";
1311 let result = extract_context_multi(text, &["widget".to_string()]);
1312 assert!(result.contains("widget"));
1313 }
1314
1315 #[test]
1316 fn highlight_needles_highlights_first_match() {
1317 let spans = highlight_needles(
1318 "see widget and gadget",
1319 &["gadget".to_string()],
1320 ratatui::style::Color::Gray,
1321 ratatui::style::Color::Black,
1322 &crate::settings::themes::Theme::default(),
1323 );
1324 assert!(
1325 spans
1326 .iter()
1327 .any(|s| s.content.contains("gadget")
1328 && s.style.add_modifier.contains(Modifier::BOLD))
1329 );
1330 }
1331
1332 #[test]
1333 fn default_query_recognized_in_all_spellings() {
1334 assert!(is_default_query(DEFAULT_QUERY));
1338 assert!(is_default_query("<"));
1339 assert!(is_default_query("lk:"));
1340 assert!(is_default_query("< or:title"));
1341 assert!(is_default_query("<{note} -or:file"));
1342 assert!(!is_default_query("<projects"));
1343 assert!(!is_default_query(">"));
1344 assert!(!is_default_query(""));
1345 }
1346
1347 #[test]
1348 fn query_needles_extracts_terms_and_links() {
1349 let n = query_needles("widget <spec");
1350 assert!(n.iter().any(|x| x == "widget"));
1351 assert!(n.iter().any(|x| x == "spec"));
1352 }
1353
1354 #[test]
1355 fn query_needles_extracts_forward_links() {
1356 let n = query_needles(">spec");
1359 assert!(n.iter().any(|x| x == "spec"));
1360 }
1361
1362 #[tokio::test]
1363 async fn query_panel_load_query_lists_matches() {
1364 let vault = crate::test_support::temp_vault("qp").await;
1365 vault.validate_and_init().await.unwrap();
1366 vault
1367 .create_note(&VaultPath::note_path_from("/a.md"), "alpha #todo")
1368 .await
1369 .unwrap();
1370 vault
1371 .create_note(&VaultPath::note_path_from("/b.md"), "beta")
1372 .await
1373 .unwrap();
1374 let entries = load_query(&vault, "#todo").await;
1375 assert_eq!(entries.len(), 1);
1376 assert!(entries[0].filename.contains("a"));
1377 }
1378
1379 fn make_panel(vault: Arc<NoteVault>) -> QueryPanel {
1380 let kb = crate::settings::AppSettings::default().key_bindings.clone();
1381 QueryPanel::new(vault, kb, Icons::new(false))
1382 }
1383
1384 #[tokio::test(flavor = "multi_thread")]
1387 async fn ctrl_enter_opens_selected_result() {
1388 let vault = crate::test_support::temp_vault("qp-ctrl-enter").await;
1389 vault.validate_and_init().await.unwrap();
1390 vault
1391 .save_note(&VaultPath::note_path_from("target"), "the note body")
1392 .await
1393 .unwrap();
1394 let mut panel = make_panel(vault);
1395 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1396
1397 panel.apply_query("target".to_string(), None, tx.clone());
1399 for _ in 0..50 {
1400 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1401 panel.list.poll();
1402 }
1403 assert!(
1404 panel.selected_path().is_some(),
1405 "result loaded and selected"
1406 );
1407
1408 panel.handle_key(
1409 &KeyEvent::new(
1410 KeyCode::Enter,
1411 ratatui::crossterm::event::KeyModifiers::CONTROL,
1412 ),
1413 &tx,
1414 );
1415
1416 let mut opened = None;
1417 while let Ok(ev) = rx.try_recv() {
1418 if let AppEvent::OpenPath { path, .. } = ev {
1419 opened = Some(path);
1420 }
1421 }
1422 assert_eq!(opened, Some(VaultPath::note_path_from("target")));
1423 }
1424
1425 #[tokio::test]
1428 async fn cached_needles_track_query_and_note() {
1429 let vault = crate::test_support::temp_vault("qp_needles").await;
1430 vault.validate_and_init().await.unwrap();
1431 let mut panel = make_panel(vault);
1432 panel.list.set_query(DEFAULT_QUERY);
1433
1434 *panel.current_note.lock().unwrap() = VaultPath::note_path_from("spec");
1436 assert!(panel.cached_needles().iter().any(|n| n == "spec"));
1437
1438 *panel.current_note.lock().unwrap() = VaultPath::note_path_from("other");
1440 assert!(panel.cached_needles().iter().any(|n| n == "other"));
1441
1442 panel.list.set_query("widget".to_string());
1444 let needles = panel.cached_needles();
1445 assert!(needles.iter().any(|n| n == "widget"));
1446 assert!(!needles.iter().any(|n| n == "other"));
1447 }
1448
1449 async fn settle(panel: &mut QueryPanel) {
1455 for _ in 0..100 {
1456 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1457 panel.list.poll();
1458 if !panel.list.is_loading() {
1459 break;
1460 }
1461 }
1462 }
1463
1464 #[tokio::test(flavor = "multi_thread")]
1465 async fn apply_sort_rewrites_query_order_directive() {
1466 let vault = crate::test_support::temp_vault("qp-sort").await;
1467 vault.validate_and_init().await.unwrap();
1468 let mut panel = make_panel(vault);
1469 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1470 panel.set_active_query("widget".to_string());
1471
1472 panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1473 assert_eq!(panel.active_query(), "widget or:title");
1474
1475 panel.apply_sort(SortField::Name, SortOrder::Descending, &tx);
1476 assert_eq!(panel.active_query(), "widget -or:file");
1477 }
1478
1479 #[tokio::test(flavor = "multi_thread")]
1483 async fn directiveless_query_is_name_ascending() {
1484 let vault = crate::test_support::temp_vault("qp-defaultorder").await;
1485 vault.validate_and_init().await.unwrap();
1486 for name in ["/charlie.md", "/alpha.md", "/bravo.md"] {
1488 vault
1489 .create_note(&VaultPath::note_path_from(name), "widget")
1490 .await
1491 .unwrap();
1492 }
1493 let mut panel = make_panel(vault);
1494 panel.set_active_query("widget".to_string()); settle(&mut panel).await;
1496
1497 let names: Vec<String> = panel
1498 .list
1499 .visible_rows()
1500 .iter()
1501 .map(|e| e.filename.clone())
1502 .collect();
1503 let mut sorted = names.clone();
1504 sorted.sort();
1505 assert_eq!(names, sorted, "directive-less query must be name-ascending");
1506 }
1507
1508 #[tokio::test(flavor = "multi_thread")]
1511 async fn accepting_saved_search_pins_breadcrumb() {
1512 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1513 let vault = crate::test_support::temp_vault("qp-ss-accept").await;
1514 vault.validate_and_init().await.unwrap();
1515 vault.save_search("todo-week", "#todo").await.unwrap();
1516 let mut panel = make_panel(vault);
1517 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1518
1519 panel.set_active_query(String::new());
1523 for ch in ['?', 't', 'o'] {
1524 panel.handle_key(&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
1525 for _ in 0..30 {
1526 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1527 panel.list.poll();
1528 }
1529 }
1530 panel.handle_key(&KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &tx);
1531
1532 assert_eq!(panel.active_query(), "#todo");
1533 assert_eq!(
1534 panel.saved_search_breadcrumb().as_deref(),
1535 Some("todo-week")
1536 );
1537 }
1538
1539 #[tokio::test(flavor = "multi_thread")]
1542 async fn editing_expanded_query_keeps_breadcrumb_marked_edited() {
1543 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1544 let vault = crate::test_support::temp_vault("qp-ss-edit").await;
1545 vault.validate_and_init().await.unwrap();
1546 let mut panel = make_panel(vault);
1547 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1548 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1549 assert_eq!(panel.saved_search_breadcrumb().as_deref(), Some("todo"));
1550
1551 panel.handle_key(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
1553 assert_eq!(panel.active_query(), "#todox");
1554 assert_eq!(
1555 panel.saved_search_breadcrumb().as_deref(),
1556 Some("todo • edited")
1557 );
1558 }
1559
1560 #[tokio::test(flavor = "multi_thread")]
1563 async fn emptying_field_clears_breadcrumb() {
1564 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1565 let vault = crate::test_support::temp_vault("qp-ss-empty").await;
1566 vault.validate_and_init().await.unwrap();
1567 let mut panel = make_panel(vault);
1568 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1569 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1570
1571 for _ in 0.."#todo".len() {
1573 panel.handle_key(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), &tx);
1574 }
1575 assert_eq!(panel.active_query(), "");
1576 assert_eq!(panel.saved_search_breadcrumb(), None);
1577 }
1578
1579 #[tokio::test(flavor = "multi_thread")]
1580 async fn apply_query_pins_breadcrumb() {
1581 let vault = crate::test_support::temp_vault("qp-name").await;
1582 vault.validate_and_init().await.unwrap();
1583 let mut panel = make_panel(vault);
1584 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1585 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1586 assert_eq!(panel.saved_search_breadcrumb().as_deref(), Some("todo"));
1587 }
1588
1589 #[tokio::test(flavor = "multi_thread")]
1593 async fn apply_sort_marks_saved_search_breadcrumb_edited() {
1594 let vault = crate::test_support::temp_vault("qp-sort-name").await;
1595 vault.validate_and_init().await.unwrap();
1596 let mut panel = make_panel(vault);
1597 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1598 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1599
1600 panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1601 assert_eq!(panel.active_query(), "#todo or:title");
1602 assert_eq!(
1603 panel.saved_search_breadcrumb().as_deref(),
1604 Some("todo • edited"),
1605 "sorting diverges from the stored query, so the breadcrumb is edited"
1606 );
1607 }
1608
1609 #[tokio::test(flavor = "multi_thread")]
1612 async fn repin_after_save_adopts_saved_identity() {
1613 let vault = crate::test_support::temp_vault("qp-repin").await;
1614 vault.validate_and_init().await.unwrap();
1615 let mut panel = make_panel(vault);
1616 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1617 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1618
1619 panel.set_active_query("#todo and #urgent".to_string());
1620 assert_eq!(
1621 panel.saved_search_breadcrumb().as_deref(),
1622 Some("todo • edited")
1623 );
1624
1625 panel.repin_saved_search("urgent-todos".to_string(), "#todo and #urgent");
1626 assert_eq!(
1627 panel.saved_search_breadcrumb().as_deref(),
1628 Some("urgent-todos"),
1629 "after a save the saved identity is the provenance — no edited marker"
1630 );
1631 }
1632
1633 #[tokio::test(flavor = "multi_thread")]
1637 async fn apply_sort_updates_visible_input_bar() {
1638 let vault = crate::test_support::temp_vault("qp-bar").await;
1639 vault.validate_and_init().await.unwrap();
1640 let mut panel = make_panel(vault);
1641 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1642 panel.set_active_query("widget".to_string());
1643 assert_eq!(
1644 panel.list.input_value(),
1645 "widget",
1646 "set_active_query syncs the bar"
1647 );
1648
1649 panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1650 assert_eq!(panel.active_query(), "widget or:title");
1651 assert_eq!(
1652 panel.list.input_value(),
1653 "widget or:title",
1654 "the input bar must reflect the rewritten query"
1655 );
1656 }
1657
1658 #[tokio::test(flavor = "multi_thread")]
1659 async fn current_order_reads_query_directive() {
1660 let vault = crate::test_support::temp_vault("qp-order").await;
1661 vault.validate_and_init().await.unwrap();
1662 let mut panel = make_panel(vault);
1663 panel.set_active_query("widget -or:title".to_string());
1664 assert_eq!(
1665 panel.current_order(),
1666 (SortField::Title, SortOrder::Descending)
1667 );
1668 panel.set_active_query("widget".to_string());
1669 assert_eq!(
1670 panel.current_order(),
1671 (SortField::Name, SortOrder::Ascending)
1672 );
1673 }
1674
1675 #[tokio::test(flavor = "multi_thread")]
1679 async fn context_preview_wheel_scrolls_preview_not_list() {
1680 use ratatui::Terminal;
1681 use ratatui::backend::TestBackend;
1682 use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
1683
1684 let vault = crate::test_support::temp_vault("qp-preview-wheel").await;
1685 vault.validate_and_init().await.unwrap();
1686 let mut body = String::from("#todo first line\n");
1689 for i in 0..40 {
1690 body.push_str(&format!("line {}\n", i));
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 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1698 panel.set_active_query("#todo".to_string());
1699 settle(&mut panel).await;
1700 assert!(panel.list.selected_row().is_some());
1701
1702 panel.toggle_expand();
1705 assert!(panel.expand == ExpandState::Context);
1706 let theme = crate::settings::themes::Theme::default();
1707 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1708 terminal
1709 .draw(|f| panel.render(f, f.area(), &theme, true))
1710 .unwrap();
1711 let preview = panel.list.content_rect();
1712 assert!(!preview.is_empty(), "preview rect recorded");
1713 assert_eq!(panel.scroll.offset, 0, "auto-anchor at the top needle");
1714 assert!(panel.scroll.max > 0, "content overflows viewport");
1715
1716 let wheel = move |y: u16| MouseEvent {
1717 kind: MouseEventKind::ScrollDown,
1718 column: preview.x + 1,
1719 row: y,
1720 modifiers: KeyModifiers::NONE,
1721 };
1722
1723 let over_list = wheel(preview.y.saturating_sub(3));
1725 panel.handle_mouse(&over_list, &tx);
1726 assert_eq!(panel.scroll.offset, 0, "list wheel must not move preview");
1727 assert!(panel.scroll.anchored, "anchor stays armed");
1728
1729 let over_preview = wheel(preview.y + 1);
1731 panel.handle_mouse(&over_preview, &tx);
1732 assert_eq!(panel.scroll.offset, 1, "preview wheel scrolls content");
1733 assert!(!panel.scroll.anchored, "user owns the scroll now");
1734
1735 terminal
1737 .draw(|f| panel.render(f, f.area(), &theme, true))
1738 .unwrap();
1739 assert_eq!(panel.scroll.offset, 1);
1740
1741 let up = MouseEvent {
1743 kind: MouseEventKind::ScrollUp,
1744 column: preview.x + 1,
1745 row: preview.y + 1,
1746 modifiers: KeyModifiers::NONE,
1747 };
1748 panel.handle_mouse(&up, &tx);
1749 panel.handle_mouse(&up, &tx);
1750 assert_eq!(panel.scroll.offset, 0);
1751 }
1752
1753 #[tokio::test(flavor = "multi_thread")]
1757 async fn noop_preview_wheel_keeps_autoscroll_armed() {
1758 use ratatui::Terminal;
1759 use ratatui::backend::TestBackend;
1760 use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
1761
1762 let vault = crate::test_support::temp_vault("qp-noop-wheel").await;
1763 vault.validate_and_init().await.unwrap();
1764 vault
1766 .create_note(&VaultPath::note_path_from("/short.md"), "#todo only line")
1767 .await
1768 .unwrap();
1769 let mut panel = make_panel(vault);
1770 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1771 panel.set_active_query("#todo".to_string());
1772 settle(&mut panel).await;
1773 panel.toggle_expand();
1774 let theme = crate::settings::themes::Theme::default();
1775 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1776 terminal
1777 .draw(|f| panel.render(f, f.area(), &theme, true))
1778 .unwrap();
1779 assert_eq!(panel.scroll.max, 0, "content fits the viewport");
1780
1781 let preview = panel.list.content_rect();
1782 let down = MouseEvent {
1783 kind: MouseEventKind::ScrollDown,
1784 column: preview.x + 1,
1785 row: preview.y + 1,
1786 modifiers: KeyModifiers::NONE,
1787 };
1788 panel.handle_mouse(&down, &tx);
1789 assert!(
1790 panel.scroll.anchored,
1791 "no-op wheel tick must not disarm the auto-anchor"
1792 );
1793 }
1794
1795 #[tokio::test(flavor = "multi_thread")]
1799 async fn query_keystroke_rearms_preview_autoscroll() {
1800 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1801
1802 let vault = crate::test_support::temp_vault("qp-rearm").await;
1803 vault.validate_and_init().await.unwrap();
1804 let mut body = String::from("#todo first line\n");
1805 for i in 0..40 {
1806 body.push_str(&format!("line {}\n", i));
1807 }
1808 vault
1809 .create_note(&VaultPath::note_path_from("/long.md"), &body)
1810 .await
1811 .unwrap();
1812 let mut panel = make_panel(vault);
1813 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1814 panel.set_active_query("#todo".to_string());
1815 settle(&mut panel).await;
1816 panel.toggle_expand();
1817 panel.scroll.anchored = false;
1819
1820 panel.handle_key(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
1821 assert_eq!(panel.active_query(), "#todox");
1822 assert!(
1823 panel.scroll.anchored,
1824 "a query edit must re-arm the preview auto-anchor"
1825 );
1826 }
1827
1828 #[tokio::test(flavor = "multi_thread")]
1832 async fn preview_wheel_closes_autocomplete_popup() {
1833 use ratatui::Terminal;
1834 use ratatui::backend::TestBackend;
1835 use ratatui::crossterm::event::{
1836 KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind,
1837 };
1838
1839 let vault = crate::test_support::temp_vault("qp-wheel-popup").await;
1840 vault.validate_and_init().await.unwrap();
1841 let mut body = String::from("#todo first line\n");
1842 for i in 0..40 {
1843 body.push_str(&format!("line {}\n", i));
1844 }
1845 vault
1846 .create_note(&VaultPath::note_path_from("/long.md"), &body)
1847 .await
1848 .unwrap();
1849 let mut panel = make_panel(vault);
1850 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1851 panel.set_active_query("#todo".to_string());
1852 settle(&mut panel).await;
1853 panel.toggle_expand();
1854 let theme = crate::settings::themes::Theme::default();
1855 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1856 terminal
1857 .draw(|f| panel.render(f, f.area(), &theme, true))
1858 .unwrap();
1859 let preview = panel.list.content_rect();
1860 assert!(!preview.is_empty());
1861
1862 for ch in [' ', '#'] {
1865 panel.handle_key(&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
1866 for _ in 0..30 {
1867 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1868 panel.list.poll();
1869 }
1870 }
1871 assert!(panel.list.autocomplete_is_open(), "popup open after `#`");
1872
1873 let wheel = MouseEvent {
1874 kind: MouseEventKind::ScrollDown,
1875 column: preview.x + 1,
1876 row: preview.y + 1,
1877 modifiers: KeyModifiers::NONE,
1878 };
1879 panel.handle_mouse(&wheel, &tx);
1880 assert!(
1881 !panel.list.autocomplete_is_open(),
1882 "wheel over the preview must dismiss the popup"
1883 );
1884 }
1885
1886 #[tokio::test(flavor = "multi_thread")]
1890 async fn full_expand_header_click_collapses() {
1891 use ratatui::Terminal;
1892 use ratatui::backend::TestBackend;
1893 use ratatui::crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
1894
1895 let vault = crate::test_support::temp_vault("qp-header-click").await;
1896 vault.validate_and_init().await.unwrap();
1897 vault
1898 .create_note(&VaultPath::note_path_from("/long.md"), "#todo body")
1899 .await
1900 .unwrap();
1901 let mut panel = make_panel(vault);
1902 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1903 panel.set_active_query("#todo".to_string());
1904 settle(&mut panel).await;
1905 panel.toggle_expand();
1907 panel.toggle_expand();
1908 assert!(panel.is_full_expanded());
1909 let theme = crate::settings::themes::Theme::default();
1910 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1911 terminal
1912 .draw(|f| panel.render(f, f.area(), &theme, true))
1913 .unwrap();
1914 let header = panel.full_header_rect;
1915 assert!(!header.is_empty(), "header rect recorded in full mode");
1916
1917 let click = |x: u16, y: u16| MouseEvent {
1918 kind: MouseEventKind::Down(MouseButton::Left),
1919 column: x,
1920 row: y,
1921 modifiers: KeyModifiers::NONE,
1922 };
1923
1924 panel.handle_mouse(&click(header.x + 1, header.y + 3), &tx);
1926 assert!(panel.is_full_expanded(), "content click must not collapse");
1927
1928 panel.handle_mouse(&click(header.x + 1, header.y), &tx);
1930 assert!(!panel.is_full_expanded());
1931 assert!(panel.expand == ExpandState::Collapsed);
1932 }
1933
1934 #[tokio::test(flavor = "multi_thread")]
1939 async fn toggling_expand_clears_stale_content_regions() {
1940 use ratatui::Terminal;
1941 use ratatui::backend::TestBackend;
1942
1943 let vault = crate::test_support::temp_vault("qp-stale-regions").await;
1944 vault.validate_and_init().await.unwrap();
1945 vault
1946 .create_note(&VaultPath::note_path_from("/long.md"), "#todo body")
1947 .await
1948 .unwrap();
1949 let mut panel = make_panel(vault);
1950 panel.set_active_query("#todo".to_string());
1951 settle(&mut panel).await;
1952 let theme = crate::settings::themes::Theme::default();
1953 let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1954
1955 panel.toggle_expand();
1957 panel.toggle_expand();
1958 terminal
1959 .draw(|f| panel.render(f, f.area(), &theme, true))
1960 .unwrap();
1961 assert!(!panel.list.content_rect().is_empty());
1962 assert!(!panel.full_header_rect.is_empty());
1963
1964 panel.toggle_expand();
1967 assert!(
1968 panel.list.content_rect().is_empty(),
1969 "stale content rect must not survive a state change"
1970 );
1971 assert!(
1972 panel.full_header_rect.is_empty(),
1973 "stale header rect must not survive a state change"
1974 );
1975 }
1976
1977 #[tokio::test(flavor = "multi_thread")]
1983 async fn static_query_survives_navigation() {
1984 let vault = crate::test_support::temp_vault("nav-static").await;
1985 vault.validate_and_init().await.unwrap();
1986 vault
1987 .create_note(&VaultPath::note_path_from("/a.md"), "alpha #todo")
1988 .await
1989 .unwrap();
1990 let mut panel = make_panel(vault);
1991 panel.set_active_query("#todo".to_string());
1992 settle(&mut panel).await;
1993 assert_eq!(panel.list.visible_rows().len(), 1);
1994
1995 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1996 panel.set_note(VaultPath::note_path_from("x.md"), tx);
1997
1998 assert_eq!(panel.active_query(), "#todo");
2001 assert!(!panel.list.is_loading());
2002 settle(&mut panel).await;
2003 assert_eq!(panel.list.visible_rows().len(), 1); }
2005
2006 #[tokio::test(flavor = "multi_thread")]
2009 async fn note_variable_query_reruns_on_navigation() {
2010 let vault = crate::test_support::temp_vault("nav-var").await;
2011 vault.validate_and_init().await.unwrap();
2012 vault
2015 .create_note(&VaultPath::note_path_from("/target.md"), "I am the target")
2016 .await
2017 .unwrap();
2018 vault
2019 .create_note(&VaultPath::note_path_from("/linker.md"), "see [[target]]")
2020 .await
2021 .unwrap();
2022 let mut panel = make_panel(vault);
2023 assert_eq!(panel.active_query(), "");
2026 panel.list.set_query(DEFAULT_QUERY);
2027
2028 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2029 panel.set_note(VaultPath::note_path_from("/target.md"), tx);
2030 settle(&mut panel).await;
2031
2032 assert!(
2034 panel
2035 .list
2036 .visible_rows()
2037 .iter()
2038 .any(|e| e.filename.contains("linker")),
2039 "expected linker as a backlink, got {:?}",
2040 panel
2041 .list
2042 .visible_rows()
2043 .iter()
2044 .map(|e| e.filename.clone())
2045 .collect::<Vec<_>>()
2046 );
2047 }
2048
2049 #[tokio::test(flavor = "multi_thread")]
2051 async fn note_variable_query_changes_with_note() {
2052 let vault = crate::test_support::temp_vault("nav-var2").await;
2053 vault.validate_and_init().await.unwrap();
2054 vault
2055 .create_note(&VaultPath::note_path_from("/a.md"), "I am a")
2056 .await
2057 .unwrap();
2058 vault
2059 .create_note(&VaultPath::note_path_from("/b.md"), "I am b")
2060 .await
2061 .unwrap();
2062 vault
2063 .create_note(&VaultPath::note_path_from("/links_a.md"), "see [[a]]")
2064 .await
2065 .unwrap();
2066 let mut panel = make_panel(vault);
2067 panel.list.set_query(DEFAULT_QUERY);
2068 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2069
2070 panel.set_note(VaultPath::note_path_from("/a.md"), tx.clone());
2071 settle(&mut panel).await;
2072 assert!(
2073 panel
2074 .list
2075 .visible_rows()
2076 .iter()
2077 .any(|e| e.filename.contains("links_a"))
2078 );
2079
2080 panel.set_note(VaultPath::note_path_from("/b.md"), tx);
2081 settle(&mut panel).await;
2082 assert!(
2083 !panel
2084 .list
2085 .visible_rows()
2086 .iter()
2087 .any(|e| e.filename.contains("links_a")),
2088 "b has no backlinks, expected empty"
2089 );
2090 }
2091}