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