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
163pub struct QueryPanel {
168 list: SearchList<BacklinkEntry>,
171 current_note: Arc<Mutex<VaultPath>>,
174 saved_search: SavedSearchBreadcrumb,
178 expand: ExpandState,
182 expand_path: Option<VaultPath>,
185 content_scroll: usize,
187 content_scroll_max: usize,
189 key_bindings: KeyBindings,
190 redraw_tx: Arc<Mutex<Option<AppTx>>>,
195 follow_link_combos: Vec<KeyCombo>,
197 order_cache: (SortField, SortOrder),
202 order_cache_query: String,
203 is_default_cache: bool,
207 needles_cache: Vec<String>,
213 needles_cache_key: (String, VaultPath),
214}
215
216impl QueryPanel {
217 pub fn new(vault: Arc<NoteVault>, key_bindings: KeyBindings) -> Self {
218 let icons = Icons::new(false);
219 let current_note = Arc::new(Mutex::new(VaultPath::empty()));
220 let redraw_tx: Arc<Mutex<Option<AppTx>>> = Arc::new(Mutex::new(None));
224 let redraw: Arc<dyn Fn() + Send + Sync> = {
225 let slot = redraw_tx.clone();
226 Arc::new(move || {
227 if let Some(tx) = slot.lock().unwrap().as_ref() {
228 let _ = tx.send(AppEvent::Redraw);
229 }
230 })
231 };
232 let source = BacklinkSource {
233 vault: vault.clone(),
234 current_note: current_note.clone(),
235 };
236 let combos = |action: &ActionShortcuts| -> Vec<KeyCombo> {
237 key_bindings
238 .to_hashmap()
239 .get(action)
240 .cloned()
241 .unwrap_or_default()
242 };
243 let follow_link_combos = combos(&ActionShortcuts::FollowLink);
244
245 let mut intercept = Vec::new();
246 intercept.extend(follow_link_combos.iter().cloned());
247
248 let list = SearchList::builder(source, redraw)
249 .initial_query(DEFAULT_QUERY)
250 .icons(icons.clone())
251 .autocomplete(
252 Arc::new(VaultSuggestions {
253 vault: vault.clone(),
254 }),
255 AutocompleteMode::SearchQuery,
256 )
257 .intercept(intercept)
258 .build();
259
260 Self {
261 list,
262 current_note,
263 saved_search: SavedSearchBreadcrumb::default(),
264 expand: ExpandState::Collapsed,
265 expand_path: None,
266 content_scroll: 0,
267 content_scroll_max: 0,
268 key_bindings,
269 redraw_tx,
270 follow_link_combos,
271 order_cache: (SortField::Name, SortOrder::Ascending),
273 order_cache_query: String::new(),
274 is_default_cache: true,
277 needles_cache: Vec::new(),
278 needles_cache_key: (String::new(), VaultPath::empty()),
281 }
282 }
283
284 pub fn active_query(&self) -> &str {
287 self.list.query()
288 }
289
290 pub fn set_active_query(&mut self, q: String) {
291 self.list.set_query(q);
292 self.reset_expand();
293 }
294
295 pub fn saved_search_breadcrumb(&self) -> Option<String> {
298 self.saved_search.label(self.list.query())
299 }
300
301 pub fn saved_search_name(&self) -> Option<&str> {
305 self.saved_search.name()
306 }
307
308 pub fn repin_saved_search(&mut self, name: String, query: &str) {
312 self.saved_search.set(Some(name), query);
313 }
314
315 fn query_is_blank(&self) -> bool {
320 let q = self.list.query();
321 q.trim().is_empty() || is_default_query(q)
322 }
323
324 pub fn apply_query(&mut self, query: String, name: Option<String>, tx: AppTx) {
328 self.ensure_redraw_tx(&tx);
329 self.set_active_query(query.clone());
330 self.saved_search.set(name, &query);
331 }
332
333 fn current_note(&self) -> VaultPath {
336 self.current_note.lock().unwrap().clone()
337 }
338
339 fn ensure_redraw_tx(&self, tx: &AppTx) {
342 let mut slot = self.redraw_tx.lock().unwrap();
343 if slot.is_none() {
344 *slot = Some(tx.clone());
345 }
346 }
347
348 fn cached_needles(&mut self) -> &[String] {
353 let note = self.current_note();
354 if self.needles_cache_key.0 != self.list.query() || self.needles_cache_key.1 != note {
355 self.needles_cache = query_needles(&resolve_query(self.list.query(), Some(¬e)));
356 self.needles_cache_key = (self.list.query().to_string(), note);
357 }
358 &self.needles_cache
359 }
360
361 fn is_full_expanded(&self) -> bool {
364 self.list.selected_row().is_some() && self.expand == ExpandState::Full
365 }
366
367 pub fn is_empty(&self) -> bool {
368 self.list.rows().is_empty()
369 }
370
371 pub fn selected_path(&self) -> Option<&VaultPath> {
372 self.list.selected_row().map(|e| &e.path)
373 }
374
375 fn reset_expand(&mut self) {
376 self.expand = ExpandState::Collapsed;
377 self.expand_path = None;
378 self.content_scroll = 0;
379 self.content_scroll_max = 0;
380 }
381
382 fn sync_expand_anchor(&mut self) {
387 let sel = self.list.selected_row().map(|e| e.path.clone());
388 if sel != self.expand_path {
389 if self.expand != ExpandState::Context || sel.is_none() {
390 self.expand = ExpandState::Collapsed;
391 }
392 self.expand_path = sel;
393 self.content_scroll = 0;
394 }
395 }
396
397 pub fn set_note(&mut self, note_path: VaultPath, tx: AppTx) {
402 self.ensure_redraw_tx(&tx);
403 *self.current_note.lock().unwrap() = note_path;
404 if query_has_variables(self.list.query()) {
405 self.list.reload();
406 self.reset_expand();
407 }
408 }
409
410 pub fn current_order(&self) -> (SortField, SortOrder) {
415 let st = kimun_core::SearchTerms::from_query_string(self.list.query());
416 match st.order_by.first() {
417 Some(OrderBy::Title { asc }) => (
418 SortField::Title,
419 if *asc {
420 SortOrder::Ascending
421 } else {
422 SortOrder::Descending
423 },
424 ),
425 Some(OrderBy::FileName { asc }) => (
426 SortField::Name,
427 if *asc {
428 SortOrder::Ascending
429 } else {
430 SortOrder::Descending
431 },
432 ),
433 None => (SortField::Name, SortOrder::Ascending),
434 }
435 }
436
437 pub fn apply_sort(&mut self, field: SortField, order: SortOrder, tx: &AppTx) {
440 self.ensure_redraw_tx(tx);
441 let order_field = match field {
442 SortField::Name => OrderField::FileName,
443 SortField::Title => OrderField::Title,
444 };
445 let asc = matches!(order, SortOrder::Ascending);
446 let rewritten = with_order_directive(self.list.query(), order_field, asc);
447 self.list.set_query(rewritten);
448 self.reset_expand();
452 }
453
454 pub fn handle_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
457 self.ensure_redraw_tx(tx);
458 self.sync_expand_anchor();
459
460 if self.is_full_expanded() && matches!(key.code, KeyCode::Up | KeyCode::Down) {
463 self.scroll_content(key);
464 return EventState::Consumed;
465 }
466 match self.list.handle_key(key) {
470 KeyReaction::Intercepted(c) if self.follow_link_combos.contains(&c) => {
471 if let Some(path) = self.selected_path().cloned() {
472 tx.send(AppEvent::OpenPath(path)).ok();
473 }
474 EventState::Consumed
475 }
476 KeyReaction::Consumed => {
477 let accepted = self.list.take_accepted_saved_search();
481 let blank = self.query_is_blank();
482 self.saved_search
483 .on_query_consumed(accepted, self.list.query(), blank);
484 self.sync_expand_anchor();
485 EventState::Consumed
486 }
487 KeyReaction::Submit => {
488 self.toggle_expand();
491 EventState::Consumed
492 }
493 KeyReaction::Cancel => EventState::NotConsumed,
495 KeyReaction::Unhandled => EventState::NotConsumed,
496 KeyReaction::Intercepted(_) => EventState::Consumed,
497 }
498 }
499
500 pub fn handle_mouse(
505 &mut self,
506 mouse: &ratatui::crossterm::event::MouseEvent,
507 tx: &AppTx,
508 ) -> EventState {
509 use ratatui::crossterm::event::MouseEventKind;
510 self.ensure_redraw_tx(tx);
511 self.sync_expand_anchor();
512 match mouse.kind {
513 MouseEventKind::ScrollUp if self.is_full_expanded() => {
515 self.content_scroll = self.content_scroll.saturating_sub(1);
516 EventState::Consumed
517 }
518 MouseEventKind::ScrollDown if self.is_full_expanded() => {
519 self.content_scroll += 1;
521 EventState::Consumed
522 }
523 _ if self.is_full_expanded() => EventState::Consumed,
528 _ => match self.list.handle_mouse(mouse) {
532 SearchMouse::Activated(_) => {
533 self.toggle_expand();
534 EventState::Consumed
535 }
536 SearchMouse::Selected(_) | SearchMouse::Scrolled => {
537 self.sync_expand_anchor();
538 EventState::Consumed
539 }
540 SearchMouse::None => EventState::NotConsumed,
541 },
542 }
543 }
544
545 fn scroll_content(&mut self, key: &KeyEvent) {
546 match key.code {
547 KeyCode::Up => {
548 self.content_scroll = self.content_scroll.saturating_sub(1);
549 }
550 KeyCode::Down => {
551 self.content_scroll += 1;
553 }
554 _ => {}
555 }
556 }
557
558 fn toggle_expand(&mut self) {
559 if self.list.selected_row().is_none() {
560 return;
561 }
562 self.expand_path = self.list.selected_row().map(|e| e.path.clone());
563 match self.expand {
564 ExpandState::Collapsed => {
565 self.expand = ExpandState::Context;
566 }
567 ExpandState::Context => {
568 self.content_scroll = 0;
569 self.expand = ExpandState::Full;
570 }
571 ExpandState::Full => {
572 self.content_scroll = 0;
573 self.expand = ExpandState::Collapsed;
574 }
575 }
576 }
577
578 pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
579 [
580 (ActionShortcuts::FocusSidebar, "\u{2190} editor"),
581 (ActionShortcuts::FollowLink, "open note"),
582 (ActionShortcuts::SaveCurrentQuery, "save query"),
583 (ActionShortcuts::OpenSavedSearches, "searches"),
584 (ActionShortcuts::OpenSortDialog, "sort"),
585 ]
586 .iter()
587 .filter_map(|(action, label)| {
588 self.key_bindings
589 .first_combo_for(action)
590 .map(|k| (k, label.to_string()))
591 })
592 .collect()
593 }
594
595 pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
598 self.list.poll();
599 self.sync_expand_anchor();
600 self.list.set_panel_rect(rect);
603
604 let border_style = theme.border_style(focused);
605 let fg_muted = theme.fg_muted.to_ratatui();
606 let bg = theme.bg_panel.to_ratatui();
607
608 let count = self.list.visible_rows().len();
609 if self.list.query() != self.order_cache_query {
612 self.order_cache = self.current_order();
613 self.is_default_cache = is_default_query(self.list.query());
614 self.order_cache_query = self.list.query().to_string();
615 }
616 let (sort_field, sort_order) = self.order_cache;
617 let sort_indicator = format!("{}{}", sort_field.label(), sort_order.label());
618 let title = if self.is_default_cache {
625 format!("Backlinks ({}) {}", count, sort_indicator)
626 } else {
627 format!("Query ({}) {}", count, sort_indicator)
628 };
629
630 let outer = Block::default()
631 .title(title)
632 .borders(Borders::ALL)
633 .border_style(border_style)
634 .style(theme.panel_style());
635 let outer_inner = outer.inner(rect);
636 f.render_widget(outer, rect);
637
638 let rows = Layout::default()
640 .direction(Direction::Vertical)
641 .constraints([Constraint::Length(3), Constraint::Min(0)])
642 .split(outer_inner);
643 let search_title = self.saved_search.border_title(self.list.query(), " Query");
646 let search_block = Block::default()
647 .title(search_title)
648 .borders(Borders::ALL)
649 .border_style(border_style)
650 .style(theme.panel_style());
651 let search_inner = search_block.inner(rows[0]);
652 f.render_widget(search_block, rows[0]);
653 self.list.render_query(f, search_inner, theme, focused);
654
655 let inner = rows[1];
656
657 if self.list.is_loading() {
658 f.render_widget(
659 Paragraph::new(" Loading...").style(Style::default().fg(fg_muted).bg(bg)),
660 inner,
661 );
662 self.list.render_autocomplete(f, rect, theme);
663 return;
664 }
665
666 if self.list.visible_rows().is_empty() {
667 f.render_widget(
668 Paragraph::new(" No results").style(Style::default().fg(fg_muted).bg(bg)),
669 inner,
670 );
671 self.list.render_autocomplete(f, rect, theme);
672 return;
673 }
674
675 let selected_state = self.expand;
676
677 if selected_state == ExpandState::Full {
679 if let Some(entry) = self.list.selected_row() {
680 let entry = entry.clone();
681 let text = entry.full_text.as_deref().unwrap_or(&entry.context);
682
683 let title_display = if entry.title.is_empty() {
685 &entry.filename
686 } else {
687 &entry.title
688 };
689
690 let parts = Layout::default()
691 .direction(Direction::Vertical)
692 .constraints([
693 Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
697 .split(inner);
698
699 f.render_widget(
701 Paragraph::new(Line::from(vec![
702 Span::styled(
703 format!("\u{25BC} {} ", title_display),
704 Style::default()
705 .fg(theme.fg_selected.to_ratatui())
706 .bg(bg)
707 .add_modifier(Modifier::BOLD),
708 ),
709 Span::styled(
710 format!(" {}", entry.filename),
711 Style::default().fg(fg_muted).bg(bg),
712 ),
713 ]))
714 .style(Style::default().bg(bg)),
715 parts[0],
716 );
717
718 f.render_widget(
720 Paragraph::new("\u{2500}".repeat(parts[1].width as usize))
721 .style(Style::default().fg(fg_muted).bg(bg)),
722 parts[1],
723 );
724
725 let indent = 2usize;
727 let wrap_width = parts[2].width.saturating_sub(indent as u16 + 1) as usize;
728 let needles = self.cached_needles();
729
730 let mut lines = Vec::new();
731 for line in text.lines() {
732 let wrapped = wrap_line(line, wrap_width);
733 for wline in wrapped {
734 let spans = highlight_needles(&wline, needles, fg_muted, bg, theme);
735 let mut indented =
736 vec![Span::styled(" ".repeat(indent), Style::default().bg(bg))];
737 indented.extend(spans);
738 lines.push(Line::from(indented));
739 }
740 }
741
742 let total_lines = lines.len();
743 let viewport = parts[2].height as usize;
744 self.content_scroll_max = total_lines.saturating_sub(viewport);
745 self.content_scroll = self.content_scroll.min(self.content_scroll_max);
746
747 f.render_widget(
748 Paragraph::new(lines)
749 .scroll((self.content_scroll as u16, 0))
750 .style(Style::default().bg(bg)),
751 parts[2],
752 );
753 }
754 self.list.render_autocomplete(f, rect, theme);
755 return;
756 }
757
758 let has_context = selected_state == ExpandState::Context;
760
761 let (list_area, divider_area, content_area) = if has_context {
762 let max_list = inner.height / 2;
763 let list_height = (count as u16).min(max_list).max(1);
764 let areas = Layout::default()
765 .direction(Direction::Vertical)
766 .constraints([
767 Constraint::Length(list_height),
768 Constraint::Length(1),
769 Constraint::Min(0),
770 ])
771 .split(inner);
772 (areas[0], Some(areas[1]), Some(areas[2]))
773 } else {
774 (inner, None, None)
775 };
776
777 self.list.render(f, list_area, theme, focused);
780 self.list.set_list_rect(list_area);
781
782 if let Some(div) = divider_area {
784 f.render_widget(
785 Paragraph::new("\u{2500}".repeat(div.width as usize))
786 .style(Style::default().fg(fg_muted).bg(bg)),
787 div,
788 );
789 }
790
791 if let Some(area) = content_area
794 && selected_state == ExpandState::Context
795 && let Some(entry) = self.list.selected_row()
796 {
797 let entry = entry.clone();
798 let text = entry.full_text.as_deref().unwrap_or(&entry.context);
799 let indent = 2usize;
800 let wrap_width = area.width.saturating_sub(indent as u16 + 1) as usize;
801 let needles = self.cached_needles();
802
803 let mut lines = Vec::new();
804
805 let mut link_line: Option<usize> = None;
807
808 for line in text.lines() {
809 let wrapped = wrap_line(line, wrap_width);
810 for wline in wrapped {
811 if link_line.is_none()
812 && needles
813 .iter()
814 .any(|n| !n.is_empty() && find_case_insensitive(&wline, n).is_some())
815 {
816 link_line = Some(lines.len());
817 }
818 let spans = highlight_needles(&wline, needles, fg_muted, bg, theme);
819 let mut indented =
820 vec![Span::styled(" ".repeat(indent), Style::default().bg(bg))];
821 indented.extend(spans);
822 lines.push(Line::from(indented));
823 }
824 }
825
826 let viewport = area.height as usize;
830 let total = lines.len();
831 let link_pos = link_line.unwrap_or(0);
832 let lines_after_link = total.saturating_sub(link_pos);
833 let scroll_to = if lines_after_link <= viewport {
834 total.saturating_sub(viewport)
836 } else {
837 link_pos.saturating_sub(2)
839 } as u16;
840
841 f.render_widget(
842 Paragraph::new(lines)
843 .scroll((scroll_to, 0))
844 .style(Style::default().bg(bg)),
845 area,
846 );
847 }
848
849 self.list.render_autocomplete(f, rect, theme);
850 }
851}
852
853async fn load_query(vault: &NoteVault, query: &str) -> Vec<BacklinkEntry> {
860 let needles = query_needles(query);
861 let results = vault.search_notes(query).await.unwrap_or_default();
862 let mut entries = Vec::with_capacity(results.len());
863 for (entry_data, content_data) in results {
864 let text = vault
865 .get_note_text(&entry_data.path)
866 .await
867 .unwrap_or_default();
868 let context = extract_context_multi(&text, &needles);
869 let (_p, filename) = entry_data.path.get_parent_path();
870 entries.push(BacklinkEntry {
871 path: entry_data.path,
872 title: content_data.title,
873 filename,
874 context,
875 full_text: Some(text),
876 });
877 }
878 entries
879}
880
881fn split_paragraphs(text: &str) -> Vec<String> {
884 let mut paragraphs = Vec::new();
885 let mut current: Vec<&str> = Vec::new();
886
887 for line in text.lines() {
888 if line.trim().is_empty() {
889 if !current.is_empty() {
890 paragraphs.push(current.join("\n"));
891 current.clear();
892 }
893 } else {
894 current.push(line);
895 }
896 }
897 if !current.is_empty() {
898 paragraphs.push(current.join("\n"));
899 }
900
901 paragraphs
902}
903
904fn wrap_line(line: &str, max_width: usize) -> Vec<String> {
912 if max_width == 0 || line.chars().count() <= max_width {
913 return vec![line.to_string()];
914 }
915
916 let mut result = Vec::new();
917 let mut remaining = line;
918
919 while remaining.chars().count() > max_width {
920 let byte_limit = remaining
922 .char_indices()
923 .nth(max_width)
924 .map(|(i, _)| i)
925 .unwrap_or(remaining.len());
926
927 let break_at = remaining[..byte_limit]
929 .rfind(' ')
930 .map(|i| i + 1) .unwrap_or(byte_limit); result.push(remaining[..break_at].trim_end().to_string());
933 remaining = &remaining[break_at..];
934 }
935 if !remaining.is_empty() {
936 result.push(remaining.to_string());
937 }
938 result
939}
940
941fn find_case_insensitive(haystack: &str, needle: &str) -> Option<(usize, usize)> {
946 let needle_chars: Vec<char> = needle.chars().collect();
947 if needle_chars.is_empty() {
948 return None;
949 }
950 let hay_indices: Vec<(usize, char)> = haystack.char_indices().collect();
951 'outer: for start_idx in 0..hay_indices.len() {
952 if start_idx + needle_chars.len() > hay_indices.len() {
953 break;
954 }
955 for (j, &nc) in needle_chars.iter().enumerate() {
956 let hc = hay_indices[start_idx + j].1;
957 let mut h_lower = hc.to_lowercase();
959 let mut n_lower = nc.to_lowercase();
960 if h_lower.next() != n_lower.next() {
961 continue 'outer;
962 }
963 }
964 let byte_start = hay_indices[start_idx].0;
966 let byte_end = if start_idx + needle_chars.len() < hay_indices.len() {
967 hay_indices[start_idx + needle_chars.len()].0
968 } else {
969 haystack.len()
970 };
971 return Some((byte_start, byte_end));
972 }
973 None
974}
975
976fn extract_context_multi(text: &str, needles: &[String]) -> String {
979 let lowered: Vec<String> = needles.iter().map(|n| n.to_lowercase()).collect();
980 for para in &split_paragraphs(text) {
981 let lower = para.to_lowercase();
982 if lowered.iter().any(|n| !n.is_empty() && lower.contains(n)) {
983 return para.clone();
984 }
985 }
986 text.lines()
987 .find(|l| !l.trim().is_empty())
988 .unwrap_or("")
989 .to_string()
990}
991
992fn highlight_needles(
994 line: &str,
995 needles: &[String],
996 fg_muted: ratatui::style::Color,
997 bg: ratatui::style::Color,
998 theme: &Theme,
999) -> Vec<Span<'static>> {
1000 let normal = Style::default().fg(fg_muted).bg(bg);
1001 let bold = Style::default()
1002 .fg(theme.accent.to_ratatui())
1003 .bg(bg)
1004 .add_modifier(Modifier::BOLD);
1005 let mut best: Option<(usize, usize)> = None;
1006 for needle in needles {
1007 if needle.is_empty() {
1008 continue;
1009 }
1010 if let Some((s, e)) = find_case_insensitive(line, needle)
1011 && (best.is_none() || s < best.unwrap().0)
1012 {
1013 best = Some((s, e));
1014 }
1015 }
1016 let Some((start, end)) = best else {
1017 return vec![Span::styled(line.to_string(), normal)];
1018 };
1019 let mut spans = Vec::new();
1020 if start > 0 {
1021 spans.push(Span::styled(line[..start].to_string(), normal));
1022 }
1023 spans.push(Span::styled(line[start..end].to_string(), bold));
1024 if end < line.len() {
1025 spans.push(Span::styled(line[end..].to_string(), normal));
1026 }
1027 spans
1028}
1029
1030fn query_needles(query: &str) -> Vec<String> {
1033 let st = kimun_core::SearchTerms::from_query_string(query);
1034 let mut needles = st.terms.clone();
1035 needles.extend(st.links.clone());
1036 needles.extend(st.forward_links.clone());
1037 needles
1038}
1039
1040#[cfg(test)]
1045mod tests {
1046 use super::*;
1047
1048 #[test]
1049 fn wrap_line_fits_within_width() {
1050 let result = wrap_line("short", 20);
1051 assert_eq!(result, vec!["short"]);
1052 }
1053
1054 #[test]
1055 fn wrap_line_breaks_at_word_boundary() {
1056 let result = wrap_line("hello world foo bar", 12);
1057 assert_eq!(result, vec!["hello world", "foo bar"]);
1058 }
1059
1060 #[test]
1061 fn wrap_line_hard_breaks_long_word() {
1062 let result = wrap_line("abcdefghij", 5);
1063 assert_eq!(result, vec!["abcde", "fghij"]);
1064 }
1065
1066 #[test]
1067 fn wrap_line_handles_multibyte_chars() {
1068 let result = wrap_line("日本語テスト", 3);
1070 assert_eq!(result, vec!["日本語", "テスト"]);
1071 }
1072
1073 #[test]
1074 fn wrap_line_empty_string() {
1075 let result = wrap_line("", 10);
1076 assert_eq!(result, vec![""]);
1077 }
1078
1079 #[test]
1080 fn extract_context_matches_any_needle() {
1081 let text = "# Title\n\nIntro line.\n\nA paragraph mentioning widget here.\n";
1082 let result = extract_context_multi(text, &["widget".to_string()]);
1083 assert!(result.contains("widget"));
1084 }
1085
1086 #[test]
1087 fn highlight_needles_highlights_first_match() {
1088 let spans = highlight_needles(
1089 "see widget and gadget",
1090 &["gadget".to_string()],
1091 ratatui::style::Color::Gray,
1092 ratatui::style::Color::Black,
1093 &crate::settings::themes::Theme::default(),
1094 );
1095 assert!(
1096 spans
1097 .iter()
1098 .any(|s| s.content.contains("gadget")
1099 && s.style.add_modifier.contains(Modifier::BOLD))
1100 );
1101 }
1102
1103 #[test]
1104 fn default_query_recognized_in_all_spellings() {
1105 assert!(is_default_query(DEFAULT_QUERY));
1109 assert!(is_default_query("<"));
1110 assert!(is_default_query("lk:"));
1111 assert!(is_default_query("< or:title"));
1112 assert!(is_default_query("<{note} -or:file"));
1113 assert!(!is_default_query("<projects"));
1114 assert!(!is_default_query(">"));
1115 assert!(!is_default_query(""));
1116 }
1117
1118 #[test]
1119 fn query_needles_extracts_terms_and_links() {
1120 let n = query_needles("widget <spec");
1121 assert!(n.iter().any(|x| x == "widget"));
1122 assert!(n.iter().any(|x| x == "spec"));
1123 }
1124
1125 #[test]
1126 fn query_needles_extracts_forward_links() {
1127 let n = query_needles(">spec");
1130 assert!(n.iter().any(|x| x == "spec"));
1131 }
1132
1133 #[tokio::test]
1134 async fn query_panel_load_query_lists_matches() {
1135 let vault = crate::test_support::temp_vault("qp").await;
1136 vault.validate_and_init().await.unwrap();
1137 vault
1138 .create_note(&VaultPath::note_path_from("/a.md"), "alpha #todo")
1139 .await
1140 .unwrap();
1141 vault
1142 .create_note(&VaultPath::note_path_from("/b.md"), "beta")
1143 .await
1144 .unwrap();
1145 let entries = load_query(&vault, "#todo").await;
1146 assert_eq!(entries.len(), 1);
1147 assert!(entries[0].filename.contains("a"));
1148 }
1149
1150 fn make_panel(vault: Arc<NoteVault>) -> QueryPanel {
1151 let kb = crate::settings::AppSettings::default().key_bindings.clone();
1152 QueryPanel::new(vault, kb)
1153 }
1154
1155 #[tokio::test]
1158 async fn cached_needles_track_query_and_note() {
1159 let vault = crate::test_support::temp_vault("qp_needles").await;
1160 vault.validate_and_init().await.unwrap();
1161 let mut panel = make_panel(vault);
1162
1163 *panel.current_note.lock().unwrap() = VaultPath::note_path_from("spec");
1165 assert!(panel.cached_needles().iter().any(|n| n == "spec"));
1166
1167 *panel.current_note.lock().unwrap() = VaultPath::note_path_from("other");
1169 assert!(panel.cached_needles().iter().any(|n| n == "other"));
1170
1171 panel.list.set_query("widget".to_string());
1173 let needles = panel.cached_needles();
1174 assert!(needles.iter().any(|n| n == "widget"));
1175 assert!(!needles.iter().any(|n| n == "other"));
1176 }
1177
1178 async fn settle(panel: &mut QueryPanel) {
1184 for _ in 0..100 {
1185 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1186 panel.list.poll();
1187 if !panel.list.is_loading() {
1188 break;
1189 }
1190 }
1191 }
1192
1193 #[tokio::test(flavor = "multi_thread")]
1194 async fn apply_sort_rewrites_query_order_directive() {
1195 let vault = crate::test_support::temp_vault("qp-sort").await;
1196 vault.validate_and_init().await.unwrap();
1197 let mut panel = make_panel(vault);
1198 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1199 panel.set_active_query("widget".to_string());
1200
1201 panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1202 assert_eq!(panel.active_query(), "widget or:title");
1203
1204 panel.apply_sort(SortField::Name, SortOrder::Descending, &tx);
1205 assert_eq!(panel.active_query(), "widget -or:file");
1206 }
1207
1208 #[tokio::test(flavor = "multi_thread")]
1212 async fn directiveless_query_is_name_ascending() {
1213 let vault = crate::test_support::temp_vault("qp-defaultorder").await;
1214 vault.validate_and_init().await.unwrap();
1215 for name in ["/charlie.md", "/alpha.md", "/bravo.md"] {
1217 vault
1218 .create_note(&VaultPath::note_path_from(name), "widget")
1219 .await
1220 .unwrap();
1221 }
1222 let mut panel = make_panel(vault);
1223 panel.set_active_query("widget".to_string()); settle(&mut panel).await;
1225
1226 let names: Vec<String> = panel
1227 .list
1228 .visible_rows()
1229 .iter()
1230 .map(|e| e.filename.clone())
1231 .collect();
1232 let mut sorted = names.clone();
1233 sorted.sort();
1234 assert_eq!(names, sorted, "directive-less query must be name-ascending");
1235 }
1236
1237 #[tokio::test(flavor = "multi_thread")]
1240 async fn accepting_saved_search_pins_breadcrumb() {
1241 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1242 let vault = crate::test_support::temp_vault("qp-ss-accept").await;
1243 vault.validate_and_init().await.unwrap();
1244 vault.save_search("todo-week", "#todo").await.unwrap();
1245 let mut panel = make_panel(vault);
1246 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1247
1248 panel.set_active_query(String::new());
1252 for ch in ['?', 't', 'o'] {
1253 panel.handle_key(&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
1254 for _ in 0..30 {
1255 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1256 panel.list.poll();
1257 }
1258 }
1259 panel.handle_key(&KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &tx);
1260
1261 assert_eq!(panel.active_query(), "#todo");
1262 assert_eq!(
1263 panel.saved_search_breadcrumb().as_deref(),
1264 Some("todo-week")
1265 );
1266 }
1267
1268 #[tokio::test(flavor = "multi_thread")]
1271 async fn editing_expanded_query_keeps_breadcrumb_marked_edited() {
1272 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1273 let vault = crate::test_support::temp_vault("qp-ss-edit").await;
1274 vault.validate_and_init().await.unwrap();
1275 let mut panel = make_panel(vault);
1276 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1277 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1278 assert_eq!(panel.saved_search_breadcrumb().as_deref(), Some("todo"));
1279
1280 panel.handle_key(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
1282 assert_eq!(panel.active_query(), "#todox");
1283 assert_eq!(
1284 panel.saved_search_breadcrumb().as_deref(),
1285 Some("todo • edited")
1286 );
1287 }
1288
1289 #[tokio::test(flavor = "multi_thread")]
1292 async fn emptying_field_clears_breadcrumb() {
1293 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1294 let vault = crate::test_support::temp_vault("qp-ss-empty").await;
1295 vault.validate_and_init().await.unwrap();
1296 let mut panel = make_panel(vault);
1297 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1298 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1299
1300 for _ in 0.."#todo".len() {
1302 panel.handle_key(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), &tx);
1303 }
1304 assert_eq!(panel.active_query(), "");
1305 assert_eq!(panel.saved_search_breadcrumb(), None);
1306 }
1307
1308 #[tokio::test(flavor = "multi_thread")]
1309 async fn apply_query_pins_breadcrumb() {
1310 let vault = crate::test_support::temp_vault("qp-name").await;
1311 vault.validate_and_init().await.unwrap();
1312 let mut panel = make_panel(vault);
1313 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1314 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1315 assert_eq!(panel.saved_search_breadcrumb().as_deref(), Some("todo"));
1316 }
1317
1318 #[tokio::test(flavor = "multi_thread")]
1322 async fn apply_sort_marks_saved_search_breadcrumb_edited() {
1323 let vault = crate::test_support::temp_vault("qp-sort-name").await;
1324 vault.validate_and_init().await.unwrap();
1325 let mut panel = make_panel(vault);
1326 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1327 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1328
1329 panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1330 assert_eq!(panel.active_query(), "#todo or:title");
1331 assert_eq!(
1332 panel.saved_search_breadcrumb().as_deref(),
1333 Some("todo • edited"),
1334 "sorting diverges from the stored query, so the breadcrumb is edited"
1335 );
1336 }
1337
1338 #[tokio::test(flavor = "multi_thread")]
1341 async fn repin_after_save_adopts_saved_identity() {
1342 let vault = crate::test_support::temp_vault("qp-repin").await;
1343 vault.validate_and_init().await.unwrap();
1344 let mut panel = make_panel(vault);
1345 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1346 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1347
1348 panel.set_active_query("#todo and #urgent".to_string());
1349 assert_eq!(
1350 panel.saved_search_breadcrumb().as_deref(),
1351 Some("todo • edited")
1352 );
1353
1354 panel.repin_saved_search("urgent-todos".to_string(), "#todo and #urgent");
1355 assert_eq!(
1356 panel.saved_search_breadcrumb().as_deref(),
1357 Some("urgent-todos"),
1358 "after a save the saved identity is the provenance — no edited marker"
1359 );
1360 }
1361
1362 #[tokio::test(flavor = "multi_thread")]
1366 async fn apply_sort_updates_visible_input_bar() {
1367 let vault = crate::test_support::temp_vault("qp-bar").await;
1368 vault.validate_and_init().await.unwrap();
1369 let mut panel = make_panel(vault);
1370 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1371 panel.set_active_query("widget".to_string());
1372 assert_eq!(
1373 panel.list.input_value(),
1374 "widget",
1375 "set_active_query syncs the bar"
1376 );
1377
1378 panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1379 assert_eq!(panel.active_query(), "widget or:title");
1380 assert_eq!(
1381 panel.list.input_value(),
1382 "widget or:title",
1383 "the input bar must reflect the rewritten query"
1384 );
1385 }
1386
1387 #[tokio::test(flavor = "multi_thread")]
1388 async fn current_order_reads_query_directive() {
1389 let vault = crate::test_support::temp_vault("qp-order").await;
1390 vault.validate_and_init().await.unwrap();
1391 let mut panel = make_panel(vault);
1392 panel.set_active_query("widget -or:title".to_string());
1393 assert_eq!(
1394 panel.current_order(),
1395 (SortField::Title, SortOrder::Descending)
1396 );
1397 panel.set_active_query("widget".to_string());
1398 assert_eq!(
1399 panel.current_order(),
1400 (SortField::Name, SortOrder::Ascending)
1401 );
1402 }
1403
1404 #[tokio::test(flavor = "multi_thread")]
1410 async fn static_query_survives_navigation() {
1411 let vault = crate::test_support::temp_vault("nav-static").await;
1412 vault.validate_and_init().await.unwrap();
1413 vault
1414 .create_note(&VaultPath::note_path_from("/a.md"), "alpha #todo")
1415 .await
1416 .unwrap();
1417 let mut panel = make_panel(vault);
1418 panel.set_active_query("#todo".to_string());
1419 settle(&mut panel).await;
1420 assert_eq!(panel.list.visible_rows().len(), 1);
1421
1422 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1423 panel.set_note(VaultPath::note_path_from("x.md"), tx);
1424
1425 assert_eq!(panel.active_query(), "#todo");
1428 assert!(!panel.list.is_loading());
1429 settle(&mut panel).await;
1430 assert_eq!(panel.list.visible_rows().len(), 1); }
1432
1433 #[tokio::test(flavor = "multi_thread")]
1436 async fn note_variable_query_reruns_on_navigation() {
1437 let vault = crate::test_support::temp_vault("nav-var").await;
1438 vault.validate_and_init().await.unwrap();
1439 vault
1442 .create_note(&VaultPath::note_path_from("/target.md"), "I am the target")
1443 .await
1444 .unwrap();
1445 vault
1446 .create_note(&VaultPath::note_path_from("/linker.md"), "see [[target]]")
1447 .await
1448 .unwrap();
1449 let mut panel = make_panel(vault);
1450 assert_eq!(panel.active_query(), DEFAULT_QUERY);
1451
1452 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1453 panel.set_note(VaultPath::note_path_from("/target.md"), tx);
1454 settle(&mut panel).await;
1455
1456 assert!(
1458 panel
1459 .list
1460 .visible_rows()
1461 .iter()
1462 .any(|e| e.filename.contains("linker")),
1463 "expected linker as a backlink, got {:?}",
1464 panel
1465 .list
1466 .visible_rows()
1467 .iter()
1468 .map(|e| e.filename.clone())
1469 .collect::<Vec<_>>()
1470 );
1471 }
1472
1473 #[tokio::test(flavor = "multi_thread")]
1475 async fn note_variable_query_changes_with_note() {
1476 let vault = crate::test_support::temp_vault("nav-var2").await;
1477 vault.validate_and_init().await.unwrap();
1478 vault
1479 .create_note(&VaultPath::note_path_from("/a.md"), "I am a")
1480 .await
1481 .unwrap();
1482 vault
1483 .create_note(&VaultPath::note_path_from("/b.md"), "I am b")
1484 .await
1485 .unwrap();
1486 vault
1487 .create_note(&VaultPath::note_path_from("/links_a.md"), "see [[a]]")
1488 .await
1489 .unwrap();
1490 let mut panel = make_panel(vault);
1491 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1492
1493 panel.set_note(VaultPath::note_path_from("/a.md"), tx.clone());
1494 settle(&mut panel).await;
1495 assert!(
1496 panel
1497 .list
1498 .visible_rows()
1499 .iter()
1500 .any(|e| e.filename.contains("links_a"))
1501 );
1502
1503 panel.set_note(VaultPath::note_path_from("/b.md"), tx);
1504 settle(&mut panel).await;
1505 assert!(
1506 !panel
1507 .list
1508 .visible_rows()
1509 .iter()
1510 .any(|e| e.filename.contains("links_a")),
1511 "b has no backlinks, expected empty"
1512 );
1513 }
1514}