Skip to main content

kimun_notes/components/
backlinks_panel.rs

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
30/// The default query the panel runs: backlinks to the current note.
31/// Per ADR-0005, backlinks are `<` / `lk:` (`>` is now forward links).
32const DEFAULT_QUERY: &str = "<{note}";
33
34// ---------------------------------------------------------------------------
35// BacklinkEntry
36// ---------------------------------------------------------------------------
37
38/// A single backlink entry with preloaded context.
39#[derive(Debug, Clone)]
40pub struct BacklinkEntry {
41    pub path: VaultPath,
42    pub title: String,
43    pub filename: String,
44    /// The paragraph in this note that contains the link to the current note.
45    pub context: String,
46    /// Full note text, loaded when backlinks are fetched.
47    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
90// ---------------------------------------------------------------------------
91// BacklinkSource
92// ---------------------------------------------------------------------------
93
94/// Row source for the Query panel. The engine holds the query TEMPLATE verbatim
95/// (e.g. `<{note}`, so the input shows the template); this source resolves
96/// `{note}` against the shared current note at load time, preserving the exact
97/// "input shows the template, results are backlinks of the current note" UX.
98/// Result ordering comes from the query string's order directive, applied by
99/// the vault DB — the source no longer sorts in memory.
100struct 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        // Clone the note out of the lock, then drop the guard before awaiting.
109        let note = self.current_note.lock().unwrap().clone();
110        // Skip the search when the query contains a variable but no note is
111        // open yet (startup state). Running `load_query(vault, "<")` against an
112        // empty note is a wasted DB round-trip that returns nothing; once
113        // `set_note` provides a real note the normal reload fires.
114        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(&note));
119        let mut entries = load_query(&self.vault, &q).await;
120        // The DB orders results only when the query carries an `or:` directive
121        // (core applies the sort iff `order_by` is non-empty). Keep that
122        // directive as the source of truth, but fall back to a stable
123        // Name-ascending order when the query has none — otherwise default
124        // backlinks come back in arbitrary DB scan order, and the sort dialog's
125        // reported default (Name/Ascending) would not match the displayed list.
126        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// ---------------------------------------------------------------------------
137// ExpandState (private)
138// ---------------------------------------------------------------------------
139
140#[derive(Clone, Copy, PartialEq)]
141enum ExpandState {
142    Collapsed,
143    Context,
144    Full,
145}
146
147// ---------------------------------------------------------------------------
148// QueryPanel
149// ---------------------------------------------------------------------------
150
151pub struct QueryPanel {
152    /// The SearchList engine: owns the query input, the result list, and the
153    /// hashtag/link autocomplete.
154    list: SearchList<BacklinkEntry>,
155    /// Shared handle to the current note. `BacklinkSource::load` reads this to
156    /// resolve `{note}` in the query template.
157    current_note: Arc<Mutex<VaultPath>>,
158    /// The saved-search breadcrumb shown on the query searchbox border. Owns
159    /// its own sticky/clear/edited state machine; this panel only forwards
160    /// query events to it. See [`SavedSearchBreadcrumb`] and `adr/0006`.
161    saved_search: SavedSearchBreadcrumb,
162    /// Expand state of the currently-selected row. `Context` sticks across
163    /// navigation (re-anchored on the new row); `Full` and query changes reset
164    /// to `Collapsed`.
165    expand: ExpandState,
166    /// The path the `expand` state belongs to, used to detect selection changes
167    /// (the engine owns the list, so we re-anchor expand on the selected row).
168    expand_path: Option<VaultPath>,
169    /// Scroll offset for full-expanded content view.
170    content_scroll: usize,
171    /// Maximum scroll offset (computed during render).
172    content_scroll_max: usize,
173    key_bindings: KeyBindings,
174    /// Shared sender filled the first time a `tx` arrives. The engine's redraw
175    /// callback reads this slot, so async loads/autocomplete wake the render
176    /// loop once the app event channel is wired (the panel is built before the
177    /// channel exists in some construction orders).
178    redraw_tx: Arc<Mutex<Option<AppTx>>>,
179    /// Combos that the engine intercepts: follow-link.
180    follow_link_combos: Vec<KeyCombo>,
181    /// Memoised sort field/order parsed from the query's order directive, plus
182    /// the query string it was parsed from. `render` reparses only when the
183    /// query changes, so the per-frame title indicator avoids a full query
184    /// parse every frame.
185    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        // The redraw callback reads a shared slot that `set_note`/`handle_key`
194        // fill once a `tx` is available (the panel is constructed before the
195        // app event channel in some orders). Until then it is a no-op.
196        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            // DEFAULT_QUERY carries no order directive → (Name, Ascending).
245            order_cache: (SortField::Name, SortOrder::Ascending),
246            order_cache_query: String::new(),
247        }
248    }
249
250    // ── Query accessors ─────────────────────────────────────────────────
251
252    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    /// The breadcrumb label for the query searchbox border, or `None` when no
262    /// saved search is active. See `adr/0006`.
263    pub fn saved_search_breadcrumb(&self) -> Option<String> {
264        self.saved_search.label(self.list.query())
265    }
266
267    /// `true` when the live query carries no saved-search provenance worth
268    /// showing — an empty field, or the default backlinks query (which the
269    /// panel title already renders as "Backlinks", so a breadcrumb there would
270    /// contradict it). Drives the breadcrumb's clear condition.
271    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    /// Apply a query template (e.g. from a saved search) and run it. The engine
277    /// holds the template verbatim; `{note}` is resolved at load. `name` pins
278    /// the breadcrumb (`None` for the default backlinks query).
279    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    // ── Helpers ─────────────────────────────────────────────────────────
286
287    fn current_note(&self) -> VaultPath {
288        self.current_note.lock().unwrap().clone()
289    }
290
291    /// Fill the shared redraw slot so the engine's async loads / autocomplete
292    /// wake the render loop. Idempotent.
293    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    /// Resolve the active query template against the current note (the form the
301    /// source actually searches; used to derive highlight needles).
302    fn resolved_query(&self) -> String {
303        resolve_query(self.list.query(), Some(&self.current_note()))
304    }
305
306    /// Returns true if the selected entry is in full-expand mode (content takes
307    /// the whole panel, up/down scrolls content).
308    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    /// Re-anchor the expand state on the currently-selected row. The Context
328    /// (half-height) preview sticks across selection moves: it stays open and
329    /// re-anchors on the new row, so Down/Up browse previews in place. Full
330    /// collapses, and a vanished selection always collapses.
331    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    // ── Loading ─────────────────────────────────────────────────────────
343
344    /// Record the newly-open note. Re-runs the query only when it depends on
345    /// `{note}` (otherwise the existing results stay untouched).
346    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    /// Current sort field/order, derived from the active query's order
356    /// directive. Defaults to (Name, Ascending) when the query has none.
357    /// Parses the query each call — cheap for the rare callers (dialog open).
358    /// The per-frame render path uses the memoised `order_cache` instead.
359    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    /// Apply a sort selection from the sort dialog: rewrite the query's order
383    /// directive (the query string is the single source of truth) and reload.
384    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        // A sort only rewrites the order directive — the breadcrumb stays
394        // (and `saved_search_breadcrumb` ignores the directive, so it is not
395        // marked edited). See `adr/0006`.
396        self.reset_expand();
397    }
398
399    // ── Input handling ──────────────────────────────────────────────────
400
401    pub fn handle_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
402        self.ensure_redraw_tx(tx);
403        self.sync_expand_anchor();
404
405        // Full-expand takes over Up/Down for content scroll BEFORE the engine
406        // sees them.
407        if self.is_full_expanded() && matches!(key.code, KeyCode::Up | KeyCode::Down) {
408            self.scroll_content(key);
409            return EventState::Consumed;
410        }
411        // NOTE: Enter is NOT pre-checked here. It must reach the engine so an
412        // open autocomplete popup can accept on Enter; only when the popup is
413        // closed does the engine return `Submit`, which toggles expand below.
414        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                // Forward the query event to the breadcrumb: a `?name`
423                // expansion pins it, a blank query clears it, a manual edit
424                // keeps it (sticky). See `adr/0006`.
425                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                // Enter with the autocomplete popup closed: the panel's policy
434                // is to cycle the expand state of the selected row.
435                self.toggle_expand();
436                EventState::Consumed
437            }
438            // Esc bubbles to the editor for focus changes.
439            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                // Increment freely; render() clamps to content_scroll_max.
452                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    // ── Rendering ──────────────────────────────────────────────────────
496
497    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        // Reparse the order only when the query changed (memoised) — render runs
507        // every frame and `from_query_string` is a full allocating parse.
508        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        // Compare ignoring the order directive so that sorting the default
515        // backlinks query (`<{note} or:title`) still reads as "Backlinks".
516        let base_query = kimun_core::strip_order_directive(self.list.query());
517        // The saved-search name lives on the query searchbox border (the
518        // breadcrumb below), not here, so the outer title stays generic.
519        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        // Split off the query line (top) from the list/preview (rest).
534        let rows = Layout::default()
535            .direction(Direction::Vertical)
536            .constraints([Constraint::Length(3), Constraint::Min(0)])
537            .split(outer_inner);
538        // The saved-search breadcrumb (`‹ name ›` / `‹ name • edited ›`) titles
539        // the query searchbox when a saved search is active. See `adr/0006`.
540        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        // Full mode: content takes the entire panel, no list visible.
573        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                // Split into fixed header (title + divider) and scrollable content.
579                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), // title
589                        Constraint::Length(1), // divider
590                        Constraint::Min(0),    // content
591                    ])
592                    .split(inner);
593
594                // Fixed title header.
595                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                // Fixed divider.
614                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                // Scrollable content.
621                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        // Context or Collapsed: show the list, optionally with preview below.
654        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        // The engine draws the collapsed list (1 line per row, with the
673        // selected-row marker handled in `to_list_item`).
674        self.list.render(f, list_area, theme, focused);
675        self.list.set_list_rect(list_area);
676
677        // Divider between list and content.
678        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        // Render context preview below the list: show the full note text
687        // scrolled so the first link occurrence is visible with context above.
688        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            // Track which rendered line contains the first needle match.
701            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            // Scroll to show the link with context above. If the content from
722            // the link to the end fits within the viewport, scroll back further
723            // to fill the available space.
724            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                // Content from link to end fits — scroll back to fill the viewport.
730                total.saturating_sub(viewport)
731            } else {
732                // More content below the link — show 2 lines of context above.
733                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
748// ---------------------------------------------------------------------------
749// Standalone async helpers
750// ---------------------------------------------------------------------------
751
752/// Run `query` (already a resolved plain query string) and build entries.
753/// Sources from full-text / query search via `vault.search_notes`.
754async 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
776/// Split text into paragraphs. A paragraph is one or more consecutive
777/// non-blank lines. Blank lines act as separators.
778fn 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
799// ---------------------------------------------------------------------------
800// Rendering helpers
801// ---------------------------------------------------------------------------
802
803/// Wrap a single line into multiple lines that fit within `max_width` characters.
804/// Uses character count (not byte length) for width. Wraps at word boundaries
805/// when possible, hard-breaks otherwise.
806fn 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        // Find the byte index of the max_width-th character.
816        let byte_limit = remaining
817            .char_indices()
818            .nth(max_width)
819            .map(|(i, _)| i)
820            .unwrap_or(remaining.len());
821
822        // Try to find a space to break at (within the allowed character range).
823        let break_at = remaining[..byte_limit]
824            .rfind(' ')
825            .map(|i| i + 1) // include the space on the current line
826            .unwrap_or(byte_limit); // hard break if no space
827        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
836/// Case-insensitive search for `needle` in `haystack`, returning the byte
837/// range `(start, end)` in `haystack` where the match occurs. Compares
838/// char-by-char via `to_lowercase()` so byte lengths are always derived from
839/// the original string, avoiding the case-folding byte-mismatch problem.
840fn 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            // Compare lowercased chars.
853            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        // Match found — compute byte range from haystack char indices.
860        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
871/// Find the first paragraph containing any of `needles` (case-insensitive);
872/// fall back to the first non-blank line.
873fn 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
887/// Highlight the earliest occurrence of any needle in `line` (bold accent).
888fn 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
925/// Needles to highlight for a query: its free-text terms + link targets
926/// (both backlink and forward-link targets).
927fn 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// ---------------------------------------------------------------------------
936// Tests
937// ---------------------------------------------------------------------------
938
939#[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        // 5 CJK characters — each is 1 char, should wrap at char boundary
964        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        // A forward-link query (`>target`) must contribute its target as a
1008        // highlight needle, just like a backlink query (`<target`).
1009        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    /// Drive the engine until its async load settles. Unlike the engine's
1036    /// `poll_until_idle` (tight `yield_now` loop), this gives the spawned
1037    /// sqlite-backed search task real wall-clock time to complete — `load_query`
1038    /// awaits a full-text search plus per-result `get_note_text`, which a
1039    /// yield-only loop does not advance fast enough.
1040    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    /// Regression: a directive-less query must still return a stable
1066    /// Name-ascending order (the DB only sorts when an `or:` directive is
1067    /// present). Without the fallback, results came back in arbitrary DB order.
1068    #[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        // Create in non-alphabetical order; all share the term "widget".
1073        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()); // no order directive
1081        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    /// Accepting a `?name` expansion through the panel pins the saved-search
1095    /// breadcrumb to the accepted name and runs the stored query. See `adr/0006`.
1096    #[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        // Clear the default query so `?` is the leading char, then type a
1106        // prefix, draining the async popup load between keystrokes so the
1107        // suggestion lands before we accept.
1108        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    /// Editing the expanded query keeps the breadcrumb (sticky provenance) and
1126    /// marks it `• edited` once the text diverges from the stored query. See
1127    /// `adr/0006`.
1128    #[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        // A manual edit must NOT drop the breadcrumb; it gains the marker.
1139        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    /// Emptying the query field clears the breadcrumb entirely (one of the two
1148    /// clear triggers, the other being a fresh expansion). See `adr/0006`.
1149    #[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        // Backspace the whole "#todo" away.
1159        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    /// Applying a sort rewrites the query's order directive but keeps the
1177    /// breadcrumb sticky — and NOT marked edited, because the edited check
1178    /// ignores the order directive. See `adr/0006`.
1179    #[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    /// Regression: a programmatic sort change must update the VISIBLE input bar,
1197    /// not just the internal query string. (Previously `set_query` left the
1198    /// input widget stale, so the bar didn't show the `or:` directive.)
1199    #[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    /// A static query (no `{note}`) must survive navigation: `set_note` leaves
1239    /// its query template untouched and does NOT reload the engine.
1240    // Multi-thread flavour: the engine drives the source load on a spawned
1241    // task, and `search_notes` awaits a sqlite pool that needs the IO driver
1242    // (a current-thread runtime only advances the spawned task on `yield_now`).
1243    #[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        // Query template untouched (not reset to <{note}); a static query is
1260        // not reloaded, so it is not in a loading state.
1261        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); // results untouched
1265    }
1266
1267    /// A `{note}` query re-runs on navigation: `set_note` resolves `{note}`
1268    /// against the new note and reloads, so results follow the open note.
1269    #[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        // `target` is linked from `linker`; opening `target` should surface
1274        // `linker` as a backlink.
1275        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        // The `{note}` query resolved against `target` and found the backlink.
1291        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    /// Navigating to a different `{note}` re-resolves and changes results.
1308    #[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}