Skip to main content

kimun_notes/components/
query_panel.rs

1use std::sync::{Arc, Mutex};
2
3use async_trait::async_trait;
4use kimun_core::NoteVault;
5use kimun_core::nfs::VaultPath;
6use ratatui::Frame;
7use ratatui::crossterm::event::{KeyCode, KeyEvent};
8use ratatui::layout::{Constraint, Direction, Layout, Rect};
9use ratatui::style::{Modifier, Style};
10use ratatui::text::Span;
11use ratatui::widgets::{Block, Borders, ListItem, Paragraph};
12
13use kimun_core::{OrderBy, OrderField, with_order_directive};
14
15use crate::components::autocomplete::AutocompleteMode;
16use crate::components::event_state::EventState;
17use crate::components::events::{AppEvent, AppTx, FileOp};
18use crate::components::file_list::{SortField, SortOrder};
19use crate::components::preview_pane::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::FileOp(FileOp::ShowMenu(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        // The index returns canonical (vault-absolute) paths (adr/0021).
1002        assert_eq!(opened, Some(VaultPath::note_path_from("target").absolute()));
1003    }
1004
1005    /// The memoised highlight needles must follow both cache keys: recompute
1006    /// when the current note changes and when the query template changes.
1007    #[tokio::test]
1008    async fn cached_needles_track_query_and_note() {
1009        let vault = crate::test_support::temp_vault("qp_needles").await;
1010        vault.validate_and_init().await.unwrap();
1011        let mut panel = make_panel(vault);
1012        panel.list.set_query(DEFAULT_QUERY);
1013
1014        // Backlinks query `<{note}` resolved against "spec".
1015        *panel.current_note.lock().unwrap() = VaultPath::note_path_from("spec");
1016        assert!(panel.cached_needles().iter().any(|n| n == "spec"));
1017
1018        // Note change invalidates.
1019        *panel.current_note.lock().unwrap() = VaultPath::note_path_from("other");
1020        assert!(panel.cached_needles().iter().any(|n| n == "other"));
1021
1022        // Query change invalidates.
1023        panel.list.set_query("widget".to_string());
1024        let needles = panel.cached_needles();
1025        assert!(needles.iter().any(|n| n == "widget"));
1026        assert!(!needles.iter().any(|n| n == "other"));
1027
1028        // Labels are highlight needles too (consistent with the editor handoff
1029        // and the note-browser preview): `#todo` → needle `#todo`.
1030        panel.list.set_query("#todo".to_string());
1031        assert!(
1032            panel.cached_needles().iter().any(|n| n == "#todo"),
1033            "preview needles must include labels"
1034        );
1035    }
1036
1037    /// Drive the engine until its async load settles. Unlike the engine's
1038    /// `poll_until_idle` (tight `yield_now` loop), this gives the spawned
1039    /// sqlite-backed search task real wall-clock time to complete — `load_query`
1040    /// awaits a full-text search plus per-result `get_note_text`, which a
1041    /// yield-only loop does not advance fast enough.
1042    async fn settle(panel: &mut QueryPanel) {
1043        for _ in 0..100 {
1044            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1045            panel.list.poll();
1046            if !panel.list.is_loading() {
1047                break;
1048            }
1049        }
1050    }
1051
1052    #[tokio::test(flavor = "multi_thread")]
1053    async fn apply_sort_rewrites_query_order_directive() {
1054        let vault = crate::test_support::temp_vault("qp-sort").await;
1055        vault.validate_and_init().await.unwrap();
1056        let mut panel = make_panel(vault);
1057        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1058        panel.set_active_query("widget".to_string());
1059
1060        panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1061        assert_eq!(panel.active_query(), "widget or:title");
1062
1063        panel.apply_sort(SortField::Name, SortOrder::Descending, &tx);
1064        assert_eq!(panel.active_query(), "widget -or:file");
1065    }
1066
1067    /// Regression: a directive-less query must still return a stable
1068    /// Name-ascending order (the DB only sorts when an `or:` directive is
1069    /// present). Without the fallback, results came back in arbitrary DB order.
1070    #[tokio::test(flavor = "multi_thread")]
1071    async fn directiveless_query_is_name_ascending() {
1072        let vault = crate::test_support::temp_vault("qp-defaultorder").await;
1073        vault.validate_and_init().await.unwrap();
1074        // Create in non-alphabetical order; all share the term "widget".
1075        for name in ["/charlie.md", "/alpha.md", "/bravo.md"] {
1076            vault
1077                .create_note(&VaultPath::note_path_from(name), "widget")
1078                .await
1079                .unwrap();
1080        }
1081        let mut panel = make_panel(vault);
1082        panel.set_active_query("widget".to_string()); // no order directive
1083        settle(&mut panel).await;
1084
1085        let names: Vec<String> = panel
1086            .list
1087            .visible_rows()
1088            .iter()
1089            .map(|e| e.filename.clone())
1090            .collect();
1091        let mut sorted = names.clone();
1092        sorted.sort();
1093        assert_eq!(names, sorted, "directive-less query must be name-ascending");
1094    }
1095
1096    /// Accepting a `?name` expansion through the panel pins the saved-search
1097    /// breadcrumb to the accepted name and runs the stored query.
1098    #[tokio::test(flavor = "multi_thread")]
1099    async fn accepting_saved_search_pins_breadcrumb() {
1100        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1101        let vault = crate::test_support::temp_vault("qp-ss-accept").await;
1102        vault.validate_and_init().await.unwrap();
1103        vault.save_search("todo-week", "#todo").await.unwrap();
1104        let mut panel = make_panel(vault);
1105        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1106
1107        // Clear the default query so `?` is the leading char, then type a
1108        // prefix, draining the async popup load between keystrokes so the
1109        // suggestion lands before we accept.
1110        panel.set_active_query(String::new());
1111        for ch in ['?', 't', 'o'] {
1112            panel.handle_key(&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
1113            for _ in 0..30 {
1114                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1115                panel.list.poll();
1116            }
1117        }
1118        panel.handle_key(&KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &tx);
1119
1120        assert_eq!(panel.active_query(), "#todo");
1121        assert_eq!(
1122            panel.saved_search_breadcrumb().as_deref(),
1123            Some("todo-week")
1124        );
1125    }
1126
1127    /// Editing the expanded query keeps the breadcrumb (sticky provenance) and
1128    /// marks it `• edited` once the text diverges from the stored query.
1129    #[tokio::test(flavor = "multi_thread")]
1130    async fn editing_expanded_query_keeps_breadcrumb_marked_edited() {
1131        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1132        let vault = crate::test_support::temp_vault("qp-ss-edit").await;
1133        vault.validate_and_init().await.unwrap();
1134        let mut panel = make_panel(vault);
1135        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1136        panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1137        assert_eq!(panel.saved_search_breadcrumb().as_deref(), Some("todo"));
1138
1139        // A manual edit must NOT drop the breadcrumb; it gains the marker.
1140        panel.handle_key(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
1141        assert_eq!(panel.active_query(), "#todox");
1142        assert_eq!(
1143            panel.saved_search_breadcrumb().as_deref(),
1144            Some("todo • edited")
1145        );
1146    }
1147
1148    /// Emptying the query field clears the breadcrumb entirely (one of the two
1149    /// clear triggers, the other being a fresh expansion).
1150    #[tokio::test(flavor = "multi_thread")]
1151    async fn emptying_field_clears_breadcrumb() {
1152        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1153        let vault = crate::test_support::temp_vault("qp-ss-empty").await;
1154        vault.validate_and_init().await.unwrap();
1155        let mut panel = make_panel(vault);
1156        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1157        panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1158
1159        // Backspace the whole "#todo" away.
1160        for _ in 0.."#todo".len() {
1161            panel.handle_key(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), &tx);
1162        }
1163        assert_eq!(panel.active_query(), "");
1164        assert_eq!(panel.saved_search_breadcrumb(), None);
1165    }
1166
1167    #[tokio::test(flavor = "multi_thread")]
1168    async fn apply_query_pins_breadcrumb() {
1169        let vault = crate::test_support::temp_vault("qp-name").await;
1170        vault.validate_and_init().await.unwrap();
1171        let mut panel = make_panel(vault);
1172        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1173        panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1174        assert_eq!(panel.saved_search_breadcrumb().as_deref(), Some("todo"));
1175    }
1176
1177    /// Applying a sort rewrites the query's order directive — the breadcrumb
1178    /// stays sticky but gains the edited marker, because the stored query is
1179    /// saved verbatim and any text divergence counts as an edit.
1180    #[tokio::test(flavor = "multi_thread")]
1181    async fn apply_sort_marks_saved_search_breadcrumb_edited() {
1182        let vault = crate::test_support::temp_vault("qp-sort-name").await;
1183        vault.validate_and_init().await.unwrap();
1184        let mut panel = make_panel(vault);
1185        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1186        panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1187
1188        panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1189        assert_eq!(panel.active_query(), "#todo or:title");
1190        assert_eq!(
1191            panel.saved_search_breadcrumb().as_deref(),
1192            Some("todo • edited"),
1193            "sorting diverges from the stored query, so the breadcrumb is edited"
1194        );
1195    }
1196
1197    /// Saving the live query re-pins the breadcrumb to the saved identity:
1198    /// the edited marker drops, and a save-as-new switches the name.
1199    #[tokio::test(flavor = "multi_thread")]
1200    async fn repin_after_save_adopts_saved_identity() {
1201        let vault = crate::test_support::temp_vault("qp-repin").await;
1202        vault.validate_and_init().await.unwrap();
1203        let mut panel = make_panel(vault);
1204        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1205        panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1206
1207        panel.set_active_query("#todo and #urgent".to_string());
1208        assert_eq!(
1209            panel.saved_search_breadcrumb().as_deref(),
1210            Some("todo • edited")
1211        );
1212
1213        panel.repin_saved_search("urgent-todos".to_string(), "#todo and #urgent");
1214        assert_eq!(
1215            panel.saved_search_breadcrumb().as_deref(),
1216            Some("urgent-todos"),
1217            "after a save the saved identity is the provenance — no edited marker"
1218        );
1219    }
1220
1221    /// Regression: a programmatic sort change must update the VISIBLE input bar,
1222    /// not just the internal query string. (Previously `set_query` left the
1223    /// input widget stale, so the bar didn't show the `or:` directive.)
1224    #[tokio::test(flavor = "multi_thread")]
1225    async fn apply_sort_updates_visible_input_bar() {
1226        let vault = crate::test_support::temp_vault("qp-bar").await;
1227        vault.validate_and_init().await.unwrap();
1228        let mut panel = make_panel(vault);
1229        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1230        panel.set_active_query("widget".to_string());
1231        assert_eq!(
1232            panel.list.input_value(),
1233            "widget",
1234            "set_active_query syncs the bar"
1235        );
1236
1237        panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1238        assert_eq!(panel.active_query(), "widget or:title");
1239        assert_eq!(
1240            panel.list.input_value(),
1241            "widget or:title",
1242            "the input bar must reflect the rewritten query"
1243        );
1244    }
1245
1246    #[tokio::test(flavor = "multi_thread")]
1247    async fn current_order_reads_query_directive() {
1248        let vault = crate::test_support::temp_vault("qp-order").await;
1249        vault.validate_and_init().await.unwrap();
1250        let mut panel = make_panel(vault);
1251        panel.set_active_query("widget -or:title".to_string());
1252        assert_eq!(
1253            panel.current_order(),
1254            (SortField::Title, SortOrder::Descending)
1255        );
1256        panel.set_active_query("widget".to_string());
1257        assert_eq!(
1258            panel.current_order(),
1259            (SortField::Name, SortOrder::Ascending)
1260        );
1261    }
1262
1263    /// The wheel over the half-height Context preview scrolls the preview
1264    /// text (taking over from the link auto-anchor); over the list it keeps
1265    /// scrolling the list and leaves the preview's scroll untouched.
1266    #[tokio::test(flavor = "multi_thread")]
1267    async fn context_preview_wheel_scrolls_preview_not_list() {
1268        use ratatui::Terminal;
1269        use ratatui::backend::TestBackend;
1270        use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
1271
1272        let vault = crate::test_support::temp_vault("qp-preview-wheel").await;
1273        vault.validate_and_init().await.unwrap();
1274        // Long note so the preview content overflows its half-height viewport;
1275        // the needle on the first line anchors the auto-scroll at 0.
1276        let mut body = String::from("#todo first line\n");
1277        for i in 0..40 {
1278            body.push_str(&format!("line {}\n", i));
1279        }
1280        vault
1281            .create_note(&VaultPath::note_path_from("/long.md"), &body)
1282            .await
1283            .unwrap();
1284        let mut panel = make_panel(vault);
1285        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1286        panel.set_active_query("#todo".to_string());
1287        settle(&mut panel).await;
1288        assert!(panel.list.selected_row().is_some());
1289
1290        // Open the half-height Context preview and render once to record the
1291        // list/preview rects.
1292        panel.toggle_expand();
1293        assert!(panel.preview.is_context());
1294        let theme = crate::settings::themes::Theme::default();
1295        let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1296        terminal
1297            .draw(|f| panel.render(f, f.area(), &theme, true))
1298            .unwrap();
1299        let preview = panel.list.content_rect();
1300        assert!(!preview.is_empty(), "preview rect recorded");
1301        assert_eq!(
1302            panel.preview.scroll_offset(),
1303            0,
1304            "auto-anchor at the top needle"
1305        );
1306        assert!(panel.preview.scroll_max() > 0, "content overflows viewport");
1307
1308        let wheel = move |y: u16| MouseEvent {
1309            kind: MouseEventKind::ScrollDown,
1310            column: preview.x + 1,
1311            row: y,
1312            modifiers: KeyModifiers::NONE,
1313        };
1314
1315        // Wheel over the LIST area: list scroll path, preview untouched.
1316        let over_list = wheel(preview.y.saturating_sub(3));
1317        panel.handle_mouse(&over_list, &tx);
1318        assert_eq!(
1319            panel.preview.scroll_offset(),
1320            0,
1321            "list wheel must not move preview"
1322        );
1323        assert!(panel.preview.is_anchored(), "anchor stays armed");
1324
1325        // Wheel over the PREVIEW area: preview scrolls, anchor hands over.
1326        let over_preview = wheel(preview.y + 1);
1327        panel.handle_mouse(&over_preview, &tx);
1328        assert_eq!(
1329            panel.preview.scroll_offset(),
1330            1,
1331            "preview wheel scrolls content"
1332        );
1333        assert!(!panel.preview.is_anchored(), "user owns the scroll now");
1334
1335        // Re-render keeps the user position (no re-anchor) and clamps.
1336        terminal
1337            .draw(|f| panel.render(f, f.area(), &theme, true))
1338            .unwrap();
1339        assert_eq!(panel.preview.scroll_offset(), 1);
1340
1341        // Scrolling up past the top saturates at 0.
1342        let up = MouseEvent {
1343            kind: MouseEventKind::ScrollUp,
1344            column: preview.x + 1,
1345            row: preview.y + 1,
1346            modifiers: KeyModifiers::NONE,
1347        };
1348        panel.handle_mouse(&up, &tx);
1349        panel.handle_mouse(&up, &tx);
1350        assert_eq!(panel.preview.scroll_offset(), 0);
1351    }
1352
1353    /// A wheel tick that cannot move the preview (content fits the viewport,
1354    /// or already at the top) is a no-op and must NOT disarm the link
1355    /// auto-anchor.
1356    #[tokio::test(flavor = "multi_thread")]
1357    async fn noop_preview_wheel_keeps_autoscroll_armed() {
1358        use ratatui::Terminal;
1359        use ratatui::backend::TestBackend;
1360        use ratatui::crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind};
1361
1362        let vault = crate::test_support::temp_vault("qp-noop-wheel").await;
1363        vault.validate_and_init().await.unwrap();
1364        // Short note: the preview content fits the half-height viewport.
1365        vault
1366            .create_note(&VaultPath::note_path_from("/short.md"), "#todo only line")
1367            .await
1368            .unwrap();
1369        let mut panel = make_panel(vault);
1370        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1371        panel.set_active_query("#todo".to_string());
1372        settle(&mut panel).await;
1373        panel.toggle_expand();
1374        let theme = crate::settings::themes::Theme::default();
1375        let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1376        terminal
1377            .draw(|f| panel.render(f, f.area(), &theme, true))
1378            .unwrap();
1379        assert_eq!(panel.preview.scroll_max(), 0, "content fits the viewport");
1380
1381        let preview = panel.list.content_rect();
1382        let down = MouseEvent {
1383            kind: MouseEventKind::ScrollDown,
1384            column: preview.x + 1,
1385            row: preview.y + 1,
1386            modifiers: KeyModifiers::NONE,
1387        };
1388        panel.handle_mouse(&down, &tx);
1389        assert!(
1390            panel.preview.is_anchored(),
1391            "no-op wheel tick must not disarm the auto-anchor"
1392        );
1393    }
1394
1395    /// Editing the query by keystroke moves the needle highlights, so a
1396    /// wheel-scrolled Context preview hands the scroll back to the
1397    /// auto-anchor.
1398    #[tokio::test(flavor = "multi_thread")]
1399    async fn query_keystroke_rearms_preview_autoscroll() {
1400        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1401
1402        let vault = crate::test_support::temp_vault("qp-rearm").await;
1403        vault.validate_and_init().await.unwrap();
1404        let mut body = String::from("#todo first line\n");
1405        for i in 0..40 {
1406            body.push_str(&format!("line {}\n", i));
1407        }
1408        vault
1409            .create_note(&VaultPath::note_path_from("/long.md"), &body)
1410            .await
1411            .unwrap();
1412        let mut panel = make_panel(vault);
1413        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1414        panel.set_active_query("#todo".to_string());
1415        settle(&mut panel).await;
1416        panel.toggle_expand();
1417        // Simulate a user-owned scroll.
1418        panel.preview.force_user_scrolled();
1419
1420        panel.handle_key(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
1421        assert_eq!(panel.active_query(), "#todox");
1422        assert!(
1423            panel.preview.is_anchored(),
1424            "a query edit must re-arm the preview auto-anchor"
1425        );
1426    }
1427
1428    /// A wheel over the Context preview consumes the event without reaching
1429    /// the engine, but must still dismiss an open autocomplete popup (the
1430    /// any-mouse-interaction-dismisses rule).
1431    #[tokio::test(flavor = "multi_thread")]
1432    async fn preview_wheel_closes_autocomplete_popup() {
1433        use ratatui::Terminal;
1434        use ratatui::backend::TestBackend;
1435        use ratatui::crossterm::event::{
1436            KeyCode, KeyEvent, KeyModifiers, MouseEvent, MouseEventKind,
1437        };
1438
1439        let vault = crate::test_support::temp_vault("qp-wheel-popup").await;
1440        vault.validate_and_init().await.unwrap();
1441        let mut body = String::from("#todo first line\n");
1442        for i in 0..40 {
1443            body.push_str(&format!("line {}\n", i));
1444        }
1445        vault
1446            .create_note(&VaultPath::note_path_from("/long.md"), &body)
1447            .await
1448            .unwrap();
1449        let mut panel = make_panel(vault);
1450        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1451        panel.set_active_query("#todo".to_string());
1452        settle(&mut panel).await;
1453        panel.toggle_expand();
1454        let theme = crate::settings::themes::Theme::default();
1455        let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1456        terminal
1457            .draw(|f| panel.render(f, f.area(), &theme, true))
1458            .unwrap();
1459        let preview = panel.list.content_rect();
1460        assert!(!preview.is_empty());
1461
1462        // Type ` #` to open the hashtag autocomplete popup (the note's #todo
1463        // tag is a suggestion), draining the async suggestion load.
1464        for ch in [' ', '#'] {
1465            panel.handle_key(&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
1466            for _ in 0..30 {
1467                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1468                panel.list.poll();
1469            }
1470        }
1471        assert!(panel.list.autocomplete_is_open(), "popup open after `#`");
1472
1473        let wheel = MouseEvent {
1474            kind: MouseEventKind::ScrollDown,
1475            column: preview.x + 1,
1476            row: preview.y + 1,
1477            modifiers: KeyModifiers::NONE,
1478        };
1479        panel.handle_mouse(&wheel, &tx);
1480        assert!(
1481            !panel.list.autocomplete_is_open(),
1482            "wheel over the preview must dismiss the popup"
1483        );
1484    }
1485
1486    /// In full-expand, a click on the fixed title header collapses the view
1487    /// (mirroring Enter); clicks elsewhere are swallowed (the list under the
1488    /// content is not rendered).
1489    #[tokio::test(flavor = "multi_thread")]
1490    async fn full_expand_header_click_collapses() {
1491        use ratatui::Terminal;
1492        use ratatui::backend::TestBackend;
1493        use ratatui::crossterm::event::{KeyModifiers, MouseButton, MouseEvent, MouseEventKind};
1494
1495        let vault = crate::test_support::temp_vault("qp-header-click").await;
1496        vault.validate_and_init().await.unwrap();
1497        vault
1498            .create_note(&VaultPath::note_path_from("/long.md"), "#todo body")
1499            .await
1500            .unwrap();
1501        let mut panel = make_panel(vault);
1502        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1503        panel.set_active_query("#todo".to_string());
1504        settle(&mut panel).await;
1505        // Collapsed -> Context -> Full.
1506        panel.toggle_expand();
1507        panel.toggle_expand();
1508        assert!(panel.is_full_expanded());
1509        let theme = crate::settings::themes::Theme::default();
1510        let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1511        terminal
1512            .draw(|f| panel.render(f, f.area(), &theme, true))
1513            .unwrap();
1514        let header = panel.preview.full_header_rect();
1515        assert!(!header.is_empty(), "header rect recorded in full mode");
1516
1517        let click = |x: u16, y: u16| MouseEvent {
1518            kind: MouseEventKind::Down(MouseButton::Left),
1519            column: x,
1520            row: y,
1521            modifiers: KeyModifiers::NONE,
1522        };
1523
1524        // A click below the header (over the content) is swallowed.
1525        panel.handle_mouse(&click(header.x + 1, header.y + 3), &tx);
1526        assert!(panel.is_full_expanded(), "content click must not collapse");
1527
1528        // A click on the header collapses, like Enter.
1529        panel.handle_mouse(&click(header.x + 1, header.y), &tx);
1530        assert!(!panel.is_full_expanded());
1531        assert!(panel.preview.is_collapsed());
1532    }
1533
1534    /// Every expand-state change must drop the recorded content regions: the
1535    /// event loop drains queued events between renders, so a mouse event in
1536    /// the same batch as the toggle must not be routed against rects from
1537    /// the previous frame's content view.
1538    #[tokio::test(flavor = "multi_thread")]
1539    async fn toggling_expand_clears_stale_content_regions() {
1540        use ratatui::Terminal;
1541        use ratatui::backend::TestBackend;
1542
1543        let vault = crate::test_support::temp_vault("qp-stale-regions").await;
1544        vault.validate_and_init().await.unwrap();
1545        vault
1546            .create_note(&VaultPath::note_path_from("/long.md"), "#todo body")
1547            .await
1548            .unwrap();
1549        let mut panel = make_panel(vault);
1550        panel.set_active_query("#todo".to_string());
1551        settle(&mut panel).await;
1552        let theme = crate::settings::themes::Theme::default();
1553        let mut terminal = Terminal::new(TestBackend::new(40, 30)).unwrap();
1554
1555        // Render in Full so both regions are recorded.
1556        panel.toggle_expand();
1557        panel.toggle_expand();
1558        terminal
1559            .draw(|f| panel.render(f, f.area(), &theme, true))
1560            .unwrap();
1561        assert!(!panel.list.content_rect().is_empty());
1562        assert!(!panel.preview.full_header_rect().is_empty());
1563
1564        // Toggle (Full -> Collapsed) WITHOUT a render in between — as when
1565        // Enter and a mouse event are drained in the same batch.
1566        panel.toggle_expand();
1567        assert!(
1568            panel.list.content_rect().is_empty(),
1569            "stale content rect must not survive a state change"
1570        );
1571        assert!(
1572            panel.preview.full_header_rect().is_empty(),
1573            "stale header rect must not survive a state change"
1574        );
1575    }
1576
1577    /// A static query (no `{note}`) must survive navigation: `set_note` leaves
1578    /// its query template untouched and does NOT reload the engine.
1579    // Multi-thread flavour: the engine drives the source load on a spawned
1580    // task, and `search_notes` awaits a sqlite pool that needs the IO driver
1581    // (a current-thread runtime only advances the spawned task on `yield_now`).
1582    #[tokio::test(flavor = "multi_thread")]
1583    async fn static_query_survives_navigation() {
1584        let vault = crate::test_support::temp_vault("nav-static").await;
1585        vault.validate_and_init().await.unwrap();
1586        vault
1587            .create_note(&VaultPath::note_path_from("/a.md"), "alpha #todo")
1588            .await
1589            .unwrap();
1590        let mut panel = make_panel(vault);
1591        panel.set_active_query("#todo".to_string());
1592        settle(&mut panel).await;
1593        assert_eq!(panel.list.visible_rows().len(), 1);
1594
1595        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1596        panel.set_note(VaultPath::note_path_from("x.md"), tx);
1597
1598        // Query template untouched (not reset to <{note}); a static query is
1599        // not reloaded, so it is not in a loading state.
1600        assert_eq!(panel.active_query(), "#todo");
1601        assert!(!panel.list.is_loading());
1602        settle(&mut panel).await;
1603        assert_eq!(panel.list.visible_rows().len(), 1); // results untouched
1604    }
1605
1606    /// A `{note}` query re-runs on navigation: `set_note` resolves `{note}`
1607    /// against the new note and reloads, so results follow the open note.
1608    #[tokio::test(flavor = "multi_thread")]
1609    async fn note_variable_query_reruns_on_navigation() {
1610        let vault = crate::test_support::temp_vault("nav-var").await;
1611        vault.validate_and_init().await.unwrap();
1612        // `target` is linked from `linker`; opening `target` should surface
1613        // `linker` as a backlink.
1614        vault
1615            .create_note(&VaultPath::note_path_from("/target.md"), "I am the target")
1616            .await
1617            .unwrap();
1618        vault
1619            .create_note(&VaultPath::note_path_from("/linker.md"), "see [[target]]")
1620            .await
1621            .unwrap();
1622        let mut panel = make_panel(vault);
1623        // The panel starts empty (LINKS owns backlinks); type the backlinks
1624        // query to exercise the `{note}` re-resolution machinery.
1625        assert_eq!(panel.active_query(), "");
1626        panel.list.set_query(DEFAULT_QUERY);
1627
1628        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1629        panel.set_note(VaultPath::note_path_from("/target.md"), tx);
1630        settle(&mut panel).await;
1631
1632        // The `{note}` query resolved against `target` and found the backlink.
1633        assert!(
1634            panel
1635                .list
1636                .visible_rows()
1637                .iter()
1638                .any(|e| e.filename.contains("linker")),
1639            "expected linker as a backlink, got {:?}",
1640            panel
1641                .list
1642                .visible_rows()
1643                .iter()
1644                .map(|e| e.filename.clone())
1645                .collect::<Vec<_>>()
1646        );
1647    }
1648
1649    /// Navigating to a different `{note}` re-resolves and changes results.
1650    #[tokio::test(flavor = "multi_thread")]
1651    async fn note_variable_query_changes_with_note() {
1652        let vault = crate::test_support::temp_vault("nav-var2").await;
1653        vault.validate_and_init().await.unwrap();
1654        vault
1655            .create_note(&VaultPath::note_path_from("/a.md"), "I am a")
1656            .await
1657            .unwrap();
1658        vault
1659            .create_note(&VaultPath::note_path_from("/b.md"), "I am b")
1660            .await
1661            .unwrap();
1662        vault
1663            .create_note(&VaultPath::note_path_from("/links_a.md"), "see [[a]]")
1664            .await
1665            .unwrap();
1666        let mut panel = make_panel(vault);
1667        panel.list.set_query(DEFAULT_QUERY);
1668        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1669
1670        panel.set_note(VaultPath::note_path_from("/a.md"), tx.clone());
1671        settle(&mut panel).await;
1672        assert!(
1673            panel
1674                .list
1675                .visible_rows()
1676                .iter()
1677                .any(|e| e.filename.contains("links_a"))
1678        );
1679
1680        panel.set_note(VaultPath::note_path_from("/b.md"), tx);
1681        settle(&mut panel).await;
1682        assert!(
1683            !panel
1684                .list
1685                .visible_rows()
1686                .iter()
1687                .any(|e| e.filename.contains("links_a")),
1688            "b has no backlinks, expected empty"
1689        );
1690    }
1691}