completion_provider.rs

  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}