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
20const DEFAULT_FETCH_LIMIT: usize = 50;
24
25const DEFAULT_DEBOUNCE: Duration = Duration::from_millis(80);
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum AutocompleteMode {
46 Both,
47 HashtagOnly,
48 SearchQuery,
50}
51
52pub struct AutocompleteController {
56 state: Option<AutocompleteState>,
57 suggestions: Arc<dyn SuggestionSource>,
58 mode: AutocompleteMode,
59 trigger_opts: TriggerOptions,
63 generation: u64,
66 result_tx: UnboundedSender<QueryResult>,
67 result_rx: UnboundedReceiver<QueryResult>,
68 fetch_limit: usize,
69 max_visible_rows: usize,
70 in_flight: SingleSlotTask<()>,
76 debounce: Duration,
82 cached_text: Option<(NonZeroU64, String, Option<ExclusionZones>)>,
92 redraw_cb: Option<RedrawCallback>,
97}
98
99pub 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
111struct 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 pub fn with_trigger_opts(mut self, opts: TriggerOptions) -> Self {
160 self.trigger_opts = opts;
161 self
162 }
163
164 pub fn with_debounce(mut self, debounce: Duration) -> Self {
167 self.debounce = debounce;
168 self
169 }
170
171 pub fn set_redraw_callback(&mut self, cb: RedrawCallback) {
177 self.redraw_cb = Some(cb);
178 }
179
180 pub fn is_open(&self) -> bool {
188 self.state.as_ref().is_some_and(|s| !s.items.is_empty())
189 }
190
191 pub fn state(&self) -> Option<&AutocompleteState> {
195 self.state.as_ref()
196 }
197
198 pub fn state_mut(&mut self) -> Option<&mut AutocompleteState> {
207 self.state.as_mut()
208 }
209
210 pub fn close(&mut self) {
220 self.state = None;
221 self.in_flight.abort();
222 self.cached_text = None;
227 }
228
229 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 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 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 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 pub fn sync<H: AutocompleteHost>(&mut self, host: &H) {
304 self.reconcile(host, true);
305 }
306
307 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 let snap = host.buffer_snapshot();
323 let cursor = snap.cursor_byte_offset();
324 let cache_key = host.cache_key();
325 let opts = TriggerOptions {
339 allow_saved_search: matches!(self.mode, AutocompleteMode::SearchQuery),
340 ..self.trigger_opts
341 };
342
343 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 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 (_, 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 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 let instant = kind_changed;
439 self.fire_query(trigger.kind, trigger.query, instant);
440 }
441 }
442
443 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 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 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 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 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 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 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 let buffer = host.buffer_snapshot().text.to_string();
574
575 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 let new_range = range.start..extent.end;
592 let display = &suggestion.display;
593 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 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 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
641struct WikilinkExtent {
644 end: usize,
647 suffix: WikilinkSuffix,
648}
649
650#[derive(Debug, Clone, Copy, PartialEq, Eq)]
652enum WikilinkSuffix {
653 ExistingClose,
655 Kept,
658 AppendClose,
660}
661
662fn scan_wikilink_extent(buffer: &str, start: usize) -> WikilinkExtent {
687 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 (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#[derive(Debug, Clone, PartialEq, Eq)]
720pub enum HandleKeyOutcome {
721 Consumed,
723 Dismissed,
725 Accepted(AcceptAction),
727 NotHandled,
730}
731
732#[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 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 static FAKE_REV: AtomicU64 = AtomicU64::new(1);
758
759 struct FakeHost {
760 buffer: String,
761 cursor: usize,
762 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 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; }
801 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 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 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 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 #[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 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 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 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 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 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 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); c.sync(&host);
975 drain_results(&mut c).await;
976 assert!(c.is_open());
977 assert_eq!(c.state().unwrap().kind, TriggerKind::Hashtag);
978 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 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 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 let (_tmp, vault) = new_vault_with(&["meeting"], &[]).await;
1011 let mut c = make_controller(vault, AutocompleteMode::Both);
1012 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 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 let (_tmp, vault) = new_vault_with(&[], &[]).await; 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 host.buffer = "see [[me\n".into();
1082 host.cursor = 9;
1083 c.sync(&host);
1084 assert!(!c.is_open());
1085 }
1086
1087 #[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 assert_eq!(action.saved_search_name.as_deref(), Some("todo-week"));
1113 host.apply(&action);
1114 assert_eq!(host.buffer, "#todo ^modified");
1116 assert_eq!(host.cursor, host.buffer.len());
1117 }
1118
1119 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 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 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 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 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 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 (
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 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 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 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 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 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 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 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 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 #[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 let host1 = FakeHost::new("see [[me", 8);
1405 c.sync(&host1);
1406 let host2 = FakeHost::new("see [[mem", 9);
1409 c.sync(&host2);
1410 drain_results(&mut c).await;
1411 let st = c.state().unwrap();
1412 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 #[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 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 let host = FakeHost::new("see [[me", 8); 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 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 #[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 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 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 #[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 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 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 assert!(
1571 !s.iter().any(|x| x.display == "{note}"),
1572 "{{note}} must not appear for prefix 'pro'"
1573 );
1574 }
1575
1576 #[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}