Skip to main content

kimun_notes/components/
query_panel.rs

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