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