Skip to main content

kimun_notes/components/
query_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::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, FileOp};
18use crate::components::file_list::{SortField, SortOrder};
19use crate::components::preview_pane::{Highlight, PreviewPane};
20use crate::components::query_vars::{QueryContext, query_has_variables, resolve_query};
21use crate::components::saved_search_breadcrumb::SavedSearchBreadcrumb;
22use crate::components::search_list::{
23    Emit, Focus, KeyReaction, ResolvingRowSource, RowSource, SearchList, SearchMouse, SearchRow,
24    Unresolvable, VaultSuggestions,
25};
26use crate::keys::KeyBindings;
27use crate::keys::action_shortcuts::ActionShortcuts;
28use crate::keys::key_combo::KeyCombo;
29use crate::settings::icons::Icons;
30use crate::settings::themes::Theme;
31
32/// The canonical backlinks query (`<` / `lk:`; `>` is forward links). The
33/// panel no longer starts on it — the LINKS drawer owns backlinks — but any
34/// spelling of it still titles the panel "Backlinks".
35const DEFAULT_QUERY: &str = "<{note}";
36/// The long-form spelling of [`DEFAULT_QUERY`] (`lk:` is the documented
37/// synonym of `<`), recognized so it also reads as the default.
38const DEFAULT_QUERY_LONG: &str = "lk:{note}";
39
40/// True when `query` is the default backlinks query in any spelling: the
41/// canonical `<{note}`, the bare `<` sugar, the long form `lk:` — with or
42/// without an order directive. Drives the "Backlinks" title and the
43/// breadcrumb's blank-query condition, so every synonym reads as the default.
44fn is_default_query(query: &str) -> bool {
45    let expanded = kimun_core::expand_bare_note_prefixes(
46        &kimun_core::strip_order_directive(query),
47        crate::components::query_vars::VAR_NOTE,
48    );
49    expanded == DEFAULT_QUERY || expanded == DEFAULT_QUERY_LONG
50}
51
52// ---------------------------------------------------------------------------
53// BacklinkEntry
54// ---------------------------------------------------------------------------
55
56/// A single backlink entry with preloaded context.
57#[derive(Debug, Clone)]
58pub struct BacklinkEntry {
59    pub path: VaultPath,
60    pub title: String,
61    pub filename: String,
62    /// The paragraph in this note that contains the link to the current note.
63    pub context: String,
64    /// Full note text, loaded when backlinks are fetched.
65    pub full_text: Option<String>,
66}
67
68impl SearchRow for BacklinkEntry {
69    fn to_list_item(&self, theme: &Theme, icons: &Icons, selected: bool) -> ListItem<'static> {
70        let title_display = if self.title.is_empty() {
71            &self.filename
72        } else {
73            &self.title
74        };
75        let title_style = if selected {
76            Style::default()
77                .fg(theme.selection_fg.to_ratatui())
78                .bg(theme.selection_bg.to_ratatui())
79                .add_modifier(Modifier::BOLD)
80        } else {
81            Style::default()
82                .fg(theme.fg.to_ratatui())
83                .bg(theme.bg_panel.to_ratatui())
84        };
85        crate::components::rich_row::RichRow::new(icons.note, title_display.clone())
86            .title_style(title_style)
87            .meta(self.filename.clone())
88            .into_list_item(theme)
89    }
90
91    fn match_text(&self) -> Option<&str> {
92        Some(&self.filename)
93    }
94
95    fn visual_height(&self) -> u16 {
96        1
97    }
98
99    fn yank_target(&self) -> Option<crate::components::search_list::YankTarget> {
100        Some(crate::components::search_list::YankTarget::path(
101            self.path.to_string(),
102        ))
103    }
104}
105
106// ---------------------------------------------------------------------------
107// BacklinkSource
108// ---------------------------------------------------------------------------
109
110/// Row source for the Query panel. It receives an already-resolved query
111/// string — [`ResolvingRowSource`] substitutes `{note}` and short-circuits the
112/// purely-note-dependent-but-no-note case to an empty list ([`Unresolvable::Empty`])
113/// before this source is asked to load. Result ordering comes from the query
114/// string's order directive, applied by the vault DB — the source no longer
115/// sorts in memory beyond the no-directive default.
116struct BacklinkSource {
117    vault: Arc<NoteVault>,
118}
119
120#[async_trait]
121impl RowSource<BacklinkEntry> for BacklinkSource {
122    async fn load(&self, query: &str, emit: Emit<BacklinkEntry>) {
123        let mut entries = load_query(&self.vault, query).await;
124        // The DB orders results only when the query carries an `or:` directive
125        // (core applies the sort iff `order_by` is non-empty). Keep that
126        // directive as the source of truth, but fall back to a stable
127        // Name-ascending order when the query has none — otherwise default
128        // backlinks come back in arbitrary DB scan order, and the sort dialog's
129        // reported default (Name/Ascending) would not match the displayed list.
130        if kimun_core::SearchTerms::from_query_string(query)
131            .order_by
132            .is_empty()
133        {
134            entries.sort_by_key(|e| e.filename.to_lowercase());
135        }
136        emit.replace(entries);
137    }
138}
139
140// ---------------------------------------------------------------------------
141// QueryPanel
142// ---------------------------------------------------------------------------
143
144pub struct QueryPanel {
145    /// The SearchList engine: owns the query input, the result list, and the
146    /// hashtag/link autocomplete.
147    list: SearchList<BacklinkEntry>,
148    /// Shared handle to the current note. `BacklinkSource::load` reads this to
149    /// resolve `{note}` in the query template.
150    current_note: Arc<Mutex<VaultPath>>,
151    /// The saved-search breadcrumb shown on the query searchbox border. Owns
152    /// its own sticky/clear/edited state machine; this panel only forwards
153    /// query events to it. See [`SavedSearchBreadcrumb`].
154    saved_search: SavedSearchBreadcrumb,
155    /// The note-preview surface (expand state machine + content scroll +
156    /// content render). The panel feeds it the selected note's text and the
157    /// highlight needles; it owns where the preview is and how far it scrolls.
158    /// See [`PreviewPane`].
159    preview: PreviewPane,
160    key_bindings: KeyBindings,
161    /// Shared sender filled the first time a `tx` arrives. The engine's redraw
162    /// callback reads this slot, so async loads/autocomplete wake the render
163    /// loop once the app event channel is wired (the panel is built before the
164    /// channel exists in some construction orders).
165    redraw_tx: Arc<Mutex<Option<AppTx>>>,
166    /// Combos that the engine intercepts: follow-link.
167    follow_link_combos: Vec<KeyCombo>,
168    /// Memoised sort field/order parsed from the query's order directive, plus
169    /// the query string it was parsed from. `render` reparses only when the
170    /// query changes, so the per-frame title indicator avoids a full query
171    /// parse every frame.
172    order_cache: (SortField, SortOrder),
173    order_cache_query: String,
174    /// Memoised `is_default_query` result for `order_cache_query` — the title
175    /// reads it every frame, and the helper allocates (strip + expand), so it
176    /// is refreshed in the same query-changed gate as `order_cache`.
177    is_default_cache: bool,
178    /// Memoised highlight needles derived from the resolved query, plus the
179    /// (query template, note) pair they were computed from. The expand/context
180    /// preview branches of `render` read needles every frame; recomputing them
181    /// means resolving the template and a full query parse, so they are cached
182    /// like `order_cache` and refreshed only when a key changes.
183    needles_cache: Vec<String>,
184    needles_cache_key: (String, VaultPath),
185}
186
187impl QueryPanel {
188    pub fn new(vault: Arc<NoteVault>, key_bindings: KeyBindings, icons: Icons) -> Self {
189        let current_note = Arc::new(Mutex::new(VaultPath::empty()));
190        // The redraw callback reads a shared slot that `set_note`/`handle_key`
191        // fill once a `tx` is available (the panel is constructed before the
192        // app event channel in some orders). Until then it is a no-op.
193        let redraw_tx: Arc<Mutex<Option<AppTx>>> = Arc::new(Mutex::new(None));
194        let redraw: Arc<dyn Fn() + Send + Sync> = {
195            let slot = redraw_tx.clone();
196            Arc::new(move || {
197                if let Some(tx) = slot.lock().unwrap().as_ref() {
198                    let _ = tx.send(AppEvent::Redraw);
199                }
200            })
201        };
202        // Resolve `{note}` against the shared (live) current note at load time;
203        // a purely note-dependent query with no note open yet shows nothing
204        // (the panel has no recent-notes fallback). See [`ResolvingRowSource`].
205        let source = ResolvingRowSource::new(
206            Arc::new(BacklinkSource {
207                vault: vault.clone(),
208            }),
209            {
210                let note = current_note.clone();
211                move || QueryContext::with_note(Some(note.lock().unwrap().clone()))
212            },
213            Unresolvable::Empty,
214        );
215        let combos = |action: &ActionShortcuts| -> Vec<KeyCombo> {
216            key_bindings
217                .to_hashmap()
218                .get(action)
219                .cloned()
220                .unwrap_or_default()
221        };
222        let follow_link_combos = combos(&ActionShortcuts::FollowLink);
223
224        let mut intercept = Vec::new();
225        intercept.extend(follow_link_combos.iter().cloned());
226
227        let list = SearchList::builder(source, redraw)
228            .highlight_query()
229            .yank_combos_from(&key_bindings)
230            .icons(icons.clone())
231            .autocomplete(
232                Arc::new(VaultSuggestions {
233                    vault: vault.clone(),
234                }),
235                AutocompleteMode::SearchQuery,
236            )
237            .intercept(intercept)
238            // List-focus verbs (fire once the user Esc-es into the list): the
239            // same set the Sources drawer uses — `l`/`h` cycle the preview
240            // forward/back, `o` opens, `y` yanks the selected note's path.
241            .list_verb('l')
242            .list_verb('h')
243            .list_verb('o')
244            .list_verb('y')
245            .build();
246
247        Self {
248            list,
249            current_note,
250            saved_search: SavedSearchBreadcrumb::default(),
251            preview: PreviewPane::new(),
252            key_bindings,
253            redraw_tx,
254            follow_link_combos,
255            // An empty query carries no order directive → (Name, Ascending).
256            order_cache: (SortField::Name, SortOrder::Ascending),
257            order_cache_query: String::new(),
258            // The panel starts empty; the first render's query-changed gate
259            // recomputes this anyway.
260            is_default_cache: false,
261            needles_cache: Vec::new(),
262            needles_cache_key: (String::new(), VaultPath::empty()),
263        }
264    }
265
266    // ── Query accessors ─────────────────────────────────────────────────
267
268    pub fn active_query(&self) -> &str {
269        self.list.query()
270    }
271
272    /// The emphasis payload an open from this panel carries: the resolved
273    /// query's needles (spec §5.1) — resolved, not the template, so `{note}`
274    /// never leaks.
275    fn emphasis(&self) -> Option<Vec<String>> {
276        let resolved = resolve_query(self.list.query(), &self.query_ctx());
277        let needles = crate::components::query_highlight::emphasis_needles(&resolved);
278        (!needles.is_empty()).then_some(needles)
279    }
280
281    /// Number of results currently listed — the status bar's match count.
282    pub fn result_count(&self) -> usize {
283        self.list.match_count()
284    }
285
286    pub fn set_active_query(&mut self, q: String) {
287        self.list.set_query(q);
288        self.reset_expand();
289    }
290
291    /// The breadcrumb label for the query searchbox border, or `None` when no
292    /// saved search is active.
293    pub fn saved_search_breadcrumb(&self) -> Option<String> {
294        self.saved_search.label(self.list.query())
295    }
296
297    /// The saved-search name the active query came from (breadcrumb
298    /// provenance, no edited marker), or `None`. Pre-fills the save-search
299    /// dialog's name field.
300    pub fn saved_search_name(&self) -> Option<&str> {
301        self.saved_search.name()
302    }
303
304    /// Re-pin the breadcrumb to a just-saved search: the saved identity is
305    /// the provenance from now on, so the edited marker drops on an update
306    /// and the name switches on a save-as-new.
307    pub fn repin_saved_search(&mut self, name: String, query: &str) {
308        self.saved_search.set(Some(name), query);
309    }
310
311    /// `true` when the live query carries no saved-search provenance worth
312    /// showing — an empty field, or the default backlinks query (which the
313    /// panel title already renders as "Backlinks", so a breadcrumb there would
314    /// contradict it). Drives the breadcrumb's clear condition.
315    fn query_is_blank(&self) -> bool {
316        let q = self.list.query();
317        q.trim().is_empty() || is_default_query(q)
318    }
319
320    /// Apply a query template (e.g. from a saved search) and run it. The engine
321    /// holds the template verbatim; `{note}` is resolved at load. `name` pins
322    /// the breadcrumb (`None` for the default backlinks query).
323    pub fn apply_query(&mut self, query: String, name: Option<String>, tx: AppTx) {
324        self.ensure_redraw_tx(&tx);
325        self.set_active_query(query.clone());
326        self.saved_search.set(name, &query);
327    }
328
329    // ── Helpers ─────────────────────────────────────────────────────────
330
331    fn current_note(&self) -> VaultPath {
332        self.current_note.lock().unwrap().clone()
333    }
334
335    /// The query-resolution context for this panel: the open note. Mirrors what
336    /// the panel's [`ResolvingRowSource`] reads at load time, so the panel's own
337    /// `{note}` resolutions (emphasis, needles) match the loaded results.
338    fn query_ctx(&self) -> QueryContext {
339        QueryContext::with_note(Some(self.current_note()))
340    }
341
342    /// Fill the shared redraw slot so the engine's async loads / autocomplete
343    /// wake the render loop. Idempotent.
344    fn ensure_redraw_tx(&self, tx: &AppTx) {
345        let mut slot = self.redraw_tx.lock().unwrap();
346        if slot.is_none() {
347            *slot = Some(tx.clone());
348        }
349    }
350
351    /// The highlight needles for the active query, memoised on the
352    /// (query template, current note) pair — `render` reads these every frame
353    /// while a preview is open, and deriving them costs a template resolution
354    /// plus a full query parse.
355    fn cached_needles(&mut self) -> &[String] {
356        let note = self.current_note();
357        if self.needles_cache_key.0 != self.list.query() || self.needles_cache_key.1 != note {
358            let resolved = resolve_query(self.list.query(), &self.query_ctx());
359            // Same needle source as the editor handoff (`emphasis`) and the note
360            // browser preview: terms, labels (`#tag`), and link targets. Keeps
361            // the preview highlight consistent with what the editor emphasizes.
362            self.needles_cache = crate::components::query_highlight::emphasis_needles(&resolved);
363            self.needles_cache_key = (self.list.query().to_string(), note);
364        }
365        &self.needles_cache
366    }
367
368    /// Returns true if the selected entry is in full-expand mode (content takes
369    /// the whole panel, up/down scrolls content).
370    fn is_full_expanded(&self) -> bool {
371        self.list.selected_row().is_some() && self.preview.is_full()
372    }
373
374    pub fn is_empty(&self) -> bool {
375        self.list.rows().is_empty()
376    }
377
378    pub fn selected_path(&self) -> Option<&VaultPath> {
379        self.list.selected_row().map(|e| &e.path)
380    }
381
382    fn reset_expand(&mut self) {
383        self.preview.reset();
384        self.list.set_content_rect(Rect::default());
385    }
386
387    /// Re-anchor the preview on the currently-selected row (see
388    /// [`PreviewPane::sync`]); drop the stale wheel-routing region when it
389    /// changed.
390    fn sync_expand_anchor(&mut self) {
391        let sel = self.list.selected_row().map(|e| e.path.clone());
392        if self.preview.sync(sel) {
393            self.list.set_content_rect(Rect::default());
394        }
395    }
396
397    // ── Loading ─────────────────────────────────────────────────────────
398
399    /// Record the newly-open note. Re-runs the query only when it depends on
400    /// `{note}` (otherwise the existing results stay untouched).
401    pub fn set_note(&mut self, note_path: VaultPath, tx: AppTx) {
402        self.ensure_redraw_tx(&tx);
403        *self.current_note.lock().unwrap() = note_path;
404        if query_has_variables(self.list.query()) {
405            self.list.reload();
406            self.reset_expand();
407        }
408    }
409
410    /// Current sort field/order, derived from the active query's order
411    /// directive. Defaults to (Name, Ascending) when the query has none.
412    /// Parses the query each call — cheap for the rare callers (dialog open).
413    /// The per-frame render path uses the memoised `order_cache` instead.
414    pub fn current_order(&self) -> (SortField, SortOrder) {
415        let st = kimun_core::SearchTerms::from_query_string(self.list.query());
416        match st.order_by.first() {
417            Some(OrderBy::Title { asc }) => (
418                SortField::Title,
419                if *asc {
420                    SortOrder::Ascending
421                } else {
422                    SortOrder::Descending
423                },
424            ),
425            Some(OrderBy::FileName { asc }) => (
426                SortField::Name,
427                if *asc {
428                    SortOrder::Ascending
429                } else {
430                    SortOrder::Descending
431                },
432            ),
433            None => (SortField::Name, SortOrder::Ascending),
434        }
435    }
436
437    /// Apply a sort selection from the sort dialog: rewrite the query's order
438    /// directive (the query string is the single source of truth) and reload.
439    pub fn apply_sort(&mut self, field: SortField, order: SortOrder, tx: &AppTx) {
440        self.ensure_redraw_tx(tx);
441        let order_field = match field {
442            SortField::Name => OrderField::FileName,
443            SortField::Title => OrderField::Title,
444        };
445        let asc = matches!(order, SortOrder::Ascending);
446        let rewritten = with_order_directive(self.list.query(), order_field, asc);
447        self.list.set_query(rewritten);
448        // A sort only rewrites the order directive — the breadcrumb stays
449        // (and `saved_search_breadcrumb` ignores the directive, so it is not
450        // marked edited).
451        self.reset_expand();
452    }
453
454    // ── Input handling ──────────────────────────────────────────────────
455
456    pub fn handle_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
457        self.ensure_redraw_tx(tx);
458        self.sync_expand_anchor();
459
460        // Full-expand takes over Up/Down for content scroll BEFORE the engine
461        // sees them.
462        if self.is_full_expanded() && matches!(key.code, KeyCode::Up | KeyCode::Down) {
463            self.scroll_content(key);
464            return EventState::Consumed;
465        }
466        // Ctrl+Enter opens the selected note (kitty-protocol terminals; the
467        // FollowLink combo below is the always-works path). Pre-checked here
468        // because Enter-with-modifiers never participates in the engine's
469        // autocomplete/Submit flow.
470        if key.code == KeyCode::Enter
471            && key
472                .modifiers
473                .contains(ratatui::crossterm::event::KeyModifiers::CONTROL)
474        {
475            if let Some(path) = self.selected_path().cloned() {
476                tx.send(AppEvent::OpenPath {
477                    path,
478                    emphasis: self.emphasis(),
479                })
480                .ok();
481            }
482            return EventState::Consumed;
483        }
484        // NOTE: the yank chord is NOT pre-checked here any more — SearchList
485        // claims it and reports the selected row's target.
486        //
487        // NOTE: plain Enter is NOT pre-checked here. It must reach the engine
488        // so an open autocomplete popup can accept on Enter; only when the
489        // popup is closed does the engine return `Submit`, which toggles
490        // expand below.
491        let prev_query = self.list.query().to_string();
492        match self.list.handle_key(key) {
493            KeyReaction::Intercepted(c) if self.follow_link_combos.contains(&c) => {
494                if let Some(path) = self.selected_path().cloned() {
495                    tx.send(AppEvent::OpenPath {
496                        path,
497                        emphasis: self.emphasis(),
498                    })
499                    .ok();
500                }
501                EventState::Consumed
502            }
503            KeyReaction::Consumed => {
504                // Forward the query event to the breadcrumb: a `?name`
505                // expansion pins it, a blank query clears it, a manual edit
506                // keeps it (sticky).
507                let accepted = self.list.take_accepted_saved_search();
508                let blank = self.query_is_blank();
509                self.saved_search
510                    .on_query_consumed(accepted, self.list.query(), blank);
511                // A query edit moves the needle highlights, so the preview
512                // scroll goes back to the link auto-anchor — a user scroll
513                // position is stale against the new matches. (Programmatic
514                // query changes re-arm via `reset_expand`.)
515                if self.list.query() != prev_query {
516                    self.preview.re_anchor();
517                }
518                self.sync_expand_anchor();
519                EventState::Consumed
520            }
521            KeyReaction::Submit => {
522                // Enter with the autocomplete popup closed: the panel's policy
523                // is to cycle the expand state of the selected row.
524                self.toggle_expand();
525                EventState::Consumed
526            }
527            // List-focus verbs (fired only once the user Esc-es into the list):
528            // the engine reports which char fired; the panel maps it to an
529            // action. `l`/`h` cycle the preview, `o` opens, `y` yanks.
530            KeyReaction::ListVerb(c) => {
531                match c {
532                    'l' => self.toggle_expand(),
533                    'h' => self.collapse_expand(),
534                    'o' => self.open_selected(tx),
535                    'y' => self.yank_selected_row(tx),
536                    _ => {}
537                }
538                self.sync_expand_anchor();
539                EventState::Consumed
540            }
541            // Esc bubbles to the editor for focus changes — reached now only
542            // from list focus (the first Esc entered it).
543            KeyReaction::Cancel => EventState::NotConsumed,
544            KeyReaction::Unhandled => EventState::NotConsumed,
545            KeyReaction::Intercepted(_) => EventState::Consumed,
546            KeyReaction::Yank(target) => {
547                crate::components::yank_row(target, tx);
548                EventState::Consumed
549            }
550        }
551    }
552
553    /// Mouse behavior: the wheel scrolls — the result list (viewport moves,
554    /// selection keeps its screen position), the half-height Context preview
555    /// when hovering over it, or, in full-expand, the content — anywhere
556    /// within the panel; clicks select/activate list rows (a second click on
557    /// the selected row cycles its expand state, mirroring Enter). The engine
558    /// owns the wheel routing: render records the content view (preview or
559    /// full) as its content sub-region, which wins over the panel bounds and
560    /// comes back as `ContentScroll*`.
561    pub fn handle_mouse(
562        &mut self,
563        mouse: &ratatui::crossterm::event::MouseEvent,
564        tx: &AppTx,
565    ) -> EventState {
566        use ratatui::crossterm::event::{MouseButton, MouseEventKind};
567        use ratatui::layout::Position;
568        self.ensure_redraw_tx(tx);
569        // Read BEFORE the sync: a selection that vanished in this same event
570        // batch collapses the expand state, but the screen still shows the
571        // full view — the event must be handled against what the user saw,
572        // not let through to the engine's stale list rect.
573        let was_full = self.is_full_expanded();
574        self.sync_expand_anchor();
575        // In full-expand the list is not rendered (its recorded rect is
576        // stale, from the last non-full frame), so only the wheel may reach
577        // the engine — it routes via the content rect, which covers the
578        // whole panel in full-expand. Everything else is the panel's;
579        // closing the popup here keeps the any-mouse-interaction-dismisses
580        // rule for events the engine never sees.
581        if was_full {
582            match mouse.kind {
583                // Fall through to the engine below.
584                MouseEventKind::ScrollUp | MouseEventKind::ScrollDown => {}
585                // A click on the header collapses the view, mirroring Enter.
586                // (A sync collapse above already cleared the header rect, so
587                // this cannot toggle a no-longer-full view.)
588                MouseEventKind::Down(MouseButton::Left)
589                    if self.preview.full_header_rect().contains(Position {
590                        x: mouse.column,
591                        y: mouse.row,
592                    }) =>
593                {
594                    self.list.close_autocomplete();
595                    self.toggle_expand();
596                    return EventState::Consumed;
597                }
598                _ => {
599                    self.list.close_autocomplete();
600                    return EventState::Consumed;
601                }
602            }
603        }
604        match self.list.handle_mouse(mouse) {
605            SearchMouse::ContentScrollUp => {
606                self.preview.scroll_up();
607                EventState::Consumed
608            }
609            SearchMouse::ContentScrollDown => {
610                self.preview.scroll_down();
611                EventState::Consumed
612            }
613            SearchMouse::Activated(_) => {
614                self.toggle_expand();
615                EventState::Consumed
616            }
617            // Right-click on a result row → file/note context menu (spec §10).
618            SearchMouse::Context(_) => {
619                if let Some(path) = self.selected_path().cloned() {
620                    tx.send(AppEvent::FileOp(FileOp::ShowMenu(path))).ok();
621                }
622                EventState::Consumed
623            }
624            SearchMouse::Selected(_) | SearchMouse::Scrolled => {
625                self.sync_expand_anchor();
626                EventState::Consumed
627            }
628            SearchMouse::None => EventState::NotConsumed,
629        }
630    }
631
632    /// The list-focus `y` verb. Reads the same [`SearchRow::yank_target`] the
633    /// yank chord does, so the verb and the chord cannot report differently —
634    /// including when there is nothing to copy.
635    fn yank_selected_row(&self, tx: &AppTx) {
636        crate::components::yank_row(self.list.selected_row().and_then(|r| r.yank_target()), tx);
637    }
638
639    fn scroll_content(&mut self, key: &KeyEvent) {
640        match key.code {
641            KeyCode::Up => self.preview.scroll_up(),
642            KeyCode::Down => self.preview.scroll_down(),
643            _ => {}
644        }
645    }
646
647    fn toggle_expand(&mut self) {
648        let sel = self.list.selected_row().map(|e| e.path.clone());
649        if sel.is_none() {
650            return;
651        }
652        self.preview.toggle(sel);
653        self.list.set_content_rect(Rect::default());
654    }
655
656    /// Step the preview reveal backward (Full → Context → Collapsed): the `h`
657    /// list-focus verb, mirroring `l`'s forward cycle via [`Self::toggle_expand`].
658    fn collapse_expand(&mut self) {
659        let sel = self.list.selected_row().map(|e| e.path.clone());
660        if sel.is_none() {
661            return;
662        }
663        self.preview.collapse_step(sel);
664        self.list.set_content_rect(Rect::default());
665    }
666
667    /// Open the selected result (the `o` list-focus verb), carrying the same
668    /// resolved-needle emphasis as the FollowLink / Ctrl+Enter open paths.
669    fn open_selected(&self, tx: &AppTx) {
670        if let Some(path) = self.selected_path().cloned() {
671            tx.send(AppEvent::OpenPath {
672                path,
673                emphasis: self.emphasis(),
674            })
675            .ok();
676        }
677    }
678
679    pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
680        // In list focus, plain letters are verbs — advertise them instead of
681        // the input-focus keybinding hints.
682        if self.list.focus() == Focus::List {
683            return vec![
684                ("j/k".to_string(), "navigate".to_string()),
685                ("h/l".to_string(), "preview".to_string()),
686                ("o".to_string(), "open".to_string()),
687                ("y".to_string(), "yank".to_string()),
688                ("i".to_string(), "filter".to_string()),
689                ("Esc".to_string(), "\u{2190} editor".to_string()),
690            ];
691        }
692        crate::components::hints::hints_for(
693            &self.key_bindings,
694            &[
695                (ActionShortcuts::FocusSidebar, "\u{2190} editor"),
696                (ActionShortcuts::FollowLink, "open note"),
697                (ActionShortcuts::SaveCurrentQuery, "save query"),
698                (ActionShortcuts::OpenSavedSearches, "searches"),
699                (ActionShortcuts::OpenSortDialog, "sort"),
700            ],
701        )
702    }
703
704    // ── Rendering ──────────────────────────────────────────────────────
705
706    pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
707        self.list.poll();
708        self.sync_expand_anchor();
709        // The whole panel is wheel-scrollable (query box and preview included);
710        // recorded up front so it is fresh on every expand-state branch.
711        self.list.set_panel_rect(rect);
712        // Cleared every frame; only the branches that draw a content view
713        // (Context preview, full-expand) record it, so the engine's wheel
714        // routing never sees a stale sub-region from a frame where no
715        // content view was drawn. Same life cycle for the full-expand header.
716        self.list.set_content_rect(Rect::default());
717        self.preview.clear_header();
718
719        let border_style = theme.border_style(focused);
720        let gray = theme.gray.to_ratatui();
721        let bg = theme.bg_panel.to_ratatui();
722
723        let count = self.list.visible_rows().len();
724        // Reparse the order only when the query changed (memoised) — render runs
725        // every frame and `from_query_string` is a full allocating parse.
726        if self.list.query() != self.order_cache_query {
727            self.order_cache = self.current_order();
728            self.is_default_cache = is_default_query(self.list.query());
729            self.order_cache_query = self.list.query().to_string();
730        }
731        let (sort_field, sort_order) = self.order_cache;
732        let sort_indicator = format!("{}{}", sort_field.label(), sort_order.label());
733        // The saved-search name lives on the query searchbox border (the
734        // breadcrumb below), not here, so the outer title stays generic.
735        // `is_default_query` ignores the order directive and recognizes every
736        // spelling of the default (`<{note}`, bare `<`, `lk:`), so sorting or
737        // typing a synonym still reads as "Backlinks". Memoised above — the
738        // helper allocates and this runs every frame.
739        let title = if self.list.query().trim().is_empty() {
740            "Find".to_string()
741        } else if self.is_default_cache {
742            format!("Backlinks ({}) {}", count, sort_indicator)
743        } else {
744            format!("Query ({}) {}", count, sort_indicator)
745        };
746
747        let outer = Block::default()
748            .title(title)
749            .borders(Borders::ALL)
750            .border_style(border_style)
751            .style(theme.panel_style());
752        let outer_inner = outer.inner(rect);
753        f.render_widget(outer, rect);
754
755        // Split off the query line (top) from the list/preview (rest).
756        let rows = Layout::default()
757            .direction(Direction::Vertical)
758            .constraints([Constraint::Length(3), Constraint::Min(0)])
759            .split(outer_inner);
760        // The saved-search breadcrumb (`‹ name ›` / `‹ name • edited ›`) titles
761        // the query searchbox when a saved search is active.
762        let search_title = self.saved_search.border_title(self.list.query(), " Query");
763        let mut search_block = Block::default()
764            .title(search_title)
765            .borders(Borders::ALL)
766            .border_style(border_style)
767            .style(theme.panel_style());
768        // Parse problems surface as a second, red title segment — the input
769        // itself never blocks (spec §9).
770        if let Some(reason) = crate::components::query_highlight::error_reason(self.list.query()) {
771            search_block = search_block.title(
772                ratatui::text::Line::from(ratatui::text::Span::styled(
773                    format!(" ⚠ {reason} "),
774                    Style::default().fg(theme.red.to_ratatui()),
775                ))
776                .right_aligned(),
777            );
778        }
779        let search_inner = search_block.inner(rows[0]);
780        f.render_widget(search_block, rows[0]);
781        self.list.render_query(f, search_inner, theme, focused);
782
783        let inner = rows[1];
784
785        if self.list.is_loading() {
786            f.render_widget(
787                Paragraph::new("  Loading...").style(Style::default().fg(gray).bg(bg)),
788                inner,
789            );
790            self.list.render_autocomplete(f, rect, theme);
791            return;
792        }
793
794        if self.list.visible_rows().is_empty() {
795            f.render_widget(
796                Paragraph::new("  No results").style(Style::default().fg(gray).bg(bg)),
797                inner,
798            );
799            self.list.render_autocomplete(f, rect, theme);
800            return;
801        }
802
803        // Full mode: content takes the entire panel, no list visible. The
804        // wheel scrolls the content from anywhere in the panel, so the whole
805        // panel is the engine's content sub-region.
806        if self.preview.is_full() {
807            self.list.set_content_rect(rect);
808            if let Some(entry) = self.list.selected_row() {
809                let entry = entry.clone();
810                let text = entry
811                    .full_text
812                    .clone()
813                    .unwrap_or_else(|| entry.context.clone());
814                let needles = self.cached_needles().to_vec();
815                self.preview.render_full(
816                    f,
817                    inner,
818                    &entry.title,
819                    &entry.filename,
820                    &text,
821                    Highlight::Needles(&needles),
822                    theme,
823                );
824            }
825            self.list.render_autocomplete(f, rect, theme);
826            return;
827        }
828
829        // Context or Collapsed: show the list, optionally with preview below.
830        let has_context = self.preview.is_context();
831
832        let (list_area, divider_area, content_area) = if has_context {
833            let max_list = inner.height / 2;
834            let list_height = (count as u16).min(max_list).max(1);
835            let areas = Layout::default()
836                .direction(Direction::Vertical)
837                .constraints([
838                    Constraint::Length(list_height),
839                    Constraint::Length(1),
840                    Constraint::Min(0),
841                ])
842                .split(inner);
843            (areas[0], Some(areas[1]), Some(areas[2]))
844        } else {
845            (inner, None, None)
846        };
847
848        // The engine draws the collapsed list (1 line per row, with the
849        // selected-row marker handled in `to_list_item`).
850        if self.list.query().trim().is_empty() {
851            // Empty-state: a short query-syntax primer instead of a blank
852            // list (spec §9 discoverability; the panel no longer pre-fills
853            // a backlinks query — the LINKS drawer owns those).
854            let dim = Style::default().fg(theme.gray.to_ratatui());
855            let key = Style::default().fg(theme.yellow.to_ratatui());
856            let lines = vec![
857                ratatui::text::Line::from(Span::styled("type to search the vault", dim)),
858                ratatui::text::Line::default(),
859                ratatui::text::Line::from(vec![
860                    Span::styled(" #tag      ", key),
861                    Span::styled("label", dim),
862                ]),
863                ratatui::text::Line::from(vec![
864                    Span::styled(" <  >      ", key),
865                    Span::styled("backlinks · links", dim),
866                ]),
867                ratatui::text::Line::from(vec![
868                    Span::styled(" \"phrase\"  ", key),
869                    Span::styled("exact match", dim),
870                ]),
871                ratatui::text::Line::from(vec![
872                    Span::styled(" =date     ", key),
873                    Span::styled("modified", dim),
874                ]),
875                ratatui::text::Line::from(vec![
876                    Span::styled(" ?name     ", key),
877                    Span::styled("saved search", dim),
878                ]),
879            ];
880            f.render_widget(ratatui::widgets::Paragraph::new(lines), list_area);
881        } else {
882            self.list.render(f, list_area, theme, focused);
883        }
884        self.list.set_list_rect(list_area);
885
886        // Divider between list and content.
887        if let Some(div) = divider_area {
888            f.render_widget(
889                Paragraph::new("\u{2500}".repeat(div.width as usize))
890                    .style(Style::default().fg(gray).bg(bg)),
891                div,
892            );
893        }
894
895        // Render context preview below the list: show the full note text
896        // scrolled so the first link occurrence is visible with context above.
897        if let Some(area) = content_area
898            && self.preview.is_context()
899            && let Some(entry) = self.list.selected_row()
900        {
901            let entry = entry.clone();
902            let text = entry
903                .full_text
904                .clone()
905                .unwrap_or_else(|| entry.context.clone());
906            let needles = self.cached_needles().to_vec();
907            self.preview
908                .render_context(f, area, &text, Highlight::Needles(&needles), theme);
909            // The preview is the engine's content sub-region: wheel events
910            // inside it come back as ContentScroll* instead of moving the
911            // list.
912            self.list.set_content_rect(area);
913        }
914
915        self.list.render_autocomplete(f, rect, theme);
916    }
917}
918
919// ---------------------------------------------------------------------------
920// Standalone async helpers
921// ---------------------------------------------------------------------------
922
923/// Run `query` (already a resolved plain query string) and build entries.
924/// Sources from full-text / query search via `vault.search_notes`.
925async fn load_query(vault: &NoteVault, query: &str) -> Vec<BacklinkEntry> {
926    let needles = crate::components::query_highlight::emphasis_needles(query);
927    let results = vault.search_notes(query).await.unwrap_or_default();
928    let mut entries = Vec::with_capacity(results.len());
929    for (entry_data, content_data) in results {
930        let text = vault
931            .get_note_text(&entry_data.path)
932            .await
933            .unwrap_or_default();
934        let context = extract_context_multi(&text, &needles);
935        let (_p, filename) = entry_data.path.get_parent_path();
936        entries.push(BacklinkEntry {
937            path: entry_data.path,
938            title: content_data.title,
939            filename,
940            context,
941            full_text: Some(text),
942        });
943    }
944    entries
945}
946
947/// Split text into paragraphs. A paragraph is one or more consecutive
948/// non-blank lines. Blank lines act as separators.
949fn split_paragraphs(text: &str) -> Vec<String> {
950    let mut paragraphs = Vec::new();
951    let mut current: Vec<&str> = Vec::new();
952
953    for line in text.lines() {
954        if line.trim().is_empty() {
955            if !current.is_empty() {
956                paragraphs.push(current.join("\n"));
957                current.clear();
958            }
959        } else {
960            current.push(line);
961        }
962    }
963    if !current.is_empty() {
964        paragraphs.push(current.join("\n"));
965    }
966
967    paragraphs
968}
969
970// ---------------------------------------------------------------------------
971// Rendering helpers
972// ---------------------------------------------------------------------------
973
974/// Find the first paragraph containing any of `needles` (case-insensitive);
975/// fall back to the first non-blank line.
976fn extract_context_multi(text: &str, needles: &[String]) -> String {
977    let lowered: Vec<String> = needles.iter().map(|n| n.to_lowercase()).collect();
978    for para in &split_paragraphs(text) {
979        let lower = para.to_lowercase();
980        if lowered.iter().any(|n| !n.is_empty() && lower.contains(n)) {
981            return para.clone();
982        }
983    }
984    text.lines()
985        .find(|l| !l.trim().is_empty())
986        .unwrap_or("")
987        .to_string()
988}
989
990// ---------------------------------------------------------------------------
991// Tests
992// ---------------------------------------------------------------------------
993
994#[cfg(test)]
995mod tests {
996    use super::*;
997
998    #[test]
999    fn extract_context_matches_any_needle() {
1000        let text = "# Title\n\nIntro line.\n\nA paragraph mentioning widget here.\n";
1001        let result = extract_context_multi(text, &["widget".to_string()]);
1002        assert!(result.contains("widget"));
1003    }
1004
1005    #[test]
1006    fn default_query_recognized_in_all_spellings() {
1007        // Bare `<` and the long form are first-class synonyms of the default
1008        // backlinks query: the panel title must read "Backlinks" and the
1009        // breadcrumb clear condition must treat them as blank.
1010        assert!(is_default_query(DEFAULT_QUERY));
1011        assert!(is_default_query("<"));
1012        assert!(is_default_query("lk:"));
1013        assert!(is_default_query("< or:title"));
1014        assert!(is_default_query("<{note} -or:file"));
1015        assert!(!is_default_query("<projects"));
1016        assert!(!is_default_query(">"));
1017        assert!(!is_default_query(""));
1018    }
1019
1020    #[tokio::test]
1021    async fn query_panel_load_query_lists_matches() {
1022        let vault = crate::test_support::temp_vault("qp").await;
1023        vault.validate_and_init().await.unwrap();
1024        vault
1025            .create_note(&VaultPath::note_path_from("/a.md"), "alpha #todo")
1026            .await
1027            .unwrap();
1028        vault
1029            .create_note(&VaultPath::note_path_from("/b.md"), "beta")
1030            .await
1031            .unwrap();
1032        let entries = load_query(&vault, "#todo").await;
1033        assert_eq!(entries.len(), 1);
1034        assert!(entries[0].filename.contains("a"));
1035    }
1036
1037    fn make_panel(vault: Arc<NoteVault>) -> QueryPanel {
1038        let kb = crate::settings::AppSettings::default().key_bindings.clone();
1039        QueryPanel::new(vault, kb, Icons::new(false))
1040    }
1041
1042    /// Ctrl+Enter opens the selected result (kitty-protocol terminals) —
1043    /// regression: it must not fall through to the engine as a plain key.
1044    #[tokio::test(flavor = "multi_thread")]
1045    async fn ctrl_enter_opens_selected_result() {
1046        let vault = crate::test_support::temp_vault("qp-ctrl-enter").await;
1047        vault.validate_and_init().await.unwrap();
1048        vault
1049            .save_note(&VaultPath::note_path_from("target"), "the note body")
1050            .await
1051            .unwrap();
1052        let mut panel = make_panel(vault);
1053        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1054
1055        // Query for the note and let the async load land.
1056        panel.apply_query("target".to_string(), None, tx.clone());
1057        for _ in 0..50 {
1058            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1059            panel.list.poll();
1060        }
1061        assert!(
1062            panel.selected_path().is_some(),
1063            "result loaded and selected"
1064        );
1065
1066        panel.handle_key(
1067            &KeyEvent::new(
1068                KeyCode::Enter,
1069                ratatui::crossterm::event::KeyModifiers::CONTROL,
1070            ),
1071            &tx,
1072        );
1073
1074        let mut opened = None;
1075        while let Ok(ev) = rx.try_recv() {
1076            if let AppEvent::OpenPath { path, .. } = ev {
1077                opened = Some(path);
1078            }
1079        }
1080        // The index returns canonical (vault-absolute) paths.
1081        assert_eq!(opened, Some(VaultPath::note_path_from("target").absolute()));
1082    }
1083
1084    /// Ctrl+Y yanks the selected result's path (converged with Sources) — it
1085    /// must claim the key before the engine drops it, and emit a flash message.
1086    #[tokio::test(flavor = "multi_thread")]
1087    async fn ctrl_y_yanks_selected_path() {
1088        use ratatui::crossterm::event::KeyModifiers;
1089        let vault = crate::test_support::temp_vault("qp-ctrl-y").await;
1090        vault.validate_and_init().await.unwrap();
1091        vault
1092            .save_note(&VaultPath::note_path_from("target"), "the note body")
1093            .await
1094            .unwrap();
1095        let mut panel = make_panel(vault);
1096        let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
1097        panel.apply_query("target".to_string(), None, tx.clone());
1098        settle(&mut panel).await;
1099        assert!(panel.selected_path().is_some(), "result selected");
1100
1101        let st = panel.handle_key(
1102            &KeyEvent::new(KeyCode::Char('y'), KeyModifiers::CONTROL),
1103            &tx,
1104        );
1105        assert_eq!(st, EventState::Consumed);
1106        let mut flashed = false;
1107        while let Ok(ev) = rx.try_recv() {
1108            if matches!(ev, AppEvent::FlashMessage(_)) {
1109                flashed = true;
1110            }
1111        }
1112        assert!(
1113            flashed,
1114            "Ctrl+Y emits a flash message (ok or clipboard error)"
1115        );
1116    }
1117
1118    /// Plain letters (`l`/`h`/`o`/`y`) stay query text in FIND — the query input
1119    /// always has focus, so they must edit the query, never trigger the Sources
1120    /// vim shortcuts. Guards the asymmetry in the converged key table.
1121    #[tokio::test(flavor = "multi_thread")]
1122    async fn plain_letters_stay_query_text_in_find() {
1123        use ratatui::crossterm::event::{KeyEvent, KeyModifiers};
1124        let vault = crate::test_support::temp_vault("qp-letters").await;
1125        vault.validate_and_init().await.unwrap();
1126        let mut panel = make_panel(vault);
1127        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1128        panel.set_active_query(String::new());
1129        for ch in ['l', 'h', 'o', 'y'] {
1130            panel.handle_key(&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
1131        }
1132        assert_eq!(panel.active_query(), "lhoy", "letters edit the query");
1133        assert!(
1134            panel.preview.is_collapsed(),
1135            "letters must not cycle the preview in FIND"
1136        );
1137    }
1138
1139    /// The memoised highlight needles must follow both cache keys: recompute
1140    /// when the current note changes and when the query template changes.
1141    #[tokio::test]
1142    async fn cached_needles_track_query_and_note() {
1143        let vault = crate::test_support::temp_vault("qp_needles").await;
1144        vault.validate_and_init().await.unwrap();
1145        let mut panel = make_panel(vault);
1146        panel.list.set_query(DEFAULT_QUERY);
1147
1148        // Backlinks query `<{note}` resolved against "spec".
1149        *panel.current_note.lock().unwrap() = VaultPath::note_path_from("spec");
1150        assert!(panel.cached_needles().iter().any(|n| n == "spec"));
1151
1152        // Note change invalidates.
1153        *panel.current_note.lock().unwrap() = VaultPath::note_path_from("other");
1154        assert!(panel.cached_needles().iter().any(|n| n == "other"));
1155
1156        // Query change invalidates.
1157        panel.list.set_query("widget".to_string());
1158        let needles = panel.cached_needles();
1159        assert!(needles.iter().any(|n| n == "widget"));
1160        assert!(!needles.iter().any(|n| n == "other"));
1161
1162        // Labels are highlight needles too (consistent with the editor handoff
1163        // and the note-browser preview): `#todo` → needle `#todo`.
1164        panel.list.set_query("#todo".to_string());
1165        assert!(
1166            panel.cached_needles().iter().any(|n| n == "#todo"),
1167            "preview needles must include labels"
1168        );
1169    }
1170
1171    /// Drive the engine until its async load settles. Unlike the engine's
1172    /// `poll_until_idle` (tight `yield_now` loop), this gives the spawned
1173    /// sqlite-backed search task real wall-clock time to complete — `load_query`
1174    /// awaits a full-text search plus per-result `get_note_text`, which a
1175    /// yield-only loop does not advance fast enough.
1176    async fn settle(panel: &mut QueryPanel) {
1177        for _ in 0..100 {
1178            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1179            panel.list.poll();
1180            if !panel.list.is_loading() {
1181                break;
1182            }
1183        }
1184    }
1185
1186    #[tokio::test(flavor = "multi_thread")]
1187    async fn apply_sort_rewrites_query_order_directive() {
1188        let vault = crate::test_support::temp_vault("qp-sort").await;
1189        vault.validate_and_init().await.unwrap();
1190        let mut panel = make_panel(vault);
1191        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1192        panel.set_active_query("widget".to_string());
1193
1194        panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1195        assert_eq!(panel.active_query(), "widget or:title");
1196
1197        panel.apply_sort(SortField::Name, SortOrder::Descending, &tx);
1198        assert_eq!(panel.active_query(), "widget -or:file");
1199    }
1200
1201    /// Regression: a directive-less query must still return a stable
1202    /// Name-ascending order (the DB only sorts when an `or:` directive is
1203    /// present). Without the fallback, results came back in arbitrary DB order.
1204    #[tokio::test(flavor = "multi_thread")]
1205    async fn directiveless_query_is_name_ascending() {
1206        let vault = crate::test_support::temp_vault("qp-defaultorder").await;
1207        vault.validate_and_init().await.unwrap();
1208        // Create in non-alphabetical order; all share the term "widget".
1209        for name in ["/charlie.md", "/alpha.md", "/bravo.md"] {
1210            vault
1211                .create_note(&VaultPath::note_path_from(name), "widget")
1212                .await
1213                .unwrap();
1214        }
1215        let mut panel = make_panel(vault);
1216        panel.set_active_query("widget".to_string()); // no order directive
1217        settle(&mut panel).await;
1218
1219        let names: Vec<String> = panel
1220            .list
1221            .visible_rows()
1222            .iter()
1223            .map(|e| e.filename.clone())
1224            .collect();
1225        let mut sorted = names.clone();
1226        sorted.sort();
1227        assert_eq!(names, sorted, "directive-less query must be name-ascending");
1228    }
1229
1230    /// Accepting a `?name` expansion through the panel pins the saved-search
1231    /// breadcrumb to the accepted name and runs the stored query.
1232    #[tokio::test(flavor = "multi_thread")]
1233    async fn accepting_saved_search_pins_breadcrumb() {
1234        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1235        let vault = crate::test_support::temp_vault("qp-ss-accept").await;
1236        vault.validate_and_init().await.unwrap();
1237        vault.save_search("todo-week", "#todo").await.unwrap();
1238        let mut panel = make_panel(vault);
1239        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1240
1241        // Clear the default query so `?` is the leading char, then type a
1242        // prefix, draining the async popup load between keystrokes so the
1243        // suggestion lands before we accept.
1244        panel.set_active_query(String::new());
1245        for ch in ['?', 't', 'o'] {
1246            panel.handle_key(&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
1247            for _ in 0..30 {
1248                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1249                panel.list.poll();
1250            }
1251        }
1252        panel.handle_key(&KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &tx);
1253
1254        assert_eq!(panel.active_query(), "#todo");
1255        assert_eq!(
1256            panel.saved_search_breadcrumb().as_deref(),
1257            Some("todo-week")
1258        );
1259    }
1260
1261    /// Editing the expanded query keeps the breadcrumb (sticky provenance) and
1262    /// marks it `• edited` once the text diverges from the stored query.
1263    #[tokio::test(flavor = "multi_thread")]
1264    async fn editing_expanded_query_keeps_breadcrumb_marked_edited() {
1265        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1266        let vault = crate::test_support::temp_vault("qp-ss-edit").await;
1267        vault.validate_and_init().await.unwrap();
1268        let mut panel = make_panel(vault);
1269        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1270        panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1271        assert_eq!(panel.saved_search_breadcrumb().as_deref(), Some("todo"));
1272
1273        // A manual edit must NOT drop the breadcrumb; it gains the marker.
1274        panel.handle_key(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
1275        assert_eq!(panel.active_query(), "#todox");
1276        assert_eq!(
1277            panel.saved_search_breadcrumb().as_deref(),
1278            Some("todo • edited")
1279        );
1280    }
1281
1282    /// Emptying the query field clears the breadcrumb entirely (one of the two
1283    /// clear triggers, the other being a fresh expansion).
1284    #[tokio::test(flavor = "multi_thread")]
1285    async fn emptying_field_clears_breadcrumb() {
1286        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1287        let vault = crate::test_support::temp_vault("qp-ss-empty").await;
1288        vault.validate_and_init().await.unwrap();
1289        let mut panel = make_panel(vault);
1290        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1291        panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1292
1293        // Backspace the whole "#todo" away.
1294        for _ in 0.."#todo".len() {
1295            panel.handle_key(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), &tx);
1296        }
1297        assert_eq!(panel.active_query(), "");
1298        assert_eq!(panel.saved_search_breadcrumb(), None);
1299    }
1300
1301    #[tokio::test(flavor = "multi_thread")]
1302    async fn apply_query_pins_breadcrumb() {
1303        let vault = crate::test_support::temp_vault("qp-name").await;
1304        vault.validate_and_init().await.unwrap();
1305        let mut panel = make_panel(vault);
1306        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1307        panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1308        assert_eq!(panel.saved_search_breadcrumb().as_deref(), Some("todo"));
1309    }
1310
1311    /// Applying a sort rewrites the query's order directive — the breadcrumb
1312    /// stays sticky but gains the edited marker, because the stored query is
1313    /// saved verbatim and any text divergence counts as an edit.
1314    #[tokio::test(flavor = "multi_thread")]
1315    async fn apply_sort_marks_saved_search_breadcrumb_edited() {
1316        let vault = crate::test_support::temp_vault("qp-sort-name").await;
1317        vault.validate_and_init().await.unwrap();
1318        let mut panel = make_panel(vault);
1319        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1320        panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1321
1322        panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1323        assert_eq!(panel.active_query(), "#todo or:title");
1324        assert_eq!(
1325            panel.saved_search_breadcrumb().as_deref(),
1326            Some("todo • edited"),
1327            "sorting diverges from the stored query, so the breadcrumb is edited"
1328        );
1329    }
1330
1331    /// Saving the live query re-pins the breadcrumb to the saved identity:
1332    /// the edited marker drops, and a save-as-new switches the name.
1333    #[tokio::test(flavor = "multi_thread")]
1334    async fn repin_after_save_adopts_saved_identity() {
1335        let vault = crate::test_support::temp_vault("qp-repin").await;
1336        vault.validate_and_init().await.unwrap();
1337        let mut panel = make_panel(vault);
1338        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1339        panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1340
1341        panel.set_active_query("#todo and #urgent".to_string());
1342        assert_eq!(
1343            panel.saved_search_breadcrumb().as_deref(),
1344            Some("todo • edited")
1345        );
1346
1347        panel.repin_saved_search("urgent-todos".to_string(), "#todo and #urgent");
1348        assert_eq!(
1349            panel.saved_search_breadcrumb().as_deref(),
1350            Some("urgent-todos"),
1351            "after a save the saved identity is the provenance — no edited marker"
1352        );
1353    }
1354
1355    /// Regression: a programmatic sort change must update the VISIBLE input bar,
1356    /// not just the internal query string. (Previously `set_query` left the
1357    /// input widget stale, so the bar didn't show the `or:` directive.)
1358    #[tokio::test(flavor = "multi_thread")]
1359    async fn apply_sort_updates_visible_input_bar() {
1360        let vault = crate::test_support::temp_vault("qp-bar").await;
1361        vault.validate_and_init().await.unwrap();
1362        let mut panel = make_panel(vault);
1363        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1364        panel.set_active_query("widget".to_string());
1365        assert_eq!(
1366            panel.list.input_value(),
1367            "widget",
1368            "set_active_query syncs the bar"
1369        );
1370
1371        panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1372        assert_eq!(panel.active_query(), "widget or:title");
1373        assert_eq!(
1374            panel.list.input_value(),
1375            "widget or:title",
1376            "the input bar must reflect the rewritten query"
1377        );
1378    }
1379
1380    #[tokio::test(flavor = "multi_thread")]
1381    async fn current_order_reads_query_directive() {
1382        let vault = crate::test_support::temp_vault("qp-order").await;
1383        vault.validate_and_init().await.unwrap();
1384        let mut panel = make_panel(vault);
1385        panel.set_active_query("widget -or:title".to_string());
1386        assert_eq!(
1387            panel.current_order(),
1388            (SortField::Title, SortOrder::Descending)
1389        );
1390        panel.set_active_query("widget".to_string());
1391        assert_eq!(
1392            panel.current_order(),
1393            (SortField::Name, SortOrder::Ascending)
1394        );
1395    }
1396
1397    /// The wheel over the half-height Context preview scrolls the preview
1398    /// text (taking over from the link auto-anchor); over the list it keeps
1399    /// scrolling the list and leaves the preview's scroll untouched.
1400    #[tokio::test(flavor = "multi_thread")]
1401    async fn context_preview_wheel_scrolls_preview_not_list() {
1402        use ratatui::Terminal;
1403        use ratatui::backend::TestBackend;
1404        use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
1405
1406        let vault = crate::test_support::temp_vault("qp-preview-wheel").await;
1407        vault.validate_and_init().await.unwrap();
1408        // Long note so the preview content overflows its half-height viewport;
1409        // the needle on the first line anchors the auto-scroll at 0.
1410        let mut body = String::from("#todo first line\n");
1411        for i in 0..40 {
1412            body.push_str(&format!("line {}\n", i));
1413        }
1414        vault
1415            .create_note(&VaultPath::note_path_from("/long.md"), &body)
1416            .await
1417            .unwrap();
1418        let mut panel = make_panel(vault);
1419        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1420        panel.set_active_query("#todo".to_string());
1421        settle(&mut panel).await;
1422        assert!(panel.list.selected_row().is_some());
1423
1424        // Open the half-height Context preview and render once to record the
1425        // list/preview rects.
1426        panel.toggle_expand();
1427        assert!(panel.preview.is_context());
1428        let theme = crate::settings::themes::Theme::default();
1429        let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1430        terminal
1431            .draw(|f| panel.render(f, f.area(), &theme, true))
1432            .unwrap();
1433        let preview = panel.list.content_rect();
1434        assert!(!preview.is_empty(), "preview rect recorded");
1435        assert_eq!(
1436            panel.preview.scroll_offset(),
1437            0,
1438            "auto-anchor at the top needle"
1439        );
1440        assert!(panel.preview.scroll_max() > 0, "content overflows viewport");
1441
1442        let wheel = move |y: u16| MouseEvent {
1443            kind: MouseEventKind::ScrollDown,
1444            column: preview.x + 1,
1445            row: y,
1446            modifiers: KeyModifiers::NONE,
1447        };
1448
1449        // Wheel over the LIST area: list scroll path, preview untouched.
1450        let over_list = wheel(preview.y.saturating_sub(3));
1451        panel.handle_mouse(&over_list, &tx);
1452        assert_eq!(
1453            panel.preview.scroll_offset(),
1454            0,
1455            "list wheel must not move preview"
1456        );
1457        assert!(panel.preview.is_anchored(), "anchor stays armed");
1458
1459        // Wheel over the PREVIEW area: preview scrolls, anchor hands over.
1460        let over_preview = wheel(preview.y + 1);
1461        panel.handle_mouse(&over_preview, &tx);
1462        assert_eq!(
1463            panel.preview.scroll_offset(),
1464            1,
1465            "preview wheel scrolls content"
1466        );
1467        assert!(!panel.preview.is_anchored(), "user owns the scroll now");
1468
1469        // Re-render keeps the user position (no re-anchor) and clamps.
1470        terminal
1471            .draw(|f| panel.render(f, f.area(), &theme, true))
1472            .unwrap();
1473        assert_eq!(panel.preview.scroll_offset(), 1);
1474
1475        // Scrolling up past the top saturates at 0.
1476        let up = MouseEvent {
1477            kind: MouseEventKind::ScrollUp,
1478            column: preview.x + 1,
1479            row: preview.y + 1,
1480            modifiers: KeyModifiers::NONE,
1481        };
1482        panel.handle_mouse(&up, &tx);
1483        panel.handle_mouse(&up, &tx);
1484        assert_eq!(panel.preview.scroll_offset(), 0);
1485    }
1486
1487    /// A wheel tick that cannot move the preview (content fits the viewport,
1488    /// or already at the top) is a no-op and must NOT disarm the link
1489    /// auto-anchor.
1490    #[tokio::test(flavor = "multi_thread")]
1491    async fn noop_preview_wheel_keeps_autoscroll_armed() {
1492        use ratatui::Terminal;
1493        use ratatui::backend::TestBackend;
1494        use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
1495
1496        let vault = crate::test_support::temp_vault("qp-noop-wheel").await;
1497        vault.validate_and_init().await.unwrap();
1498        // Short note: the preview content fits the half-height viewport.
1499        vault
1500            .create_note(&VaultPath::note_path_from("/short.md"), "#todo only line")
1501            .await
1502            .unwrap();
1503        let mut panel = make_panel(vault);
1504        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1505        panel.set_active_query("#todo".to_string());
1506        settle(&mut panel).await;
1507        panel.toggle_expand();
1508        let theme = crate::settings::themes::Theme::default();
1509        let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1510        terminal
1511            .draw(|f| panel.render(f, f.area(), &theme, true))
1512            .unwrap();
1513        assert_eq!(panel.preview.scroll_max(), 0, "content fits the viewport");
1514
1515        let preview = panel.list.content_rect();
1516        let down = MouseEvent {
1517            kind: MouseEventKind::ScrollDown,
1518            column: preview.x + 1,
1519            row: preview.y + 1,
1520            modifiers: KeyModifiers::NONE,
1521        };
1522        panel.handle_mouse(&down, &tx);
1523        assert!(
1524            panel.preview.is_anchored(),
1525            "no-op wheel tick must not disarm the auto-anchor"
1526        );
1527    }
1528
1529    /// Editing the query by keystroke moves the needle highlights, so a
1530    /// wheel-scrolled Context preview hands the scroll back to the
1531    /// auto-anchor.
1532    #[tokio::test(flavor = "multi_thread")]
1533    async fn query_keystroke_rearms_preview_autoscroll() {
1534        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1535
1536        let vault = crate::test_support::temp_vault("qp-rearm").await;
1537        vault.validate_and_init().await.unwrap();
1538        let mut body = String::from("#todo first line\n");
1539        for i in 0..40 {
1540            body.push_str(&format!("line {}\n", i));
1541        }
1542        vault
1543            .create_note(&VaultPath::note_path_from("/long.md"), &body)
1544            .await
1545            .unwrap();
1546        let mut panel = make_panel(vault);
1547        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1548        panel.set_active_query("#todo".to_string());
1549        settle(&mut panel).await;
1550        panel.toggle_expand();
1551        // Simulate a user-owned scroll.
1552        panel.preview.force_user_scrolled();
1553
1554        panel.handle_key(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
1555        assert_eq!(panel.active_query(), "#todox");
1556        assert!(
1557            panel.preview.is_anchored(),
1558            "a query edit must re-arm the preview auto-anchor"
1559        );
1560    }
1561
1562    /// A wheel over the Context preview consumes the event without reaching
1563    /// the engine, but must still dismiss an open autocomplete popup (the
1564    /// any-mouse-interaction-dismisses rule).
1565    #[tokio::test(flavor = "multi_thread")]
1566    async fn preview_wheel_closes_autocomplete_popup() {
1567        use ratatui::Terminal;
1568        use ratatui::backend::TestBackend;
1569        use ratatui::crossterm::event::{
1570            KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind,
1571        };
1572
1573        let vault = crate::test_support::temp_vault("qp-wheel-popup").await;
1574        vault.validate_and_init().await.unwrap();
1575        let mut body = String::from("#todo first line\n");
1576        for i in 0..40 {
1577            body.push_str(&format!("line {}\n", i));
1578        }
1579        vault
1580            .create_note(&VaultPath::note_path_from("/long.md"), &body)
1581            .await
1582            .unwrap();
1583        let mut panel = make_panel(vault);
1584        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1585        panel.set_active_query("#todo".to_string());
1586        settle(&mut panel).await;
1587        panel.toggle_expand();
1588        let theme = crate::settings::themes::Theme::default();
1589        let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1590        terminal
1591            .draw(|f| panel.render(f, f.area(), &theme, true))
1592            .unwrap();
1593        let preview = panel.list.content_rect();
1594        assert!(!preview.is_empty());
1595
1596        // Type ` #` to open the hashtag autocomplete popup (the note's #todo
1597        // tag is a suggestion), draining the async suggestion load.
1598        for ch in [' ', '#'] {
1599            panel.handle_key(&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
1600            for _ in 0..30 {
1601                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1602                panel.list.poll();
1603            }
1604        }
1605        assert!(panel.list.autocomplete_is_open(), "popup open after `#`");
1606
1607        let wheel = MouseEvent {
1608            kind: MouseEventKind::ScrollDown,
1609            column: preview.x + 1,
1610            row: preview.y + 1,
1611            modifiers: KeyModifiers::NONE,
1612        };
1613        panel.handle_mouse(&wheel, &tx);
1614        assert!(
1615            !panel.list.autocomplete_is_open(),
1616            "wheel over the preview must dismiss the popup"
1617        );
1618    }
1619
1620    /// In full-expand, a click on the fixed title header collapses the view
1621    /// (mirroring Enter); clicks elsewhere are swallowed (the list under the
1622    /// content is not rendered).
1623    #[tokio::test(flavor = "multi_thread")]
1624    async fn full_expand_header_click_collapses() {
1625        use ratatui::Terminal;
1626        use ratatui::backend::TestBackend;
1627        use ratatui::crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
1628
1629        let vault = crate::test_support::temp_vault("qp-header-click").await;
1630        vault.validate_and_init().await.unwrap();
1631        vault
1632            .create_note(&VaultPath::note_path_from("/long.md"), "#todo body")
1633            .await
1634            .unwrap();
1635        let mut panel = make_panel(vault);
1636        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1637        panel.set_active_query("#todo".to_string());
1638        settle(&mut panel).await;
1639        // Collapsed -> Context -> Full.
1640        panel.toggle_expand();
1641        panel.toggle_expand();
1642        assert!(panel.is_full_expanded());
1643        let theme = crate::settings::themes::Theme::default();
1644        let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1645        terminal
1646            .draw(|f| panel.render(f, f.area(), &theme, true))
1647            .unwrap();
1648        let header = panel.preview.full_header_rect();
1649        assert!(!header.is_empty(), "header rect recorded in full mode");
1650
1651        let click = |x: u16, y: u16| MouseEvent {
1652            kind: MouseEventKind::Down(MouseButton::Left),
1653            column: x,
1654            row: y,
1655            modifiers: KeyModifiers::NONE,
1656        };
1657
1658        // A click below the header (over the content) is swallowed.
1659        panel.handle_mouse(&click(header.x + 1, header.y + 3), &tx);
1660        assert!(panel.is_full_expanded(), "content click must not collapse");
1661
1662        // A click on the header collapses, like Enter.
1663        panel.handle_mouse(&click(header.x + 1, header.y), &tx);
1664        assert!(!panel.is_full_expanded());
1665        assert!(panel.preview.is_collapsed());
1666    }
1667
1668    /// Deliberate unification (preview-pane `Highlight` seam): FIND's Full
1669    /// preview now auto-anchors on the first needle match, like the Context
1670    /// preview and the Ask Sources range variant already did. Previously Full
1671    /// was the one render path that ignored the anchor and always opened at
1672    /// the top; mirrors `ask_sources.rs`'s
1673    /// `full_preview_anchors_scroll_to_the_highlighted_section`.
1674    #[tokio::test(flavor = "multi_thread")]
1675    async fn full_preview_anchors_scroll_to_first_needle_match() {
1676        use ratatui::Terminal;
1677        use ratatui::backend::TestBackend;
1678
1679        let vault = crate::test_support::temp_vault("qp-full-anchor").await;
1680        vault.validate_and_init().await.unwrap();
1681        // The needle is deep enough that anchoring scrolls past the top (the
1682        // "two lines of context above the match" rule needs room above it).
1683        let mut body = String::new();
1684        for i in 0..8 {
1685            body.push_str(&format!("line{i}\n"));
1686        }
1687        body.push_str("#todo widget line\n");
1688        for i in 0..8 {
1689            body.push_str(&format!("tail{i}\n"));
1690        }
1691        vault
1692            .create_note(&VaultPath::note_path_from("/long.md"), &body)
1693            .await
1694            .unwrap();
1695        let mut panel = make_panel(vault);
1696        panel.set_active_query("#todo".to_string());
1697        settle(&mut panel).await;
1698        assert!(panel.list.selected_row().is_some());
1699
1700        // Collapsed -> Context -> Full.
1701        panel.toggle_expand();
1702        panel.toggle_expand();
1703        assert!(panel.is_full_expanded());
1704
1705        let theme = crate::settings::themes::Theme::default();
1706        let mut terminal = Terminal::new(TestBackend::new(40, 6)).unwrap();
1707        terminal
1708            .draw(|f| panel.render(f, f.area(), &theme, true))
1709            .unwrap();
1710
1711        assert!(
1712            panel.preview.scroll_offset() > 0,
1713            "Full preview anchors on the first needle match, offset={}",
1714            panel.preview.scroll_offset()
1715        );
1716    }
1717
1718    /// Every expand-state change must drop the recorded content regions: the
1719    /// event loop drains queued events between renders, so a mouse event in
1720    /// the same batch as the toggle must not be routed against rects from
1721    /// the previous frame's content view.
1722    #[tokio::test(flavor = "multi_thread")]
1723    async fn toggling_expand_clears_stale_content_regions() {
1724        use ratatui::Terminal;
1725        use ratatui::backend::TestBackend;
1726
1727        let vault = crate::test_support::temp_vault("qp-stale-regions").await;
1728        vault.validate_and_init().await.unwrap();
1729        vault
1730            .create_note(&VaultPath::note_path_from("/long.md"), "#todo body")
1731            .await
1732            .unwrap();
1733        let mut panel = make_panel(vault);
1734        panel.set_active_query("#todo".to_string());
1735        settle(&mut panel).await;
1736        let theme = crate::settings::themes::Theme::default();
1737        let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1738
1739        // Render in Full so both regions are recorded.
1740        panel.toggle_expand();
1741        panel.toggle_expand();
1742        terminal
1743            .draw(|f| panel.render(f, f.area(), &theme, true))
1744            .unwrap();
1745        assert!(!panel.list.content_rect().is_empty());
1746        assert!(!panel.preview.full_header_rect().is_empty());
1747
1748        // Toggle (Full -> Collapsed) WITHOUT a render in between — as when
1749        // Enter and a mouse event are drained in the same batch.
1750        panel.toggle_expand();
1751        assert!(
1752            panel.list.content_rect().is_empty(),
1753            "stale content rect must not survive a state change"
1754        );
1755        assert!(
1756            panel.preview.full_header_rect().is_empty(),
1757            "stale header rect must not survive a state change"
1758        );
1759    }
1760
1761    /// A static query (no `{note}`) must survive navigation: `set_note` leaves
1762    /// its query template untouched and does NOT reload the engine.
1763    // Multi-thread flavour: the engine drives the source load on a spawned
1764    // task, and `search_notes` awaits a sqlite pool that needs the IO driver
1765    // (a current-thread runtime only advances the spawned task on `yield_now`).
1766    #[tokio::test(flavor = "multi_thread")]
1767    async fn static_query_survives_navigation() {
1768        let vault = crate::test_support::temp_vault("nav-static").await;
1769        vault.validate_and_init().await.unwrap();
1770        vault
1771            .create_note(&VaultPath::note_path_from("/a.md"), "alpha #todo")
1772            .await
1773            .unwrap();
1774        let mut panel = make_panel(vault);
1775        panel.set_active_query("#todo".to_string());
1776        settle(&mut panel).await;
1777        assert_eq!(panel.list.visible_rows().len(), 1);
1778
1779        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1780        panel.set_note(VaultPath::note_path_from("x.md"), tx);
1781
1782        // Query template untouched (not reset to <{note}); a static query is
1783        // not reloaded, so it is not in a loading state.
1784        assert_eq!(panel.active_query(), "#todo");
1785        assert!(!panel.list.is_loading());
1786        settle(&mut panel).await;
1787        assert_eq!(panel.list.visible_rows().len(), 1); // results untouched
1788    }
1789
1790    /// A `{note}` query re-runs on navigation: `set_note` resolves `{note}`
1791    /// against the new note and reloads, so results follow the open note.
1792    #[tokio::test(flavor = "multi_thread")]
1793    async fn note_variable_query_reruns_on_navigation() {
1794        let vault = crate::test_support::temp_vault("nav-var").await;
1795        vault.validate_and_init().await.unwrap();
1796        // `target` is linked from `linker`; opening `target` should surface
1797        // `linker` as a backlink.
1798        vault
1799            .create_note(&VaultPath::note_path_from("/target.md"), "I am the target")
1800            .await
1801            .unwrap();
1802        vault
1803            .create_note(&VaultPath::note_path_from("/linker.md"), "see [[target]]")
1804            .await
1805            .unwrap();
1806        let mut panel = make_panel(vault);
1807        // The panel starts empty (LINKS owns backlinks); type the backlinks
1808        // query to exercise the `{note}` re-resolution machinery.
1809        assert_eq!(panel.active_query(), "");
1810        panel.list.set_query(DEFAULT_QUERY);
1811
1812        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1813        panel.set_note(VaultPath::note_path_from("/target.md"), tx);
1814        settle(&mut panel).await;
1815
1816        // The `{note}` query resolved against `target` and found the backlink.
1817        assert!(
1818            panel
1819                .list
1820                .visible_rows()
1821                .iter()
1822                .any(|e| e.filename.contains("linker")),
1823            "expected linker as a backlink, got {:?}",
1824            panel
1825                .list
1826                .visible_rows()
1827                .iter()
1828                .map(|e| e.filename.clone())
1829                .collect::<Vec<_>>()
1830        );
1831    }
1832
1833    /// Navigating to a different `{note}` re-resolves and changes results.
1834    #[tokio::test(flavor = "multi_thread")]
1835    async fn note_variable_query_changes_with_note() {
1836        let vault = crate::test_support::temp_vault("nav-var2").await;
1837        vault.validate_and_init().await.unwrap();
1838        vault
1839            .create_note(&VaultPath::note_path_from("/a.md"), "I am a")
1840            .await
1841            .unwrap();
1842        vault
1843            .create_note(&VaultPath::note_path_from("/b.md"), "I am b")
1844            .await
1845            .unwrap();
1846        vault
1847            .create_note(&VaultPath::note_path_from("/links_a.md"), "see [[a]]")
1848            .await
1849            .unwrap();
1850        let mut panel = make_panel(vault);
1851        panel.list.set_query(DEFAULT_QUERY);
1852        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1853
1854        panel.set_note(VaultPath::note_path_from("/a.md"), tx.clone());
1855        settle(&mut panel).await;
1856        assert!(
1857            panel
1858                .list
1859                .visible_rows()
1860                .iter()
1861                .any(|e| e.filename.contains("links_a"))
1862        );
1863
1864        panel.set_note(VaultPath::note_path_from("/b.md"), tx);
1865        settle(&mut panel).await;
1866        assert!(
1867            !panel
1868                .list
1869                .visible_rows()
1870                .iter()
1871                .any(|e| e.filename.contains("links_a")),
1872            "b has no backlinks, expected empty"
1873        );
1874    }
1875}