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, resolve_query};
20use crate::components::saved_search_breadcrumb::SavedSearchBreadcrumb;
21use crate::components::search_list::{
22 Emit, KeyReaction, RowSource, SearchList, 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}";
33
34#[derive(Debug, Clone)]
40pub struct BacklinkEntry {
41 pub path: VaultPath,
42 pub title: String,
43 pub filename: String,
44 pub context: String,
46 pub full_text: Option<String>,
48}
49
50impl SearchRow for BacklinkEntry {
51 fn to_list_item(&self, theme: &Theme, _icons: &Icons, selected: bool) -> ListItem<'static> {
52 let fg = theme.fg.to_ratatui();
53 let fg_muted = theme.fg_muted.to_ratatui();
54 let bg = theme.bg_panel.to_ratatui();
55 let title_style = if selected {
56 Style::default()
57 .fg(theme.fg_selected.to_ratatui())
58 .bg(theme.bg_selected.to_ratatui())
59 .add_modifier(Modifier::BOLD)
60 } else {
61 Style::default().fg(fg).bg(bg)
62 };
63 let title_display = if self.title.is_empty() {
64 &self.filename
65 } else {
66 &self.title
67 };
68 ListItem::new(Line::from(vec![
69 Span::styled(format!(" {} ", title_display), title_style),
70 Span::styled(
71 format!(" {}", self.filename),
72 Style::default().fg(fg_muted).bg(if selected {
73 theme.bg_selected.to_ratatui()
74 } else {
75 bg
76 }),
77 ),
78 ]))
79 }
80
81 fn match_text(&self) -> Option<&str> {
82 Some(&self.filename)
83 }
84
85 fn visual_height(&self) -> u16 {
86 1
87 }
88}
89
90struct BacklinkSource {
101 vault: Arc<NoteVault>,
102 current_note: Arc<Mutex<VaultPath>>,
103}
104
105#[async_trait]
106impl RowSource<BacklinkEntry> for BacklinkSource {
107 async fn load(&self, query: &str, emit: Emit<BacklinkEntry>) {
108 let note = self.current_note.lock().unwrap().clone();
110 if query_has_variables(query) && note.is_root_or_empty() {
115 emit.replace(Vec::new());
116 return;
117 }
118 let q = resolve_query(query, Some(¬e));
119 let mut entries = load_query(&self.vault, &q).await;
120 if kimun_core::SearchTerms::from_query_string(query)
127 .order_by
128 .is_empty()
129 {
130 entries.sort_by_key(|e| e.filename.to_lowercase());
131 }
132 emit.replace(entries);
133 }
134}
135
136#[derive(Clone, Copy, PartialEq)]
141enum ExpandState {
142 Collapsed,
143 Context,
144 Full,
145}
146
147pub struct QueryPanel {
152 list: SearchList<BacklinkEntry>,
155 current_note: Arc<Mutex<VaultPath>>,
158 saved_search: SavedSearchBreadcrumb,
162 expand: ExpandState,
166 expand_path: Option<VaultPath>,
169 content_scroll: usize,
171 content_scroll_max: usize,
173 key_bindings: KeyBindings,
174 redraw_tx: Arc<Mutex<Option<AppTx>>>,
179 follow_link_combos: Vec<KeyCombo>,
181 order_cache: (SortField, SortOrder),
186 order_cache_query: String,
187}
188
189impl QueryPanel {
190 pub fn new(vault: Arc<NoteVault>, key_bindings: KeyBindings) -> Self {
191 let icons = Icons::new(false);
192 let current_note = Arc::new(Mutex::new(VaultPath::empty()));
193 let redraw_tx: Arc<Mutex<Option<AppTx>>> = Arc::new(Mutex::new(None));
197 let redraw: Arc<dyn Fn() + Send + Sync> = {
198 let slot = redraw_tx.clone();
199 Arc::new(move || {
200 if let Some(tx) = slot.lock().unwrap().as_ref() {
201 let _ = tx.send(AppEvent::Redraw);
202 }
203 })
204 };
205 let source = BacklinkSource {
206 vault: vault.clone(),
207 current_note: current_note.clone(),
208 };
209 let combos = |action: &ActionShortcuts| -> Vec<KeyCombo> {
210 key_bindings
211 .to_hashmap()
212 .get(action)
213 .cloned()
214 .unwrap_or_default()
215 };
216 let follow_link_combos = combos(&ActionShortcuts::FollowLink);
217
218 let mut intercept = Vec::new();
219 intercept.extend(follow_link_combos.iter().cloned());
220
221 let list = SearchList::builder(source, redraw)
222 .initial_query(DEFAULT_QUERY)
223 .icons(icons.clone())
224 .autocomplete(
225 Arc::new(VaultSuggestions {
226 vault: vault.clone(),
227 }),
228 AutocompleteMode::SearchQuery,
229 )
230 .intercept(intercept)
231 .build();
232
233 Self {
234 list,
235 current_note,
236 saved_search: SavedSearchBreadcrumb::default(),
237 expand: ExpandState::Collapsed,
238 expand_path: None,
239 content_scroll: 0,
240 content_scroll_max: 0,
241 key_bindings,
242 redraw_tx,
243 follow_link_combos,
244 order_cache: (SortField::Name, SortOrder::Ascending),
246 order_cache_query: String::new(),
247 }
248 }
249
250 pub fn active_query(&self) -> &str {
253 self.list.query()
254 }
255
256 pub fn set_active_query(&mut self, q: String) {
257 self.list.set_query(q);
258 self.reset_expand();
259 }
260
261 pub fn saved_search_breadcrumb(&self) -> Option<String> {
264 self.saved_search.label(self.list.query())
265 }
266
267 fn query_is_blank(&self) -> bool {
272 let q = self.list.query();
273 q.trim().is_empty() || kimun_core::strip_order_directive(q) == DEFAULT_QUERY
274 }
275
276 pub fn apply_query(&mut self, query: String, name: Option<String>, tx: AppTx) {
280 self.ensure_redraw_tx(&tx);
281 self.set_active_query(query.clone());
282 self.saved_search.set(name, &query);
283 }
284
285 fn current_note(&self) -> VaultPath {
288 self.current_note.lock().unwrap().clone()
289 }
290
291 fn ensure_redraw_tx(&self, tx: &AppTx) {
294 let mut slot = self.redraw_tx.lock().unwrap();
295 if slot.is_none() {
296 *slot = Some(tx.clone());
297 }
298 }
299
300 fn resolved_query(&self) -> String {
303 resolve_query(self.list.query(), Some(&self.current_note()))
304 }
305
306 fn is_full_expanded(&self) -> bool {
309 self.list.selected_row().is_some() && self.expand == ExpandState::Full
310 }
311
312 pub fn is_empty(&self) -> bool {
313 self.list.rows().is_empty()
314 }
315
316 pub fn selected_path(&self) -> Option<&VaultPath> {
317 self.list.selected_row().map(|e| &e.path)
318 }
319
320 fn reset_expand(&mut self) {
321 self.expand = ExpandState::Collapsed;
322 self.expand_path = None;
323 self.content_scroll = 0;
324 self.content_scroll_max = 0;
325 }
326
327 fn sync_expand_anchor(&mut self) {
332 let sel = self.list.selected_row().map(|e| e.path.clone());
333 if sel != self.expand_path {
334 if self.expand != ExpandState::Context || sel.is_none() {
335 self.expand = ExpandState::Collapsed;
336 }
337 self.expand_path = sel;
338 self.content_scroll = 0;
339 }
340 }
341
342 pub fn set_note(&mut self, note_path: VaultPath, tx: AppTx) {
347 self.ensure_redraw_tx(&tx);
348 *self.current_note.lock().unwrap() = note_path;
349 if query_has_variables(self.list.query()) {
350 self.list.reload();
351 self.reset_expand();
352 }
353 }
354
355 pub fn current_order(&self) -> (SortField, SortOrder) {
360 let st = kimun_core::SearchTerms::from_query_string(self.list.query());
361 match st.order_by.first() {
362 Some(OrderBy::Title { asc }) => (
363 SortField::Title,
364 if *asc {
365 SortOrder::Ascending
366 } else {
367 SortOrder::Descending
368 },
369 ),
370 Some(OrderBy::FileName { asc }) => (
371 SortField::Name,
372 if *asc {
373 SortOrder::Ascending
374 } else {
375 SortOrder::Descending
376 },
377 ),
378 None => (SortField::Name, SortOrder::Ascending),
379 }
380 }
381
382 pub fn apply_sort(&mut self, field: SortField, order: SortOrder, tx: &AppTx) {
385 self.ensure_redraw_tx(tx);
386 let order_field = match field {
387 SortField::Name => OrderField::FileName,
388 SortField::Title => OrderField::Title,
389 };
390 let asc = matches!(order, SortOrder::Ascending);
391 let rewritten = with_order_directive(self.list.query(), order_field, asc);
392 self.list.set_query(rewritten);
393 self.reset_expand();
397 }
398
399 pub fn handle_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
402 self.ensure_redraw_tx(tx);
403 self.sync_expand_anchor();
404
405 if self.is_full_expanded() && matches!(key.code, KeyCode::Up | KeyCode::Down) {
408 self.scroll_content(key);
409 return EventState::Consumed;
410 }
411 match self.list.handle_key(key) {
415 KeyReaction::Intercepted(c) if self.follow_link_combos.contains(&c) => {
416 if let Some(path) = self.selected_path().cloned() {
417 tx.send(AppEvent::OpenPath(path)).ok();
418 }
419 EventState::Consumed
420 }
421 KeyReaction::Consumed => {
422 let accepted = self.list.take_accepted_saved_search();
426 let blank = self.query_is_blank();
427 self.saved_search
428 .on_query_consumed(accepted, self.list.query(), blank);
429 self.sync_expand_anchor();
430 EventState::Consumed
431 }
432 KeyReaction::Submit => {
433 self.toggle_expand();
436 EventState::Consumed
437 }
438 KeyReaction::Cancel => EventState::NotConsumed,
440 KeyReaction::Unhandled => EventState::NotConsumed,
441 KeyReaction::Intercepted(_) => EventState::Consumed,
442 }
443 }
444
445 fn scroll_content(&mut self, key: &KeyEvent) {
446 match key.code {
447 KeyCode::Up => {
448 self.content_scroll = self.content_scroll.saturating_sub(1);
449 }
450 KeyCode::Down => {
451 self.content_scroll += 1;
453 }
454 _ => {}
455 }
456 }
457
458 fn toggle_expand(&mut self) {
459 if self.list.selected_row().is_none() {
460 return;
461 }
462 self.expand_path = self.list.selected_row().map(|e| e.path.clone());
463 match self.expand {
464 ExpandState::Collapsed => {
465 self.expand = ExpandState::Context;
466 }
467 ExpandState::Context => {
468 self.content_scroll = 0;
469 self.expand = ExpandState::Full;
470 }
471 ExpandState::Full => {
472 self.content_scroll = 0;
473 self.expand = ExpandState::Collapsed;
474 }
475 }
476 }
477
478 pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
479 [
480 (ActionShortcuts::FocusSidebar, "\u{2190} editor"),
481 (ActionShortcuts::FollowLink, "open note"),
482 (ActionShortcuts::SaveCurrentQuery, "save query"),
483 (ActionShortcuts::OpenSavedSearches, "searches"),
484 (ActionShortcuts::OpenSortDialog, "sort"),
485 ]
486 .iter()
487 .filter_map(|(action, label)| {
488 self.key_bindings
489 .first_combo_for(action)
490 .map(|k| (k, label.to_string()))
491 })
492 .collect()
493 }
494
495 pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
498 self.list.poll();
499 self.sync_expand_anchor();
500
501 let border_style = theme.border_style(focused);
502 let fg_muted = theme.fg_muted.to_ratatui();
503 let bg = theme.bg_panel.to_ratatui();
504
505 let count = self.list.visible_rows().len();
506 if self.list.query() != self.order_cache_query {
509 self.order_cache = self.current_order();
510 self.order_cache_query = self.list.query().to_string();
511 }
512 let (sort_field, sort_order) = self.order_cache;
513 let sort_indicator = format!("{}{}", sort_field.label(), sort_order.label());
514 let base_query = kimun_core::strip_order_directive(self.list.query());
517 let title = if base_query == DEFAULT_QUERY {
520 format!("Backlinks ({}) {}", count, sort_indicator)
521 } else {
522 format!("Query ({}) {}", count, sort_indicator)
523 };
524
525 let outer = Block::default()
526 .title(title)
527 .borders(Borders::ALL)
528 .border_style(border_style)
529 .style(theme.panel_style());
530 let outer_inner = outer.inner(rect);
531 f.render_widget(outer, rect);
532
533 let rows = Layout::default()
535 .direction(Direction::Vertical)
536 .constraints([Constraint::Length(3), Constraint::Min(0)])
537 .split(outer_inner);
538 let search_title = self.saved_search.border_title(self.list.query(), " Query");
541 let search_block = Block::default()
542 .title(search_title)
543 .borders(Borders::ALL)
544 .border_style(border_style)
545 .style(theme.panel_style());
546 let search_inner = search_block.inner(rows[0]);
547 f.render_widget(search_block, rows[0]);
548 self.list.render_query(f, search_inner, theme, focused);
549
550 let inner = rows[1];
551
552 if self.list.is_loading() {
553 f.render_widget(
554 Paragraph::new(" Loading...").style(Style::default().fg(fg_muted).bg(bg)),
555 inner,
556 );
557 self.list.render_autocomplete(f, rect, theme);
558 return;
559 }
560
561 if self.list.visible_rows().is_empty() {
562 f.render_widget(
563 Paragraph::new(" No results").style(Style::default().fg(fg_muted).bg(bg)),
564 inner,
565 );
566 self.list.render_autocomplete(f, rect, theme);
567 return;
568 }
569
570 let selected_state = self.expand;
571
572 if selected_state == ExpandState::Full {
574 if let Some(entry) = self.list.selected_row() {
575 let entry = entry.clone();
576 let text = entry.full_text.as_deref().unwrap_or(&entry.context);
577
578 let title_display = if entry.title.is_empty() {
580 &entry.filename
581 } else {
582 &entry.title
583 };
584
585 let parts = Layout::default()
586 .direction(Direction::Vertical)
587 .constraints([
588 Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
592 .split(inner);
593
594 f.render_widget(
596 Paragraph::new(Line::from(vec![
597 Span::styled(
598 format!("\u{25BC} {} ", title_display),
599 Style::default()
600 .fg(theme.fg_selected.to_ratatui())
601 .bg(bg)
602 .add_modifier(Modifier::BOLD),
603 ),
604 Span::styled(
605 format!(" {}", entry.filename),
606 Style::default().fg(fg_muted).bg(bg),
607 ),
608 ]))
609 .style(Style::default().bg(bg)),
610 parts[0],
611 );
612
613 f.render_widget(
615 Paragraph::new("\u{2500}".repeat(parts[1].width as usize))
616 .style(Style::default().fg(fg_muted).bg(bg)),
617 parts[1],
618 );
619
620 let indent = 2usize;
622 let wrap_width = parts[2].width.saturating_sub(indent as u16 + 1) as usize;
623 let needles = query_needles(&self.resolved_query());
624
625 let mut lines = Vec::new();
626 for line in text.lines() {
627 let wrapped = wrap_line(line, wrap_width);
628 for wline in wrapped {
629 let spans = highlight_needles(&wline, &needles, fg_muted, bg, theme);
630 let mut indented =
631 vec![Span::styled(" ".repeat(indent), Style::default().bg(bg))];
632 indented.extend(spans);
633 lines.push(Line::from(indented));
634 }
635 }
636
637 let total_lines = lines.len();
638 let viewport = parts[2].height as usize;
639 self.content_scroll_max = total_lines.saturating_sub(viewport);
640 self.content_scroll = self.content_scroll.min(self.content_scroll_max);
641
642 f.render_widget(
643 Paragraph::new(lines)
644 .scroll((self.content_scroll as u16, 0))
645 .style(Style::default().bg(bg)),
646 parts[2],
647 );
648 }
649 self.list.render_autocomplete(f, rect, theme);
650 return;
651 }
652
653 let has_context = selected_state == ExpandState::Context;
655
656 let (list_area, divider_area, content_area) = if has_context {
657 let max_list = inner.height / 2;
658 let list_height = (count as u16).min(max_list).max(1);
659 let areas = Layout::default()
660 .direction(Direction::Vertical)
661 .constraints([
662 Constraint::Length(list_height),
663 Constraint::Length(1),
664 Constraint::Min(0),
665 ])
666 .split(inner);
667 (areas[0], Some(areas[1]), Some(areas[2]))
668 } else {
669 (inner, None, None)
670 };
671
672 self.list.render(f, list_area, theme, focused);
675 self.list.set_list_rect(list_area);
676
677 if let Some(div) = divider_area {
679 f.render_widget(
680 Paragraph::new("\u{2500}".repeat(div.width as usize))
681 .style(Style::default().fg(fg_muted).bg(bg)),
682 div,
683 );
684 }
685
686 if let Some(area) = content_area
689 && selected_state == ExpandState::Context
690 && let Some(entry) = self.list.selected_row()
691 {
692 let entry = entry.clone();
693 let text = entry.full_text.as_deref().unwrap_or(&entry.context);
694 let indent = 2usize;
695 let wrap_width = area.width.saturating_sub(indent as u16 + 1) as usize;
696 let needles = query_needles(&self.resolved_query());
697
698 let mut lines = Vec::new();
699
700 let mut link_line: Option<usize> = None;
702
703 for line in text.lines() {
704 let wrapped = wrap_line(line, wrap_width);
705 for wline in wrapped {
706 if link_line.is_none()
707 && needles
708 .iter()
709 .any(|n| !n.is_empty() && find_case_insensitive(&wline, n).is_some())
710 {
711 link_line = Some(lines.len());
712 }
713 let spans = highlight_needles(&wline, &needles, fg_muted, bg, theme);
714 let mut indented =
715 vec![Span::styled(" ".repeat(indent), Style::default().bg(bg))];
716 indented.extend(spans);
717 lines.push(Line::from(indented));
718 }
719 }
720
721 let viewport = area.height as usize;
725 let total = lines.len();
726 let link_pos = link_line.unwrap_or(0);
727 let lines_after_link = total.saturating_sub(link_pos);
728 let scroll_to = if lines_after_link <= viewport {
729 total.saturating_sub(viewport)
731 } else {
732 link_pos.saturating_sub(2)
734 } as u16;
735
736 f.render_widget(
737 Paragraph::new(lines)
738 .scroll((scroll_to, 0))
739 .style(Style::default().bg(bg)),
740 area,
741 );
742 }
743
744 self.list.render_autocomplete(f, rect, theme);
745 }
746}
747
748async fn load_query(vault: &NoteVault, query: &str) -> Vec<BacklinkEntry> {
755 let needles = query_needles(query);
756 let results = vault.search_notes(query).await.unwrap_or_default();
757 let mut entries = Vec::with_capacity(results.len());
758 for (entry_data, content_data) in results {
759 let text = vault
760 .get_note_text(&entry_data.path)
761 .await
762 .unwrap_or_default();
763 let context = extract_context_multi(&text, &needles);
764 let (_p, filename) = entry_data.path.get_parent_path();
765 entries.push(BacklinkEntry {
766 path: entry_data.path,
767 title: content_data.title,
768 filename,
769 context,
770 full_text: Some(text),
771 });
772 }
773 entries
774}
775
776fn split_paragraphs(text: &str) -> Vec<String> {
779 let mut paragraphs = Vec::new();
780 let mut current: Vec<&str> = Vec::new();
781
782 for line in text.lines() {
783 if line.trim().is_empty() {
784 if !current.is_empty() {
785 paragraphs.push(current.join("\n"));
786 current.clear();
787 }
788 } else {
789 current.push(line);
790 }
791 }
792 if !current.is_empty() {
793 paragraphs.push(current.join("\n"));
794 }
795
796 paragraphs
797}
798
799fn wrap_line(line: &str, max_width: usize) -> Vec<String> {
807 if max_width == 0 || line.chars().count() <= max_width {
808 return vec![line.to_string()];
809 }
810
811 let mut result = Vec::new();
812 let mut remaining = line;
813
814 while remaining.chars().count() > max_width {
815 let byte_limit = remaining
817 .char_indices()
818 .nth(max_width)
819 .map(|(i, _)| i)
820 .unwrap_or(remaining.len());
821
822 let break_at = remaining[..byte_limit]
824 .rfind(' ')
825 .map(|i| i + 1) .unwrap_or(byte_limit); result.push(remaining[..break_at].trim_end().to_string());
828 remaining = &remaining[break_at..];
829 }
830 if !remaining.is_empty() {
831 result.push(remaining.to_string());
832 }
833 result
834}
835
836fn find_case_insensitive(haystack: &str, needle: &str) -> Option<(usize, usize)> {
841 let needle_chars: Vec<char> = needle.chars().collect();
842 if needle_chars.is_empty() {
843 return None;
844 }
845 let hay_indices: Vec<(usize, char)> = haystack.char_indices().collect();
846 'outer: for start_idx in 0..hay_indices.len() {
847 if start_idx + needle_chars.len() > hay_indices.len() {
848 break;
849 }
850 for (j, &nc) in needle_chars.iter().enumerate() {
851 let hc = hay_indices[start_idx + j].1;
852 let mut h_lower = hc.to_lowercase();
854 let mut n_lower = nc.to_lowercase();
855 if h_lower.next() != n_lower.next() {
856 continue 'outer;
857 }
858 }
859 let byte_start = hay_indices[start_idx].0;
861 let byte_end = if start_idx + needle_chars.len() < hay_indices.len() {
862 hay_indices[start_idx + needle_chars.len()].0
863 } else {
864 haystack.len()
865 };
866 return Some((byte_start, byte_end));
867 }
868 None
869}
870
871fn extract_context_multi(text: &str, needles: &[String]) -> String {
874 let lowered: Vec<String> = needles.iter().map(|n| n.to_lowercase()).collect();
875 for para in &split_paragraphs(text) {
876 let lower = para.to_lowercase();
877 if lowered.iter().any(|n| !n.is_empty() && lower.contains(n)) {
878 return para.clone();
879 }
880 }
881 text.lines()
882 .find(|l| !l.trim().is_empty())
883 .unwrap_or("")
884 .to_string()
885}
886
887fn highlight_needles(
889 line: &str,
890 needles: &[String],
891 fg_muted: ratatui::style::Color,
892 bg: ratatui::style::Color,
893 theme: &Theme,
894) -> Vec<Span<'static>> {
895 let normal = Style::default().fg(fg_muted).bg(bg);
896 let bold = Style::default()
897 .fg(theme.accent.to_ratatui())
898 .bg(bg)
899 .add_modifier(Modifier::BOLD);
900 let mut best: Option<(usize, usize)> = None;
901 for needle in needles {
902 if needle.is_empty() {
903 continue;
904 }
905 if let Some((s, e)) = find_case_insensitive(line, needle)
906 && (best.is_none() || s < best.unwrap().0)
907 {
908 best = Some((s, e));
909 }
910 }
911 let Some((start, end)) = best else {
912 return vec![Span::styled(line.to_string(), normal)];
913 };
914 let mut spans = Vec::new();
915 if start > 0 {
916 spans.push(Span::styled(line[..start].to_string(), normal));
917 }
918 spans.push(Span::styled(line[start..end].to_string(), bold));
919 if end < line.len() {
920 spans.push(Span::styled(line[end..].to_string(), normal));
921 }
922 spans
923}
924
925fn query_needles(query: &str) -> Vec<String> {
928 let st = kimun_core::SearchTerms::from_query_string(query);
929 let mut needles = st.terms.clone();
930 needles.extend(st.links.clone());
931 needles.extend(st.forward_links.clone());
932 needles
933}
934
935#[cfg(test)]
940mod tests {
941 use super::*;
942
943 #[test]
944 fn wrap_line_fits_within_width() {
945 let result = wrap_line("short", 20);
946 assert_eq!(result, vec!["short"]);
947 }
948
949 #[test]
950 fn wrap_line_breaks_at_word_boundary() {
951 let result = wrap_line("hello world foo bar", 12);
952 assert_eq!(result, vec!["hello world", "foo bar"]);
953 }
954
955 #[test]
956 fn wrap_line_hard_breaks_long_word() {
957 let result = wrap_line("abcdefghij", 5);
958 assert_eq!(result, vec!["abcde", "fghij"]);
959 }
960
961 #[test]
962 fn wrap_line_handles_multibyte_chars() {
963 let result = wrap_line("日本語テスト", 3);
965 assert_eq!(result, vec!["日本語", "テスト"]);
966 }
967
968 #[test]
969 fn wrap_line_empty_string() {
970 let result = wrap_line("", 10);
971 assert_eq!(result, vec![""]);
972 }
973
974 #[test]
975 fn extract_context_matches_any_needle() {
976 let text = "# Title\n\nIntro line.\n\nA paragraph mentioning widget here.\n";
977 let result = extract_context_multi(text, &["widget".to_string()]);
978 assert!(result.contains("widget"));
979 }
980
981 #[test]
982 fn highlight_needles_highlights_first_match() {
983 let spans = highlight_needles(
984 "see widget and gadget",
985 &["gadget".to_string()],
986 ratatui::style::Color::Gray,
987 ratatui::style::Color::Black,
988 &crate::settings::themes::Theme::default(),
989 );
990 assert!(
991 spans
992 .iter()
993 .any(|s| s.content.contains("gadget")
994 && s.style.add_modifier.contains(Modifier::BOLD))
995 );
996 }
997
998 #[test]
999 fn query_needles_extracts_terms_and_links() {
1000 let n = query_needles("widget <spec");
1001 assert!(n.iter().any(|x| x == "widget"));
1002 assert!(n.iter().any(|x| x == "spec"));
1003 }
1004
1005 #[test]
1006 fn query_needles_extracts_forward_links() {
1007 let n = query_needles(">spec");
1010 assert!(n.iter().any(|x| x == "spec"));
1011 }
1012
1013 #[tokio::test]
1014 async fn query_panel_load_query_lists_matches() {
1015 let vault = crate::test_support::temp_vault("qp").await;
1016 vault.validate_and_init().await.unwrap();
1017 vault
1018 .create_note(&VaultPath::note_path_from("/a.md"), "alpha #todo")
1019 .await
1020 .unwrap();
1021 vault
1022 .create_note(&VaultPath::note_path_from("/b.md"), "beta")
1023 .await
1024 .unwrap();
1025 let entries = load_query(&vault, "#todo").await;
1026 assert_eq!(entries.len(), 1);
1027 assert!(entries[0].filename.contains("a"));
1028 }
1029
1030 fn make_panel(vault: Arc<NoteVault>) -> QueryPanel {
1031 let kb = crate::settings::AppSettings::default().key_bindings.clone();
1032 QueryPanel::new(vault, kb)
1033 }
1034
1035 async fn settle(panel: &mut QueryPanel) {
1041 for _ in 0..100 {
1042 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1043 panel.list.poll();
1044 if !panel.list.is_loading() {
1045 break;
1046 }
1047 }
1048 }
1049
1050 #[tokio::test(flavor = "multi_thread")]
1051 async fn apply_sort_rewrites_query_order_directive() {
1052 let vault = crate::test_support::temp_vault("qp-sort").await;
1053 vault.validate_and_init().await.unwrap();
1054 let mut panel = make_panel(vault);
1055 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1056 panel.set_active_query("widget".to_string());
1057
1058 panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1059 assert_eq!(panel.active_query(), "widget or:title");
1060
1061 panel.apply_sort(SortField::Name, SortOrder::Descending, &tx);
1062 assert_eq!(panel.active_query(), "widget -or:file");
1063 }
1064
1065 #[tokio::test(flavor = "multi_thread")]
1069 async fn directiveless_query_is_name_ascending() {
1070 let vault = crate::test_support::temp_vault("qp-defaultorder").await;
1071 vault.validate_and_init().await.unwrap();
1072 for name in ["/charlie.md", "/alpha.md", "/bravo.md"] {
1074 vault
1075 .create_note(&VaultPath::note_path_from(name), "widget")
1076 .await
1077 .unwrap();
1078 }
1079 let mut panel = make_panel(vault);
1080 panel.set_active_query("widget".to_string()); settle(&mut panel).await;
1082
1083 let names: Vec<String> = panel
1084 .list
1085 .visible_rows()
1086 .iter()
1087 .map(|e| e.filename.clone())
1088 .collect();
1089 let mut sorted = names.clone();
1090 sorted.sort();
1091 assert_eq!(names, sorted, "directive-less query must be name-ascending");
1092 }
1093
1094 #[tokio::test(flavor = "multi_thread")]
1097 async fn accepting_saved_search_pins_breadcrumb() {
1098 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1099 let vault = crate::test_support::temp_vault("qp-ss-accept").await;
1100 vault.validate_and_init().await.unwrap();
1101 vault.save_search("todo-week", "#todo").await.unwrap();
1102 let mut panel = make_panel(vault);
1103 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1104
1105 panel.set_active_query(String::new());
1109 for ch in ['?', 't', 'o'] {
1110 panel.handle_key(&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
1111 for _ in 0..30 {
1112 tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1113 panel.list.poll();
1114 }
1115 }
1116 panel.handle_key(&KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &tx);
1117
1118 assert_eq!(panel.active_query(), "#todo");
1119 assert_eq!(
1120 panel.saved_search_breadcrumb().as_deref(),
1121 Some("todo-week")
1122 );
1123 }
1124
1125 #[tokio::test(flavor = "multi_thread")]
1129 async fn editing_expanded_query_keeps_breadcrumb_marked_edited() {
1130 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1131 let vault = crate::test_support::temp_vault("qp-ss-edit").await;
1132 vault.validate_and_init().await.unwrap();
1133 let mut panel = make_panel(vault);
1134 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1135 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1136 assert_eq!(panel.saved_search_breadcrumb().as_deref(), Some("todo"));
1137
1138 panel.handle_key(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
1140 assert_eq!(panel.active_query(), "#todox");
1141 assert_eq!(
1142 panel.saved_search_breadcrumb().as_deref(),
1143 Some("todo • edited")
1144 );
1145 }
1146
1147 #[tokio::test(flavor = "multi_thread")]
1150 async fn emptying_field_clears_breadcrumb() {
1151 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1152 let vault = crate::test_support::temp_vault("qp-ss-empty").await;
1153 vault.validate_and_init().await.unwrap();
1154 let mut panel = make_panel(vault);
1155 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1156 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1157
1158 for _ in 0.."#todo".len() {
1160 panel.handle_key(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), &tx);
1161 }
1162 assert_eq!(panel.active_query(), "");
1163 assert_eq!(panel.saved_search_breadcrumb(), None);
1164 }
1165
1166 #[tokio::test(flavor = "multi_thread")]
1167 async fn apply_query_pins_breadcrumb() {
1168 let vault = crate::test_support::temp_vault("qp-name").await;
1169 vault.validate_and_init().await.unwrap();
1170 let mut panel = make_panel(vault);
1171 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1172 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1173 assert_eq!(panel.saved_search_breadcrumb().as_deref(), Some("todo"));
1174 }
1175
1176 #[tokio::test(flavor = "multi_thread")]
1180 async fn apply_sort_keeps_saved_search_breadcrumb() {
1181 let vault = crate::test_support::temp_vault("qp-sort-name").await;
1182 vault.validate_and_init().await.unwrap();
1183 let mut panel = make_panel(vault);
1184 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1185 panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1186
1187 panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1188 assert_eq!(panel.active_query(), "#todo or:title");
1189 assert_eq!(
1190 panel.saved_search_breadcrumb().as_deref(),
1191 Some("todo"),
1192 "sorting keeps the unedited breadcrumb"
1193 );
1194 }
1195
1196 #[tokio::test(flavor = "multi_thread")]
1200 async fn apply_sort_updates_visible_input_bar() {
1201 let vault = crate::test_support::temp_vault("qp-bar").await;
1202 vault.validate_and_init().await.unwrap();
1203 let mut panel = make_panel(vault);
1204 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1205 panel.set_active_query("widget".to_string());
1206 assert_eq!(
1207 panel.list.input_value(),
1208 "widget",
1209 "set_active_query syncs the bar"
1210 );
1211
1212 panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1213 assert_eq!(panel.active_query(), "widget or:title");
1214 assert_eq!(
1215 panel.list.input_value(),
1216 "widget or:title",
1217 "the input bar must reflect the rewritten query"
1218 );
1219 }
1220
1221 #[tokio::test(flavor = "multi_thread")]
1222 async fn current_order_reads_query_directive() {
1223 let vault = crate::test_support::temp_vault("qp-order").await;
1224 vault.validate_and_init().await.unwrap();
1225 let mut panel = make_panel(vault);
1226 panel.set_active_query("widget -or:title".to_string());
1227 assert_eq!(
1228 panel.current_order(),
1229 (SortField::Title, SortOrder::Descending)
1230 );
1231 panel.set_active_query("widget".to_string());
1232 assert_eq!(
1233 panel.current_order(),
1234 (SortField::Name, SortOrder::Ascending)
1235 );
1236 }
1237
1238 #[tokio::test(flavor = "multi_thread")]
1244 async fn static_query_survives_navigation() {
1245 let vault = crate::test_support::temp_vault("nav-static").await;
1246 vault.validate_and_init().await.unwrap();
1247 vault
1248 .create_note(&VaultPath::note_path_from("/a.md"), "alpha #todo")
1249 .await
1250 .unwrap();
1251 let mut panel = make_panel(vault);
1252 panel.set_active_query("#todo".to_string());
1253 settle(&mut panel).await;
1254 assert_eq!(panel.list.visible_rows().len(), 1);
1255
1256 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1257 panel.set_note(VaultPath::note_path_from("x.md"), tx);
1258
1259 assert_eq!(panel.active_query(), "#todo");
1262 assert!(!panel.list.is_loading());
1263 settle(&mut panel).await;
1264 assert_eq!(panel.list.visible_rows().len(), 1); }
1266
1267 #[tokio::test(flavor = "multi_thread")]
1270 async fn note_variable_query_reruns_on_navigation() {
1271 let vault = crate::test_support::temp_vault("nav-var").await;
1272 vault.validate_and_init().await.unwrap();
1273 vault
1276 .create_note(&VaultPath::note_path_from("/target.md"), "I am the target")
1277 .await
1278 .unwrap();
1279 vault
1280 .create_note(&VaultPath::note_path_from("/linker.md"), "see [[target]]")
1281 .await
1282 .unwrap();
1283 let mut panel = make_panel(vault);
1284 assert_eq!(panel.active_query(), DEFAULT_QUERY);
1285
1286 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1287 panel.set_note(VaultPath::note_path_from("/target.md"), tx);
1288 settle(&mut panel).await;
1289
1290 assert!(
1292 panel
1293 .list
1294 .visible_rows()
1295 .iter()
1296 .any(|e| e.filename.contains("linker")),
1297 "expected linker as a backlink, got {:?}",
1298 panel
1299 .list
1300 .visible_rows()
1301 .iter()
1302 .map(|e| e.filename.clone())
1303 .collect::<Vec<_>>()
1304 );
1305 }
1306
1307 #[tokio::test(flavor = "multi_thread")]
1309 async fn note_variable_query_changes_with_note() {
1310 let vault = crate::test_support::temp_vault("nav-var2").await;
1311 vault.validate_and_init().await.unwrap();
1312 vault
1313 .create_note(&VaultPath::note_path_from("/a.md"), "I am a")
1314 .await
1315 .unwrap();
1316 vault
1317 .create_note(&VaultPath::note_path_from("/b.md"), "I am b")
1318 .await
1319 .unwrap();
1320 vault
1321 .create_note(&VaultPath::note_path_from("/links_a.md"), "see [[a]]")
1322 .await
1323 .unwrap();
1324 let mut panel = make_panel(vault);
1325 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1326
1327 panel.set_note(VaultPath::note_path_from("/a.md"), tx.clone());
1328 settle(&mut panel).await;
1329 assert!(
1330 panel
1331 .list
1332 .visible_rows()
1333 .iter()
1334 .any(|e| e.filename.contains("links_a"))
1335 );
1336
1337 panel.set_note(VaultPath::note_path_from("/b.md"), tx);
1338 settle(&mut panel).await;
1339 assert!(
1340 !panel
1341 .list
1342 .visible_rows()
1343 .iter()
1344 .any(|e| e.filename.contains("links_a")),
1345 "b has no backlinks, expected empty"
1346 );
1347 }
1348}