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