1use std::num::NonZeroU64;
2use std::ops::Range;
3use std::sync::Arc;
4use std::time::Duration;
5
6use kimun_core::NoteVault;
7use kimun_core::note::ExclusionZones;
8use tokio::sync::mpsc::{UnboundedReceiver, UnboundedSender, unbounded_channel};
9
10use super::host::AutocompleteHost;
11use super::popup::{PopupAction, PopupOutcome, handle_key as popup_handle_key};
12use super::state::{AutocompleteState, DEFAULT_MAX_VISIBLE_ROWS, Suggestion};
13use super::trigger::{TriggerKind, TriggerOptions, ZoneOracle, detect_trigger_with_oracle};
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}
47
48pub struct AutocompleteController {
52 state: Option<AutocompleteState>,
53 vault: Arc<NoteVault>,
54 mode: AutocompleteMode,
55 trigger_opts: TriggerOptions,
59 generation: u64,
62 result_tx: UnboundedSender<QueryResult>,
63 result_rx: UnboundedReceiver<QueryResult>,
64 fetch_limit: usize,
65 max_visible_rows: usize,
66 in_flight: SingleSlotTask<()>,
72 debounce: Duration,
78 cached_text: Option<(NonZeroU64, String, Option<ExclusionZones>)>,
88 redraw_cb: Option<RedrawCallback>,
93}
94
95pub type RedrawCallback = Arc<dyn Fn() + Send + Sync + 'static>;
99
100#[derive(Debug)]
101struct QueryResult {
102 generation: u64,
103 kind: TriggerKind,
104 items: Vec<Suggestion>,
105}
106
107struct LazyZoneOracle<'a> {
112 text: &'a str,
113 zones: &'a mut Option<ExclusionZones>,
114}
115
116impl ZoneOracle for LazyZoneOracle<'_> {
117 fn contains(&mut self, cursor: usize) -> bool {
118 let text = self.text;
119 self.zones
120 .get_or_insert_with(|| ExclusionZones::from_text(text))
121 .contains(cursor)
122 }
123
124 fn contains_code_link_or_frontmatter(&mut self, cursor: usize) -> bool {
125 let text = self.text;
126 self.zones
127 .get_or_insert_with(|| ExclusionZones::from_text(text))
128 .contains_code_link_or_frontmatter(cursor)
129 }
130}
131
132impl AutocompleteController {
133 pub fn new(vault: Arc<NoteVault>, mode: AutocompleteMode) -> Self {
134 let (result_tx, result_rx) = unbounded_channel();
135 Self {
136 state: None,
137 vault,
138 mode,
139 trigger_opts: TriggerOptions::default(),
140 generation: 0,
141 result_tx,
142 result_rx,
143 fetch_limit: DEFAULT_FETCH_LIMIT,
144 max_visible_rows: DEFAULT_MAX_VISIBLE_ROWS,
145 in_flight: SingleSlotTask::empty(),
146 debounce: DEFAULT_DEBOUNCE,
147 cached_text: None,
148 redraw_cb: None,
149 }
150 }
151
152 pub fn with_trigger_opts(mut self, opts: TriggerOptions) -> Self {
156 self.trigger_opts = opts;
157 self
158 }
159
160 #[cfg(test)]
163 pub fn with_debounce(mut self, debounce: Duration) -> Self {
164 self.debounce = debounce;
165 self
166 }
167
168 pub fn set_redraw_callback(&mut self, cb: RedrawCallback) {
174 self.redraw_cb = Some(cb);
175 }
176
177 pub fn is_open(&self) -> bool {
185 self.state.as_ref().is_some_and(|s| !s.items.is_empty())
186 }
187
188 pub fn state(&self) -> Option<&AutocompleteState> {
192 self.state.as_ref()
193 }
194
195 pub fn state_mut(&mut self) -> Option<&mut AutocompleteState> {
204 self.state.as_mut()
205 }
206
207 pub fn close(&mut self) {
217 self.state = None;
218 self.in_flight.abort();
219 self.cached_text = None;
224 }
225
226 pub fn handle_key<H: AutocompleteHost>(
232 &mut self,
233 key: ratatui::crossterm::event::KeyEvent,
234 host: &H,
235 ) -> HandleKeyOutcome {
236 let Some(state) = self.state.as_mut() else {
237 return HandleKeyOutcome::NotHandled;
238 };
239 let outcome = popup_handle_key(state, key);
240 match outcome {
241 PopupOutcome::Consumed(PopupAction::None) => HandleKeyOutcome::Consumed,
242 PopupOutcome::Consumed(PopupAction::Accept) => {
243 match self.compute_accept(host) {
250 Some(action) => {
251 self.close();
252 HandleKeyOutcome::Accepted(action)
253 }
254 None => {
255 self.close();
256 HandleKeyOutcome::NotHandled
257 }
258 }
259 }
260 PopupOutcome::Consumed(PopupAction::Dismiss) => {
261 self.close();
262 HandleKeyOutcome::Dismissed
263 }
264 PopupOutcome::NotHandled => HandleKeyOutcome::NotHandled,
265 }
266 }
267
268 pub fn sync<H: AutocompleteHost>(&mut self, host: &H) {
275 self.reconcile(host, true);
276 }
277
278 pub fn refresh_if_open<H: AutocompleteHost>(&mut self, host: &H) {
284 if self.state.is_some() {
285 self.reconcile(host, false);
286 }
287 }
288
289 fn reconcile<H: AutocompleteHost>(&mut self, host: &H, allow_open: bool) {
290 let snap = host.buffer_snapshot();
294 let cursor = snap.cursor_byte_offset();
295 let cache_key = host.cache_key();
296 let trigger = match cache_key {
308 Some(rev) => {
309 let hit = matches!(&self.cached_text, Some((r, _, _)) if *r == rev);
310 if !hit {
311 self.cached_text = Some((rev, snap.lines.join("\n"), None));
312 }
313 let (_, text, zones_slot) = self.cached_text.as_mut().expect("just populated");
314 let text: &str = text;
315 let mut oracle = LazyZoneOracle {
316 text,
317 zones: zones_slot,
318 };
319 detect_trigger_with_oracle(text, cursor, self.trigger_opts, &mut oracle)
320 }
321 None => {
322 let text = snap.lines.join("\n");
323 let mut zones: Option<ExclusionZones> = None;
324 let mut oracle = LazyZoneOracle {
325 text: &text,
326 zones: &mut zones,
327 };
328 detect_trigger_with_oracle(&text, cursor, self.trigger_opts, &mut oracle)
329 }
330 };
331
332 let trigger = trigger.filter(|t| match (self.mode, t.kind) {
334 (AutocompleteMode::Both, _) => true,
335 (AutocompleteMode::HashtagOnly, TriggerKind::Hashtag) => true,
336 (AutocompleteMode::HashtagOnly, TriggerKind::Wikilink) => false,
337 });
338
339 let Some(trigger) = trigger else {
340 self.close();
341 return;
342 };
343
344 let Some(anchor) = host.screen_anchor_for(trigger.anchor_col) else {
345 self.close();
346 return;
347 };
348
349 let query_changed;
350 let kind_changed;
351 match self.state.as_ref() {
352 None => {
353 kind_changed = true;
354 query_changed = true;
355 }
356 Some(existing) => {
357 kind_changed = existing.kind != trigger.kind;
358 query_changed = kind_changed || existing.query != trigger.query;
359 }
360 }
361
362 if !allow_open && (self.state.is_none() || kind_changed) {
369 self.close();
370 return;
371 }
372
373 if self.state.is_none() || kind_changed {
374 let mut st = AutocompleteState::new(trigger.kind, anchor);
375 st.max_visible_rows = self.max_visible_rows;
376 self.state = Some(st);
377 }
378
379 if let Some(state) = self.state.as_mut() {
380 state.kind = trigger.kind;
381 state.query = trigger.query.clone();
382 state.replace_range = trigger.replace_range.clone();
383 state.anchor = anchor;
384 }
385
386 if query_changed {
387 let instant = kind_changed;
391 self.fire_query(trigger.kind, trigger.query, instant);
392 }
393 }
394
395 pub fn poll_results(&mut self) {
399 while let Ok(result) = self.result_rx.try_recv() {
400 if result.generation != self.generation {
401 continue;
402 }
403 let Some(state) = self.state.as_mut() else {
404 continue;
405 };
406 if state.kind != result.kind {
407 continue;
408 }
409 state.set_items(result.items);
410 }
411 }
412
413 fn fire_query(&mut self, kind: TriggerKind, query: String, instant: bool) {
414 self.generation = self.generation.wrapping_add(1);
419 let req_gen = self.generation;
420 let tx = self.result_tx.clone();
421 let redraw = self.redraw_cb.clone();
422 let vault = self.vault.clone();
423 let limit = self.fetch_limit;
424 let debounce = if instant {
425 Duration::ZERO
426 } else {
427 self.debounce
428 };
429 self.in_flight.spawn(async move {
430 if !debounce.is_zero() {
433 tokio::time::sleep(debounce).await;
434 }
435 let items: Vec<Suggestion> = match kind {
436 TriggerKind::Wikilink => match vault.suggest_notes_by_prefix(&query, limit).await {
437 Ok(notes) => notes
438 .into_iter()
439 .map(|n| Suggestion {
440 display: n.name,
441 secondary: Some(n.path.to_string()),
442 })
443 .collect(),
444 Err(e) => {
445 log::warn!(
446 "autocomplete: suggest_notes_by_prefix({:?}) failed: {}",
447 query,
448 e
449 );
450 Vec::new()
451 }
452 },
453 TriggerKind::Hashtag => match vault.suggest_tags_by_prefix(&query, limit).await {
454 Ok(tags) => tags
455 .into_iter()
456 .map(|t| Suggestion {
457 display: t.label,
458 secondary: Some(format!("{}×", t.usage_count)),
459 })
460 .collect(),
461 Err(e) => {
462 log::warn!(
463 "autocomplete: suggest_tags_by_prefix({:?}) failed: {}",
464 query,
465 e
466 );
467 Vec::new()
468 }
469 },
470 };
471 let _ = tx.send(QueryResult {
472 generation: req_gen,
473 kind,
474 items,
475 });
476 if let Some(redraw) = redraw {
480 redraw();
481 }
482 });
483 }
484
485 fn compute_accept<H: AutocompleteHost>(&self, host: &H) -> Option<AcceptAction> {
486 let state = self.state.as_ref()?;
487 let suggestion = state.selected()?.clone();
488 let kind = state.kind;
489 let range = state.replace_range.clone();
490 let buffer = host.buffer_snapshot().lines.join("\n");
494
495 if range.start > range.end || range.end > buffer.len() {
500 return None;
501 }
502 if !buffer.is_char_boundary(range.start) || !buffer.is_char_boundary(range.end) {
503 return None;
504 }
505
506 match kind {
507 TriggerKind::Wikilink => {
508 let extent = scan_wikilink_extent(&buffer, range.end);
509 let new_range = range.start..extent.end;
516 let needs_close = !extent.existing_close && !extent.has_alias;
517 let new_text = if needs_close {
518 format!("{}]]", suggestion.display)
519 } else {
520 suggestion.display.clone()
521 };
522 let cursor_offset_in_target = suggestion.display.len();
523 let new_cursor_byte = if extent.has_alias {
524 range.start.saturating_add(cursor_offset_in_target)
527 } else {
528 range
531 .start
532 .saturating_add(cursor_offset_in_target)
533 .saturating_add(2)
534 };
535 Some(AcceptAction {
536 range: new_range,
537 new_text,
538 new_cursor_byte,
539 })
540 }
541 TriggerKind::Hashtag => {
542 let new_cursor_byte = range.start.saturating_add(suggestion.display.len());
543 Some(AcceptAction {
544 range,
545 new_text: suggestion.display,
546 new_cursor_byte,
547 })
548 }
549 }
550 }
551}
552
553struct WikilinkExtent {
556 end: usize,
560 existing_close: bool,
562 has_alias: bool,
565}
566
567fn scan_wikilink_extent(buffer: &str, start: usize) -> WikilinkExtent {
574 let bytes = buffer.as_bytes();
575 let mut i = start.min(bytes.len());
576 while i < bytes.len() {
577 match bytes[i] {
578 b']' => {
579 if bytes.get(i + 1) == Some(&b']') {
580 return WikilinkExtent {
581 end: i,
582 existing_close: true,
583 has_alias: false,
584 };
585 }
586 i += 1;
589 }
590 b'|' => {
591 return WikilinkExtent {
592 end: i,
593 existing_close: false,
594 has_alias: true,
595 };
596 }
597 b'\n' | b'\r' | b'[' => {
598 return WikilinkExtent {
599 end: i,
600 existing_close: false,
601 has_alias: false,
602 };
603 }
604 _ => i += 1,
605 }
606 }
607 WikilinkExtent {
608 end: i,
609 existing_close: false,
610 has_alias: false,
611 }
612}
613
614#[derive(Debug, Clone, PartialEq, Eq)]
616pub enum HandleKeyOutcome {
617 Consumed,
619 Dismissed,
621 Accepted(AcceptAction),
623 NotHandled,
626}
627
628#[derive(Debug, Clone, PartialEq, Eq)]
630pub struct AcceptAction {
631 pub range: Range<usize>,
632 pub new_text: String,
633 pub new_cursor_byte: usize,
634}
635
636#[cfg(test)]
637mod tests {
638 use super::*;
639 use kimun_core::nfs::VaultPath;
640 use kimun_core::{NoteVault, VaultConfig};
641 use std::sync::Arc;
642 use std::sync::atomic::{AtomicU64, Ordering};
643 use tempfile::TempDir;
644
645 static FAKE_REV: AtomicU64 = AtomicU64::new(1);
650
651 struct FakeHost {
652 buffer: String,
653 cursor: usize,
654 revision: Option<NonZeroU64>,
657 }
658
659 impl FakeHost {
660 fn new(buffer: &str, cursor: usize) -> Self {
661 Self {
662 buffer: buffer.to_string(),
663 cursor,
664 revision: NonZeroU64::new(FAKE_REV.fetch_add(1, Ordering::SeqCst)),
665 }
666 }
667
668 fn apply(&mut self, action: &AcceptAction) {
669 self.buffer
670 .replace_range(action.range.clone(), &action.new_text);
671 self.cursor = action.new_cursor_byte;
672 self.revision = self
673 .revision
674 .and_then(|r| NonZeroU64::new(r.get().wrapping_add(1)));
675 }
676
677 fn lines_and_cursor(&self) -> (Vec<String>, (usize, usize)) {
682 let lines: Vec<String> = self.buffer.split('\n').map(|s| s.to_string()).collect();
683 let mut byte_running = 0;
684 for (row, line) in lines.iter().enumerate() {
685 let line_end = byte_running + line.len();
686 if self.cursor <= line_end {
687 let col_byte = self.cursor - byte_running;
688 let col = line[..col_byte].chars().count();
689 return (lines, (row, col));
690 }
691 byte_running = line_end + 1; }
693 let row = lines.len().saturating_sub(1);
695 let col = lines.get(row).map(|l| l.chars().count()).unwrap_or(0);
696 (lines, (row, col))
697 }
698 }
699
700 impl AutocompleteHost for FakeHost {
701 fn buffer_snapshot(&self) -> EditorSnapshot<'_> {
702 let rev = self.revision.unwrap_or_else(|| NonZeroU64::new(1).unwrap());
703 let (lines, cursor) = self.lines_and_cursor();
704 EditorSnapshot::owned(lines, cursor, rev)
708 }
709 fn cache_key(&self) -> Option<NonZeroU64> {
710 self.revision
711 }
712 fn screen_anchor_for(&self, _byte_offset: usize) -> Option<(u16, u16)> {
713 Some((0, 0))
714 }
715 }
716
717 async fn new_vault_with(
718 notes: &[&str],
719 tag_notes: &[(&str, &str)],
720 ) -> (TempDir, Arc<NoteVault>) {
721 let tmp = TempDir::new().unwrap();
722 let cfg = VaultConfig::new(tmp.path().to_path_buf());
723 let vault = NoteVault::new(cfg).await.unwrap();
724 vault.validate_and_init().await.unwrap();
725 for name in notes {
726 vault
727 .create_note(&VaultPath::note_path_from(format!("/{name}.md")), "body")
728 .await
729 .unwrap();
730 }
731 for (path, body) in tag_notes {
732 vault
733 .create_note(&VaultPath::note_path_from(format!("/{path}.md")), *body)
734 .await
735 .unwrap();
736 }
737 (tmp, Arc::new(vault))
738 }
739
740 async fn drain_results(controller: &mut AutocompleteController) {
741 tokio::task::yield_now().await;
744 tokio::time::sleep(std::time::Duration::from_millis(20)).await;
745 controller.poll_results();
746 }
747
748 fn make_controller(vault: Arc<NoteVault>, mode: AutocompleteMode) -> AutocompleteController {
751 AutocompleteController::new(vault, mode).with_debounce(Duration::ZERO)
752 }
753
754 #[tokio::test]
757 async fn no_trigger_keeps_popup_closed() {
758 let (_tmp, vault) = new_vault_with(&[], &[]).await;
759 let mut c = make_controller(vault, AutocompleteMode::Both);
760 let host = FakeHost::new("plain text", 5);
761 c.sync(&host);
762 assert!(!c.is_open());
763 }
764
765 #[tokio::test]
766 async fn wikilink_trigger_opens_popup_and_loads_results() {
767 let (_tmp, vault) = new_vault_with(&["meeting", "music", "novel"], &[]).await;
768 let mut c = make_controller(vault, AutocompleteMode::Both);
769 let host = FakeHost::new("see [[me", 8);
770 c.sync(&host);
771 assert!(c.state().is_some());
775 assert!(!c.is_open());
776 drain_results(&mut c).await;
777 assert!(c.is_open());
778 let st = c.state().unwrap();
779 assert_eq!(st.kind, TriggerKind::Wikilink);
780 assert_eq!(st.query, "me");
781 let names: Vec<&str> = st.items.iter().map(|s| s.display.as_str()).collect();
782 assert!(names.contains(&"meeting"));
783 assert!(!names.contains(&"novel"));
784 }
785
786 #[tokio::test]
787 async fn refresh_if_open_closes_on_kind_change() {
788 let (_tmp, vault) = new_vault_with(&["meeting"], &[("a", "x #proj")]).await;
793 let mut c = make_controller(vault, AutocompleteMode::Both);
794 let mut host = FakeHost::new("#pro [[me", 4); c.sync(&host);
796 drain_results(&mut c).await;
797 assert!(c.is_open());
798 assert_eq!(c.state().unwrap().kind, TriggerKind::Hashtag);
799 host.cursor = 9;
801 c.refresh_if_open(&host);
802 assert!(c.state().is_none(), "kind change on movement must close");
803 }
804
805 #[tokio::test]
806 async fn accept_with_stale_range_falls_through_not_consumed() {
807 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
812 let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
813 let mut c = make_controller(vault, AutocompleteMode::Both);
814 let mut host = FakeHost::new("see [[me", 8);
815 c.sync(&host);
816 drain_results(&mut c).await;
817 host.buffer = "see [".into();
820 host.cursor = 5;
821 let outcome = c.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &host);
822 assert_eq!(outcome, HandleKeyOutcome::NotHandled);
823 assert!(c.state().is_none(), "popup must close even on fallthrough");
824 }
825
826 #[tokio::test]
827 async fn refresh_if_open_does_not_open_new_popup() {
828 let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
833 let mut c = make_controller(vault, AutocompleteMode::Both);
834 let host = FakeHost::new("[[meeting]]", 4);
836 c.refresh_if_open(&host);
837 assert!(c.state().is_none());
838 }
839
840 #[tokio::test]
841 async fn refresh_if_open_closes_popup_when_cursor_leaves_trigger() {
842 let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
843 let mut c = make_controller(vault, AutocompleteMode::Both);
844 let mut host = FakeHost::new("see [[me", 8);
845 c.sync(&host);
846 drain_results(&mut c).await;
847 assert!(c.is_open());
848 host.cursor = 0;
850 c.refresh_if_open(&host);
851 assert!(c.state().is_none());
852 }
853
854 #[tokio::test]
855 async fn popup_with_zero_results_is_not_interactive() {
856 let (_tmp, vault) = new_vault_with(&[], &[]).await; let mut c = make_controller(vault, AutocompleteMode::Both);
861 let host = FakeHost::new("see [[xyz", 9);
862 c.sync(&host);
863 drain_results(&mut c).await;
864 assert!(c.state().is_some());
865 assert_eq!(c.state().unwrap().items.len(), 0);
866 assert!(!c.is_open());
867 }
868
869 #[tokio::test]
870 async fn hashtag_trigger_opens_popup_and_loads_results() {
871 let (_tmp, vault) = new_vault_with(&[], &[("a", "x #projects"), ("b", "y #pro")]).await;
872 let mut c = make_controller(vault, AutocompleteMode::Both);
873 let host = FakeHost::new("about #pro", 10);
874 c.sync(&host);
875 drain_results(&mut c).await;
876 let st = c.state().unwrap();
877 assert_eq!(st.kind, TriggerKind::Hashtag);
878 let labels: Vec<&str> = st.items.iter().map(|s| s.display.as_str()).collect();
879 assert!(labels.contains(&"pro"));
880 assert!(labels.contains(&"projects"));
881 }
882
883 #[tokio::test]
884 async fn hashtag_only_mode_ignores_wikilinks() {
885 let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
886 let mut c = make_controller(vault, AutocompleteMode::HashtagOnly);
887 let host = FakeHost::new("see [[me", 8);
888 c.sync(&host);
889 assert!(!c.is_open());
890 }
891
892 #[tokio::test]
893 async fn losing_trigger_context_closes_popup() {
894 let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
895 let mut c = make_controller(vault, AutocompleteMode::Both);
896 let mut host = FakeHost::new("see [[me", 8);
897 c.sync(&host);
898 drain_results(&mut c).await;
899 assert!(c.is_open());
900 host.buffer = "see [[me\n".into();
904 host.cursor = 9;
905 c.sync(&host);
906 assert!(!c.is_open());
907 }
908
909 #[tokio::test]
912 async fn accepting_wikilink_inserts_name_and_closes_brackets() {
913 let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
914 let mut c = make_controller(vault, AutocompleteMode::Both);
915 let mut host = FakeHost::new("see [[me", 8);
916 c.sync(&host);
917 drain_results(&mut c).await;
918 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
919 let outcome = c.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &host);
920 let HandleKeyOutcome::Accepted(action) = outcome else {
921 panic!("expected Accepted, got {:?}", outcome);
922 };
923 host.apply(&action);
924 assert_eq!(host.buffer, "see [[meeting]]");
925 assert_eq!(host.cursor, host.buffer.len());
926 assert!(!c.is_open());
927 }
928
929 #[tokio::test]
930 async fn accepting_wikilink_consumes_stale_chars_before_existing_close() {
931 let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
936 let mut c = make_controller(vault, AutocompleteMode::Both);
937 let mut host = FakeHost::new("see [[me]]", 7); c.sync(&host);
939 drain_results(&mut c).await;
940 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
941 let outcome = c.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &host);
942 let HandleKeyOutcome::Accepted(action) = outcome else {
943 panic!("expected Accepted, got {:?}", outcome);
944 };
945 host.apply(&action);
946 assert_eq!(host.buffer, "see [[meeting]]");
947 assert_eq!(host.cursor, host.buffer.len());
948 }
949
950 #[tokio::test]
951 async fn accepting_wikilink_with_lone_trailing_bracket_does_not_triple() {
952 let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
955 let mut c = make_controller(vault, AutocompleteMode::Both);
956 let mut host = FakeHost::new("see [[me]", 8);
957 c.sync(&host);
958 drain_results(&mut c).await;
959 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
960 let outcome = c.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &host);
961 let HandleKeyOutcome::Accepted(action) = outcome else {
962 panic!("expected Accepted, got {:?}", outcome);
963 };
964 host.apply(&action);
965 assert_eq!(host.buffer, "see [[meeting]]");
966 }
967
968 #[tokio::test]
969 async fn accepting_wikilink_preserves_existing_alias() {
970 let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
973 let mut c = make_controller(vault, AutocompleteMode::Both);
974 let mut host = FakeHost::new("see [[me|alias]]", 8); c.sync(&host);
976 drain_results(&mut c).await;
977 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
978 let outcome = c.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &host);
979 let HandleKeyOutcome::Accepted(action) = outcome else {
980 panic!("expected Accepted, got {:?}", outcome);
981 };
982 host.apply(&action);
983 assert_eq!(host.buffer, "see [[meeting|alias]]");
984 assert_eq!(host.cursor, "see [[meeting".len());
986 }
987
988 #[tokio::test]
989 async fn accepting_wikilink_preserves_existing_closing_brackets() {
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 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
996 let outcome = c.handle_key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), &host);
997 let HandleKeyOutcome::Accepted(action) = outcome else {
998 panic!("expected Accepted, got {:?}", outcome);
999 };
1000 host.apply(&action);
1001 assert_eq!(host.buffer, "see [[meeting]]");
1002 assert_eq!(host.cursor, host.buffer.len());
1003 }
1004
1005 #[tokio::test]
1006 async fn accepting_hashtag_inserts_label_no_trailing_space() {
1007 let (_tmp, vault) = new_vault_with(&[], &[("a", "x #projects")]).await;
1008 let mut c = make_controller(vault, AutocompleteMode::Both);
1009 let mut host = FakeHost::new("about #pro", 10);
1010 c.sync(&host);
1011 drain_results(&mut c).await;
1012 use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
1013 let outcome = c.handle_key(KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE), &host);
1014 let HandleKeyOutcome::Accepted(action) = outcome else {
1015 panic!("expected Accepted, got {:?}", outcome);
1016 };
1017 host.apply(&action);
1018 assert_eq!(host.buffer, "about #projects");
1019 assert_eq!(host.cursor, host.buffer.len());
1020 }
1021
1022 #[tokio::test]
1023 async fn esc_dismisses_without_changing_buffer() {
1024 let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
1025 let mut c = make_controller(vault, AutocompleteMode::Both);
1026 let 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::Esc, KeyModifiers::NONE), &host);
1031 assert_eq!(outcome, HandleKeyOutcome::Dismissed);
1032 assert_eq!(host.buffer, "see [[me");
1033 assert!(!c.is_open());
1034 }
1035
1036 #[tokio::test]
1039 async fn stale_results_are_dropped_on_query_change() {
1040 let (_tmp, vault) = new_vault_with(&["meeting", "memory"], &[]).await;
1041 let mut c = make_controller(vault, AutocompleteMode::Both);
1042 let host1 = FakeHost::new("see [[me", 8);
1044 c.sync(&host1);
1045 let host2 = FakeHost::new("see [[mem", 9);
1048 c.sync(&host2);
1049 drain_results(&mut c).await;
1050 let st = c.state().unwrap();
1051 assert_eq!(st.query, "mem");
1054 let names: Vec<&str> = st.items.iter().map(|s| s.display.as_str()).collect();
1055 assert_eq!(names, vec!["memory"]);
1056 }
1057
1058 #[tokio::test]
1070 async fn opt_out_revision_does_not_populate_or_consult_cache() {
1071 let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
1072 let mut c = make_controller(vault, AutocompleteMode::Both);
1073
1074 let mut sentinel = FakeHost::new("see [[me", 8);
1076 sentinel.revision = None;
1077 c.sync(&sentinel);
1078 assert!(
1079 c.cached_text.is_none(),
1080 "opt-out sync must not write to cached_text"
1081 );
1082
1083 let host = FakeHost::new("see [[me", 8); c.sync(&host);
1086 let cached_rev = c
1087 .cached_text
1088 .as_ref()
1089 .map(|(rev, _, _)| *rev)
1090 .expect("cached sync should have populated the cache");
1091
1092 let mut sentinel2 = FakeHost::new("see [[nope", 10);
1095 sentinel2.revision = None;
1096 c.sync(&sentinel2);
1097 let preserved_rev = c
1098 .cached_text
1099 .as_ref()
1100 .map(|(rev, _, _)| *rev)
1101 .expect("opt-out sync should leave the previous cache entry alone");
1102 assert_eq!(
1103 preserved_rev, cached_rev,
1104 "opt-out sync must not overwrite cached_text"
1105 );
1106 }
1107
1108 #[tokio::test]
1116 async fn cursor_only_move_within_trigger_hits_cache() {
1117 let (_tmp, vault) = new_vault_with(&["memory"], &[]).await;
1118 let mut c = make_controller(vault, AutocompleteMode::Both);
1119
1120 let mut host = FakeHost::new("see [[me", 8);
1122 c.sync(&host);
1123 let (cached_rev, cached_text_ptr) = {
1124 let (rev, text, _) = c
1125 .cached_text
1126 .as_ref()
1127 .expect("initial sync populates cache");
1128 (*rev, text.as_ptr())
1129 };
1130
1131 host.cursor = 7;
1134 c.sync(&host);
1135 let (preserved_rev, preserved_text_ptr) = {
1136 let (rev, text, _) = c
1137 .cached_text
1138 .as_ref()
1139 .expect("cursor-only sync must leave the cache populated");
1140 (*rev, text.as_ptr())
1141 };
1142 assert_eq!(
1143 preserved_rev, cached_rev,
1144 "cursor-only sync must not change the cache key"
1145 );
1146 assert_eq!(
1147 preserved_text_ptr, cached_text_ptr,
1148 "cursor-only sync must reuse the cached String, not rebuild it"
1149 );
1150 }
1151
1152 #[tokio::test]
1157 async fn close_clears_cached_text() {
1158 let (_tmp, vault) = new_vault_with(&["memory"], &[]).await;
1159 let mut c = make_controller(vault, AutocompleteMode::Both);
1160
1161 let host = FakeHost::new("see [[me", 8);
1162 c.sync(&host);
1163 assert!(
1164 c.cached_text.is_some(),
1165 "sync should have populated cached_text"
1166 );
1167
1168 c.close();
1169 assert!(
1170 c.cached_text.is_none(),
1171 "close() must drop cached_text so the buffer clone doesn't outlive the popup"
1172 );
1173 }
1174
1175 #[tokio::test]
1179 async fn trigger_candidate_computes_zones_once() {
1180 let (_tmp, vault) = new_vault_with(&["memory"], &[]).await;
1181 let mut c = make_controller(vault, AutocompleteMode::Both);
1182
1183 let mut host = FakeHost::new("see [[me", 8);
1184 c.sync(&host);
1185 assert!(
1186 c.cached_text.as_ref().unwrap().2.is_some(),
1187 "a [[ candidate reaching the veto must compute and memoize zones"
1188 );
1189
1190 host.cursor = 7;
1191 c.sync(&host);
1192 assert!(
1193 c.cached_text.as_ref().unwrap().2.is_some(),
1194 "memoized zones survive a cursor-only move at the same revision"
1195 );
1196 }
1197}