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