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// QueryPanel
165// ---------------------------------------------------------------------------
166
167pub struct QueryPanel {
168    /// The SearchList engine: owns the query input, the result list, and the
169    /// hashtag/link autocomplete.
170    list: SearchList<BacklinkEntry>,
171    /// Shared handle to the current note. `BacklinkSource::load` reads this to
172    /// resolve `{note}` in the query template.
173    current_note: Arc<Mutex<VaultPath>>,
174    /// The saved-search breadcrumb shown on the query searchbox border. Owns
175    /// its own sticky/clear/edited state machine; this panel only forwards
176    /// query events to it. See [`SavedSearchBreadcrumb`].
177    saved_search: SavedSearchBreadcrumb,
178    /// Expand state of the currently-selected row. `Context` sticks across
179    /// navigation (re-anchored on the new row); `Full` and query changes reset
180    /// to `Collapsed`.
181    expand: ExpandState,
182    /// The path the `expand` state belongs to, used to detect selection changes
183    /// (the engine owns the list, so we re-anchor expand on the selected row).
184    expand_path: Option<VaultPath>,
185    /// Scroll offset for full-expanded content view.
186    content_scroll: usize,
187    /// Maximum scroll offset (computed during render).
188    content_scroll_max: usize,
189    key_bindings: KeyBindings,
190    /// Shared sender filled the first time a `tx` arrives. The engine's redraw
191    /// callback reads this slot, so async loads/autocomplete wake the render
192    /// loop once the app event channel is wired (the panel is built before the
193    /// channel exists in some construction orders).
194    redraw_tx: Arc<Mutex<Option<AppTx>>>,
195    /// Combos that the engine intercepts: follow-link.
196    follow_link_combos: Vec<KeyCombo>,
197    /// Memoised sort field/order parsed from the query's order directive, plus
198    /// the query string it was parsed from. `render` reparses only when the
199    /// query changes, so the per-frame title indicator avoids a full query
200    /// parse every frame.
201    order_cache: (SortField, SortOrder),
202    order_cache_query: String,
203    /// Memoised `is_default_query` result for `order_cache_query` — the title
204    /// reads it every frame, and the helper allocates (strip + expand), so it
205    /// is refreshed in the same query-changed gate as `order_cache`.
206    is_default_cache: bool,
207    /// Memoised highlight needles derived from the resolved query, plus the
208    /// (query template, note) pair they were computed from. The expand/context
209    /// preview branches of `render` read needles every frame; recomputing them
210    /// means resolving the template and a full query parse, so they are cached
211    /// like `order_cache` and refreshed only when a key changes.
212    needles_cache: Vec<String>,
213    needles_cache_key: (String, VaultPath),
214}
215
216impl QueryPanel {
217    pub fn new(vault: Arc<NoteVault>, key_bindings: KeyBindings) -> Self {
218        let icons = Icons::new(false);
219        let current_note = Arc::new(Mutex::new(VaultPath::empty()));
220        // The redraw callback reads a shared slot that `set_note`/`handle_key`
221        // fill once a `tx` is available (the panel is constructed before the
222        // app event channel in some orders). Until then it is a no-op.
223        let redraw_tx: Arc<Mutex<Option<AppTx>>> = Arc::new(Mutex::new(None));
224        let redraw: Arc<dyn Fn() + Send + Sync> = {
225            let slot = redraw_tx.clone();
226            Arc::new(move || {
227                if let Some(tx) = slot.lock().unwrap().as_ref() {
228                    let _ = tx.send(AppEvent::Redraw);
229                }
230            })
231        };
232        let source = BacklinkSource {
233            vault: vault.clone(),
234            current_note: current_note.clone(),
235        };
236        let combos = |action: &ActionShortcuts| -> Vec<KeyCombo> {
237            key_bindings
238                .to_hashmap()
239                .get(action)
240                .cloned()
241                .unwrap_or_default()
242        };
243        let follow_link_combos = combos(&ActionShortcuts::FollowLink);
244
245        let mut intercept = Vec::new();
246        intercept.extend(follow_link_combos.iter().cloned());
247
248        let list = SearchList::builder(source, redraw)
249            .initial_query(DEFAULT_QUERY)
250            .icons(icons.clone())
251            .autocomplete(
252                Arc::new(VaultSuggestions {
253                    vault: vault.clone(),
254                }),
255                AutocompleteMode::SearchQuery,
256            )
257            .intercept(intercept)
258            .build();
259
260        Self {
261            list,
262            current_note,
263            saved_search: SavedSearchBreadcrumb::default(),
264            expand: ExpandState::Collapsed,
265            expand_path: None,
266            content_scroll: 0,
267            content_scroll_max: 0,
268            key_bindings,
269            redraw_tx,
270            follow_link_combos,
271            // DEFAULT_QUERY carries no order directive → (Name, Ascending).
272            order_cache: (SortField::Name, SortOrder::Ascending),
273            order_cache_query: String::new(),
274            // The panel starts on DEFAULT_QUERY; the first render's
275            // query-changed gate recomputes this anyway.
276            is_default_cache: true,
277            needles_cache: Vec::new(),
278            // An impossible key (queries are never empty in practice, and the
279            // panel starts with DEFAULT_QUERY) so the first read computes.
280            needles_cache_key: (String::new(), VaultPath::empty()),
281        }
282    }
283
284    // ── Query accessors ─────────────────────────────────────────────────
285
286    pub fn active_query(&self) -> &str {
287        self.list.query()
288    }
289
290    pub fn set_active_query(&mut self, q: String) {
291        self.list.set_query(q);
292        self.reset_expand();
293    }
294
295    /// The breadcrumb label for the query searchbox border, or `None` when no
296    /// saved search is active.
297    pub fn saved_search_breadcrumb(&self) -> Option<String> {
298        self.saved_search.label(self.list.query())
299    }
300
301    /// The saved-search name the active query came from (breadcrumb
302    /// provenance, no edited marker), or `None`. Pre-fills the save-search
303    /// dialog's name field.
304    pub fn saved_search_name(&self) -> Option<&str> {
305        self.saved_search.name()
306    }
307
308    /// Re-pin the breadcrumb to a just-saved search: the saved identity is
309    /// the provenance from now on, so the edited marker drops on an update
310    /// and the name switches on a save-as-new.
311    pub fn repin_saved_search(&mut self, name: String, query: &str) {
312        self.saved_search.set(Some(name), query);
313    }
314
315    /// `true` when the live query carries no saved-search provenance worth
316    /// showing — an empty field, or the default backlinks query (which the
317    /// panel title already renders as "Backlinks", so a breadcrumb there would
318    /// contradict it). Drives the breadcrumb's clear condition.
319    fn query_is_blank(&self) -> bool {
320        let q = self.list.query();
321        q.trim().is_empty() || is_default_query(q)
322    }
323
324    /// Apply a query template (e.g. from a saved search) and run it. The engine
325    /// holds the template verbatim; `{note}` is resolved at load. `name` pins
326    /// the breadcrumb (`None` for the default backlinks query).
327    pub fn apply_query(&mut self, query: String, name: Option<String>, tx: AppTx) {
328        self.ensure_redraw_tx(&tx);
329        self.set_active_query(query.clone());
330        self.saved_search.set(name, &query);
331    }
332
333    // ── Helpers ─────────────────────────────────────────────────────────
334
335    fn current_note(&self) -> VaultPath {
336        self.current_note.lock().unwrap().clone()
337    }
338
339    /// Fill the shared redraw slot so the engine's async loads / autocomplete
340    /// wake the render loop. Idempotent.
341    fn ensure_redraw_tx(&self, tx: &AppTx) {
342        let mut slot = self.redraw_tx.lock().unwrap();
343        if slot.is_none() {
344            *slot = Some(tx.clone());
345        }
346    }
347
348    /// The highlight needles for the active query, memoised on the
349    /// (query template, current note) pair — `render` reads these every frame
350    /// while a preview is open, and deriving them costs a template resolution
351    /// plus a full query parse.
352    fn cached_needles(&mut self) -> &[String] {
353        let note = self.current_note();
354        if self.needles_cache_key.0 != self.list.query() || self.needles_cache_key.1 != note {
355            self.needles_cache = query_needles(&resolve_query(self.list.query(), Some(&note)));
356            self.needles_cache_key = (self.list.query().to_string(), note);
357        }
358        &self.needles_cache
359    }
360
361    /// Returns true if the selected entry is in full-expand mode (content takes
362    /// the whole panel, up/down scrolls content).
363    fn is_full_expanded(&self) -> bool {
364        self.list.selected_row().is_some() && self.expand == ExpandState::Full
365    }
366
367    pub fn is_empty(&self) -> bool {
368        self.list.rows().is_empty()
369    }
370
371    pub fn selected_path(&self) -> Option<&VaultPath> {
372        self.list.selected_row().map(|e| &e.path)
373    }
374
375    fn reset_expand(&mut self) {
376        self.expand = ExpandState::Collapsed;
377        self.expand_path = None;
378        self.content_scroll = 0;
379        self.content_scroll_max = 0;
380    }
381
382    /// Re-anchor the expand state on the currently-selected row. The Context
383    /// (half-height) preview sticks across selection moves: it stays open and
384    /// re-anchors on the new row, so Down/Up browse previews in place. Full
385    /// collapses, and a vanished selection always collapses.
386    fn sync_expand_anchor(&mut self) {
387        let sel = self.list.selected_row().map(|e| e.path.clone());
388        if sel != self.expand_path {
389            if self.expand != ExpandState::Context || sel.is_none() {
390                self.expand = ExpandState::Collapsed;
391            }
392            self.expand_path = sel;
393            self.content_scroll = 0;
394        }
395    }
396
397    // ── Loading ─────────────────────────────────────────────────────────
398
399    /// Record the newly-open note. Re-runs the query only when it depends on
400    /// `{note}` (otherwise the existing results stay untouched).
401    pub fn set_note(&mut self, note_path: VaultPath, tx: AppTx) {
402        self.ensure_redraw_tx(&tx);
403        *self.current_note.lock().unwrap() = note_path;
404        if query_has_variables(self.list.query()) {
405            self.list.reload();
406            self.reset_expand();
407        }
408    }
409
410    /// Current sort field/order, derived from the active query's order
411    /// directive. Defaults to (Name, Ascending) when the query has none.
412    /// Parses the query each call — cheap for the rare callers (dialog open).
413    /// The per-frame render path uses the memoised `order_cache` instead.
414    pub fn current_order(&self) -> (SortField, SortOrder) {
415        let st = kimun_core::SearchTerms::from_query_string(self.list.query());
416        match st.order_by.first() {
417            Some(OrderBy::Title { asc }) => (
418                SortField::Title,
419                if *asc {
420                    SortOrder::Ascending
421                } else {
422                    SortOrder::Descending
423                },
424            ),
425            Some(OrderBy::FileName { asc }) => (
426                SortField::Name,
427                if *asc {
428                    SortOrder::Ascending
429                } else {
430                    SortOrder::Descending
431                },
432            ),
433            None => (SortField::Name, SortOrder::Ascending),
434        }
435    }
436
437    /// Apply a sort selection from the sort dialog: rewrite the query's order
438    /// directive (the query string is the single source of truth) and reload.
439    pub fn apply_sort(&mut self, field: SortField, order: SortOrder, tx: &AppTx) {
440        self.ensure_redraw_tx(tx);
441        let order_field = match field {
442            SortField::Name => OrderField::FileName,
443            SortField::Title => OrderField::Title,
444        };
445        let asc = matches!(order, SortOrder::Ascending);
446        let rewritten = with_order_directive(self.list.query(), order_field, asc);
447        self.list.set_query(rewritten);
448        // A sort only rewrites the order directive — the breadcrumb stays
449        // (and `saved_search_breadcrumb` ignores the directive, so it is not
450        // marked edited).
451        self.reset_expand();
452    }
453
454    // ── Input handling ──────────────────────────────────────────────────
455
456    pub fn handle_key(&mut self, key: &KeyEvent, tx: &AppTx) -> EventState {
457        self.ensure_redraw_tx(tx);
458        self.sync_expand_anchor();
459
460        // Full-expand takes over Up/Down for content scroll BEFORE the engine
461        // sees them.
462        if self.is_full_expanded() && matches!(key.code, KeyCode::Up | KeyCode::Down) {
463            self.scroll_content(key);
464            return EventState::Consumed;
465        }
466        // NOTE: Enter is NOT pre-checked here. It must reach the engine so an
467        // open autocomplete popup can accept on Enter; only when the popup is
468        // closed does the engine return `Submit`, which toggles expand below.
469        match self.list.handle_key(key) {
470            KeyReaction::Intercepted(c) if self.follow_link_combos.contains(&c) => {
471                if let Some(path) = self.selected_path().cloned() {
472                    tx.send(AppEvent::OpenPath(path)).ok();
473                }
474                EventState::Consumed
475            }
476            KeyReaction::Consumed => {
477                // Forward the query event to the breadcrumb: a `?name`
478                // expansion pins it, a blank query clears it, a manual edit
479                // keeps it (sticky).
480                let accepted = self.list.take_accepted_saved_search();
481                let blank = self.query_is_blank();
482                self.saved_search
483                    .on_query_consumed(accepted, self.list.query(), blank);
484                self.sync_expand_anchor();
485                EventState::Consumed
486            }
487            KeyReaction::Submit => {
488                // Enter with the autocomplete popup closed: the panel's policy
489                // is to cycle the expand state of the selected row.
490                self.toggle_expand();
491                EventState::Consumed
492            }
493            // Esc bubbles to the editor for focus changes.
494            KeyReaction::Cancel => EventState::NotConsumed,
495            KeyReaction::Unhandled => EventState::NotConsumed,
496            KeyReaction::Intercepted(_) => EventState::Consumed,
497        }
498    }
499
500    /// Mouse behavior: the wheel scrolls — the result list (viewport moves,
501    /// selection keeps its screen position) or, in full-expand, the content —
502    /// anywhere within the panel; clicks select/activate list rows (a second
503    /// click on the selected row cycles its expand state, mirroring Enter).
504    pub fn handle_mouse(
505        &mut self,
506        mouse: &ratatui::crossterm::event::MouseEvent,
507        tx: &AppTx,
508    ) -> EventState {
509        use ratatui::crossterm::event::MouseEventKind;
510        self.ensure_redraw_tx(tx);
511        self.sync_expand_anchor();
512        match mouse.kind {
513            // Full-expand: the wheel scrolls the content view, not the list.
514            MouseEventKind::ScrollUp if self.is_full_expanded() => {
515                self.content_scroll = self.content_scroll.saturating_sub(1);
516                EventState::Consumed
517            }
518            MouseEventKind::ScrollDown if self.is_full_expanded() => {
519                // Increment freely; render() clamps to content_scroll_max.
520                self.content_scroll += 1;
521                EventState::Consumed
522            }
523            // In full-expand the list is not rendered (its recorded rect is
524            // stale, from the last non-full frame), so clicks must not reach
525            // the engine's hit-test — they would select/activate a phantom
526            // row under the content view.
527            _ if self.is_full_expanded() => EventState::Consumed,
528            // The engine hit-tests the wheel against the recorded panel rect
529            // (the whole panel — query box and preview included) and clicks
530            // against the list rect.
531            _ => match self.list.handle_mouse(mouse) {
532                SearchMouse::Activated(_) => {
533                    self.toggle_expand();
534                    EventState::Consumed
535                }
536                SearchMouse::Selected(_) | SearchMouse::Scrolled => {
537                    self.sync_expand_anchor();
538                    EventState::Consumed
539                }
540                SearchMouse::None => EventState::NotConsumed,
541            },
542        }
543    }
544
545    fn scroll_content(&mut self, key: &KeyEvent) {
546        match key.code {
547            KeyCode::Up => {
548                self.content_scroll = self.content_scroll.saturating_sub(1);
549            }
550            KeyCode::Down => {
551                // Increment freely; render() clamps to content_scroll_max.
552                self.content_scroll += 1;
553            }
554            _ => {}
555        }
556    }
557
558    fn toggle_expand(&mut self) {
559        if self.list.selected_row().is_none() {
560            return;
561        }
562        self.expand_path = self.list.selected_row().map(|e| e.path.clone());
563        match self.expand {
564            ExpandState::Collapsed => {
565                self.expand = ExpandState::Context;
566            }
567            ExpandState::Context => {
568                self.content_scroll = 0;
569                self.expand = ExpandState::Full;
570            }
571            ExpandState::Full => {
572                self.content_scroll = 0;
573                self.expand = ExpandState::Collapsed;
574            }
575        }
576    }
577
578    pub fn hint_shortcuts(&self) -> Vec<(String, String)> {
579        [
580            (ActionShortcuts::FocusSidebar, "\u{2190} editor"),
581            (ActionShortcuts::FollowLink, "open note"),
582            (ActionShortcuts::SaveCurrentQuery, "save query"),
583            (ActionShortcuts::OpenSavedSearches, "searches"),
584            (ActionShortcuts::OpenSortDialog, "sort"),
585        ]
586        .iter()
587        .filter_map(|(action, label)| {
588            self.key_bindings
589                .first_combo_for(action)
590                .map(|k| (k, label.to_string()))
591        })
592        .collect()
593    }
594
595    // ── Rendering ──────────────────────────────────────────────────────
596
597    pub fn render(&mut self, f: &mut Frame, rect: Rect, theme: &Theme, focused: bool) {
598        self.list.poll();
599        self.sync_expand_anchor();
600        // The whole panel is wheel-scrollable (query box and preview included);
601        // recorded up front so it is fresh on every expand-state branch.
602        self.list.set_panel_rect(rect);
603
604        let border_style = theme.border_style(focused);
605        let fg_muted = theme.fg_muted.to_ratatui();
606        let bg = theme.bg_panel.to_ratatui();
607
608        let count = self.list.visible_rows().len();
609        // Reparse the order only when the query changed (memoised) — render runs
610        // every frame and `from_query_string` is a full allocating parse.
611        if self.list.query() != self.order_cache_query {
612            self.order_cache = self.current_order();
613            self.is_default_cache = is_default_query(self.list.query());
614            self.order_cache_query = self.list.query().to_string();
615        }
616        let (sort_field, sort_order) = self.order_cache;
617        let sort_indicator = format!("{}{}", sort_field.label(), sort_order.label());
618        // The saved-search name lives on the query searchbox border (the
619        // breadcrumb below), not here, so the outer title stays generic.
620        // `is_default_query` ignores the order directive and recognizes every
621        // spelling of the default (`<{note}`, bare `<`, `lk:`), so sorting or
622        // typing a synonym still reads as "Backlinks". Memoised above — the
623        // helper allocates and this runs every frame.
624        let title = if self.is_default_cache {
625            format!("Backlinks ({}) {}", count, sort_indicator)
626        } else {
627            format!("Query ({}) {}", count, sort_indicator)
628        };
629
630        let outer = Block::default()
631            .title(title)
632            .borders(Borders::ALL)
633            .border_style(border_style)
634            .style(theme.panel_style());
635        let outer_inner = outer.inner(rect);
636        f.render_widget(outer, rect);
637
638        // Split off the query line (top) from the list/preview (rest).
639        let rows = Layout::default()
640            .direction(Direction::Vertical)
641            .constraints([Constraint::Length(3), Constraint::Min(0)])
642            .split(outer_inner);
643        // The saved-search breadcrumb (`‹ name ›` / `‹ name • edited ›`) titles
644        // the query searchbox when a saved search is active.
645        let search_title = self.saved_search.border_title(self.list.query(), " Query");
646        let search_block = Block::default()
647            .title(search_title)
648            .borders(Borders::ALL)
649            .border_style(border_style)
650            .style(theme.panel_style());
651        let search_inner = search_block.inner(rows[0]);
652        f.render_widget(search_block, rows[0]);
653        self.list.render_query(f, search_inner, theme, focused);
654
655        let inner = rows[1];
656
657        if self.list.is_loading() {
658            f.render_widget(
659                Paragraph::new("  Loading...").style(Style::default().fg(fg_muted).bg(bg)),
660                inner,
661            );
662            self.list.render_autocomplete(f, rect, theme);
663            return;
664        }
665
666        if self.list.visible_rows().is_empty() {
667            f.render_widget(
668                Paragraph::new("  No results").style(Style::default().fg(fg_muted).bg(bg)),
669                inner,
670            );
671            self.list.render_autocomplete(f, rect, theme);
672            return;
673        }
674
675        let selected_state = self.expand;
676
677        // Full mode: content takes the entire panel, no list visible.
678        if selected_state == ExpandState::Full {
679            if let Some(entry) = self.list.selected_row() {
680                let entry = entry.clone();
681                let text = entry.full_text.as_deref().unwrap_or(&entry.context);
682
683                // Split into fixed header (title + divider) and scrollable content.
684                let title_display = if entry.title.is_empty() {
685                    &entry.filename
686                } else {
687                    &entry.title
688                };
689
690                let parts = Layout::default()
691                    .direction(Direction::Vertical)
692                    .constraints([
693                        Constraint::Length(1), // title
694                        Constraint::Length(1), // divider
695                        Constraint::Min(0),    // content
696                    ])
697                    .split(inner);
698
699                // Fixed title header.
700                f.render_widget(
701                    Paragraph::new(Line::from(vec![
702                        Span::styled(
703                            format!("\u{25BC} {} ", title_display),
704                            Style::default()
705                                .fg(theme.fg_selected.to_ratatui())
706                                .bg(bg)
707                                .add_modifier(Modifier::BOLD),
708                        ),
709                        Span::styled(
710                            format!(" {}", entry.filename),
711                            Style::default().fg(fg_muted).bg(bg),
712                        ),
713                    ]))
714                    .style(Style::default().bg(bg)),
715                    parts[0],
716                );
717
718                // Fixed divider.
719                f.render_widget(
720                    Paragraph::new("\u{2500}".repeat(parts[1].width as usize))
721                        .style(Style::default().fg(fg_muted).bg(bg)),
722                    parts[1],
723                );
724
725                // Scrollable content.
726                let indent = 2usize;
727                let wrap_width = parts[2].width.saturating_sub(indent as u16 + 1) as usize;
728                let needles = self.cached_needles();
729
730                let mut lines = Vec::new();
731                for line in text.lines() {
732                    let wrapped = wrap_line(line, wrap_width);
733                    for wline in wrapped {
734                        let spans = highlight_needles(&wline, needles, fg_muted, bg, theme);
735                        let mut indented =
736                            vec![Span::styled(" ".repeat(indent), Style::default().bg(bg))];
737                        indented.extend(spans);
738                        lines.push(Line::from(indented));
739                    }
740                }
741
742                let total_lines = lines.len();
743                let viewport = parts[2].height as usize;
744                self.content_scroll_max = total_lines.saturating_sub(viewport);
745                self.content_scroll = self.content_scroll.min(self.content_scroll_max);
746
747                f.render_widget(
748                    Paragraph::new(lines)
749                        .scroll((self.content_scroll as u16, 0))
750                        .style(Style::default().bg(bg)),
751                    parts[2],
752                );
753            }
754            self.list.render_autocomplete(f, rect, theme);
755            return;
756        }
757
758        // Context or Collapsed: show the list, optionally with preview below.
759        let has_context = selected_state == ExpandState::Context;
760
761        let (list_area, divider_area, content_area) = if has_context {
762            let max_list = inner.height / 2;
763            let list_height = (count as u16).min(max_list).max(1);
764            let areas = Layout::default()
765                .direction(Direction::Vertical)
766                .constraints([
767                    Constraint::Length(list_height),
768                    Constraint::Length(1),
769                    Constraint::Min(0),
770                ])
771                .split(inner);
772            (areas[0], Some(areas[1]), Some(areas[2]))
773        } else {
774            (inner, None, None)
775        };
776
777        // The engine draws the collapsed list (1 line per row, with the
778        // selected-row marker handled in `to_list_item`).
779        self.list.render(f, list_area, theme, focused);
780        self.list.set_list_rect(list_area);
781
782        // Divider between list and content.
783        if let Some(div) = divider_area {
784            f.render_widget(
785                Paragraph::new("\u{2500}".repeat(div.width as usize))
786                    .style(Style::default().fg(fg_muted).bg(bg)),
787                div,
788            );
789        }
790
791        // Render context preview below the list: show the full note text
792        // scrolled so the first link occurrence is visible with context above.
793        if let Some(area) = content_area
794            && selected_state == ExpandState::Context
795            && let Some(entry) = self.list.selected_row()
796        {
797            let entry = entry.clone();
798            let text = entry.full_text.as_deref().unwrap_or(&entry.context);
799            let indent = 2usize;
800            let wrap_width = area.width.saturating_sub(indent as u16 + 1) as usize;
801            let needles = self.cached_needles();
802
803            let mut lines = Vec::new();
804
805            // Track which rendered line contains the first needle match.
806            let mut link_line: Option<usize> = None;
807
808            for line in text.lines() {
809                let wrapped = wrap_line(line, wrap_width);
810                for wline in wrapped {
811                    if link_line.is_none()
812                        && needles
813                            .iter()
814                            .any(|n| !n.is_empty() && find_case_insensitive(&wline, n).is_some())
815                    {
816                        link_line = Some(lines.len());
817                    }
818                    let spans = highlight_needles(&wline, needles, fg_muted, bg, theme);
819                    let mut indented =
820                        vec![Span::styled(" ".repeat(indent), Style::default().bg(bg))];
821                    indented.extend(spans);
822                    lines.push(Line::from(indented));
823                }
824            }
825
826            // Scroll to show the link with context above. If the content from
827            // the link to the end fits within the viewport, scroll back further
828            // to fill the available space.
829            let viewport = area.height as usize;
830            let total = lines.len();
831            let link_pos = link_line.unwrap_or(0);
832            let lines_after_link = total.saturating_sub(link_pos);
833            let scroll_to = if lines_after_link <= viewport {
834                // Content from link to end fits — scroll back to fill the viewport.
835                total.saturating_sub(viewport)
836            } else {
837                // More content below the link — show 2 lines of context above.
838                link_pos.saturating_sub(2)
839            } as u16;
840
841            f.render_widget(
842                Paragraph::new(lines)
843                    .scroll((scroll_to, 0))
844                    .style(Style::default().bg(bg)),
845                area,
846            );
847        }
848
849        self.list.render_autocomplete(f, rect, theme);
850    }
851}
852
853// ---------------------------------------------------------------------------
854// Standalone async helpers
855// ---------------------------------------------------------------------------
856
857/// Run `query` (already a resolved plain query string) and build entries.
858/// Sources from full-text / query search via `vault.search_notes`.
859async fn load_query(vault: &NoteVault, query: &str) -> Vec<BacklinkEntry> {
860    let needles = query_needles(query);
861    let results = vault.search_notes(query).await.unwrap_or_default();
862    let mut entries = Vec::with_capacity(results.len());
863    for (entry_data, content_data) in results {
864        let text = vault
865            .get_note_text(&entry_data.path)
866            .await
867            .unwrap_or_default();
868        let context = extract_context_multi(&text, &needles);
869        let (_p, filename) = entry_data.path.get_parent_path();
870        entries.push(BacklinkEntry {
871            path: entry_data.path,
872            title: content_data.title,
873            filename,
874            context,
875            full_text: Some(text),
876        });
877    }
878    entries
879}
880
881/// Split text into paragraphs. A paragraph is one or more consecutive
882/// non-blank lines. Blank lines act as separators.
883fn split_paragraphs(text: &str) -> Vec<String> {
884    let mut paragraphs = Vec::new();
885    let mut current: Vec<&str> = Vec::new();
886
887    for line in text.lines() {
888        if line.trim().is_empty() {
889            if !current.is_empty() {
890                paragraphs.push(current.join("\n"));
891                current.clear();
892            }
893        } else {
894            current.push(line);
895        }
896    }
897    if !current.is_empty() {
898        paragraphs.push(current.join("\n"));
899    }
900
901    paragraphs
902}
903
904// ---------------------------------------------------------------------------
905// Rendering helpers
906// ---------------------------------------------------------------------------
907
908/// Wrap a single line into multiple lines that fit within `max_width` characters.
909/// Uses character count (not byte length) for width. Wraps at word boundaries
910/// when possible, hard-breaks otherwise.
911fn wrap_line(line: &str, max_width: usize) -> Vec<String> {
912    if max_width == 0 || line.chars().count() <= max_width {
913        return vec![line.to_string()];
914    }
915
916    let mut result = Vec::new();
917    let mut remaining = line;
918
919    while remaining.chars().count() > max_width {
920        // Find the byte index of the max_width-th character.
921        let byte_limit = remaining
922            .char_indices()
923            .nth(max_width)
924            .map(|(i, _)| i)
925            .unwrap_or(remaining.len());
926
927        // Try to find a space to break at (within the allowed character range).
928        let break_at = remaining[..byte_limit]
929            .rfind(' ')
930            .map(|i| i + 1) // include the space on the current line
931            .unwrap_or(byte_limit); // hard break if no space
932        result.push(remaining[..break_at].trim_end().to_string());
933        remaining = &remaining[break_at..];
934    }
935    if !remaining.is_empty() {
936        result.push(remaining.to_string());
937    }
938    result
939}
940
941/// Case-insensitive search for `needle` in `haystack`, returning the byte
942/// range `(start, end)` in `haystack` where the match occurs. Compares
943/// char-by-char via `to_lowercase()` so byte lengths are always derived from
944/// the original string, avoiding the case-folding byte-mismatch problem.
945fn find_case_insensitive(haystack: &str, needle: &str) -> Option<(usize, usize)> {
946    let needle_chars: Vec<char> = needle.chars().collect();
947    if needle_chars.is_empty() {
948        return None;
949    }
950    let hay_indices: Vec<(usize, char)> = haystack.char_indices().collect();
951    'outer: for start_idx in 0..hay_indices.len() {
952        if start_idx + needle_chars.len() > hay_indices.len() {
953            break;
954        }
955        for (j, &nc) in needle_chars.iter().enumerate() {
956            let hc = hay_indices[start_idx + j].1;
957            // Compare lowercased chars.
958            let mut h_lower = hc.to_lowercase();
959            let mut n_lower = nc.to_lowercase();
960            if h_lower.next() != n_lower.next() {
961                continue 'outer;
962            }
963        }
964        // Match found — compute byte range from haystack char indices.
965        let byte_start = hay_indices[start_idx].0;
966        let byte_end = if start_idx + needle_chars.len() < hay_indices.len() {
967            hay_indices[start_idx + needle_chars.len()].0
968        } else {
969            haystack.len()
970        };
971        return Some((byte_start, byte_end));
972    }
973    None
974}
975
976/// Find the first paragraph containing any of `needles` (case-insensitive);
977/// fall back to the first non-blank line.
978fn extract_context_multi(text: &str, needles: &[String]) -> String {
979    let lowered: Vec<String> = needles.iter().map(|n| n.to_lowercase()).collect();
980    for para in &split_paragraphs(text) {
981        let lower = para.to_lowercase();
982        if lowered.iter().any(|n| !n.is_empty() && lower.contains(n)) {
983            return para.clone();
984        }
985    }
986    text.lines()
987        .find(|l| !l.trim().is_empty())
988        .unwrap_or("")
989        .to_string()
990}
991
992/// Highlight the earliest occurrence of any needle in `line` (bold accent).
993fn highlight_needles(
994    line: &str,
995    needles: &[String],
996    fg_muted: ratatui::style::Color,
997    bg: ratatui::style::Color,
998    theme: &Theme,
999) -> Vec<Span<'static>> {
1000    let normal = Style::default().fg(fg_muted).bg(bg);
1001    let bold = Style::default()
1002        .fg(theme.accent.to_ratatui())
1003        .bg(bg)
1004        .add_modifier(Modifier::BOLD);
1005    let mut best: Option<(usize, usize)> = None;
1006    for needle in needles {
1007        if needle.is_empty() {
1008            continue;
1009        }
1010        if let Some((s, e)) = find_case_insensitive(line, needle)
1011            && (best.is_none() || s < best.unwrap().0)
1012        {
1013            best = Some((s, e));
1014        }
1015    }
1016    let Some((start, end)) = best else {
1017        return vec![Span::styled(line.to_string(), normal)];
1018    };
1019    let mut spans = Vec::new();
1020    if start > 0 {
1021        spans.push(Span::styled(line[..start].to_string(), normal));
1022    }
1023    spans.push(Span::styled(line[start..end].to_string(), bold));
1024    if end < line.len() {
1025        spans.push(Span::styled(line[end..].to_string(), normal));
1026    }
1027    spans
1028}
1029
1030/// Needles to highlight for a query: its free-text terms + link targets
1031/// (both backlink and forward-link targets).
1032fn query_needles(query: &str) -> Vec<String> {
1033    let st = kimun_core::SearchTerms::from_query_string(query);
1034    let mut needles = st.terms.clone();
1035    needles.extend(st.links.clone());
1036    needles.extend(st.forward_links.clone());
1037    needles
1038}
1039
1040// ---------------------------------------------------------------------------
1041// Tests
1042// ---------------------------------------------------------------------------
1043
1044#[cfg(test)]
1045mod tests {
1046    use super::*;
1047
1048    #[test]
1049    fn wrap_line_fits_within_width() {
1050        let result = wrap_line("short", 20);
1051        assert_eq!(result, vec!["short"]);
1052    }
1053
1054    #[test]
1055    fn wrap_line_breaks_at_word_boundary() {
1056        let result = wrap_line("hello world foo bar", 12);
1057        assert_eq!(result, vec!["hello world", "foo bar"]);
1058    }
1059
1060    #[test]
1061    fn wrap_line_hard_breaks_long_word() {
1062        let result = wrap_line("abcdefghij", 5);
1063        assert_eq!(result, vec!["abcde", "fghij"]);
1064    }
1065
1066    #[test]
1067    fn wrap_line_handles_multibyte_chars() {
1068        // 5 CJK characters — each is 1 char, should wrap at char boundary
1069        let result = wrap_line("日本語テスト", 3);
1070        assert_eq!(result, vec!["日本語", "テスト"]);
1071    }
1072
1073    #[test]
1074    fn wrap_line_empty_string() {
1075        let result = wrap_line("", 10);
1076        assert_eq!(result, vec![""]);
1077    }
1078
1079    #[test]
1080    fn extract_context_matches_any_needle() {
1081        let text = "# Title\n\nIntro line.\n\nA paragraph mentioning widget here.\n";
1082        let result = extract_context_multi(text, &["widget".to_string()]);
1083        assert!(result.contains("widget"));
1084    }
1085
1086    #[test]
1087    fn highlight_needles_highlights_first_match() {
1088        let spans = highlight_needles(
1089            "see widget and gadget",
1090            &["gadget".to_string()],
1091            ratatui::style::Color::Gray,
1092            ratatui::style::Color::Black,
1093            &crate::settings::themes::Theme::default(),
1094        );
1095        assert!(
1096            spans
1097                .iter()
1098                .any(|s| s.content.contains("gadget")
1099                    && s.style.add_modifier.contains(Modifier::BOLD))
1100        );
1101    }
1102
1103    #[test]
1104    fn default_query_recognized_in_all_spellings() {
1105        // Bare `<` and the long form are first-class synonyms of the default
1106        // backlinks query: the panel title must read "Backlinks" and the
1107        // breadcrumb clear condition must treat them as blank.
1108        assert!(is_default_query(DEFAULT_QUERY));
1109        assert!(is_default_query("<"));
1110        assert!(is_default_query("lk:"));
1111        assert!(is_default_query("< or:title"));
1112        assert!(is_default_query("<{note} -or:file"));
1113        assert!(!is_default_query("<projects"));
1114        assert!(!is_default_query(">"));
1115        assert!(!is_default_query(""));
1116    }
1117
1118    #[test]
1119    fn query_needles_extracts_terms_and_links() {
1120        let n = query_needles("widget <spec");
1121        assert!(n.iter().any(|x| x == "widget"));
1122        assert!(n.iter().any(|x| x == "spec"));
1123    }
1124
1125    #[test]
1126    fn query_needles_extracts_forward_links() {
1127        // A forward-link query (`>target`) must contribute its target as a
1128        // highlight needle, just like a backlink query (`<target`).
1129        let n = query_needles(">spec");
1130        assert!(n.iter().any(|x| x == "spec"));
1131    }
1132
1133    #[tokio::test]
1134    async fn query_panel_load_query_lists_matches() {
1135        let vault = crate::test_support::temp_vault("qp").await;
1136        vault.validate_and_init().await.unwrap();
1137        vault
1138            .create_note(&VaultPath::note_path_from("/a.md"), "alpha #todo")
1139            .await
1140            .unwrap();
1141        vault
1142            .create_note(&VaultPath::note_path_from("/b.md"), "beta")
1143            .await
1144            .unwrap();
1145        let entries = load_query(&vault, "#todo").await;
1146        assert_eq!(entries.len(), 1);
1147        assert!(entries[0].filename.contains("a"));
1148    }
1149
1150    fn make_panel(vault: Arc<NoteVault>) -> QueryPanel {
1151        let kb = crate::settings::AppSettings::default().key_bindings.clone();
1152        QueryPanel::new(vault, kb)
1153    }
1154
1155    /// The memoised highlight needles must follow both cache keys: recompute
1156    /// when the current note changes and when the query template changes.
1157    #[tokio::test]
1158    async fn cached_needles_track_query_and_note() {
1159        let vault = crate::test_support::temp_vault("qp_needles").await;
1160        vault.validate_and_init().await.unwrap();
1161        let mut panel = make_panel(vault);
1162
1163        // Default query `<{note}` resolved against "spec".
1164        *panel.current_note.lock().unwrap() = VaultPath::note_path_from("spec");
1165        assert!(panel.cached_needles().iter().any(|n| n == "spec"));
1166
1167        // Note change invalidates.
1168        *panel.current_note.lock().unwrap() = VaultPath::note_path_from("other");
1169        assert!(panel.cached_needles().iter().any(|n| n == "other"));
1170
1171        // Query change invalidates.
1172        panel.list.set_query("widget".to_string());
1173        let needles = panel.cached_needles();
1174        assert!(needles.iter().any(|n| n == "widget"));
1175        assert!(!needles.iter().any(|n| n == "other"));
1176    }
1177
1178    /// Drive the engine until its async load settles. Unlike the engine's
1179    /// `poll_until_idle` (tight `yield_now` loop), this gives the spawned
1180    /// sqlite-backed search task real wall-clock time to complete — `load_query`
1181    /// awaits a full-text search plus per-result `get_note_text`, which a
1182    /// yield-only loop does not advance fast enough.
1183    async fn settle(panel: &mut QueryPanel) {
1184        for _ in 0..100 {
1185            tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1186            panel.list.poll();
1187            if !panel.list.is_loading() {
1188                break;
1189            }
1190        }
1191    }
1192
1193    #[tokio::test(flavor = "multi_thread")]
1194    async fn apply_sort_rewrites_query_order_directive() {
1195        let vault = crate::test_support::temp_vault("qp-sort").await;
1196        vault.validate_and_init().await.unwrap();
1197        let mut panel = make_panel(vault);
1198        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1199        panel.set_active_query("widget".to_string());
1200
1201        panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1202        assert_eq!(panel.active_query(), "widget or:title");
1203
1204        panel.apply_sort(SortField::Name, SortOrder::Descending, &tx);
1205        assert_eq!(panel.active_query(), "widget -or:file");
1206    }
1207
1208    /// Regression: a directive-less query must still return a stable
1209    /// Name-ascending order (the DB only sorts when an `or:` directive is
1210    /// present). Without the fallback, results came back in arbitrary DB order.
1211    #[tokio::test(flavor = "multi_thread")]
1212    async fn directiveless_query_is_name_ascending() {
1213        let vault = crate::test_support::temp_vault("qp-defaultorder").await;
1214        vault.validate_and_init().await.unwrap();
1215        // Create in non-alphabetical order; all share the term "widget".
1216        for name in ["/charlie.md", "/alpha.md", "/bravo.md"] {
1217            vault
1218                .create_note(&VaultPath::note_path_from(name), "widget")
1219                .await
1220                .unwrap();
1221        }
1222        let mut panel = make_panel(vault);
1223        panel.set_active_query("widget".to_string()); // no order directive
1224        settle(&mut panel).await;
1225
1226        let names: Vec<String> = panel
1227            .list
1228            .visible_rows()
1229            .iter()
1230            .map(|e| e.filename.clone())
1231            .collect();
1232        let mut sorted = names.clone();
1233        sorted.sort();
1234        assert_eq!(names, sorted, "directive-less query must be name-ascending");
1235    }
1236
1237    /// Accepting a `?name` expansion through the panel pins the saved-search
1238    /// breadcrumb to the accepted name and runs the stored query.
1239    #[tokio::test(flavor = "multi_thread")]
1240    async fn accepting_saved_search_pins_breadcrumb() {
1241        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1242        let vault = crate::test_support::temp_vault("qp-ss-accept").await;
1243        vault.validate_and_init().await.unwrap();
1244        vault.save_search("todo-week", "#todo").await.unwrap();
1245        let mut panel = make_panel(vault);
1246        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1247
1248        // Clear the default query so `?` is the leading char, then type a
1249        // prefix, draining the async popup load between keystrokes so the
1250        // suggestion lands before we accept.
1251        panel.set_active_query(String::new());
1252        for ch in ['?', 't', 'o'] {
1253            panel.handle_key(&KeyEvent::new(KeyCode::Char(ch), KeyModifiers::NONE), &tx);
1254            for _ in 0..30 {
1255                tokio::time::sleep(std::time::Duration::from_millis(5)).await;
1256                panel.list.poll();
1257            }
1258        }
1259        panel.handle_key(&KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &tx);
1260
1261        assert_eq!(panel.active_query(), "#todo");
1262        assert_eq!(
1263            panel.saved_search_breadcrumb().as_deref(),
1264            Some("todo-week")
1265        );
1266    }
1267
1268    /// Editing the expanded query keeps the breadcrumb (sticky provenance) and
1269    /// marks it `• edited` once the text diverges from the stored query.
1270    #[tokio::test(flavor = "multi_thread")]
1271    async fn editing_expanded_query_keeps_breadcrumb_marked_edited() {
1272        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1273        let vault = crate::test_support::temp_vault("qp-ss-edit").await;
1274        vault.validate_and_init().await.unwrap();
1275        let mut panel = make_panel(vault);
1276        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1277        panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1278        assert_eq!(panel.saved_search_breadcrumb().as_deref(), Some("todo"));
1279
1280        // A manual edit must NOT drop the breadcrumb; it gains the marker.
1281        panel.handle_key(&KeyEvent::new(KeyCode::Char('x'), KeyModifiers::NONE), &tx);
1282        assert_eq!(panel.active_query(), "#todox");
1283        assert_eq!(
1284            panel.saved_search_breadcrumb().as_deref(),
1285            Some("todo • edited")
1286        );
1287    }
1288
1289    /// Emptying the query field clears the breadcrumb entirely (one of the two
1290    /// clear triggers, the other being a fresh expansion).
1291    #[tokio::test(flavor = "multi_thread")]
1292    async fn emptying_field_clears_breadcrumb() {
1293        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1294        let vault = crate::test_support::temp_vault("qp-ss-empty").await;
1295        vault.validate_and_init().await.unwrap();
1296        let mut panel = make_panel(vault);
1297        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1298        panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1299
1300        // Backspace the whole "#todo" away.
1301        for _ in 0.."#todo".len() {
1302            panel.handle_key(&KeyEvent::new(KeyCode::Backspace, KeyModifiers::NONE), &tx);
1303        }
1304        assert_eq!(panel.active_query(), "");
1305        assert_eq!(panel.saved_search_breadcrumb(), None);
1306    }
1307
1308    #[tokio::test(flavor = "multi_thread")]
1309    async fn apply_query_pins_breadcrumb() {
1310        let vault = crate::test_support::temp_vault("qp-name").await;
1311        vault.validate_and_init().await.unwrap();
1312        let mut panel = make_panel(vault);
1313        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1314        panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1315        assert_eq!(panel.saved_search_breadcrumb().as_deref(), Some("todo"));
1316    }
1317
1318    /// Applying a sort rewrites the query's order directive — the breadcrumb
1319    /// stays sticky but gains the edited marker, because the stored query is
1320    /// saved verbatim and any text divergence counts as an edit.
1321    #[tokio::test(flavor = "multi_thread")]
1322    async fn apply_sort_marks_saved_search_breadcrumb_edited() {
1323        let vault = crate::test_support::temp_vault("qp-sort-name").await;
1324        vault.validate_and_init().await.unwrap();
1325        let mut panel = make_panel(vault);
1326        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1327        panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1328
1329        panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1330        assert_eq!(panel.active_query(), "#todo or:title");
1331        assert_eq!(
1332            panel.saved_search_breadcrumb().as_deref(),
1333            Some("todo • edited"),
1334            "sorting diverges from the stored query, so the breadcrumb is edited"
1335        );
1336    }
1337
1338    /// Saving the live query re-pins the breadcrumb to the saved identity:
1339    /// the edited marker drops, and a save-as-new switches the name.
1340    #[tokio::test(flavor = "multi_thread")]
1341    async fn repin_after_save_adopts_saved_identity() {
1342        let vault = crate::test_support::temp_vault("qp-repin").await;
1343        vault.validate_and_init().await.unwrap();
1344        let mut panel = make_panel(vault);
1345        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1346        panel.apply_query("#todo".to_string(), Some("todo".to_string()), tx.clone());
1347
1348        panel.set_active_query("#todo and #urgent".to_string());
1349        assert_eq!(
1350            panel.saved_search_breadcrumb().as_deref(),
1351            Some("todo • edited")
1352        );
1353
1354        panel.repin_saved_search("urgent-todos".to_string(), "#todo and #urgent");
1355        assert_eq!(
1356            panel.saved_search_breadcrumb().as_deref(),
1357            Some("urgent-todos"),
1358            "after a save the saved identity is the provenance — no edited marker"
1359        );
1360    }
1361
1362    /// Regression: a programmatic sort change must update the VISIBLE input bar,
1363    /// not just the internal query string. (Previously `set_query` left the
1364    /// input widget stale, so the bar didn't show the `or:` directive.)
1365    #[tokio::test(flavor = "multi_thread")]
1366    async fn apply_sort_updates_visible_input_bar() {
1367        let vault = crate::test_support::temp_vault("qp-bar").await;
1368        vault.validate_and_init().await.unwrap();
1369        let mut panel = make_panel(vault);
1370        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1371        panel.set_active_query("widget".to_string());
1372        assert_eq!(
1373            panel.list.input_value(),
1374            "widget",
1375            "set_active_query syncs the bar"
1376        );
1377
1378        panel.apply_sort(SortField::Title, SortOrder::Ascending, &tx);
1379        assert_eq!(panel.active_query(), "widget or:title");
1380        assert_eq!(
1381            panel.list.input_value(),
1382            "widget or:title",
1383            "the input bar must reflect the rewritten query"
1384        );
1385    }
1386
1387    #[tokio::test(flavor = "multi_thread")]
1388    async fn current_order_reads_query_directive() {
1389        let vault = crate::test_support::temp_vault("qp-order").await;
1390        vault.validate_and_init().await.unwrap();
1391        let mut panel = make_panel(vault);
1392        panel.set_active_query("widget -or:title".to_string());
1393        assert_eq!(
1394            panel.current_order(),
1395            (SortField::Title, SortOrder::Descending)
1396        );
1397        panel.set_active_query("widget".to_string());
1398        assert_eq!(
1399            panel.current_order(),
1400            (SortField::Name, SortOrder::Ascending)
1401        );
1402    }
1403
1404    /// A static query (no `{note}`) must survive navigation: `set_note` leaves
1405    /// its query template untouched and does NOT reload the engine.
1406    // Multi-thread flavour: the engine drives the source load on a spawned
1407    // task, and `search_notes` awaits a sqlite pool that needs the IO driver
1408    // (a current-thread runtime only advances the spawned task on `yield_now`).
1409    #[tokio::test(flavor = "multi_thread")]
1410    async fn static_query_survives_navigation() {
1411        let vault = crate::test_support::temp_vault("nav-static").await;
1412        vault.validate_and_init().await.unwrap();
1413        vault
1414            .create_note(&VaultPath::note_path_from("/a.md"), "alpha #todo")
1415            .await
1416            .unwrap();
1417        let mut panel = make_panel(vault);
1418        panel.set_active_query("#todo".to_string());
1419        settle(&mut panel).await;
1420        assert_eq!(panel.list.visible_rows().len(), 1);
1421
1422        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1423        panel.set_note(VaultPath::note_path_from("x.md"), tx);
1424
1425        // Query template untouched (not reset to <{note}); a static query is
1426        // not reloaded, so it is not in a loading state.
1427        assert_eq!(panel.active_query(), "#todo");
1428        assert!(!panel.list.is_loading());
1429        settle(&mut panel).await;
1430        assert_eq!(panel.list.visible_rows().len(), 1); // results untouched
1431    }
1432
1433    /// A `{note}` query re-runs on navigation: `set_note` resolves `{note}`
1434    /// against the new note and reloads, so results follow the open note.
1435    #[tokio::test(flavor = "multi_thread")]
1436    async fn note_variable_query_reruns_on_navigation() {
1437        let vault = crate::test_support::temp_vault("nav-var").await;
1438        vault.validate_and_init().await.unwrap();
1439        // `target` is linked from `linker`; opening `target` should surface
1440        // `linker` as a backlink.
1441        vault
1442            .create_note(&VaultPath::note_path_from("/target.md"), "I am the target")
1443            .await
1444            .unwrap();
1445        vault
1446            .create_note(&VaultPath::note_path_from("/linker.md"), "see [[target]]")
1447            .await
1448            .unwrap();
1449        let mut panel = make_panel(vault);
1450        assert_eq!(panel.active_query(), DEFAULT_QUERY);
1451
1452        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1453        panel.set_note(VaultPath::note_path_from("/target.md"), tx);
1454        settle(&mut panel).await;
1455
1456        // The `{note}` query resolved against `target` and found the backlink.
1457        assert!(
1458            panel
1459                .list
1460                .visible_rows()
1461                .iter()
1462                .any(|e| e.filename.contains("linker")),
1463            "expected linker as a backlink, got {:?}",
1464            panel
1465                .list
1466                .visible_rows()
1467                .iter()
1468                .map(|e| e.filename.clone())
1469                .collect::<Vec<_>>()
1470        );
1471    }
1472
1473    /// Navigating to a different `{note}` re-resolves and changes results.
1474    #[tokio::test(flavor = "multi_thread")]
1475    async fn note_variable_query_changes_with_note() {
1476        let vault = crate::test_support::temp_vault("nav-var2").await;
1477        vault.validate_and_init().await.unwrap();
1478        vault
1479            .create_note(&VaultPath::note_path_from("/a.md"), "I am a")
1480            .await
1481            .unwrap();
1482        vault
1483            .create_note(&VaultPath::note_path_from("/b.md"), "I am b")
1484            .await
1485            .unwrap();
1486        vault
1487            .create_note(&VaultPath::note_path_from("/links_a.md"), "see [[a]]")
1488            .await
1489            .unwrap();
1490        let mut panel = make_panel(vault);
1491        let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
1492
1493        panel.set_note(VaultPath::note_path_from("/a.md"), tx.clone());
1494        settle(&mut panel).await;
1495        assert!(
1496            panel
1497                .list
1498                .visible_rows()
1499                .iter()
1500                .any(|e| e.filename.contains("links_a"))
1501        );
1502
1503        panel.set_note(VaultPath::note_path_from("/b.md"), tx);
1504        settle(&mut panel).await;
1505        assert!(
1506            !panel
1507                .list
1508                .visible_rows()
1509                .iter()
1510                .any(|e| e.filename.contains("links_a")),
1511            "b has no backlinks, expected empty"
1512        );
1513    }
1514}