Skip to main content

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