1use std::ops::Range;
2use std::sync::Arc;
3use std::sync::atomic::AtomicBool;
4
5use acp_thread::MentionUri;
6use agent2::{HistoryEntry, HistoryStore};
7use anyhow::Result;
8use editor::{CompletionProvider, Editor, ExcerptId};
9use fuzzy::{StringMatch, StringMatchCandidate};
10use gpui::{App, Entity, Task, WeakEntity};
11use language::{Buffer, CodeLabel, HighlightId};
12use lsp::CompletionContext;
13use project::{
14 Completion, CompletionIntent, CompletionResponse, Project, ProjectPath, Symbol, WorktreeId,
15};
16use prompt_store::PromptStore;
17use rope::Point;
18use text::{Anchor, ToPoint as _};
19use ui::prelude::*;
20use workspace::Workspace;
21
22use crate::AgentPanel;
23use crate::acp::message_editor::MessageEditor;
24use crate::context_picker::file_context_picker::{FileMatch, search_files};
25use crate::context_picker::rules_context_picker::{RulesContextEntry, search_rules};
26use crate::context_picker::symbol_context_picker::SymbolMatch;
27use crate::context_picker::symbol_context_picker::search_symbols;
28use crate::context_picker::{
29 ContextPickerAction, ContextPickerEntry, ContextPickerMode, selection_ranges,
30};
31
32pub(crate) enum Match {
33 File(FileMatch),
34 Symbol(SymbolMatch),
35 Thread(HistoryEntry),
36 RecentThread(HistoryEntry),
37 Fetch(SharedString),
38 Rules(RulesContextEntry),
39 Entry(EntryMatch),
40}
41
42pub struct EntryMatch {
43 mat: Option<StringMatch>,
44 entry: ContextPickerEntry,
45}
46
47impl Match {
48 pub fn score(&self) -> f64 {
49 match self {
50 Match::File(file) => file.mat.score,
51 Match::Entry(mode) => mode.mat.as_ref().map(|mat| mat.score).unwrap_or(1.),
52 Match::Thread(_) => 1.,
53 Match::RecentThread(_) => 1.,
54 Match::Symbol(_) => 1.,
55 Match::Rules(_) => 1.,
56 Match::Fetch(_) => 1.,
57 }
58 }
59}
60
61pub struct ContextPickerCompletionProvider {
62 message_editor: WeakEntity<MessageEditor>,
63 workspace: WeakEntity<Workspace>,
64 history_store: Entity<HistoryStore>,
65 prompt_store: Option<Entity<PromptStore>>,
66}
67
68impl ContextPickerCompletionProvider {
69 pub fn new(
70 message_editor: WeakEntity<MessageEditor>,
71 workspace: WeakEntity<Workspace>,
72 history_store: Entity<HistoryStore>,
73 prompt_store: Option<Entity<PromptStore>>,
74 ) -> Self {
75 Self {
76 message_editor,
77 workspace,
78 history_store,
79 prompt_store,
80 }
81 }
82
83 fn completion_for_entry(
84 entry: ContextPickerEntry,
85 source_range: Range<Anchor>,
86 message_editor: WeakEntity<MessageEditor>,
87 workspace: &Entity<Workspace>,
88 cx: &mut App,
89 ) -> Option<Completion> {
90 match entry {
91 ContextPickerEntry::Mode(mode) => Some(Completion {
92 replace_range: source_range,
93 new_text: format!("@{} ", mode.keyword()),
94 label: CodeLabel::plain(mode.label().to_string(), None),
95 icon_path: Some(mode.icon().path().into()),
96 documentation: None,
97 source: project::CompletionSource::Custom,
98 insert_text_mode: None,
99 // This ensures that when a user accepts this completion, the
100 // completion menu will still be shown after "@category " is
101 // inserted
102 confirm: Some(Arc::new(|_, _, _| true)),
103 }),
104 ContextPickerEntry::Action(action) => {
105 let (new_text, on_action) = match action {
106 ContextPickerAction::AddSelections => {
107 const PLACEHOLDER: &str = "selection ";
108 let selections = selection_ranges(workspace, cx)
109 .into_iter()
110 .enumerate()
111 .map(|(ix, (buffer, range))| {
112 (
113 buffer,
114 range,
115 (PLACEHOLDER.len() * ix)..(PLACEHOLDER.len() * (ix + 1) - 1),
116 )
117 })
118 .collect::<Vec<_>>();
119
120 let new_text: String = PLACEHOLDER.repeat(selections.len());
121
122 let callback = Arc::new({
123 let source_range = source_range.clone();
124 move |_, window: &mut Window, cx: &mut App| {
125 let selections = selections.clone();
126 let message_editor = message_editor.clone();
127 let source_range = source_range.clone();
128 window.defer(cx, move |window, cx| {
129 message_editor
130 .update(cx, |message_editor, cx| {
131 message_editor.confirm_mention_for_selection(
132 source_range,
133 selections,
134 window,
135 cx,
136 )
137 })
138 .ok();
139 });
140 false
141 }
142 });
143
144 (new_text, callback)
145 }
146 };
147
148 Some(Completion {
149 replace_range: source_range,
150 new_text,
151 label: CodeLabel::plain(action.label().to_string(), None),
152 icon_path: Some(action.icon().path().into()),
153 documentation: None,
154 source: project::CompletionSource::Custom,
155 insert_text_mode: None,
156 // This ensures that when a user accepts this completion, the
157 // completion menu will still be shown after "@category " is
158 // inserted
159 confirm: Some(on_action),
160 })
161 }
162 }
163 }
164
165 fn completion_for_thread(
166 thread_entry: HistoryEntry,
167 source_range: Range<Anchor>,
168 recent: bool,
169 editor: WeakEntity<MessageEditor>,
170 cx: &mut App,
171 ) -> Completion {
172 let uri = thread_entry.mention_uri();
173
174 let icon_for_completion = if recent {
175 IconName::HistoryRerun.path().into()
176 } else {
177 uri.icon_path(cx)
178 };
179
180 let new_text = format!("{} ", uri.as_link());
181
182 let new_text_len = new_text.len();
183 Completion {
184 replace_range: source_range.clone(),
185 new_text,
186 label: CodeLabel::plain(thread_entry.title().to_string(), None),
187 documentation: None,
188 insert_text_mode: None,
189 source: project::CompletionSource::Custom,
190 icon_path: Some(icon_for_completion),
191 confirm: Some(confirm_completion_callback(
192 thread_entry.title().clone(),
193 source_range.start,
194 new_text_len - 1,
195 editor,
196 uri,
197 )),
198 }
199 }
200
201 fn completion_for_rules(
202 rule: RulesContextEntry,
203 source_range: Range<Anchor>,
204 editor: WeakEntity<MessageEditor>,
205 cx: &mut App,
206 ) -> Completion {
207 let uri = MentionUri::Rule {
208 id: rule.prompt_id.into(),
209 name: rule.title.to_string(),
210 };
211 let new_text = format!("{} ", uri.as_link());
212 let new_text_len = new_text.len();
213 let icon_path = uri.icon_path(cx);
214 Completion {
215 replace_range: source_range.clone(),
216 new_text,
217 label: CodeLabel::plain(rule.title.to_string(), None),
218 documentation: None,
219 insert_text_mode: None,
220 source: project::CompletionSource::Custom,
221 icon_path: Some(icon_path),
222 confirm: Some(confirm_completion_callback(
223 rule.title,
224 source_range.start,
225 new_text_len - 1,
226 editor,
227 uri,
228 )),
229 }
230 }
231
232 pub(crate) fn completion_for_path(
233 project_path: ProjectPath,
234 path_prefix: &str,
235 is_recent: bool,
236 is_directory: bool,
237 source_range: Range<Anchor>,
238 message_editor: WeakEntity<MessageEditor>,
239 project: Entity<Project>,
240 cx: &mut App,
241 ) -> Option<Completion> {
242 let (file_name, directory) =
243 crate::context_picker::file_context_picker::extract_file_name_and_directory(
244 &project_path.path,
245 path_prefix,
246 );
247
248 let label =
249 build_code_label_for_full_path(&file_name, directory.as_ref().map(|s| s.as_ref()), cx);
250
251 let abs_path = project.read(cx).absolute_path(&project_path, cx)?;
252
253 let uri = if is_directory {
254 MentionUri::Directory { abs_path }
255 } else {
256 MentionUri::File { abs_path }
257 };
258
259 let crease_icon_path = uri.icon_path(cx);
260 let completion_icon_path = if is_recent {
261 IconName::HistoryRerun.path().into()
262 } else {
263 crease_icon_path
264 };
265
266 let new_text = format!("{} ", uri.as_link());
267 let new_text_len = new_text.len();
268 Some(Completion {
269 replace_range: source_range.clone(),
270 new_text,
271 label,
272 documentation: None,
273 source: project::CompletionSource::Custom,
274 icon_path: Some(completion_icon_path),
275 insert_text_mode: None,
276 confirm: Some(confirm_completion_callback(
277 file_name,
278 source_range.start,
279 new_text_len - 1,
280 message_editor,
281 uri,
282 )),
283 })
284 }
285
286 fn completion_for_symbol(
287 symbol: Symbol,
288 source_range: Range<Anchor>,
289 message_editor: WeakEntity<MessageEditor>,
290 workspace: Entity<Workspace>,
291 cx: &mut App,
292 ) -> Option<Completion> {
293 let project = workspace.read(cx).project().clone();
294
295 let label = CodeLabel::plain(symbol.name.clone(), None);
296
297 let abs_path = project.read(cx).absolute_path(&symbol.path, cx)?;
298 let uri = MentionUri::Symbol {
299 path: abs_path,
300 name: symbol.name.clone(),
301 line_range: symbol.range.start.0.row..symbol.range.end.0.row,
302 };
303 let new_text = format!("{} ", uri.as_link());
304 let new_text_len = new_text.len();
305 let icon_path = uri.icon_path(cx);
306 Some(Completion {
307 replace_range: source_range.clone(),
308 new_text,
309 label,
310 documentation: None,
311 source: project::CompletionSource::Custom,
312 icon_path: Some(icon_path),
313 insert_text_mode: None,
314 confirm: Some(confirm_completion_callback(
315 symbol.name.into(),
316 source_range.start,
317 new_text_len - 1,
318 message_editor,
319 uri,
320 )),
321 })
322 }
323
324 fn completion_for_fetch(
325 source_range: Range<Anchor>,
326 url_to_fetch: SharedString,
327 message_editor: WeakEntity<MessageEditor>,
328 cx: &mut App,
329 ) -> Option<Completion> {
330 let new_text = format!("@fetch {} ", url_to_fetch);
331 let url_to_fetch = url::Url::parse(url_to_fetch.as_ref())
332 .or_else(|_| url::Url::parse(&format!("https://{url_to_fetch}")))
333 .ok()?;
334 let mention_uri = MentionUri::Fetch {
335 url: url_to_fetch.clone(),
336 };
337 let icon_path = mention_uri.icon_path(cx);
338 Some(Completion {
339 replace_range: source_range.clone(),
340 new_text: new_text.clone(),
341 label: CodeLabel::plain(url_to_fetch.to_string(), None),
342 documentation: None,
343 source: project::CompletionSource::Custom,
344 icon_path: Some(icon_path),
345 insert_text_mode: None,
346 confirm: Some(confirm_completion_callback(
347 url_to_fetch.to_string().into(),
348 source_range.start,
349 new_text.len() - 1,
350 message_editor,
351 mention_uri,
352 )),
353 })
354 }
355
356 fn search(
357 &self,
358 mode: Option<ContextPickerMode>,
359 query: String,
360 cancellation_flag: Arc<AtomicBool>,
361 cx: &mut App,
362 ) -> Task<Vec<Match>> {
363 let Some(workspace) = self.workspace.upgrade() else {
364 return Task::ready(Vec::default());
365 };
366 match mode {
367 Some(ContextPickerMode::File) => {
368 let search_files_task = search_files(query, cancellation_flag, &workspace, cx);
369 cx.background_spawn(async move {
370 search_files_task
371 .await
372 .into_iter()
373 .map(Match::File)
374 .collect()
375 })
376 }
377
378 Some(ContextPickerMode::Symbol) => {
379 let search_symbols_task = search_symbols(query, cancellation_flag, &workspace, cx);
380 cx.background_spawn(async move {
381 search_symbols_task
382 .await
383 .into_iter()
384 .map(Match::Symbol)
385 .collect()
386 })
387 }
388
389 Some(ContextPickerMode::Thread) => {
390 let search_threads_task =
391 search_threads(query, cancellation_flag, &self.history_store, cx);
392 cx.background_spawn(async move {
393 search_threads_task
394 .await
395 .into_iter()
396 .map(Match::Thread)
397 .collect()
398 })
399 }
400
401 Some(ContextPickerMode::Fetch) => {
402 if !query.is_empty() {
403 Task::ready(vec![Match::Fetch(query.into())])
404 } else {
405 Task::ready(Vec::new())
406 }
407 }
408
409 Some(ContextPickerMode::Rules) => {
410 if let Some(prompt_store) = self.prompt_store.as_ref() {
411 let search_rules_task =
412 search_rules(query, cancellation_flag, prompt_store, cx);
413 cx.background_spawn(async move {
414 search_rules_task
415 .await
416 .into_iter()
417 .map(Match::Rules)
418 .collect::<Vec<_>>()
419 })
420 } else {
421 Task::ready(Vec::new())
422 }
423 }
424
425 None if query.is_empty() => {
426 let mut matches = self.recent_context_picker_entries(&workspace, cx);
427
428 matches.extend(
429 self.available_context_picker_entries(&workspace, cx)
430 .into_iter()
431 .map(|mode| {
432 Match::Entry(EntryMatch {
433 entry: mode,
434 mat: None,
435 })
436 }),
437 );
438
439 Task::ready(matches)
440 }
441 None => {
442 let executor = cx.background_executor().clone();
443
444 let search_files_task =
445 search_files(query.clone(), cancellation_flag, &workspace, cx);
446
447 let entries = self.available_context_picker_entries(&workspace, cx);
448 let entry_candidates = entries
449 .iter()
450 .enumerate()
451 .map(|(ix, entry)| StringMatchCandidate::new(ix, entry.keyword()))
452 .collect::<Vec<_>>();
453
454 cx.background_spawn(async move {
455 let mut matches = search_files_task
456 .await
457 .into_iter()
458 .map(Match::File)
459 .collect::<Vec<_>>();
460
461 let entry_matches = fuzzy::match_strings(
462 &entry_candidates,
463 &query,
464 false,
465 true,
466 100,
467 &Arc::new(AtomicBool::default()),
468 executor,
469 )
470 .await;
471
472 matches.extend(entry_matches.into_iter().map(|mat| {
473 Match::Entry(EntryMatch {
474 entry: entries[mat.candidate_id],
475 mat: Some(mat),
476 })
477 }));
478
479 matches.sort_by(|a, b| {
480 b.score()
481 .partial_cmp(&a.score())
482 .unwrap_or(std::cmp::Ordering::Equal)
483 });
484
485 matches
486 })
487 }
488 }
489 }
490
491 fn recent_context_picker_entries(
492 &self,
493 workspace: &Entity<Workspace>,
494 cx: &mut App,
495 ) -> Vec<Match> {
496 let mut recent = Vec::with_capacity(6);
497
498 let mut mentions = self
499 .message_editor
500 .read_with(cx, |message_editor, _cx| message_editor.mentions())
501 .unwrap_or_default();
502 let workspace = workspace.read(cx);
503 let project = workspace.project().read(cx);
504
505 if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx)
506 && let Some(thread) = agent_panel.read(cx).active_agent_thread(cx)
507 {
508 let thread = thread.read(cx);
509 mentions.insert(MentionUri::Thread {
510 id: thread.session_id().clone(),
511 name: thread.title().into(),
512 });
513 }
514
515 recent.extend(
516 workspace
517 .recent_navigation_history_iter(cx)
518 .filter(|(_, abs_path)| {
519 abs_path.as_ref().is_none_or(|path| {
520 !mentions.contains(&MentionUri::File {
521 abs_path: path.clone(),
522 })
523 })
524 })
525 .take(4)
526 .filter_map(|(project_path, _)| {
527 project
528 .worktree_for_id(project_path.worktree_id, cx)
529 .map(|worktree| {
530 let path_prefix = worktree.read(cx).root_name().into();
531 Match::File(FileMatch {
532 mat: fuzzy::PathMatch {
533 score: 1.,
534 positions: Vec::new(),
535 worktree_id: project_path.worktree_id.to_usize(),
536 path: project_path.path,
537 path_prefix,
538 is_dir: false,
539 distance_to_relative_ancestor: 0,
540 },
541 is_recent: true,
542 })
543 })
544 }),
545 );
546
547 const RECENT_COUNT: usize = 2;
548 let threads = self
549 .history_store
550 .read(cx)
551 .recently_opened_entries(cx)
552 .into_iter()
553 .filter(|thread| !mentions.contains(&thread.mention_uri()))
554 .take(RECENT_COUNT)
555 .collect::<Vec<_>>();
556
557 recent.extend(threads.into_iter().map(Match::RecentThread));
558
559 recent
560 }
561
562 fn available_context_picker_entries(
563 &self,
564 workspace: &Entity<Workspace>,
565 cx: &mut App,
566 ) -> Vec<ContextPickerEntry> {
567 let mut entries = vec![
568 ContextPickerEntry::Mode(ContextPickerMode::File),
569 ContextPickerEntry::Mode(ContextPickerMode::Symbol),
570 ContextPickerEntry::Mode(ContextPickerMode::Thread),
571 ];
572
573 let has_selection = workspace
574 .read(cx)
575 .active_item(cx)
576 .and_then(|item| item.downcast::<Editor>())
577 .is_some_and(|editor| {
578 editor.update(cx, |editor, cx| editor.has_non_empty_selection(cx))
579 });
580 if has_selection {
581 entries.push(ContextPickerEntry::Action(
582 ContextPickerAction::AddSelections,
583 ));
584 }
585
586 if self.prompt_store.is_some() {
587 entries.push(ContextPickerEntry::Mode(ContextPickerMode::Rules));
588 }
589
590 entries.push(ContextPickerEntry::Mode(ContextPickerMode::Fetch));
591
592 entries
593 }
594}
595
596fn build_code_label_for_full_path(file_name: &str, directory: Option<&str>, cx: &App) -> CodeLabel {
597 let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId);
598 let mut label = CodeLabel::default();
599
600 label.push_str(file_name, None);
601 label.push_str(" ", None);
602
603 if let Some(directory) = directory {
604 label.push_str(directory, comment_id);
605 }
606
607 label.filter_range = 0..label.text().len();
608
609 label
610}
611
612impl CompletionProvider for ContextPickerCompletionProvider {
613 fn completions(
614 &self,
615 _excerpt_id: ExcerptId,
616 buffer: &Entity<Buffer>,
617 buffer_position: Anchor,
618 _trigger: CompletionContext,
619 _window: &mut Window,
620 cx: &mut Context<Editor>,
621 ) -> Task<Result<Vec<CompletionResponse>>> {
622 let state = buffer.update(cx, |buffer, _cx| {
623 let position = buffer_position.to_point(buffer);
624 let line_start = Point::new(position.row, 0);
625 let offset_to_line = buffer.point_to_offset(line_start);
626 let mut lines = buffer.text_for_range(line_start..position).lines();
627 let line = lines.next()?;
628 MentionCompletion::try_parse(line, offset_to_line)
629 });
630 let Some(state) = state else {
631 return Task::ready(Ok(Vec::new()));
632 };
633
634 let Some(workspace) = self.workspace.upgrade() else {
635 return Task::ready(Ok(Vec::new()));
636 };
637
638 let project = workspace.read(cx).project().clone();
639 let snapshot = buffer.read(cx).snapshot();
640 let source_range = snapshot.anchor_before(state.source_range.start)
641 ..snapshot.anchor_after(state.source_range.end);
642
643 let editor = self.message_editor.clone();
644
645 let MentionCompletion { mode, argument, .. } = state;
646 let query = argument.unwrap_or_else(|| "".to_string());
647
648 let search_task = self.search(mode, query, Arc::<AtomicBool>::default(), cx);
649
650 cx.spawn(async move |_, cx| {
651 let matches = search_task.await;
652
653 let completions = cx.update(|cx| {
654 matches
655 .into_iter()
656 .filter_map(|mat| match mat {
657 Match::File(FileMatch { mat, is_recent }) => {
658 let project_path = ProjectPath {
659 worktree_id: WorktreeId::from_usize(mat.worktree_id),
660 path: mat.path.clone(),
661 };
662
663 Self::completion_for_path(
664 project_path,
665 &mat.path_prefix,
666 is_recent,
667 mat.is_dir,
668 source_range.clone(),
669 editor.clone(),
670 project.clone(),
671 cx,
672 )
673 }
674
675 Match::Symbol(SymbolMatch { symbol, .. }) => Self::completion_for_symbol(
676 symbol,
677 source_range.clone(),
678 editor.clone(),
679 workspace.clone(),
680 cx,
681 ),
682
683 Match::Thread(thread) => Some(Self::completion_for_thread(
684 thread,
685 source_range.clone(),
686 false,
687 editor.clone(),
688 cx,
689 )),
690
691 Match::RecentThread(thread) => Some(Self::completion_for_thread(
692 thread,
693 source_range.clone(),
694 true,
695 editor.clone(),
696 cx,
697 )),
698
699 Match::Rules(user_rules) => Some(Self::completion_for_rules(
700 user_rules,
701 source_range.clone(),
702 editor.clone(),
703 cx,
704 )),
705
706 Match::Fetch(url) => Self::completion_for_fetch(
707 source_range.clone(),
708 url,
709 editor.clone(),
710 cx,
711 ),
712
713 Match::Entry(EntryMatch { entry, .. }) => Self::completion_for_entry(
714 entry,
715 source_range.clone(),
716 editor.clone(),
717 &workspace,
718 cx,
719 ),
720 })
721 .collect()
722 })?;
723
724 Ok(vec![CompletionResponse {
725 completions,
726 // Since this does its own filtering (see `filter_completions()` returns false),
727 // there is no benefit to computing whether this set of completions is incomplete.
728 is_incomplete: true,
729 }])
730 })
731 }
732
733 fn is_completion_trigger(
734 &self,
735 buffer: &Entity<language::Buffer>,
736 position: language::Anchor,
737 _text: &str,
738 _trigger_in_words: bool,
739 _menu_is_open: bool,
740 cx: &mut Context<Editor>,
741 ) -> bool {
742 let buffer = buffer.read(cx);
743 let position = position.to_point(buffer);
744 let line_start = Point::new(position.row, 0);
745 let offset_to_line = buffer.point_to_offset(line_start);
746 let mut lines = buffer.text_for_range(line_start..position).lines();
747 if let Some(line) = lines.next() {
748 MentionCompletion::try_parse(line, offset_to_line)
749 .map(|completion| {
750 completion.source_range.start <= offset_to_line + position.column as usize
751 && completion.source_range.end >= offset_to_line + position.column as usize
752 })
753 .unwrap_or(false)
754 } else {
755 false
756 }
757 }
758
759 fn sort_completions(&self) -> bool {
760 false
761 }
762
763 fn filter_completions(&self) -> bool {
764 false
765 }
766}
767
768pub(crate) fn search_threads(
769 query: String,
770 cancellation_flag: Arc<AtomicBool>,
771 history_store: &Entity<HistoryStore>,
772 cx: &mut App,
773) -> Task<Vec<HistoryEntry>> {
774 let threads = history_store.read(cx).entries(cx);
775 if query.is_empty() {
776 return Task::ready(threads);
777 }
778
779 let executor = cx.background_executor().clone();
780 cx.background_spawn(async move {
781 let candidates = threads
782 .iter()
783 .enumerate()
784 .map(|(id, thread)| StringMatchCandidate::new(id, thread.title()))
785 .collect::<Vec<_>>();
786 let matches = fuzzy::match_strings(
787 &candidates,
788 &query,
789 false,
790 true,
791 100,
792 &cancellation_flag,
793 executor,
794 )
795 .await;
796
797 matches
798 .into_iter()
799 .map(|mat| threads[mat.candidate_id].clone())
800 .collect()
801 })
802}
803
804fn confirm_completion_callback(
805 crease_text: SharedString,
806 start: Anchor,
807 content_len: usize,
808 message_editor: WeakEntity<MessageEditor>,
809 mention_uri: MentionUri,
810) -> Arc<dyn Fn(CompletionIntent, &mut Window, &mut App) -> bool + Send + Sync> {
811 Arc::new(move |_, window, cx| {
812 let message_editor = message_editor.clone();
813 let crease_text = crease_text.clone();
814 let mention_uri = mention_uri.clone();
815 window.defer(cx, move |window, cx| {
816 message_editor
817 .clone()
818 .update(cx, |message_editor, cx| {
819 message_editor
820 .confirm_completion(
821 crease_text,
822 start,
823 content_len,
824 mention_uri,
825 window,
826 cx,
827 )
828 .detach();
829 })
830 .ok();
831 });
832 false
833 })
834}
835
836#[derive(Debug, Default, PartialEq)]
837struct MentionCompletion {
838 source_range: Range<usize>,
839 mode: Option<ContextPickerMode>,
840 argument: Option<String>,
841}
842
843impl MentionCompletion {
844 fn try_parse(line: &str, offset_to_line: usize) -> Option<Self> {
845 let last_mention_start = line.rfind('@')?;
846 if last_mention_start >= line.len() {
847 return Some(Self::default());
848 }
849 if last_mention_start > 0
850 && line
851 .chars()
852 .nth(last_mention_start - 1)
853 .is_some_and(|c| !c.is_whitespace())
854 {
855 return None;
856 }
857
858 let rest_of_line = &line[last_mention_start + 1..];
859
860 let mut mode = None;
861 let mut argument = None;
862
863 let mut parts = rest_of_line.split_whitespace();
864 let mut end = last_mention_start + 1;
865 if let Some(mode_text) = parts.next() {
866 end += mode_text.len();
867
868 if let Some(parsed_mode) = ContextPickerMode::try_from(mode_text).ok() {
869 mode = Some(parsed_mode);
870 } else {
871 argument = Some(mode_text.to_string());
872 }
873 match rest_of_line[mode_text.len()..].find(|c: char| !c.is_whitespace()) {
874 Some(whitespace_count) => {
875 if let Some(argument_text) = parts.next() {
876 argument = Some(argument_text.to_string());
877 end += whitespace_count + argument_text.len();
878 }
879 }
880 None => {
881 // Rest of line is entirely whitespace
882 end += rest_of_line.len() - mode_text.len();
883 }
884 }
885 }
886
887 Some(Self {
888 source_range: last_mention_start + offset_to_line..end + offset_to_line,
889 mode,
890 argument,
891 })
892 }
893}
894
895#[cfg(test)]
896mod tests {
897 use super::*;
898
899 #[test]
900 fn test_mention_completion_parse() {
901 assert_eq!(MentionCompletion::try_parse("Lorem Ipsum", 0), None);
902
903 assert_eq!(
904 MentionCompletion::try_parse("Lorem @", 0),
905 Some(MentionCompletion {
906 source_range: 6..7,
907 mode: None,
908 argument: None,
909 })
910 );
911
912 assert_eq!(
913 MentionCompletion::try_parse("Lorem @file", 0),
914 Some(MentionCompletion {
915 source_range: 6..11,
916 mode: Some(ContextPickerMode::File),
917 argument: None,
918 })
919 );
920
921 assert_eq!(
922 MentionCompletion::try_parse("Lorem @file ", 0),
923 Some(MentionCompletion {
924 source_range: 6..12,
925 mode: Some(ContextPickerMode::File),
926 argument: None,
927 })
928 );
929
930 assert_eq!(
931 MentionCompletion::try_parse("Lorem @file main.rs", 0),
932 Some(MentionCompletion {
933 source_range: 6..19,
934 mode: Some(ContextPickerMode::File),
935 argument: Some("main.rs".to_string()),
936 })
937 );
938
939 assert_eq!(
940 MentionCompletion::try_parse("Lorem @file main.rs ", 0),
941 Some(MentionCompletion {
942 source_range: 6..19,
943 mode: Some(ContextPickerMode::File),
944 argument: Some("main.rs".to_string()),
945 })
946 );
947
948 assert_eq!(
949 MentionCompletion::try_parse("Lorem @file main.rs Ipsum", 0),
950 Some(MentionCompletion {
951 source_range: 6..19,
952 mode: Some(ContextPickerMode::File),
953 argument: Some("main.rs".to_string()),
954 })
955 );
956
957 assert_eq!(
958 MentionCompletion::try_parse("Lorem @main", 0),
959 Some(MentionCompletion {
960 source_range: 6..11,
961 mode: None,
962 argument: Some("main".to_string()),
963 })
964 );
965
966 assert_eq!(MentionCompletion::try_parse("test@", 0), None);
967 }
968}