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