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
18const DEFAULT_FETCH_LIMIT: usize = 50;
22
23const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(80);
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
43pub enum AutocompleteMode {
44 Both,
45 HashtagOnly,
46 SearchQuery,
48}
49
50pub struct AutocompleteController {
54 state: Option<AutocompleteState>,
55 suggestions: Arc<dyn SuggestionSource>,
56 mode: AutocompleteMode,
57 trigger_opts: TriggerOptions,
61 generation: u64,
64 result_tx: UnboundedSender<QueryResult>,
65 result_rx: UnboundedReceiver<QueryResult>,
66 fetch_limit: usize,
67 max_visible_rows: usize,
68 in_flight: SingleSlotTask<()>,
74 debounce: Duration,
80 cached_text: Option<(NonZeroU64, String, Option<ExclusionZones>)>,
90 redraw_cb: Option<RedrawCallback>,
95}
96
97pub 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
109struct 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 pub fn with_trigger_opts(mut self, opts: TriggerOptions) -> Self {
158 self.trigger_opts = opts;
159 self
160 }
161
162 pub fn with_debounce(mut self, debounce: Duration) -> Self {
165 self.debounce = debounce;
166 self
167 }
168
169 pub fn set_redraw_callback(&mut self, cb: RedrawCallback) {
175 self.redraw_cb = Some(cb);
176 }
177
178 pub fn is_open(&self) -> bool {
186 self.state.as_ref().is_some_and(|s| !s.items.is_empty())
187 }
188
189 pub fn state(&self) -> Option<&AutocompleteState> {
193 self.state.as_ref()
194 }
195
196 pub fn state_mut(&mut self) -> Option<&mut AutocompleteState> {
205 self.state.as_mut()
206 }
207
208 pub fn close(&mut self) {
218 self.state = None;
219 self.in_flight.abort();
220 self.cached_text = None;
225 }
226
227 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 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 pub fn sync<H: AutocompleteHost>(&mut self, host: &H) {
276 self.reconcile(host, true);
277 }
278
279 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 let snap = host.buffer_snapshot();
295 let cursor = snap.cursor_byte_offset();
296 let cache_key = host.cache_key();
297 let opts = TriggerOptions {
311 allow_saved_search: matches!(self.mode, AutocompleteMode::SearchQuery),
312 ..self.trigger_opts
313 };
314
315 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 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 (_, 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 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 let instant = kind_changed;
411 self.fire_query(trigger.kind, trigger.query, instant);
412 }
413 }
414
415 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 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 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 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 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 let buffer = host.buffer_snapshot().lines.join("\n");
530
531 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 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 range.start.saturating_add(cursor_offset_in_target)
563 } else {
564 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 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
604struct WikilinkExtent {
607 end: usize,
611 existing_close: bool,
613 has_alias: bool,
616}
617
618fn 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 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#[derive(Debug, Clone, PartialEq, Eq)]
667pub enum HandleKeyOutcome {
668 Consumed,
670 Dismissed,
672 Accepted(AcceptAction),
674 NotHandled,
677}
678
679#[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 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 static FAKE_REV: AtomicU64 = AtomicU64::new(1);
704
705 struct FakeHost {
706 buffer: String,
707 cursor: usize,
708 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 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; }
747 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 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 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 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 #[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 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 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 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 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 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); c.sync(&host);
907 drain_results(&mut c).await;
908 assert!(c.is_open());
909 assert_eq!(c.state().unwrap().kind, TriggerKind::Hashtag);
910 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 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 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 let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
944 let mut c = make_controller(vault, AutocompleteMode::Both);
945 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 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 let (_tmp, vault) = new_vault_with(&[], &[]).await; 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 host.buffer = "see [[me\n".into();
1015 host.cursor = 9;
1016 c.sync(&host);
1017 assert!(!c.is_open());
1018 }
1019
1020 #[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 assert_eq!(action.saved_search_name.as_deref(), Some("todo-week"));
1058 host.apply(&action);
1059 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 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); 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 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 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); 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 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 #[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 let host1 = FakeHost::new("see [[me", 8);
1179 c.sync(&host1);
1180 let host2 = FakeHost::new("see [[mem", 9);
1183 c.sync(&host2);
1184 drain_results(&mut c).await;
1185 let st = c.state().unwrap();
1186 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 #[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 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 let host = FakeHost::new("see [[me", 8); 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 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 #[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 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 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 #[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 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 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 assert!(
1345 !s.iter().any(|x| x.display == "{note}"),
1346 "{{note}} must not appear for prefix 'pro'"
1347 );
1348 }
1349
1350 #[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}