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.clone(),
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.clone(),
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.clone()),
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.clone()),
222 confirm: Some(confirm_completion_callback(
223 rule.title.clone(),
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.clone()
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.clone()),
313 insert_text_mode: None,
314 confirm: Some(confirm_completion_callback(
315 symbol.name.clone().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.clone());
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.clone()),
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 =
369 search_files(query.clone(), cancellation_flag.clone(), &workspace, cx);
370 cx.background_spawn(async move {
371 search_files_task
372 .await
373 .into_iter()
374 .map(Match::File)
375 .collect()
376 })
377 }
378
379 Some(ContextPickerMode::Symbol) => {
380 let search_symbols_task =
381 search_symbols(query.clone(), cancellation_flag.clone(), &workspace, cx);
382 cx.background_spawn(async move {
383 search_symbols_task
384 .await
385 .into_iter()
386 .map(Match::Symbol)
387 .collect()
388 })
389 }
390
391 Some(ContextPickerMode::Thread) => {
392 let search_threads_task = search_threads(
393 query.clone(),
394 cancellation_flag.clone(),
395 &self.history_store,
396 cx,
397 );
398 cx.background_spawn(async move {
399 search_threads_task
400 .await
401 .into_iter()
402 .map(Match::Thread)
403 .collect()
404 })
405 }
406
407 Some(ContextPickerMode::Fetch) => {
408 if !query.is_empty() {
409 Task::ready(vec![Match::Fetch(query.into())])
410 } else {
411 Task::ready(Vec::new())
412 }
413 }
414
415 Some(ContextPickerMode::Rules) => {
416 if let Some(prompt_store) = self.prompt_store.as_ref() {
417 let search_rules_task =
418 search_rules(query.clone(), cancellation_flag.clone(), prompt_store, cx);
419 cx.background_spawn(async move {
420 search_rules_task
421 .await
422 .into_iter()
423 .map(Match::Rules)
424 .collect::<Vec<_>>()
425 })
426 } else {
427 Task::ready(Vec::new())
428 }
429 }
430
431 None if query.is_empty() => {
432 let mut matches = self.recent_context_picker_entries(&workspace, cx);
433
434 matches.extend(
435 self.available_context_picker_entries(&workspace, cx)
436 .into_iter()
437 .map(|mode| {
438 Match::Entry(EntryMatch {
439 entry: mode,
440 mat: None,
441 })
442 }),
443 );
444
445 Task::ready(matches)
446 }
447 None => {
448 let executor = cx.background_executor().clone();
449
450 let search_files_task =
451 search_files(query.clone(), cancellation_flag.clone(), &workspace, cx);
452
453 let entries = self.available_context_picker_entries(&workspace, cx);
454 let entry_candidates = entries
455 .iter()
456 .enumerate()
457 .map(|(ix, entry)| StringMatchCandidate::new(ix, entry.keyword()))
458 .collect::<Vec<_>>();
459
460 cx.background_spawn(async move {
461 let mut matches = search_files_task
462 .await
463 .into_iter()
464 .map(Match::File)
465 .collect::<Vec<_>>();
466
467 let entry_matches = fuzzy::match_strings(
468 &entry_candidates,
469 &query,
470 false,
471 true,
472 100,
473 &Arc::new(AtomicBool::default()),
474 executor,
475 )
476 .await;
477
478 matches.extend(entry_matches.into_iter().map(|mat| {
479 Match::Entry(EntryMatch {
480 entry: entries[mat.candidate_id],
481 mat: Some(mat),
482 })
483 }));
484
485 matches.sort_by(|a, b| {
486 b.score()
487 .partial_cmp(&a.score())
488 .unwrap_or(std::cmp::Ordering::Equal)
489 });
490
491 matches
492 })
493 }
494 }
495 }
496
497 fn recent_context_picker_entries(
498 &self,
499 workspace: &Entity<Workspace>,
500 cx: &mut App,
501 ) -> Vec<Match> {
502 let mut recent = Vec::with_capacity(6);
503
504 let mut mentions = self
505 .message_editor
506 .read_with(cx, |message_editor, _cx| message_editor.mentions())
507 .unwrap_or_default();
508 let workspace = workspace.read(cx);
509 let project = workspace.project().read(cx);
510
511 if let Some(agent_panel) = workspace.panel::<AgentPanel>(cx)
512 && let Some(thread) = agent_panel.read(cx).active_agent_thread(cx)
513 {
514 let thread = thread.read(cx);
515 mentions.insert(MentionUri::Thread {
516 id: thread.session_id().clone(),
517 name: thread.title().into(),
518 });
519 }
520
521 recent.extend(
522 workspace
523 .recent_navigation_history_iter(cx)
524 .filter(|(_, abs_path)| {
525 abs_path.as_ref().is_none_or(|path| {
526 !mentions.contains(&MentionUri::File {
527 abs_path: path.clone(),
528 })
529 })
530 })
531 .take(4)
532 .filter_map(|(project_path, _)| {
533 project
534 .worktree_for_id(project_path.worktree_id, cx)
535 .map(|worktree| {
536 let path_prefix = worktree.read(cx).root_name().into();
537 Match::File(FileMatch {
538 mat: fuzzy::PathMatch {
539 score: 1.,
540 positions: Vec::new(),
541 worktree_id: project_path.worktree_id.to_usize(),
542 path: project_path.path,
543 path_prefix,
544 is_dir: false,
545 distance_to_relative_ancestor: 0,
546 },
547 is_recent: true,
548 })
549 })
550 }),
551 );
552
553 const RECENT_COUNT: usize = 2;
554 let threads = self
555 .history_store
556 .read(cx)
557 .recently_opened_entries(cx)
558 .into_iter()
559 .filter(|thread| !mentions.contains(&thread.mention_uri()))
560 .take(RECENT_COUNT)
561 .collect::<Vec<_>>();
562
563 recent.extend(threads.into_iter().map(Match::RecentThread));
564
565 recent
566 }
567
568 fn available_context_picker_entries(
569 &self,
570 workspace: &Entity<Workspace>,
571 cx: &mut App,
572 ) -> Vec<ContextPickerEntry> {
573 let mut entries = vec![
574 ContextPickerEntry::Mode(ContextPickerMode::File),
575 ContextPickerEntry::Mode(ContextPickerMode::Symbol),
576 ContextPickerEntry::Mode(ContextPickerMode::Thread),
577 ];
578
579 let has_selection = workspace
580 .read(cx)
581 .active_item(cx)
582 .and_then(|item| item.downcast::<Editor>())
583 .is_some_and(|editor| {
584 editor.update(cx, |editor, cx| editor.has_non_empty_selection(cx))
585 });
586 if has_selection {
587 entries.push(ContextPickerEntry::Action(
588 ContextPickerAction::AddSelections,
589 ));
590 }
591
592 if self.prompt_store.is_some() {
593 entries.push(ContextPickerEntry::Mode(ContextPickerMode::Rules));
594 }
595
596 entries.push(ContextPickerEntry::Mode(ContextPickerMode::Fetch));
597
598 entries
599 }
600}
601
602fn build_code_label_for_full_path(file_name: &str, directory: Option<&str>, cx: &App) -> CodeLabel {
603 let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId);
604 let mut label = CodeLabel::default();
605
606 label.push_str(file_name, None);
607 label.push_str(" ", None);
608
609 if let Some(directory) = directory {
610 label.push_str(directory, comment_id);
611 }
612
613 label.filter_range = 0..label.text().len();
614
615 label
616}
617
618impl CompletionProvider for ContextPickerCompletionProvider {
619 fn completions(
620 &self,
621 _excerpt_id: ExcerptId,
622 buffer: &Entity<Buffer>,
623 buffer_position: Anchor,
624 _trigger: CompletionContext,
625 _window: &mut Window,
626 cx: &mut Context<Editor>,
627 ) -> Task<Result<Vec<CompletionResponse>>> {
628 let state = buffer.update(cx, |buffer, _cx| {
629 let position = buffer_position.to_point(buffer);
630 let line_start = Point::new(position.row, 0);
631 let offset_to_line = buffer.point_to_offset(line_start);
632 let mut lines = buffer.text_for_range(line_start..position).lines();
633 let line = lines.next()?;
634 MentionCompletion::try_parse(line, offset_to_line)
635 });
636 let Some(state) = state else {
637 return Task::ready(Ok(Vec::new()));
638 };
639
640 let Some(workspace) = self.workspace.upgrade() else {
641 return Task::ready(Ok(Vec::new()));
642 };
643
644 let project = workspace.read(cx).project().clone();
645 let snapshot = buffer.read(cx).snapshot();
646 let source_range = snapshot.anchor_before(state.source_range.start)
647 ..snapshot.anchor_after(state.source_range.end);
648
649 let editor = self.message_editor.clone();
650
651 let MentionCompletion { mode, argument, .. } = state;
652 let query = argument.unwrap_or_else(|| "".to_string());
653
654 let search_task = self.search(mode, query, Arc::<AtomicBool>::default(), cx);
655
656 cx.spawn(async move |_, cx| {
657 let matches = search_task.await;
658
659 let completions = cx.update(|cx| {
660 matches
661 .into_iter()
662 .filter_map(|mat| match mat {
663 Match::File(FileMatch { mat, is_recent }) => {
664 let project_path = ProjectPath {
665 worktree_id: WorktreeId::from_usize(mat.worktree_id),
666 path: mat.path.clone(),
667 };
668
669 Self::completion_for_path(
670 project_path,
671 &mat.path_prefix,
672 is_recent,
673 mat.is_dir,
674 source_range.clone(),
675 editor.clone(),
676 project.clone(),
677 cx,
678 )
679 }
680
681 Match::Symbol(SymbolMatch { symbol, .. }) => Self::completion_for_symbol(
682 symbol,
683 source_range.clone(),
684 editor.clone(),
685 workspace.clone(),
686 cx,
687 ),
688
689 Match::Thread(thread) => Some(Self::completion_for_thread(
690 thread,
691 source_range.clone(),
692 false,
693 editor.clone(),
694 cx,
695 )),
696
697 Match::RecentThread(thread) => Some(Self::completion_for_thread(
698 thread,
699 source_range.clone(),
700 true,
701 editor.clone(),
702 cx,
703 )),
704
705 Match::Rules(user_rules) => Some(Self::completion_for_rules(
706 user_rules,
707 source_range.clone(),
708 editor.clone(),
709 cx,
710 )),
711
712 Match::Fetch(url) => Self::completion_for_fetch(
713 source_range.clone(),
714 url,
715 editor.clone(),
716 cx,
717 ),
718
719 Match::Entry(EntryMatch { entry, .. }) => Self::completion_for_entry(
720 entry,
721 source_range.clone(),
722 editor.clone(),
723 &workspace,
724 cx,
725 ),
726 })
727 .collect()
728 })?;
729
730 Ok(vec![CompletionResponse {
731 completions,
732 // Since this does its own filtering (see `filter_completions()` returns false),
733 // there is no benefit to computing whether this set of completions is incomplete.
734 is_incomplete: true,
735 }])
736 })
737 }
738
739 fn is_completion_trigger(
740 &self,
741 buffer: &Entity<language::Buffer>,
742 position: language::Anchor,
743 _text: &str,
744 _trigger_in_words: bool,
745 _menu_is_open: bool,
746 cx: &mut Context<Editor>,
747 ) -> bool {
748 let buffer = buffer.read(cx);
749 let position = position.to_point(buffer);
750 let line_start = Point::new(position.row, 0);
751 let offset_to_line = buffer.point_to_offset(line_start);
752 let mut lines = buffer.text_for_range(line_start..position).lines();
753 if let Some(line) = lines.next() {
754 MentionCompletion::try_parse(line, offset_to_line)
755 .map(|completion| {
756 completion.source_range.start <= offset_to_line + position.column as usize
757 && completion.source_range.end >= offset_to_line + position.column as usize
758 })
759 .unwrap_or(false)
760 } else {
761 false
762 }
763 }
764
765 fn sort_completions(&self) -> bool {
766 false
767 }
768
769 fn filter_completions(&self) -> bool {
770 false
771 }
772}
773
774pub(crate) fn search_threads(
775 query: String,
776 cancellation_flag: Arc<AtomicBool>,
777 history_store: &Entity<HistoryStore>,
778 cx: &mut App,
779) -> Task<Vec<HistoryEntry>> {
780 let threads = history_store.read(cx).entries(cx);
781 if query.is_empty() {
782 return Task::ready(threads);
783 }
784
785 let executor = cx.background_executor().clone();
786 cx.background_spawn(async move {
787 let candidates = threads
788 .iter()
789 .enumerate()
790 .map(|(id, thread)| StringMatchCandidate::new(id, thread.title()))
791 .collect::<Vec<_>>();
792 let matches = fuzzy::match_strings(
793 &candidates,
794 &query,
795 false,
796 true,
797 100,
798 &cancellation_flag,
799 executor,
800 )
801 .await;
802
803 matches
804 .into_iter()
805 .map(|mat| threads[mat.candidate_id].clone())
806 .collect()
807 })
808}
809
810fn confirm_completion_callback(
811 crease_text: SharedString,
812 start: Anchor,
813 content_len: usize,
814 message_editor: WeakEntity<MessageEditor>,
815 mention_uri: MentionUri,
816) -> Arc<dyn Fn(CompletionIntent, &mut Window, &mut App) -> bool + Send + Sync> {
817 Arc::new(move |_, window, cx| {
818 let message_editor = message_editor.clone();
819 let crease_text = crease_text.clone();
820 let mention_uri = mention_uri.clone();
821 window.defer(cx, move |window, cx| {
822 message_editor
823 .clone()
824 .update(cx, |message_editor, cx| {
825 message_editor
826 .confirm_completion(
827 crease_text,
828 start,
829 content_len,
830 mention_uri,
831 window,
832 cx,
833 )
834 .detach();
835 })
836 .ok();
837 });
838 false
839 })
840}
841
842#[derive(Debug, Default, PartialEq)]
843struct MentionCompletion {
844 source_range: Range<usize>,
845 mode: Option<ContextPickerMode>,
846 argument: Option<String>,
847}
848
849impl MentionCompletion {
850 fn try_parse(line: &str, offset_to_line: usize) -> Option<Self> {
851 let last_mention_start = line.rfind('@')?;
852 if last_mention_start >= line.len() {
853 return Some(Self::default());
854 }
855 if last_mention_start > 0
856 && line
857 .chars()
858 .nth(last_mention_start - 1)
859 .is_some_and(|c| !c.is_whitespace())
860 {
861 return None;
862 }
863
864 let rest_of_line = &line[last_mention_start + 1..];
865
866 let mut mode = None;
867 let mut argument = None;
868
869 let mut parts = rest_of_line.split_whitespace();
870 let mut end = last_mention_start + 1;
871 if let Some(mode_text) = parts.next() {
872 end += mode_text.len();
873
874 if let Some(parsed_mode) = ContextPickerMode::try_from(mode_text).ok() {
875 mode = Some(parsed_mode);
876 } else {
877 argument = Some(mode_text.to_string());
878 }
879 match rest_of_line[mode_text.len()..].find(|c: char| !c.is_whitespace()) {
880 Some(whitespace_count) => {
881 if let Some(argument_text) = parts.next() {
882 argument = Some(argument_text.to_string());
883 end += whitespace_count + argument_text.len();
884 }
885 }
886 None => {
887 // Rest of line is entirely whitespace
888 end += rest_of_line.len() - mode_text.len();
889 }
890 }
891 }
892
893 Some(Self {
894 source_range: last_mention_start + offset_to_line..end + offset_to_line,
895 mode,
896 argument,
897 })
898 }
899}
900
901#[cfg(test)]
902mod tests {
903 use super::*;
904
905 #[test]
906 fn test_mention_completion_parse() {
907 assert_eq!(MentionCompletion::try_parse("Lorem Ipsum", 0), None);
908
909 assert_eq!(
910 MentionCompletion::try_parse("Lorem @", 0),
911 Some(MentionCompletion {
912 source_range: 6..7,
913 mode: None,
914 argument: None,
915 })
916 );
917
918 assert_eq!(
919 MentionCompletion::try_parse("Lorem @file", 0),
920 Some(MentionCompletion {
921 source_range: 6..11,
922 mode: Some(ContextPickerMode::File),
923 argument: None,
924 })
925 );
926
927 assert_eq!(
928 MentionCompletion::try_parse("Lorem @file ", 0),
929 Some(MentionCompletion {
930 source_range: 6..12,
931 mode: Some(ContextPickerMode::File),
932 argument: None,
933 })
934 );
935
936 assert_eq!(
937 MentionCompletion::try_parse("Lorem @file main.rs", 0),
938 Some(MentionCompletion {
939 source_range: 6..19,
940 mode: Some(ContextPickerMode::File),
941 argument: Some("main.rs".to_string()),
942 })
943 );
944
945 assert_eq!(
946 MentionCompletion::try_parse("Lorem @file main.rs ", 0),
947 Some(MentionCompletion {
948 source_range: 6..19,
949 mode: Some(ContextPickerMode::File),
950 argument: Some("main.rs".to_string()),
951 })
952 );
953
954 assert_eq!(
955 MentionCompletion::try_parse("Lorem @file main.rs Ipsum", 0),
956 Some(MentionCompletion {
957 source_range: 6..19,
958 mode: Some(ContextPickerMode::File),
959 argument: Some("main.rs".to_string()),
960 })
961 );
962
963 assert_eq!(
964 MentionCompletion::try_parse("Lorem @main", 0),
965 Some(MentionCompletion {
966 source_range: 6..11,
967 mode: None,
968 argument: Some("main".to_string()),
969 })
970 );
971
972 assert_eq!(MentionCompletion::try_parse("test@", 0), None);
973 }
974}