use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use kimun_core::NoteVault;
use kimun_core::nfs::VaultPath;
use ratatui::Frame;
use ratatui::crossterm::event::{KeyCode, KeyEvent};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, ListItem, Paragraph};
use kimun_core::{OrderBy, OrderField, with_order_directive};
use crate::components::autocomplete::AutocompleteMode;
use crate::components::event_state::EventState;
use crate::components::events::{AppEvent, AppTx};
use crate::components::file_list::{SortField, SortOrder};
use crate::components::query_vars::{query_has_variables, resolve_query};
use crate::components::saved_search_breadcrumb::SavedSearchBreadcrumb;
use crate::components::search_list::{
Emit, KeyReaction, RowSource, SearchList, SearchRow, VaultSuggestions,
};
use crate::keys::KeyBindings;
use crate::keys::action_shortcuts::ActionShortcuts;
use crate::keys::key_combo::KeyCombo;
use crate::settings::icons::Icons;
use crate::settings::themes::Theme;
const DEFAULT_QUERY: &str = "<{note}";
#[derive(Debug, Clone)]
pub struct BacklinkEntry {
pub path: VaultPath,
pub title: String,
pub filename: String,
pub context: String,
pub full_text: Option<String>,
}
impl SearchRow for BacklinkEntry {
fn to_list_item(&self, theme: &Theme, _icons: &Icons, selected: bool) -> ListItem<'static> {
let fg = theme.fg.to_ratatui();
let fg_muted = theme.fg_muted.to_ratatui();
let bg = theme.bg_panel.to_ratatui();
let title_style = if selected {
Style::default()
.fg(theme.fg_selected.to_ratatui())
.bg(theme.bg_selected.to_ratatui())
.add_modifier(Modifier::BOLD)
} else {
Style::default().fg(fg).bg(bg)
};
let title_display = if self.title.is_empty() {
&self.filename
} else {
&self.title
};
ListItem::new(Line::from(vec![
Span::styled(format!(" {} ", title_display), title_style),
Span::styled(
format!(" {}", self.filename),
Style::default().fg(fg_muted).bg(if selected {
theme.bg_selected.to_ratatui()
} else {
bg
}),
),
]))
}
fn match_text(&self) -> Option<&str> {
Some(&self.filename)
}
fn visual_height(&self) -> u16 {
1
}
}
struct BacklinkSource {
vault: Arc<NoteVault>,
current_note: Arc<Mutex<VaultPath>>,
}
#[async_trait]
impl RowSource<BacklinkEntry> for BacklinkSource {
async fn load(&self, query: &str, emit: Emit<BacklinkEntry>) {
let note = self.current_note.lock().unwrap().clone();
if query_has_variables(query) && note.is_root_or_empty() {
emit.replace(Vec::new());
return;
}
let q = resolve_query(query, Some(¬e));
let mut entries = load_query(&self.vault, &q).await;
if kimun_core::SearchTerms::from_query_string(query)
.order_by
.is_empty()
{
entries.sort_by_key(|e| e.filename.to_lowercase());
}
emit.replace(entries);
}
}
#[derive(Clone, Copy, PartialEq)]
enum ExpandState {
Collapsed,
Context,
Full,
}
pub struct QueryPanel {
list: SearchList<BacklinkEntry>,
current_note: Arc<Mutex<VaultPath>>,
saved_search: SavedSearchBreadcrumb,
expand: ExpandState,
expand_path: Option<VaultPath>,
content_scroll: usize,
content_scroll_max: usize,
key_bindings: KeyBindings,
redraw_tx: Arc<Mutex<Option<AppTx>>>,
follow_link_combos: Vec<KeyCombo>,
order_cache: (SortField, SortOrder),
order_cache_query: String,
}
impl QueryPanel {
pub fn new(vault: Arc<NoteVault>, key_bindings: KeyBindings) -> Self {
let icons = Icons::new(false);
let current_note = Arc::new(Mutex::new(VaultPath::empty()));
let redraw_tx: Arc<Mutex<Option<AppTx>>> = Arc::new(Mutex::new(None));
let redraw: Arc<dyn Fn() + Send + Sync> = {
let slot = redraw_tx.clone();
Arc::new(move || {
if let Some(tx) = slot.lock().unwrap().as_ref() {
let _ = tx.send(AppEvent::Redraw);
}
})
};
let source = BacklinkSource {
vault: vault.clone(),
current_note: current_note.clone(),
};
let combos = |action: &ActionShortcuts| -> Vec<KeyCombo> {
key_bindings
.to_hashmap()
.get(action)
.cloned()
.unwrap_or_default()
};
let follow_link_combos = combos(&ActionShortcuts::FollowLink);
let mut intercept = Vec::new();
intercept.extend(follow_link_combos.iter().cloned());
let list = SearchList::builder(source, redraw)
.initial_query(DEFAULT_QUERY)
.icons(icons.clone())
.autocomplete(
Arc::new(VaultSuggestions {
vault: vault.clone(),
}),
AutocompleteMode::SearchQuery,
)
.intercept(intercept)
.build();
Self {
list,
current_note,
saved_search: SavedSearchBreadcrumb::default(),
expand: ExpandState::Collapsed,
expand_path: None,
content_scroll: 0,
content_scroll_max: 0,
key_bindings,
redraw_tx,
follow_link_combos,
order_cache: (SortField::Name, SortOrder::Ascending),
order_cache_query: String::new(),
}
}
pub fn active_query(&self) -> &str {
self.list.query()
}
pub fn set_active_query(&mut self, q: String) {
self.list.set_query(q);
self.reset_expand();
}
pub fn saved_search_breadcrumb(&self) -> Option<String> {
self.saved_search.label(self.list.query())
}
fn query_is_blank(&self) -> bool {
let q = self.list.query();
q.trim().is_empty() || kimun_core::strip_order_directive(q) == DEFAULT_QUERY
}
pub fn apply_query(&mut self, query: String, name: Option<String>, tx: AppTx) {
self.ensure_redraw_tx(&tx);
self.set_active_query(query.clone());
self.saved_search.set(name, &query);
}
fn current_note(&self) -> VaultPath {
self.current_note.lock().unwrap().clone()
}
fn ensure_redraw_tx(&self, tx: &AppTx) {
let mut slot = self.redraw_tx.lock().unwrap();
if slot.is_none() {
*slot = Some(tx.clone());
}
}
fn resolved_query(&self) -> String {
resolve_query(self.list.query(), Some(&self.current_note()))
}
fn is_full_expanded(&self) -> bool {
self.list.selected_row().is_some() && self.expand == ExpandState::Full
}
pub fn is_empty(&self) -> bool {
self.list.rows().is_empty()
}
pub fn selected_path(&self) -> Option<&VaultPath> {
self.list.selected_row().map(|e| &e.path)
}
fn reset_expand(&mut self) {
self.expand = ExpandState::Collapsed;
self.expand_path = None;
self.content_scroll = 0;
self.content_scroll_max = 0;
}
fn sync_expand_anchor(&mut self) {
let sel = self.list.selected_row().map(|e| e.path.clone());
if sel != self.expand_path {
if self.expand != ExpandState::Context || sel.is_none() {
self.expand = ExpandState::Collapsed;
}
self.expand_path = sel;
self.content_scroll = 0;
}
}
pub fn set_note(&mut self, note_path: VaultPath, tx: AppTx) {
self.ensure_redraw_tx(&tx);
*self.current_note.lock().unwrap() = note_path;
if query_has_variables(self.list.query()) {
self.list.reload();
self.reset_expand();
}
}
pub fn current_order(&self) -> (SortField, SortOrder) {
let st = kimun_core::SearchTerms::from_query_string(self.list.query());
match st.order_by.first() {
Some(OrderBy::Title { asc }) => (
SortField::Title,
if *asc {
SortOrder::Ascending
} else {
SortOrder::Descending
},
),
Some(OrderBy::FileName { asc }) => (
SortField::Name,
if *asc {
SortOrder::Ascending
} else {
SortOrder::Descending
},
),
None => (SortField::Name, SortOrder::Ascending),
}
}
pub fn apply_sort(&mut self, field: SortField, order: SortOrder, tx: &AppTx) {
self.ensure_redraw_tx(tx);
let order_field = match field {
SortField::Name => OrderField::FileName,
SortField::Title => OrderField::Title,
};
let asc = matches!(order, SortOrder::Ascending);
let rewritten = with_order_directive(self.list.query(), order_field, asc);
self.list.set_query(rewritten);
self.reset_expand();
}
pub fn handle_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
self.ensure_redraw_tx(tx);
self.sync_expand_anchor();
if self.is_full_expanded() && matches!(key.code, KeyCode::Up | KeyCode::Down) {
self.scroll_content(key);
return EventState::Consumed;
}
match self.list.handle_key(key) {
KeyReaction::Intercepted(c) if self.follow_link_combos.contains(&c) => {
if let Some(path) = self.selected_path().cloned() {
tx.send(AppEvent::OpenPath(path)).ok();
}
EventState::Consumed
}
KeyReaction::Consumed => {
let accepted = self.list.take_accepted_saved_search();
let blank = self.query_is_blank();
self.saved_search
.on_query_consumed(accepted, self.list.query(), blank);
self.sync_expand_anchor();
EventState::Consumed
}
KeyReaction::Submit => {
self.toggle_expand();
EventState::Consumed
}
KeyReaction::Cancel => EventState::NotConsumed,
KeyReaction::Unhandled => EventState::NotConsumed,
KeyReaction::Intercepted(_) => EventState::Consumed,
}
}
fn scroll_content(&mut self, key: &KeyEvent) {
match key.code {
KeyCode::Up => {
self.content_scroll = self.content_scroll.saturating_sub(1);
}
KeyCode::Down => {
self.content_scroll += 1;
}
_ => {}
}
}
fn toggle_expand(&mut self) {
if self.list.selected_row().is_none() {
return;
}
self.expand_path = self.list.selected_row().map(|e| e.path.clone());
match self.expand {
ExpandState::Collapsed => {
self.expand = ExpandState::Context;
}
ExpandState::Context => {
self.content_scroll = 0;
self.expand = ExpandState::Full;
}
ExpandState::Full => {
self.content_scroll = 0;
self.expand = ExpandState::Collapsed;
}
}
}
pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
[
(ActionShortcuts::FocusSidebar, "\u{2190} editor"),
(ActionShortcuts::FollowLink, "open note"),
(ActionShortcuts::SaveCurrentQuery, "save query"),
(ActionShortcuts::OpenSavedSearches, "searches"),
(ActionShortcuts::OpenSortDialog, "sort"),
]
.iter()
.filter_map(|(action, label)| {
self.key_bindings
.first_combo_for(action)
.map(|k| (k, label.to_string()))
})
.collect()
}
pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
self.list.poll();
self.sync_expand_anchor();
let border_style = theme.border_style(focused);
let fg_muted = theme.fg_muted.to_ratatui();
let bg = theme.bg_panel.to_ratatui();
let count = self.list.visible_rows().len();
if self.list.query() != self.order_cache_query {
self.order_cache = self.current_order();
self.order_cache_query = self.list.query().to_string();
}
let (sort_field, sort_order) = self.order_cache;
let sort_indicator = format!("{}{}", sort_field.label(), sort_order.label());
let base_query = kimun_core::strip_order_directive(self.list.query());
let title = if base_query == DEFAULT_QUERY {
format!("Backlinks ({}) {}", count, sort_indicator)
} else {
format!("Query ({}) {}", count, sort_indicator)
};
let outer = Block::default()
.title(title)
.borders(Borders::ALL)
.border_style(border_style)
.style(theme.panel_style());
let outer_inner = outer.inner(rect);
f.render_widget(outer, rect);
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(outer_inner);
let search_title = self.saved_search.border_title(self.list.query(), " Query");
let search_block = Block::default()
.title(search_title)
.borders(Borders::ALL)
.border_style(border_style)
.style(theme.panel_style());
let search_inner = search_block.inner(rows[0]);
f.render_widget(search_block, rows[0]);
self.list.render_query(f, search_inner, theme, focused);
let inner = rows[1];
if self.list.is_loading() {
f.render_widget(
Paragraph::new(" Loading...").style(Style::default().fg(fg_muted).bg(bg)),
inner,
);
self.list.render_autocomplete(f, rect, theme);
return;
}
if self.list.visible_rows().is_empty() {
f.render_widget(
Paragraph::new(" No results").style(Style::default().fg(fg_muted).bg(bg)),
inner,
);
self.list.render_autocomplete(f, rect, theme);
return;
}
let selected_state = self.expand;
if selected_state == ExpandState::Full {
if let Some(entry) = self.list.selected_row() {
let entry = entry.clone();
let text = entry.full_text.as_deref().unwrap_or(&entry.context);
let title_display = if entry.title.is_empty() {
&entry.filename
} else {
&entry.title
};
let parts = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1), Constraint::Length(1), Constraint::Min(0), ])
.split(inner);
f.render_widget(
Paragraph::new(Line::from(vec![
Span::styled(
format!("\u{25BC} {} ", title_display),
Style::default()
.fg(theme.fg_selected.to_ratatui())
.bg(bg)
.add_modifier(Modifier::BOLD),
),
Span::styled(
format!(" {}", entry.filename),
Style::default().fg(fg_muted).bg(bg),
),
]))
.style(Style::default().bg(bg)),
parts[0],
);
f.render_widget(
Paragraph::new("\u{2500}".repeat(parts[1].width as usize))
.style(Style::default().fg(fg_muted).bg(bg)),
parts[1],
);
let indent = 2usize;
let wrap_width = parts[2].width.saturating_sub(indent as u16 + 1) as usize;
let needles = query_needles(&self.resolved_query());
let mut lines = Vec::new();
for line in text.lines() {
let wrapped = wrap_line(line, wrap_width);
for wline in wrapped {
let spans = highlight_needles(&wline, &needles, fg_muted, bg, theme);
let mut indented =
vec![Span::styled(" ".repeat(indent), Style::default().bg(bg))];
indented.extend(spans);
lines.push(Line::from(indented));
}
}
let total_lines = lines.len();
let viewport = parts[2].height as usize;
self.content_scroll_max = total_lines.saturating_sub(viewport);
self.content_scroll = self.content_scroll.min(self.content_scroll_max);
f.render_widget(
Paragraph::new(lines)
.scroll((self.content_scroll as u16, 0))
.style(Style::default().bg(bg)),
parts[2],
);
}
self.list.render_autocomplete(f, rect, theme);
return;
}
let has_context = selected_state == ExpandState::Context;
let (list_area, divider_area, content_area) = if has_context {
let max_list = inner.height / 2;
let list_height = (count as u16).min(max_list).max(1);
let areas = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(list_height),
Constraint::Length(1),
Constraint::Min(0),
])
.split(inner);
(areas[0], Some(areas[1]), Some(areas[2]))
} else {
(inner, None, None)
};
self.list.render(f, list_area, theme, focused);
self.list.set_list_rect(list_area);
if let Some(div) = divider_area {
f.render_widget(
Paragraph::new("\u{2500}".repeat(div.width as usize))
.style(Style::default().fg(fg_muted).bg(bg)),
div,
);
}
if let Some(area) = content_area
&& selected_state == ExpandState::Context
&& let Some(entry) = self.list.selected_row()
{
let entry = entry.clone();
let text = entry.full_text.as_deref().unwrap_or(&entry.context);
let indent = 2usize;
let wrap_width = area.width.saturating_sub(indent as u16 + 1) as usize;
let needles = query_needles(&self.resolved_query());
let mut lines = Vec::new();
let mut link_line: Option<usize> = None;
for line in text.lines() {
let wrapped = wrap_line(line, wrap_width);
for wline in wrapped {
if link_line.is_none()
&& needles
.iter()
.any(|n| !n.is_empty() && find_case_insensitive(&wline, n).is_some())
{
link_line = Some(lines.len());
}
let spans = highlight_needles(&wline, &needles, fg_muted, bg, theme);
let mut indented =
vec![Span::styled(" ".repeat(indent), Style::default().bg(bg))];
indented.extend(spans);
lines.push(Line::from(indented));
}
}
let viewport = area.height as usize;
let total = lines.len();
let link_pos = link_line.unwrap_or(0);
let lines_after_link = total.saturating_sub(link_pos);
let scroll_to = if lines_after_link <= viewport {
total.saturating_sub(viewport)
} else {
link_pos.saturating_sub(2)
} as u16;
f.render_widget(
Paragraph::new(lines)
.scroll((scroll_to, 0))
.style(Style::default().bg(bg)),
area,
);
}
self.list.render_autocomplete(f, rect, theme);
}
}
async fn load_query(vault: &NoteVault, query: &str) -> Vec<BacklinkEntry> {
let needles = query_needles(query);
let results = vault.search_notes(query).await.unwrap_or_default();
let mut entries = Vec::with_capacity(results.len());
for (entry_data, content_data) in results {
let text = vault
.get_note_text(&entry_data.path)
.await
.unwrap_or_default();
let context = extract_context_multi(&text, &needles);
let (_p, filename) = entry_data.path.get_parent_path();
entries.push(BacklinkEntry {
path: entry_data.path,
title: content_data.title,
filename,
context,
full_text: Some(text),
});
}
entries
}
fn split_paragraphs(text: &str) -> Vec<String> {
let mut paragraphs = Vec::new();
let mut current: Vec<&str> = Vec::new();
for line in text.lines() {
if line.trim().is_empty() {
if !current.is_empty() {
paragraphs.push(current.join("\n"));
current.clear();
}
} else {
current.push(line);
}
}
if !current.is_empty() {
paragraphs.push(current.join("\n"));
}
paragraphs
}
fn wrap_line(line: &str, max_width: usize) -> Vec<String> {
if max_width == 0 || line.chars().count() <= max_width {
return vec![line.to_string()];
}
let mut result = Vec::new();
let mut remaining = line;
while remaining.chars().count() > max_width {
let byte_limit = remaining
.char_indices()
.nth(max_width)
.map(|(i, _)| i)
.unwrap_or(remaining.len());
let break_at = remaining[..byte_limit]
.rfind(' ')
.map(|i| i + 1) .unwrap_or(byte_limit); result.push(remaining[..break_at].trim_end().to_string());
remaining = &remaining[break_at..];
}
if !remaining.is_empty() {
result.push(remaining.to_string());
}
result
}
fn find_case_insensitive(haystack: &str, needle: &str) -> Option<(usize, usize)> {
let needle_chars: Vec<char> = needle.chars().collect();
if needle_chars.is_empty() {
return None;
}
let hay_indices: Vec<(usize, char)> = haystack.char_indices().collect();
'outer: for start_idx in 0..hay_indices.len() {
if start_idx + needle_chars.len() > hay_indices.len() {
break;
}
for (j, &nc) in needle_chars.iter().enumerate() {
let hc = hay_indices[start_idx + j].1;
let mut h_lower = hc.to_lowercase();
let mut n_lower = nc.to_lowercase();
if h_lower.next() != n_lower.next() {
continue 'outer;
}
}
let byte_start = hay_indices[start_idx].0;
let byte_end = if start_idx + needle_chars.len() < hay_indices.len() {
hay_indices[start_idx + needle_chars.len()].0
} else {
haystack.len()
};
return Some((byte_start, byte_end));
}
None
}
fn extract_context_multi(text: &str, needles: &[String]) -> String {
let lowered: Vec<String> = needles.iter().map(|n| n.to_lowercase()).collect();
for para in &split_paragraphs(text) {
let lower = para.to_lowercase();
if lowered.iter().any(|n| !n.is_empty() && lower.contains(n)) {
return para.clone();
}
}
text.lines()
.find(|l| !l.trim().is_empty())
.unwrap_or("")
.to_string()
}
fn highlight_needles(
line: &str,
needles: &[String],
fg_muted: ratatui::style::Color,
bg: ratatui::style::Color,
theme: &Theme,
) -> Vec<Span<'static>> {
let normal = Style::default().fg(fg_muted).bg(bg);
let bold = Style::default()
.fg(theme.accent.to_ratatui())
.bg(bg)
.add_modifier(Modifier::BOLD);
let mut best: Option<(usize, usize)> = None;
for needle in needles {
if needle.is_empty() {
continue;
}
if let Some((s, e)) = find_case_insensitive(line, needle)
&& (best.is_none() || s < best.unwrap().0)
{
best = Some((s, e));
}
}
let Some((start, end)) = best else {
return vec![Span::styled(line.to_string(), normal)];
};
let mut spans = Vec::new();
if start > 0 {
spans.push(Span::styled(line[..start].to_string(), normal));
}
spans.push(Span::styled(line[start..end].to_string(), bold));
if end < line.len() {
spans.push(Span::styled(line[end..].to_string(), normal));
}
spans
}
fn query_needles(query: &str) -> Vec<String> {
let st = kimun_core::SearchTerms::from_query_string(query);
let mut needles = st.terms.clone();
needles.extend(st.links.clone());
needles.extend(st.forward_links.clone());
needles
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn wrap_line_fits_within_width() {
let result = wrap_line("short", 20);
assert_eq!(result, vec!["short"]);
}
#[test]
fn wrap_line_breaks_at_word_boundary() {
let result = wrap_line("hello world foo bar", 12);
assert_eq!(result, vec!["hello world", "foo bar"]);
}
#[test]
fn wrap_line_hard_breaks_long_word() {
let result = wrap_line("abcdefghij", 5);
assert_eq!(result, vec!["abcde", "fghij"]);
}
#[test]
fn wrap_line_handles_multibyte_chars() {
let result = wrap_line("日本語テスト", 3);
assert_eq!(result, vec!["日本語", "テスト"]);
}
#[test]
fn wrap_line_empty_string() {
let result = wrap_line("", 10);
assert_eq!(result, vec![""]);
}
#[test]
fn extract_context_matches_any_needle() {
let text = "# Title\n\nIntro line.\n\nA paragraph mentioning widget here.\n";
let result = extract_context_multi(text, &["widget".to_string()]);
assert!(result.contains("widget"));
}
#[test]
fn highlight_needles_highlights_first_match() {
let spans = highlight_needles(
"see widget and gadget",
&["gadget".to_string()],
ratatui::style::Color::Gray,
ratatui::style::Color::Black,
&crate::settings::themes::Theme::default(),
);
assert!(
spans
.iter()
.any(|s| s.content.contains("gadget")
&& s.style.add_modifier.contains(Modifier::BOLD))
);
}
#[test]
fn query_needles_extracts_terms_and_links() {
let n = query_needles("widget <spec");
assert!(n.iter().any(|x| x == "widget"));
assert!(n.iter().any(|x| x == "spec"));
}
#[test]
fn query_needles_extracts_forward_links() {
let n = query_needles(">spec");
assert!(n.iter().any(|x| x == "spec"));
}
#[tokio::test]
async fn query_panel_load_query_lists_matches() {
let vault = crate::test_support::temp_vault("qp").await;
vault.validate_and_init().await.unwrap();
vault
.create_note(&VaultPath::note_path_from("/a.md"), "alpha #todo")
.await
.unwrap();
vault
.create_note(&VaultPath::note_path_from("/b.md"), "beta")
.await
.unwrap();
let entries = load_query(&vault, "#todo").await;
assert_eq!(entries.len(), 1);
assert!(entries[0].filename.contains("a"));
}
fn make_panel(vault: Arc<NoteVault>) -> QueryPanel {
let kb = crate::settings::AppSettings::default().key_bindings.clone();
QueryPanel::new(vault, kb)
}
async fn settle(panel: &mut QueryPanel) {
for _ in 0..100 {
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
panel.list.poll();
if !panel.list.is_loading() {
break;
}
}
}
#[tokio::test(flavor = "multi_thread")]
async fn apply_sort_rewrites_query_order_directive() {
let vault = crate::test_support::temp_vault("qp-sort").await;
vault.validate_and_init().await.unwrap();
let mut panel = make_panel(vault);
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
panel.set_active_query("widget".to_string());
panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
assert_eq!(panel.active_query(), "widget or:title");
panel.apply_sort(SortField::Name, SortOrder::Descending, &tx);
assert_eq!(panel.active_query(), "widget -or:file");
}
#[tokio::test(flavor = "multi_thread")]
async fn directiveless_query_is_name_ascending() {
let vault = crate::test_support::temp_vault("qp-defaultorder").await;
vault.validate_and_init().await.unwrap();
for name in ["/charlie.md", "/alpha.md", "/bravo.md"] {
vault
.create_note(&VaultPath::note_path_from(name), "widget")
.await
.unwrap();
}
let mut panel = make_panel(vault);
panel.set_active_query("widget".to_string()); settle(&mut panel).await;
let names: Vec<String> = panel
.list
.visible_rows()
.iter()
.map(|e| e.filename.clone())
.collect();
let mut sorted = names.clone();
sorted.sort();
assert_eq!(names, sorted, "directive-less query must be name-ascending");
}
#[tokio::test(flavor = "multi_thread")]
async fn accepting_saved_search_pins_breadcrumb() {
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let vault = crate::test_support::temp_vault("qp-ss-accept").await;
vault.validate_and_init().await.unwrap();
vault.save_search("todo-week", "#todo").await.unwrap();
let mut panel = make_panel(vault);
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
panel.set_active_query(String::new());
for ch in ['?', 't', 'o'] {
panel.handle_key(&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
for _ in 0..30 {
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
panel.list.poll();
}
}
panel.handle_key(&KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &tx);
assert_eq!(panel.active_query(), "#todo");
assert_eq!(
panel.saved_search_breadcrumb().as_deref(),
Some("todo-week")
);
}
#[tokio::test(flavor = "multi_thread")]
async fn editing_expanded_query_keeps_breadcrumb_marked_edited() {
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let vault = crate::test_support::temp_vault("qp-ss-edit").await;
vault.validate_and_init().await.unwrap();
let mut panel = make_panel(vault);
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
assert_eq!(panel.saved_search_breadcrumb().as_deref(), Some("todo"));
panel.handle_key(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
assert_eq!(panel.active_query(), "#todox");
assert_eq!(
panel.saved_search_breadcrumb().as_deref(),
Some("todo • edited")
);
}
#[tokio::test(flavor = "multi_thread")]
async fn emptying_field_clears_breadcrumb() {
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
let vault = crate::test_support::temp_vault("qp-ss-empty").await;
vault.validate_and_init().await.unwrap();
let mut panel = make_panel(vault);
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
for _ in 0.."#todo".len() {
panel.handle_key(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), &tx);
}
assert_eq!(panel.active_query(), "");
assert_eq!(panel.saved_search_breadcrumb(), None);
}
#[tokio::test(flavor = "multi_thread")]
async fn apply_query_pins_breadcrumb() {
let vault = crate::test_support::temp_vault("qp-name").await;
vault.validate_and_init().await.unwrap();
let mut panel = make_panel(vault);
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
assert_eq!(panel.saved_search_breadcrumb().as_deref(), Some("todo"));
}
#[tokio::test(flavor = "multi_thread")]
async fn apply_sort_keeps_saved_search_breadcrumb() {
let vault = crate::test_support::temp_vault("qp-sort-name").await;
vault.validate_and_init().await.unwrap();
let mut panel = make_panel(vault);
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
assert_eq!(panel.active_query(), "#todo or:title");
assert_eq!(
panel.saved_search_breadcrumb().as_deref(),
Some("todo"),
"sorting keeps the unedited breadcrumb"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn apply_sort_updates_visible_input_bar() {
let vault = crate::test_support::temp_vault("qp-bar").await;
vault.validate_and_init().await.unwrap();
let mut panel = make_panel(vault);
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
panel.set_active_query("widget".to_string());
assert_eq!(
panel.list.input_value(),
"widget",
"set_active_query syncs the bar"
);
panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
assert_eq!(panel.active_query(), "widget or:title");
assert_eq!(
panel.list.input_value(),
"widget or:title",
"the input bar must reflect the rewritten query"
);
}
#[tokio::test(flavor = "multi_thread")]
async fn current_order_reads_query_directive() {
let vault = crate::test_support::temp_vault("qp-order").await;
vault.validate_and_init().await.unwrap();
let mut panel = make_panel(vault);
panel.set_active_query("widget -or:title".to_string());
assert_eq!(
panel.current_order(),
(SortField::Title, SortOrder::Descending)
);
panel.set_active_query("widget".to_string());
assert_eq!(
panel.current_order(),
(SortField::Name, SortOrder::Ascending)
);
}
#[tokio::test(flavor = "multi_thread")]
async fn static_query_survives_navigation() {
let vault = crate::test_support::temp_vault("nav-static").await;
vault.validate_and_init().await.unwrap();
vault
.create_note(&VaultPath::note_path_from("/a.md"), "alpha #todo")
.await
.unwrap();
let mut panel = make_panel(vault);
panel.set_active_query("#todo".to_string());
settle(&mut panel).await;
assert_eq!(panel.list.visible_rows().len(), 1);
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
panel.set_note(VaultPath::note_path_from("x.md"), tx);
assert_eq!(panel.active_query(), "#todo");
assert!(!panel.list.is_loading());
settle(&mut panel).await;
assert_eq!(panel.list.visible_rows().len(), 1); }
#[tokio::test(flavor = "multi_thread")]
async fn note_variable_query_reruns_on_navigation() {
let vault = crate::test_support::temp_vault("nav-var").await;
vault.validate_and_init().await.unwrap();
vault
.create_note(&VaultPath::note_path_from("/target.md"), "I am the target")
.await
.unwrap();
vault
.create_note(&VaultPath::note_path_from("/linker.md"), "see [[target]]")
.await
.unwrap();
let mut panel = make_panel(vault);
assert_eq!(panel.active_query(), DEFAULT_QUERY);
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
panel.set_note(VaultPath::note_path_from("/target.md"), tx);
settle(&mut panel).await;
assert!(
panel
.list
.visible_rows()
.iter()
.any(|e| e.filename.contains("linker")),
"expected linker as a backlink, got {:?}",
panel
.list
.visible_rows()
.iter()
.map(|e| e.filename.clone())
.collect::<Vec<_>>()
);
}
#[tokio::test(flavor = "multi_thread")]
async fn note_variable_query_changes_with_note() {
let vault = crate::test_support::temp_vault("nav-var2").await;
vault.validate_and_init().await.unwrap();
vault
.create_note(&VaultPath::note_path_from("/a.md"), "I am a")
.await
.unwrap();
vault
.create_note(&VaultPath::note_path_from("/b.md"), "I am b")
.await
.unwrap();
vault
.create_note(&VaultPath::note_path_from("/links_a.md"), "see [[a]]")
.await
.unwrap();
let mut panel = make_panel(vault);
let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
panel.set_note(VaultPath::note_path_from("/a.md"), tx.clone());
settle(&mut panel).await;
assert!(
panel
.list
.visible_rows()
.iter()
.any(|e| e.filename.contains("links_a"))
);
panel.set_note(VaultPath::note_path_from("/b.md"), tx);
settle(&mut panel).await;
assert!(
!panel
.list
.visible_rows()
.iter()
.any(|e| e.filename.contains("links_a")),
"b has no backlinks, expected empty"
);
}
}