Skip to main content

kimun_notes/components/autocomplete/
controller.rs

1use std::num::NonZeroU64;
2use std::ops::Range;
3use std::sync::Arc;
4use std::time::Duration;
5
6use kimun_core::note::scan::ExclusionZones;
7use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
8
9use super::host::AutocompleteHost;
10use super::popup::{PopupAction, PopupOutcome, handle_key as popup_handle_key};
11use super::state::{AutocompleteState, DEFAULT_MAX_VISIBLE_ROWS, Suggestion};
12use super::trigger::{TriggerKind, TriggerOptions, ZoneOracle, detect_trigger_with_oracle};
13use crate::components::search_list::SuggestionSource;
14#[cfg(test)]
15use crate::components::text_editor::snapshot::EditorSnapshot;
16use crate::util::single_slot_task::SingleSlotTask;
17
18/// Hard cap on suggestions fetched from core per query. The popup itself
19/// only shows `max_visible_rows` at a time and scrolls inside the fetched
20/// set, so a few dozen rows is plenty.
21const DEFAULT_FETCH_LIMIT: usize = 50;
22
23/// Wait this long after a query-refinement keystroke before hitting the
24/// vault. Two cases:
25///
26/// - Fast typing (inter-keystroke gap < `DEFAULT_DEBOUNCE`): each new
27///   keystroke aborts the previous in-flight task while it is still
28///   inside `tokio::time::sleep`, so only the final keystroke's query
29///   reaches SQLite. This is the case the debounce is optimised for.
30/// - Normal typing (inter-keystroke gap ≥ `DEFAULT_DEBOUNCE`): every
31///   keystroke still runs its own query, with `DEFAULT_DEBOUNCE` of added
32///   latency between keystroke and popup update. The debounce does NOT
33///   reduce work in this regime — it bounds responsiveness.
34///
35/// The first query of a popup (kind change / popup opening) skips the
36/// debounce entirely so the popup feels instant on open.
37const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(80);
38
39/// Whether wikilink triggers are honoured. The editor uses
40/// `Both`; the search box uses `HashtagOnly` because the search syntax has
41/// no `[[…]]` operator.
42#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum AutocompleteMode {
44    Both,
45    HashtagOnly,
46    /// Search-query box: hashtags (labels) + note-name operators (`<`, `>`, `=`).
47    SearchQuery,
48}
49
50/// Owns the popup lifecycle and the (debounced via generation tokens)
51/// query plumbing. The host calls `sync(host)` after every edit; the
52/// controller decides whether to open, refresh, or close the popup.
53pub struct AutocompleteController {
54    state: Option<AutocompleteState>,
55    suggestions: Arc<dyn SuggestionSource>,
56    mode: AutocompleteMode,
57    /// Trigger-detection options passed to `detect_trigger_with` on every
58    /// `sync`. The editor leaves header disambiguation on; the search box
59    /// switches it off because its input has no Markdown headers.
60    trigger_opts: TriggerOptions,
61    /// Monotonic counter incremented on every fired query. Responses that
62    /// arrive with a stale generation are discarded.
63    generation: u64,
64    result_tx: UnboundedSender<QueryResult>,
65    result_rx: UnboundedReceiver<QueryResult>,
66    fetch_limit: usize,
67    max_visible_rows: usize,
68    /// Handle of the most recently spawned query task. Spawning a new
69    /// query into this slot aborts the previous one, so a burst of
70    /// keystrokes does not pile up N concurrent SQLite queries holding
71    /// the vault `Arc` open. The slot's `Drop` also aborts, so the
72    /// task cannot outlive the controller.
73    in_flight: SingleSlotTask<()>,
74    /// Delay inserted before each refinement query hits the vault. A burst
75    /// of typing aborts the prior in-flight task during this window, so
76    /// only the final keystroke's query reaches SQLite. The first query of
77    /// a popup (kind change / open) bypasses the debounce. Tests override
78    /// to `Duration::ZERO` via `with_debounce`.
79    debounce: Duration,
80    /// The joined buffer text keyed on the host's `content_revision`,
81    /// plus its `ExclusionZones` computed LAZILY (`None` until the
82    /// trigger veto first needs them). The text is rebuilt only when the
83    /// revision moves; cursor moves reuse it. The zones — a full-buffer
84    /// pulldown-cmark + regex scan — are computed only when a `[[`/`#`
85    /// opener is found, then memoized here so a later cursor move at the
86    /// same revision reuses them. Hosts with no stable revision identity
87    /// (search-box modal) return `None` from `content_revision` and never
88    /// populate this slot.
89    cached_text: Option<(NonZeroU64, String, Option<ExclusionZones>)>,
90    /// Optional callback used to wake the host's render loop after an
91    /// async query posts its result. Decoupled from any specific event
92    /// bus so the controller stays usable wherever the host can
93    /// trigger a redraw.
94    redraw_cb: Option<RedrawCallback>,
95}
96
97/// Fire-and-forget redraw signal owned by the controller and invoked
98/// from the spawned query task. The host wires this to its event loop
99/// (e.g. `tx.send(AppEvent::Redraw)`).
100pub type RedrawCallback = Arc<dyn Fn() + Send + Sync + 'static>;
101
102#[derive(Debug)]
103struct QueryResult {
104    generation: u64,
105    kind: TriggerKind,
106    items: Vec<Suggestion>,
107}
108
109/// [`ZoneOracle`] that computes `ExclusionZones` from `text` on first
110/// query and memoizes the result back into the borrowed slot — so the
111/// full-buffer scan runs at most once per buffer revision, and only when
112/// a trigger candidate actually reaches the exclusion veto.
113struct LazyZoneOracle<'a> {
114    text: &'a str,
115    zones: &'a mut Option<ExclusionZones>,
116}
117
118impl ZoneOracle for LazyZoneOracle<'_> {
119    fn contains(&mut self, cursor: usize) -> bool {
120        let text = self.text;
121        self.zones
122            .get_or_insert_with(|| ExclusionZones::from_text(text))
123            .contains(cursor)
124    }
125
126    fn contains_code_link_or_frontmatter(&mut self, cursor: usize) -> bool {
127        let text = self.text;
128        self.zones
129            .get_or_insert_with(|| ExclusionZones::from_text(text))
130            .contains_code_link_or_frontmatter(cursor)
131    }
132}
133
134impl AutocompleteController {
135    pub fn new(suggestions: Arc<dyn SuggestionSource>, mode: AutocompleteMode) -> Self {
136        let (result_tx, result_rx) = unbounded_channel();
137        Self {
138            state: None,
139            suggestions,
140            mode,
141            trigger_opts: TriggerOptions::default(),
142            generation: 0,
143            result_tx,
144            result_rx,
145            fetch_limit: DEFAULT_FETCH_LIMIT,
146            max_visible_rows: DEFAULT_MAX_VISIBLE_ROWS,
147            in_flight: SingleSlotTask::empty(),
148            debounce: DEFAULT_DEBOUNCE,
149            cached_text: None,
150            redraw_cb: None,
151        }
152    }
153
154    /// Override the trigger-detection options. Used by the search-box
155    /// controller to disable the column-0 header disambiguation rule
156    /// (Markdown headers don't exist in a search input).
157    pub fn with_trigger_opts(mut self, opts: TriggerOptions) -> Self {
158        self.trigger_opts = opts;
159        self
160    }
161
162    /// Override the per-refinement debounce window. Tests pass
163    /// `Duration::ZERO` so query results land promptly inside `drain_results`.
164    pub fn with_debounce(mut self, debounce: Duration) -> Self {
165        self.debounce = debounce;
166        self
167    }
168
169    /// Register a redraw callback. Without one, the popup state updates
170    /// on background threads but the render loop has no signal to
171    /// wake. Idempotent — safe to call from a host's first
172    /// `handle_input` to lazily bind once the host has a way to
173    /// trigger redraws.
174    pub fn set_redraw_callback(&mut self, cb: RedrawCallback) {
175        self.redraw_cb = Some(cb);
176    }
177
178    /// Whether the popup is currently *interactive* — held state AND at
179    /// least one visible suggestion. Returns `false` while a query is
180    /// in flight (state exists but items not yet arrived) or when a
181    /// query returned no matches: in both cases the popup is not drawn
182    /// and must not intercept key events, so Esc/Up/Down/Tab fall
183    /// through to the host (modal Esc closes the modal, list Up/Down
184    /// navigates files, etc).
185    pub fn is_open(&self) -> bool {
186        self.state.as_ref().is_some_and(|s| !s.items.is_empty())
187    }
188
189    /// Borrow the popup state for read-only inspection (rendering,
190    /// query introspection, tests). Returns `None` whenever the popup
191    /// is not active.
192    pub fn state(&self) -> Option<&AutocompleteState> {
193        self.state.as_ref()
194    }
195
196    /// Borrow the popup state mutably. The only legitimate
197    /// caller-side use today is the host's render path, which
198    /// re-anchors `state.anchor` from the freshly rendered caret
199    /// position so the popup follows the cursor without a one-frame
200    /// lag. Mutating `items` / `highlighted` / `scroll_offset` from
201    /// outside the controller will desync the popup; use the
202    /// dedicated `sync` / `refresh_if_open` / `handle_key` entry
203    /// points for those.
204    pub fn state_mut(&mut self) -> Option<&mut AutocompleteState> {
205        self.state.as_mut()
206    }
207
208    /// Close the popup immediately. Safe to call when already closed.
209    /// Use whenever focus moves away from the host or the host
210    /// triggers a buffer-replacement that invalidates the trigger
211    /// context (e.g. `set_text`).
212    ///
213    /// Also aborts any in-flight query task — without this, pressing Esc
214    /// during the 80ms debounce window leaks a spawned tokio task that
215    /// continues to the SQLite hit and posts a result discarded later
216    /// via generation mismatch.
217    pub fn close(&mut self) {
218        self.state = None;
219        self.in_flight.abort();
220        // Drop the cached buffer text + zones. On a multi-MB note these
221        // hold a full clone of the buffer + parsed exclusion-zone
222        // ranges; without this clear they survive popup dismissal
223        // until the next text edit overwrites the slot.
224        self.cached_text = None;
225    }
226
227    /// Route a key event through the popup when one is open. Returns a
228    /// `HandleKeyOutcome` so the host can decide whether to apply an
229    /// accept, fall through to its own key handling, etc. The controller
230    /// never mutates the host's buffer directly — on accept it returns an
231    /// `AcceptAction` describing the replacement.
232    pub fn handle_key<H: AutocompleteHost>(
233        &mut self,
234        key: ratatui::crossterm::event::KeyEvent,
235        host: &H,
236    ) -> HandleKeyOutcome {
237        let Some(state) = self.state.as_mut() else {
238            return HandleKeyOutcome::NotHandled;
239        };
240        let outcome = popup_handle_key(state, key);
241        match outcome {
242            PopupOutcome::Consumed(PopupAction::None) => HandleKeyOutcome::Consumed,
243            PopupOutcome::Consumed(PopupAction::Accept) => {
244                // Compute the accept BEFORE closing so a stale-range
245                // failure (None) can fall through to the host's normal
246                // key handling instead of silently swallowing the key:
247                // user pressed Tab expecting an indent or Enter
248                // expecting a newline; if the accept can't run we
249                // should still give them the key back.
250                match self.compute_accept(host) {
251                    Some(action) => {
252                        self.close();
253                        HandleKeyOutcome::Accepted(action)
254                    }
255                    None => {
256                        self.close();
257                        HandleKeyOutcome::NotHandled
258                    }
259                }
260            }
261            PopupOutcome::Consumed(PopupAction::Dismiss) => {
262                self.close();
263                HandleKeyOutcome::Dismissed
264            }
265            PopupOutcome::NotHandled => HandleKeyOutcome::NotHandled,
266        }
267    }
268
269    /// Inspect the host's current buffer + cursor and reconcile the popup
270    /// state. Call this after a **text edit** (insert / delete / paste /
271    /// any change that modifies the buffer). Will open a fresh popup
272    /// when the cursor lands inside a trigger context, refresh an open
273    /// popup's range/query/anchor, or close the popup when the trigger
274    /// is gone.
275    pub fn sync<H: AutocompleteHost>(&mut self, host: &H) {
276        self.reconcile(host, true);
277    }
278
279    /// Refresh the popup state for a **cursor-only** event (arrow keys,
280    /// click, Home/End, etc). If the popup is closed, this is a no-op —
281    /// cursor movement never opens a new popup. If the popup is open,
282    /// it follows the cursor: query, range, and anchor update; the
283    /// popup closes when the cursor leaves the trigger range.
284    pub fn refresh_if_open<H: AutocompleteHost>(&mut self, host: &H) {
285        if self.state.is_some() {
286            self.reconcile(host, false);
287        }
288    }
289
290    fn reconcile<H: AutocompleteHost>(&mut self, host: &H, allow_open: bool) {
291        // Single borrow of the host's buffer + cursor. The snapshot
292        // is borrowed (Textarea backend) so no per-keystroke lines
293        // clone happens here.
294        let snap = host.buffer_snapshot();
295        let cursor = snap.cursor_byte_offset();
296        let cache_key = host.cache_key();
297        // Rebuild the joined buffer text only when the host's cache key
298        // has moved on; cursor moves reuse it. The expensive
299        // `ExclusionZones` scan (full-buffer pulldown-cmark + regex)
300        // stays LAZY — the oracle below computes it only if the local
301        // trigger scan finds a `[[`/`#` opener that needs the exclusion
302        // veto, and memoizes it into the cache slot so a later cursor
303        // move at the same revision reuses it. Normal prose keystrokes
304        // (no opener at the caret) never pay the scan at all.
305        //
306        // The leading-`?` SavedSearch trigger is enabled only in the
307        // search-query box (`mode == SearchQuery`); the editor leaves it off
308        // so a note opening with `?` can't shadow `#`/`[[`. Deriving it from
309        // the mode here keeps the controller the single authority on the gate.
310        let opts = TriggerOptions {
311            allow_saved_search: matches!(self.mode, AutocompleteMode::SearchQuery),
312            ..self.trigger_opts
313        };
314
315        // `cache_key == None` opts the host out (search-box modal): join
316        // locally, use a throwaway lazy memo, and never touch the cache.
317        let trigger = match cache_key {
318            Some(rev) => {
319                let hit = matches!(&self.cached_text, Some((r, _, _)) if *r == rev);
320                if !hit {
321                    self.cached_text = Some((rev, snap.lines.join("\n"), None));
322                }
323                let (_, text, zones_slot) = self.cached_text.as_mut().expect("just populated");
324                let text: &str = text;
325                let mut oracle = LazyZoneOracle {
326                    text,
327                    zones: zones_slot,
328                };
329                detect_trigger_with_oracle(text, cursor, opts, &mut oracle)
330            }
331            None => {
332                let text = snap.lines.join("\n");
333                let mut zones: Option<ExclusionZones> = None;
334                let mut oracle = LazyZoneOracle {
335                    text: &text,
336                    zones: &mut zones,
337                };
338                detect_trigger_with_oracle(&text, cursor, opts, &mut oracle)
339            }
340        };
341
342        // Filter by mode before deciding anything else.
343        let trigger = trigger.filter(|t| match (self.mode, t.kind) {
344            (AutocompleteMode::Both, TriggerKind::Wikilink | TriggerKind::Hashtag) => true,
345            (AutocompleteMode::Both, TriggerKind::LinkFilter) => false,
346            (AutocompleteMode::HashtagOnly, TriggerKind::Hashtag) => true,
347            (AutocompleteMode::HashtagOnly, TriggerKind::Wikilink | TriggerKind::LinkFilter) => {
348                false
349            }
350            (AutocompleteMode::SearchQuery, TriggerKind::Hashtag | TriggerKind::LinkFilter) => true,
351            (AutocompleteMode::SearchQuery, TriggerKind::Wikilink) => false,
352            // SavedSearch detection is already gated by mode above (only
353            // `SearchQuery` sets `allow_saved_search`), so reaching here in any
354            // other mode is impossible; accept it wherever it was detected.
355            (_, TriggerKind::SavedSearch) => true,
356        });
357
358        let Some(trigger) = trigger else {
359            self.close();
360            return;
361        };
362
363        let Some(anchor) = host.screen_anchor_for(trigger.anchor_col) else {
364            self.close();
365            return;
366        };
367
368        let query_changed;
369        let kind_changed;
370        match self.state.as_ref() {
371            None => {
372                kind_changed = true;
373                query_changed = true;
374            }
375            Some(existing) => {
376                kind_changed = existing.kind != trigger.kind;
377                query_changed = kind_changed || existing.query != trigger.query;
378            }
379        }
380
381        // On a cursor-only reconcile (allow_open=false), neither
382        // opening a brand-new popup NOR replacing an existing popup
383        // with a different trigger kind counts as a refresh — the
384        // user did not type into the new context. Close instead so
385        // the popup doesn't materialise from a mouse click or arrow
386        // move into a different trigger zone.
387        if !allow_open && (self.state.is_none() || kind_changed) {
388            self.close();
389            return;
390        }
391
392        if self.state.is_none() || kind_changed {
393            let mut st = AutocompleteState::new(trigger.kind, anchor);
394            st.max_visible_rows = self.max_visible_rows;
395            self.state = Some(st);
396        }
397
398        if let Some(state) = self.state.as_mut() {
399            state.kind = trigger.kind;
400            state.opener = trigger.opener;
401            state.query = trigger.query.clone();
402            state.replace_range = trigger.replace_range.clone();
403            state.anchor = anchor;
404        }
405
406        if query_changed {
407            // First query of a popup (kind change / open) fires instantly
408            // for snappy UX. Refinement queries on the same popup are
409            // debounced so a burst of typing only hits the vault once.
410            let instant = kind_changed;
411            self.fire_query(trigger.kind, trigger.query, instant);
412        }
413    }
414
415    /// Drain pending query responses and apply the latest one whose
416    /// generation matches the controller's current generation. Older
417    /// responses (stale) are discarded.
418    pub fn poll_results(&mut self) {
419        while let Ok(result) = self.result_rx.try_recv() {
420            if result.generation != self.generation {
421                continue;
422            }
423            let Some(state) = self.state.as_mut() else {
424                continue;
425            };
426            if state.kind != result.kind {
427                continue;
428            }
429            state.set_items(result.items);
430        }
431    }
432
433    /// Build link-filter suggestions: the `{note}` variable when it matches the
434    /// prefix, followed by note names. Pure + async so it can be unit-tested.
435    pub(super) async fn link_filter_suggestions(
436        s: &dyn SuggestionSource,
437        prefix: &str,
438    ) -> Vec<crate::components::search_list::SuggestionItem> {
439        use crate::components::search_list::SuggestionItem;
440        let mut out = Vec::new();
441        if prefix.is_empty() || "note".starts_with(&prefix.to_lowercase()) {
442            out.push(SuggestionItem::plain("{note}"));
443        }
444        out.extend(s.notes_by_prefix(prefix, 20).await);
445        out
446    }
447
448    fn fire_query(&mut self, kind: TriggerKind, query: String, instant: bool) {
449        // `SingleSlotTask::spawn` aborts the previous in-flight task —
450        // its result would be discarded on receive (generation
451        // mismatch) but the SQLite hit would still happen and the
452        // suggestions `Arc` would stay alive until the task drained.
453        self.generation = self.generation.wrapping_add(1);
454        let req_gen = self.generation;
455        let tx = self.result_tx.clone();
456        let redraw = self.redraw_cb.clone();
457        let suggestions = self.suggestions.clone();
458        let limit = self.fetch_limit;
459        let debounce = if instant {
460            Duration::ZERO
461        } else {
462            self.debounce
463        };
464        self.in_flight.spawn(async move {
465            // Aborted by the next `fire_query` before this sleep completes
466            // for a burst of typing — the source hit below never runs.
467            if !debounce.is_zero() {
468                tokio::time::sleep(debounce).await;
469            }
470            let items: Vec<Suggestion> = match kind {
471                TriggerKind::Wikilink => suggestions
472                    .notes_by_prefix(&query, limit)
473                    .await
474                    .into_iter()
475                    .map(|item| Suggestion {
476                        display: item.display,
477                        secondary: item.secondary,
478                    })
479                    .collect(),
480                TriggerKind::LinkFilter => Self::link_filter_suggestions(&*suggestions, &query)
481                    .await
482                    .into_iter()
483                    .map(|item| Suggestion {
484                        display: item.display,
485                        secondary: item.secondary,
486                    })
487                    .collect(),
488                TriggerKind::Hashtag => suggestions
489                    .tags_by_prefix(&query, limit)
490                    .await
491                    .into_iter()
492                    .map(|item| Suggestion {
493                        display: item.display,
494                        secondary: item.secondary,
495                    })
496                    .collect(),
497                TriggerKind::SavedSearch => suggestions
498                    .saved_searches_by_prefix(&query, limit)
499                    .await
500                    .into_iter()
501                    .map(|item| Suggestion {
502                        display: item.display,
503                        secondary: item.secondary,
504                    })
505                    .collect(),
506            };
507            let _ = tx.send(QueryResult {
508                generation: req_gen,
509                kind,
510                items,
511            });
512            // Wake the host's render loop so the popup actually paints
513            // with the new items. `redraw_cb` may be None in unit
514            // tests; production hosts bind it via `set_redraw_callback`.
515            if let Some(redraw) = redraw {
516                redraw();
517            }
518        });
519    }
520
521    fn compute_accept<H: AutocompleteHost>(&self, host: &H) -> Option<AcceptAction> {
522        let state = self.state.as_ref()?;
523        let suggestion = state.selected()?.clone();
524        let kind = state.kind;
525        let range = state.replace_range.clone();
526        // Accept is a once-per-popup-acceptance path; allocating the
527        // joined buffer text here is fine. The hot path is reconcile,
528        // which uses the cached `text` slot built once per text edit.
529        let buffer = host.buffer_snapshot().lines.join("\n");
530
531        // Guard against a stale snapshot — if the live buffer shrank
532        // below the trigger range, drop the accept rather than producing
533        // a malformed insertion (or panicking on String::replace_range
534        // in the search-box host).
535        if range.start > range.end || range.end > buffer.len() {
536            return None;
537        }
538        if !buffer.is_char_boundary(range.start) || !buffer.is_char_boundary(range.end) {
539            return None;
540        }
541
542        match kind {
543            TriggerKind::Wikilink => {
544                let extent = scan_wikilink_extent(&buffer, range.end);
545                // Replace from the trigger's start through the end of
546                // the stale wikilink-target region. This consumes any
547                // characters the user already typed past the cursor up
548                // to (but not including) `]]`, `|`, a newline, `[`, or
549                // EOF — preventing artefacts like `[[meeting]]e]]` when
550                // the popup is reopened mid-target.
551                let new_range = range.start..extent.end;
552                let needs_close = !extent.existing_close && !extent.has_alias;
553                let new_text = if needs_close {
554                    format!("{}]]", suggestion.display)
555                } else {
556                    suggestion.display.clone()
557                };
558                let cursor_offset_in_target = suggestion.display.len();
559                let new_cursor_byte = if extent.has_alias {
560                    // Keep the cursor right before `|alias]]` so the
561                    // user can edit the alias next.
562                    range.start.saturating_add(cursor_offset_in_target)
563                } else {
564                    // Land just past `]]` — whether we appended it or
565                    // it already existed.
566                    range
567                        .start
568                        .saturating_add(cursor_offset_in_target)
569                        .saturating_add(2)
570                };
571                Some(AcceptAction {
572                    range: new_range,
573                    new_text,
574                    new_cursor_byte,
575                    saved_search_name: None,
576                })
577            }
578            TriggerKind::Hashtag | TriggerKind::LinkFilter => {
579                let new_cursor_byte = range.start.saturating_add(suggestion.display.len());
580                Some(AcceptAction {
581                    range,
582                    new_text: suggestion.display,
583                    new_cursor_byte,
584                    saved_search_name: None,
585                })
586            }
587            // SavedSearch: expand the WHOLE field to the stored query (carried
588            // in `secondary`) and report the name so the host pins the
589            // breadcrumb.
590            TriggerKind::SavedSearch => {
591                let new_text = suggestion.secondary.unwrap_or_default();
592                let new_cursor_byte = new_text.len();
593                Some(AcceptAction {
594                    range: 0..buffer.len(),
595                    new_text,
596                    new_cursor_byte,
597                    saved_search_name: Some(suggestion.display),
598                })
599            }
600        }
601    }
602}
603
604/// Where the stale wikilink-target region around the cursor ends, plus
605/// what kind of suffix is already present.
606struct WikilinkExtent {
607    /// Byte offset (≥ `start`) of the first character that is NOT part
608    /// of the target region: either the first `]` of an existing `]]`,
609    /// a `|` alias separator, a newline, a `[`, or EOF.
610    end: usize,
611    /// `true` when an existing `]]` follows immediately at `end`.
612    existing_close: bool,
613    /// `true` when `end` points at a `|` separator — the user has
614    /// already started typing an alias which we must preserve.
615    has_alias: bool,
616}
617
618/// Walk forward from `start` over the bytes that look like wikilink
619/// target characters (anything except `]`, `|`, `\n`, `\r`, `[`).
620/// Lone `]` bytes (without a following `]`) are treated as stale
621/// characters and consumed — invalid inside a wikilink target anyway.
622///
623/// All decision bytes are ASCII so byte-level scanning is UTF-8 safe.
624fn scan_wikilink_extent(buffer: &str, start: usize) -> WikilinkExtent {
625    let bytes = buffer.as_bytes();
626    let mut i = start.min(bytes.len());
627    while i < bytes.len() {
628        match bytes[i] {
629            b']' => {
630                if bytes.get(i + 1) == Some(&b']') {
631                    return WikilinkExtent {
632                        end: i,
633                        existing_close: true,
634                        has_alias: false,
635                    };
636                }
637                // Lone `]` — consume and keep scanning. Invalid inside
638                // a wikilink target so dropping it is the safe default.
639                i += 1;
640            }
641            b'|' => {
642                return WikilinkExtent {
643                    end: i,
644                    existing_close: false,
645                    has_alias: true,
646                };
647            }
648            b'\n' | b'\r' | b'[' => {
649                return WikilinkExtent {
650                    end: i,
651                    existing_close: false,
652                    has_alias: false,
653                };
654            }
655            _ => i += 1,
656        }
657    }
658    WikilinkExtent {
659        end: i,
660        existing_close: false,
661        has_alias: false,
662    }
663}
664
665/// What the controller decided when forwarded a key event.
666#[derive(Debug, Clone, PartialEq, Eq)]
667pub enum HandleKeyOutcome {
668    /// Popup was open and consumed the key as navigation.
669    Consumed,
670    /// Popup was open; user dismissed with Esc.
671    Dismissed,
672    /// Popup was open; user accepted. The host should apply the action.
673    Accepted(AcceptAction),
674    /// Popup was either closed or did not handle this key — host should
675    /// process it as a normal key event, then call `sync()` afterward.
676    NotHandled,
677}
678
679/// A buffer replacement the host needs to perform after an accept.
680#[derive(Debug, Clone, PartialEq, Eq)]
681pub struct AcceptAction {
682    pub range: Range<usize>,
683    pub new_text: String,
684    pub new_cursor_byte: usize,
685    /// `Some(name)` when a SavedSearch suggestion was accepted — the host
686    /// pins it as the saved-search breadcrumb. `None` for all other kinds.
687    pub saved_search_name: Option<String>,
688}
689
690#[cfg(test)]
691mod tests {
692    use super::*;
693    use kimun_core::nfs::VaultPath;
694    use kimun_core::{NoteVault, VaultConfig};
695    use std::sync::Arc;
696    use std::sync::atomic::{AtomicU64, Ordering};
697    use tempfile::TempDir;
698
699    /// Global per-test counter so each `FakeHost::new` returns a distinct
700    /// `content_revision` and the controller's cache is invalidated between
701    /// successive sync calls in the same test (mirrors production where
702    /// rebuilding the buffer always advances the editor's revision).
703    static FAKE_REV: AtomicU64 = AtomicU64::new(1);
704
705    struct FakeHost {
706        buffer: String,
707        cursor: usize,
708        /// `Some(rev)` to participate in the controller's cache.
709        /// `None` mirrors the search-box modal opting out.
710        revision: Option<NonZeroU64>,
711    }
712
713    impl FakeHost {
714        fn new(buffer: &str, cursor: usize) -> Self {
715            Self {
716                buffer: buffer.to_string(),
717                cursor,
718                revision: NonZeroU64::new(FAKE_REV.fetch_add(1, Ordering::SeqCst)),
719            }
720        }
721
722        fn apply(&mut self, action: &AcceptAction) {
723            self.buffer
724                .replace_range(action.range.clone(), &action.new_text);
725            self.cursor = action.new_cursor_byte;
726            self.revision = self
727                .revision
728                .and_then(|r| NonZeroU64::new(r.get().wrapping_add(1)));
729        }
730
731        /// Split `self.buffer` into lines and convert the byte cursor
732        /// into `(row, char_col)`. Re-derived on every
733        /// `buffer_snapshot()` call so tests that mutate
734        /// `host.buffer` / `host.cursor` directly stay in sync.
735        fn lines_and_cursor(&self) -> (Vec<String>, (usize, usize)) {
736            let lines: Vec<String> = self.buffer.split('\n').map(|s| s.to_string()).collect();
737            let mut byte_running = 0;
738            for (row, line) in lines.iter().enumerate() {
739                let line_end = byte_running + line.len();
740                if self.cursor <= line_end {
741                    let col_byte = self.cursor - byte_running;
742                    let col = line[..col_byte].chars().count();
743                    return (lines, (row, col));
744                }
745                byte_running = line_end + 1; // +1 for '\n'
746            }
747            // Past EOF — clamp to last row's end.
748            let row = lines.len().saturating_sub(1);
749            let col = lines.get(row).map(|l| l.chars().count()).unwrap_or(0);
750            (lines, (row, col))
751        }
752    }
753
754    impl AutocompleteHost for FakeHost {
755        fn buffer_snapshot(&self) -> EditorSnapshot<'_> {
756            let rev = self.revision.unwrap_or_else(|| NonZeroU64::new(1).unwrap());
757            let (lines, cursor) = self.lines_and_cursor();
758            // Owned because we constructed `lines` locally — tests
759            // don't hold the snapshot long enough to care about the
760            // allocation.
761            EditorSnapshot::owned(lines, cursor, rev)
762        }
763        fn cache_key(&self) -> Option<NonZeroU64> {
764            self.revision
765        }
766        fn screen_anchor_for(&self, _byte_offset: usize) -> Option<(u16, u16)> {
767            Some((0, 0))
768        }
769    }
770
771    async fn new_vault_with(
772        notes: &[&str],
773        tag_notes: &[(&str, &str)],
774    ) -> (TempDir, Arc<NoteVault>) {
775        let tmp = TempDir::new().unwrap();
776        let cfg = VaultConfig::new(tmp.path().to_path_buf());
777        let vault = NoteVault::new(cfg).await.unwrap();
778        vault.validate_and_init().await.unwrap();
779        for name in notes {
780            vault
781                .create_note(&VaultPath::note_path_from(format!("/{name}.md")), "body")
782                .await
783                .unwrap();
784        }
785        for (path, body) in tag_notes {
786            vault
787                .create_note(&VaultPath::note_path_from(format!("/{path}.md")), *body)
788                .await
789                .unwrap();
790        }
791        (tmp, Arc::new(vault))
792    }
793
794    async fn drain_results(controller: &mut AutocompleteController) {
795        // The query task hits SQLite on a real temp-dir vault, so its
796        // completion time varies with machine load — a fixed sleep is
797        // flaky on busy CI runners. The result is sent on the channel
798        // before the task future returns, so "task finished" guarantees
799        // the result is ready to poll.
800        let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10);
801        while controller.in_flight.is_in_flight() {
802            assert!(
803                tokio::time::Instant::now() < deadline,
804                "query task did not finish within 10s"
805            );
806            tokio::time::sleep(std::time::Duration::from_millis(1)).await;
807        }
808        controller.poll_results();
809    }
810
811    /// Builds a controller with debounce disabled so tests don't pay the
812    /// 80ms refinement window before each query reaches the in-memory DB.
813    fn make_controller(vault: Arc<NoteVault>, mode: AutocompleteMode) -> AutocompleteController {
814        use crate::components::search_list::VaultSuggestions;
815        AutocompleteController::new(Arc::new(VaultSuggestions { vault }), mode)
816            .with_debounce(Duration::ZERO)
817    }
818
819    // ---- Lifecycle ----
820
821    #[tokio::test]
822    async fn no_trigger_keeps_popup_closed() {
823        let (_tmp, vault) = new_vault_with(&[], &[]).await;
824        let mut c = make_controller(vault, AutocompleteMode::Both);
825        let host = FakeHost::new("plain text", 5);
826        c.sync(&host);
827        assert!(!c.is_open());
828    }
829
830    #[tokio::test]
831    async fn wikilink_trigger_opens_popup_and_loads_results() {
832        let (_tmp, vault) = new_vault_with(&["meeting", "music", "novel"], &[]).await;
833        let mut c = make_controller(vault, AutocompleteMode::Both);
834        let host = FakeHost::new("see [[me", 8);
835        c.sync(&host);
836        // State exists immediately; is_open() flips to true only once
837        // items have arrived (the popup is not "interactive" while a
838        // query is in flight).
839        assert!(c.state().is_some());
840        assert!(!c.is_open());
841        drain_results(&mut c).await;
842        assert!(c.is_open());
843        let st = c.state().unwrap();
844        assert_eq!(st.kind, TriggerKind::Wikilink);
845        assert_eq!(st.query, "me");
846        let names: Vec<&str> = st.items.iter().map(|s| s.display.as_str()).collect();
847        assert!(names.contains(&"meeting"));
848        assert!(!names.contains(&"novel"));
849    }
850
851    #[tokio::test]
852    async fn saved_search_popup_loads_matching_searches() {
853        let (_tmp, vault) = new_vault_with(&[], &[]).await;
854        vault
855            .save_search("todo-week", "#todo ^modified")
856            .await
857            .unwrap();
858        vault.save_search("journal", "in:journal").await.unwrap();
859        let mut c = make_controller(vault, AutocompleteMode::SearchQuery);
860        let host = FakeHost::new("?to", 3);
861        c.sync(&host);
862        drain_results(&mut c).await;
863        assert!(c.is_open());
864        let st = c.state().unwrap();
865        assert_eq!(st.kind, TriggerKind::SavedSearch);
866        let names: Vec<&str> = st.items.iter().map(|s| s.display.as_str()).collect();
867        assert!(names.contains(&"todo-week"), "got {names:?}");
868        assert!(!names.contains(&"journal"), "got {names:?}");
869        // `secondary` carries the stored query — the popup preview AND the
870        // text inserted on accept.
871        let todo = st.items.iter().find(|s| s.display == "todo-week").unwrap();
872        assert_eq!(todo.secondary.as_deref(), Some("#todo ^modified"));
873    }
874
875    #[tokio::test]
876    async fn saved_search_trigger_gated_to_search_query_mode() {
877        let (_tmp, vault) = new_vault_with(&[], &[]).await;
878
879        // SearchQuery mode honors a leading `?`.
880        let mut c = make_controller(vault.clone(), AutocompleteMode::SearchQuery);
881        let host = FakeHost::new("?to", 3);
882        c.sync(&host);
883        assert_eq!(c.state().map(|s| s.kind), Some(TriggerKind::SavedSearch));
884
885        // Editor modes (`Both`, `HashtagOnly`) never open a SavedSearch popup.
886        for mode in [AutocompleteMode::Both, AutocompleteMode::HashtagOnly] {
887            let mut c = make_controller(vault.clone(), mode);
888            let host = FakeHost::new("?to", 3);
889            c.sync(&host);
890            assert!(
891                c.state().is_none(),
892                "mode {mode:?} must not open a SavedSearch popup"
893            );
894        }
895    }
896
897    #[tokio::test]
898    async fn refresh_if_open_closes_on_kind_change() {
899        // Popup is open for Hashtag; cursor-only move (refresh_if_open)
900        // into a Wikilink context must NOT replace the popup with a
901        // wikilink one — close it instead. Opening a wikilink popup
902        // on cursor movement violates the refresh-only contract.
903        let (_tmp, vault) = new_vault_with(&["meeting"], &[("a", "x #proj")]).await;
904        let mut c = make_controller(vault, AutocompleteMode::Both);
905        let mut host = FakeHost::new("#pro [[me", 4); // cursor after `#pro`
906        c.sync(&host);
907        drain_results(&mut c).await;
908        assert!(c.is_open());
909        assert_eq!(c.state().unwrap().kind, TriggerKind::Hashtag);
910        // Cursor jumps into the wikilink target (mouse click simulation).
911        host.cursor = 9;
912        c.refresh_if_open(&host);
913        assert!(c.state().is_none(), "kind change on movement must close");
914    }
915
916    #[tokio::test]
917    async fn accept_with_stale_range_falls_through_not_consumed() {
918        // Previously: Tab/Enter on a stale-range accept returned
919        // Consumed and silently ate the keystroke. Now returns
920        // NotHandled so the host can give the user back their Tab
921        // indent / Enter newline.
922        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
923        let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
924        let mut c = make_controller(vault, AutocompleteMode::Both);
925        let mut host = FakeHost::new("see [[me", 8);
926        c.sync(&host);
927        drain_results(&mut c).await;
928        // Live buffer shrinks below the trigger range between sync
929        // and accept (e.g. an async event truncated the buffer).
930        host.buffer = "see [".into();
931        host.cursor = 5;
932        let outcome = c.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &host);
933        assert_eq!(outcome, HandleKeyOutcome::NotHandled);
934        assert!(c.state().is_none(), "popup must close even on fallthrough");
935    }
936
937    #[tokio::test]
938    async fn refresh_if_open_does_not_open_new_popup() {
939        // Cursor moves into a fresh trigger context without any text
940        // edit: refresh_if_open must NOT open a popup. This is the
941        // behaviour that prevents cursor-only navigation over an
942        // existing wikilink from re-popping the suggestions.
943        let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
944        let mut c = make_controller(vault, AutocompleteMode::Both);
945        // Cursor inside an existing wikilink — but the popup is closed.
946        let host = FakeHost::new("[[meeting]]", 4);
947        c.refresh_if_open(&host);
948        assert!(c.state().is_none());
949    }
950
951    #[tokio::test]
952    async fn refresh_if_open_closes_popup_when_cursor_leaves_trigger() {
953        let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
954        let mut c = make_controller(vault, AutocompleteMode::Both);
955        let mut host = FakeHost::new("see [[me", 8);
956        c.sync(&host);
957        drain_results(&mut c).await;
958        assert!(c.is_open());
959        // Cursor moves before the `[[` — trigger context is gone.
960        host.cursor = 0;
961        c.refresh_if_open(&host);
962        assert!(c.state().is_none());
963    }
964
965    #[tokio::test]
966    async fn popup_with_zero_results_is_not_interactive() {
967        // Trigger fires but the query returns nothing → state exists but
968        // is_open() is false so Esc/Up/Down/Tab fall through to the
969        // modal/editor instead of being swallowed.
970        let (_tmp, vault) = new_vault_with(&[], &[]).await; // empty vault
971        let mut c = make_controller(vault, AutocompleteMode::Both);
972        let host = FakeHost::new("see [[xyz", 9);
973        c.sync(&host);
974        drain_results(&mut c).await;
975        assert!(c.state().is_some());
976        assert_eq!(c.state().unwrap().items.len(), 0);
977        assert!(!c.is_open());
978    }
979
980    #[tokio::test]
981    async fn hashtag_trigger_opens_popup_and_loads_results() {
982        let (_tmp, vault) = new_vault_with(&[], &[("a", "x #projects"), ("b", "y #pro")]).await;
983        let mut c = make_controller(vault, AutocompleteMode::Both);
984        let host = FakeHost::new("about #pro", 10);
985        c.sync(&host);
986        drain_results(&mut c).await;
987        let st = c.state().unwrap();
988        assert_eq!(st.kind, TriggerKind::Hashtag);
989        let labels: Vec<&str> = st.items.iter().map(|s| s.display.as_str()).collect();
990        assert!(labels.contains(&"pro"));
991        assert!(labels.contains(&"projects"));
992    }
993
994    #[tokio::test]
995    async fn hashtag_only_mode_ignores_wikilinks() {
996        let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
997        let mut c = make_controller(vault, AutocompleteMode::HashtagOnly);
998        let host = FakeHost::new("see [[me", 8);
999        c.sync(&host);
1000        assert!(!c.is_open());
1001    }
1002
1003    #[tokio::test]
1004    async fn losing_trigger_context_closes_popup() {
1005        let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
1006        let mut c = make_controller(vault, AutocompleteMode::Both);
1007        let mut host = FakeHost::new("see [[me", 8);
1008        c.sync(&host);
1009        drain_results(&mut c).await;
1010        assert!(c.is_open());
1011        // User types a space — hashtag context for the typed query is now
1012        // broken; for wikilinks, the trigger stays alive but the query
1013        // gains a space. Here we simulate the cursor jumping outside.
1014        host.buffer = "see [[me\n".into();
1015        host.cursor = 9;
1016        c.sync(&host);
1017        assert!(!c.is_open());
1018    }
1019
1020    // ---- Accept actions ----
1021
1022    #[tokio::test]
1023    async fn accepting_wikilink_inserts_name_and_closes_brackets() {
1024        let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
1025        let mut c = make_controller(vault, AutocompleteMode::Both);
1026        let mut host = FakeHost::new("see [[me", 8);
1027        c.sync(&host);
1028        drain_results(&mut c).await;
1029        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1030        let outcome = c.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &host);
1031        let HandleKeyOutcome::Accepted(action) = outcome else {
1032            panic!("expected Accepted, got {:?}", outcome);
1033        };
1034        host.apply(&action);
1035        assert_eq!(host.buffer, "see [[meeting]]");
1036        assert_eq!(host.cursor, host.buffer.len());
1037        assert!(!c.is_open());
1038    }
1039
1040    #[tokio::test]
1041    async fn accepting_saved_search_expands_whole_field_and_reports_name() {
1042        let (_tmp, vault) = new_vault_with(&[], &[]).await;
1043        vault
1044            .save_search("todo-week", "#todo ^modified")
1045            .await
1046            .unwrap();
1047        let mut c = make_controller(vault, AutocompleteMode::SearchQuery);
1048        let mut host = FakeHost::new("?to", 3);
1049        c.sync(&host);
1050        drain_results(&mut c).await;
1051        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1052        let outcome = c.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &host);
1053        let HandleKeyOutcome::Accepted(action) = outcome else {
1054            panic!("expected Accepted, got {outcome:?}");
1055        };
1056        // The accepted name flows up so the host can pin the breadcrumb.
1057        assert_eq!(action.saved_search_name.as_deref(), Some("todo-week"));
1058        host.apply(&action);
1059        // The WHOLE field is replaced with the stored query (not just `?to`).
1060        assert_eq!(host.buffer, "#todo ^modified");
1061        assert_eq!(host.cursor, host.buffer.len());
1062    }
1063
1064    #[tokio::test]
1065    async fn accepting_wikilink_consumes_stale_chars_before_existing_close() {
1066        // Reopened mid-target: the user moved the cursor back inside an
1067        // already-closed wikilink and is replacing the target. The stale
1068        // characters between the cursor and `]]` must be consumed, not
1069        // left as `[[meeting]]e]]`.
1070        let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
1071        let mut c = make_controller(vault, AutocompleteMode::Both);
1072        let mut host = FakeHost::new("see [[me]]", 7); // cursor between `m` and `e`
1073        c.sync(&host);
1074        drain_results(&mut c).await;
1075        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1076        let outcome = c.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &host);
1077        let HandleKeyOutcome::Accepted(action) = outcome else {
1078            panic!("expected Accepted, got {:?}", outcome);
1079        };
1080        host.apply(&action);
1081        assert_eq!(host.buffer, "see [[meeting]]");
1082        assert_eq!(host.cursor, host.buffer.len());
1083    }
1084
1085    #[tokio::test]
1086    async fn accepting_wikilink_with_lone_trailing_bracket_does_not_triple() {
1087        // Buffer has a single stray `]` after the target — must not
1088        // produce `]]]`.
1089        let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
1090        let mut c = make_controller(vault, AutocompleteMode::Both);
1091        let mut host = FakeHost::new("see [[me]", 8);
1092        c.sync(&host);
1093        drain_results(&mut c).await;
1094        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1095        let outcome = c.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &host);
1096        let HandleKeyOutcome::Accepted(action) = outcome else {
1097            panic!("expected Accepted, got {:?}", outcome);
1098        };
1099        host.apply(&action);
1100        assert_eq!(host.buffer, "see [[meeting]]");
1101    }
1102
1103    #[tokio::test]
1104    async fn accepting_wikilink_preserves_existing_alias() {
1105        // `[[me|alias]]` — cursor in the target portion; alias must
1106        // survive and the cursor must land right before `|alias]]`.
1107        let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
1108        let mut c = make_controller(vault, AutocompleteMode::Both);
1109        let mut host = FakeHost::new("see [[me|alias]]", 8); // cursor before `|`
1110        c.sync(&host);
1111        drain_results(&mut c).await;
1112        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1113        let outcome = c.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &host);
1114        let HandleKeyOutcome::Accepted(action) = outcome else {
1115            panic!("expected Accepted, got {:?}", outcome);
1116        };
1117        host.apply(&action);
1118        assert_eq!(host.buffer, "see [[meeting|alias]]");
1119        // Cursor right after `meeting`, before `|alias]]`.
1120        assert_eq!(host.cursor, "see [[meeting".len());
1121    }
1122
1123    #[tokio::test]
1124    async fn accepting_wikilink_preserves_existing_closing_brackets() {
1125        let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
1126        let mut c = make_controller(vault, AutocompleteMode::Both);
1127        let mut host = FakeHost::new("see [[me]]", 8);
1128        c.sync(&host);
1129        drain_results(&mut c).await;
1130        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1131        let outcome = c.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &host);
1132        let HandleKeyOutcome::Accepted(action) = outcome else {
1133            panic!("expected Accepted, got {:?}", outcome);
1134        };
1135        host.apply(&action);
1136        assert_eq!(host.buffer, "see [[meeting]]");
1137        assert_eq!(host.cursor, host.buffer.len());
1138    }
1139
1140    #[tokio::test]
1141    async fn accepting_hashtag_inserts_label_no_trailing_space() {
1142        let (_tmp, vault) = new_vault_with(&[], &[("a", "x #projects")]).await;
1143        let mut c = make_controller(vault, AutocompleteMode::Both);
1144        let mut host = FakeHost::new("about #pro", 10);
1145        c.sync(&host);
1146        drain_results(&mut c).await;
1147        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1148        let outcome = c.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &host);
1149        let HandleKeyOutcome::Accepted(action) = outcome else {
1150            panic!("expected Accepted, got {:?}", outcome);
1151        };
1152        host.apply(&action);
1153        assert_eq!(host.buffer, "about #projects");
1154        assert_eq!(host.cursor, host.buffer.len());
1155    }
1156
1157    #[tokio::test]
1158    async fn esc_dismisses_without_changing_buffer() {
1159        let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
1160        let mut c = make_controller(vault, AutocompleteMode::Both);
1161        let host = FakeHost::new("see [[me", 8);
1162        c.sync(&host);
1163        drain_results(&mut c).await;
1164        use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1165        let outcome = c.handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), &host);
1166        assert_eq!(outcome, HandleKeyOutcome::Dismissed);
1167        assert_eq!(host.buffer, "see [[me");
1168        assert!(!c.is_open());
1169    }
1170
1171    // ---- Generation / drop-stale ----
1172
1173    #[tokio::test]
1174    async fn stale_results_are_dropped_on_query_change() {
1175        let (_tmp, vault) = new_vault_with(&["meeting", "memory"], &[]).await;
1176        let mut c = make_controller(vault, AutocompleteMode::Both);
1177        // First query for `me` — fires generation 1.
1178        let host1 = FakeHost::new("see [[me", 8);
1179        c.sync(&host1);
1180        // Immediately change query to `mem` — fires generation 2 before
1181        // generation 1 has had a chance to respond.
1182        let host2 = FakeHost::new("see [[mem", 9);
1183        c.sync(&host2);
1184        drain_results(&mut c).await;
1185        let st = c.state().unwrap();
1186        // Only the `mem` results should be present — `meeting` doesn't
1187        // start with `mem` so the only match is `memory`.
1188        assert_eq!(st.query, "mem");
1189        let names: Vec<&str> = st.items.iter().map(|s| s.display.as_str()).collect();
1190        assert_eq!(names, vec!["memory"]);
1191    }
1192
1193    /// Regression for the opt-out cache-write skip (originally commit
1194    /// 5dc15309 against the `revision == 0` sentinel; now expressed
1195    /// via `content_revision() -> None`). Two invariants:
1196    ///   1. A sync from an opt-out host (revision == None) must NOT
1197    ///      populate `cached_text` — the search-box modal would
1198    ///      otherwise churn the heap allocating String + zones per
1199    ///      keystroke for a slot nothing ever reads.
1200    ///   2. An opt-out sync following a cached sync must NOT consult
1201    ///      the previously-cached entry, even though the cache slot
1202    ///      is Some(…). Otherwise stale zones from a prior editor
1203    ///      session could serve a fresh search-box host as a false hit.
1204    #[tokio::test]
1205    async fn opt_out_revision_does_not_populate_or_consult_cache() {
1206        let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
1207        let mut c = make_controller(vault, AutocompleteMode::Both);
1208
1209        // (1) Opt-out from a fresh controller: cache stays empty.
1210        let mut sentinel = FakeHost::new("see [[me", 8);
1211        sentinel.revision = None;
1212        c.sync(&sentinel);
1213        assert!(
1214            c.cached_text.is_none(),
1215            "opt-out sync must not write to cached_text"
1216        );
1217
1218        // Populate the cache with a normal (cached) reconcile.
1219        let host = FakeHost::new("see [[me", 8); // FakeHost::new auto-bumps revision
1220        c.sync(&host);
1221        let cached_rev = c
1222            .cached_text
1223            .as_ref()
1224            .map(|(rev, _, _)| *rev)
1225            .expect("cached sync should have populated the cache");
1226
1227        // (2) Opt-out sync now: must not be served by the stale cache,
1228        // and must not overwrite the cache slot either.
1229        let mut sentinel2 = FakeHost::new("see [[nope", 10);
1230        sentinel2.revision = None;
1231        c.sync(&sentinel2);
1232        let preserved_rev = c
1233            .cached_text
1234            .as_ref()
1235            .map(|(rev, _, _)| *rev)
1236            .expect("opt-out sync should leave the previous cache entry alone");
1237        assert_eq!(
1238            preserved_rev, cached_rev,
1239            "opt-out sync must not overwrite cached_text"
1240        );
1241    }
1242
1243    /// Regression: cursor moves on a host whose `content_revision`
1244    /// stays constant must HIT the cache slot — same key, same text
1245    /// pointer, same zones — not rebuild `ExclusionZones` or
1246    /// re-allocate the buffer text. This is the invariant the
1247    /// `text_revision` → `content_revision` rename was designed to
1248    /// preserve cleanly: cursor-only events never invalidate cached
1249    /// zones.
1250    #[tokio::test]
1251    async fn cursor_only_move_within_trigger_hits_cache() {
1252        let (_tmp, vault) = new_vault_with(&["memory"], &[]).await;
1253        let mut c = make_controller(vault, AutocompleteMode::Both);
1254
1255        // Open the popup at the end of `[[me`.
1256        let mut host = FakeHost::new("see [[me", 8);
1257        c.sync(&host);
1258        let (cached_rev, cached_text_ptr) = {
1259            let (rev, text, _) = c
1260                .cached_text
1261                .as_ref()
1262                .expect("initial sync populates cache");
1263            (*rev, text.as_ptr())
1264        };
1265
1266        // Cursor moves one char back inside the same trigger token.
1267        // Revision UNCHANGED — controller must serve from cache.
1268        host.cursor = 7;
1269        c.sync(&host);
1270        let (preserved_rev, preserved_text_ptr) = {
1271            let (rev, text, _) = c
1272                .cached_text
1273                .as_ref()
1274                .expect("cursor-only sync must leave the cache populated");
1275            (*rev, text.as_ptr())
1276        };
1277        assert_eq!(
1278            preserved_rev, cached_rev,
1279            "cursor-only sync must not change the cache key"
1280        );
1281        assert_eq!(
1282            preserved_text_ptr, cached_text_ptr,
1283            "cursor-only sync must reuse the cached String, not rebuild it"
1284        );
1285    }
1286
1287    /// Regression: `close()` must drop the `cached_text` slot. On a
1288    /// multi-MB note the slot holds a full clone of the buffer plus
1289    /// the parsed `ExclusionZones`; without the clear it survives
1290    /// popup dismissal until the next text edit overwrites it.
1291    #[tokio::test]
1292    async fn close_clears_cached_text() {
1293        let (_tmp, vault) = new_vault_with(&["memory"], &[]).await;
1294        let mut c = make_controller(vault, AutocompleteMode::Both);
1295
1296        let host = FakeHost::new("see [[me", 8);
1297        c.sync(&host);
1298        assert!(
1299            c.cached_text.is_some(),
1300            "sync should have populated cached_text"
1301        );
1302
1303        c.close();
1304        assert!(
1305            c.cached_text.is_none(),
1306            "close() must drop cached_text so the buffer clone doesn't outlive the popup"
1307        );
1308    }
1309
1310    #[tokio::test]
1311    async fn link_filter_suggestions_include_note_var_and_names() {
1312        use crate::components::search_list::{SuggestionItem, SuggestionSource};
1313        struct MemSuggestions;
1314        #[async_trait::async_trait]
1315        impl SuggestionSource for MemSuggestions {
1316            async fn notes_by_prefix(&self, prefix: &str, _limit: usize) -> Vec<SuggestionItem> {
1317                let all = vec![SuggestionItem::plain("projects")];
1318                all.into_iter()
1319                    .filter(|x| x.display.starts_with(prefix))
1320                    .collect()
1321            }
1322            async fn tags_by_prefix(&self, _prefix: &str, _limit: usize) -> Vec<SuggestionItem> {
1323                Vec::new()
1324            }
1325        }
1326        let mem = MemSuggestions;
1327        // Empty prefix → {note} offered and notes with empty prefix match.
1328        let s = AutocompleteController::link_filter_suggestions(&mem, "").await;
1329        assert!(
1330            s.iter().any(|x| x.display == "{note}"),
1331            "{{note}} must appear for empty prefix"
1332        );
1333        assert!(
1334            s.iter().any(|x| x.display == "projects"),
1335            "projects must appear for empty prefix"
1336        );
1337        // Prefix "pro" → note name surfaced, {note} absent.
1338        let s = AutocompleteController::link_filter_suggestions(&mem, "pro").await;
1339        assert!(
1340            s.iter().any(|x| x.display == "projects"),
1341            "projects must appear for prefix 'pro'"
1342        );
1343        // "pro" does not start "note" so {note} should not appear
1344        assert!(
1345            !s.iter().any(|x| x.display == "{note}"),
1346            "{{note}} must not appear for prefix 'pro'"
1347        );
1348    }
1349
1350    /// A `[[` candidate reaches the wikilink veto, so zones are computed
1351    /// and memoized; a subsequent cursor-only move at the same revision
1352    /// reuses them rather than recomputing.
1353    #[tokio::test]
1354    async fn trigger_candidate_computes_zones_once() {
1355        let (_tmp, vault) = new_vault_with(&["memory"], &[]).await;
1356        let mut c = make_controller(vault, AutocompleteMode::Both);
1357
1358        let mut host = FakeHost::new("see [[me", 8);
1359        c.sync(&host);
1360        assert!(
1361            c.cached_text.as_ref().unwrap().2.is_some(),
1362            "a [[ candidate reaching the veto must compute and memoize zones"
1363        );
1364
1365        host.cursor = 7;
1366        c.sync(&host);
1367        assert!(
1368            c.cached_text.as_ref().unwrap().2.is_some(),
1369            "memoized zones survive a cursor-only move at the same revision"
1370        );
1371    }
1372}