1use std::ops::Range;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4use std::sync::atomic::AtomicBool;
5
6use agent::context_store::ContextStore;
7use anyhow::Result;
8use editor::{CompletionProvider, Editor, ExcerptId, ToOffset as _};
9use file_icons::FileIcons;
10use fuzzy::{StringMatch, StringMatchCandidate};
11use gpui::{App, Entity, Task, WeakEntity};
12use http_client::HttpClientWithUrl;
13use itertools::Itertools;
14use language::{Buffer, CodeLabel, HighlightId};
15use lsp::CompletionContext;
16use project::{Completion, CompletionIntent, CompletionResponse, ProjectPath, Symbol, WorktreeId};
17use prompt_store::PromptStore;
18use rope::Point;
19use text::{Anchor, OffsetRangeExt, ToPoint};
20use ui::prelude::*;
21use util::ResultExt as _;
22use workspace::Workspace;
23
24use agent::{
25 Thread,
26 context::{AgentContextHandle, AgentContextKey, RULES_ICON},
27 thread_store::{TextThreadStore, ThreadStore},
28};
29
30use super::fetch_context_picker::fetch_url_content;
31use super::file_context_picker::{FileMatch, search_files};
32use super::rules_context_picker::{RulesContextEntry, search_rules};
33use super::symbol_context_picker::SymbolMatch;
34use super::symbol_context_picker::search_symbols;
35use super::thread_context_picker::{ThreadContextEntry, ThreadMatch, search_threads};
36use super::{
37 ContextPickerAction, ContextPickerEntry, ContextPickerMode, MentionLink, RecentEntry,
38 available_context_picker_entries, recent_context_picker_entries_with_store, selection_ranges,
39};
40use crate::message_editor::ContextCreasesAddon;
41
42pub(crate) enum Match {
43 File(FileMatch),
44 Symbol(SymbolMatch),
45 Thread(ThreadMatch),
46 Fetch(SharedString),
47 Rules(RulesContextEntry),
48 Entry(EntryMatch),
49}
50
51pub struct EntryMatch {
52 mat: Option<StringMatch>,
53 entry: ContextPickerEntry,
54}
55
56impl Match {
57 pub fn score(&self) -> f64 {
58 match self {
59 Match::File(file) => file.mat.score,
60 Match::Entry(mode) => mode.mat.as_ref().map(|mat| mat.score).unwrap_or(1.),
61 Match::Thread(_) => 1.,
62 Match::Symbol(_) => 1.,
63 Match::Fetch(_) => 1.,
64 Match::Rules(_) => 1.,
65 }
66 }
67}
68
69fn search(
70 mode: Option<ContextPickerMode>,
71 query: String,
72 cancellation_flag: Arc<AtomicBool>,
73 recent_entries: Vec<RecentEntry>,
74 prompt_store: Option<Entity<PromptStore>>,
75 thread_store: Option<WeakEntity<ThreadStore>>,
76 text_thread_context_store: Option<WeakEntity<assistant_context::ContextStore>>,
77 workspace: Entity<Workspace>,
78 cx: &mut App,
79) -> Task<Vec<Match>> {
80 match mode {
81 Some(ContextPickerMode::File) => {
82 let search_files_task = search_files(query, cancellation_flag, &workspace, cx);
83 cx.background_spawn(async move {
84 search_files_task
85 .await
86 .into_iter()
87 .map(Match::File)
88 .collect()
89 })
90 }
91
92 Some(ContextPickerMode::Symbol) => {
93 let search_symbols_task = search_symbols(query, cancellation_flag, &workspace, cx);
94 cx.background_spawn(async move {
95 search_symbols_task
96 .await
97 .into_iter()
98 .map(Match::Symbol)
99 .collect()
100 })
101 }
102
103 Some(ContextPickerMode::Thread) => {
104 if let Some((thread_store, context_store)) = thread_store
105 .as_ref()
106 .and_then(|t| t.upgrade())
107 .zip(text_thread_context_store.as_ref().and_then(|t| t.upgrade()))
108 {
109 let search_threads_task =
110 search_threads(query, cancellation_flag, thread_store, context_store, cx);
111 cx.background_spawn(async move {
112 search_threads_task
113 .await
114 .into_iter()
115 .map(Match::Thread)
116 .collect()
117 })
118 } else {
119 Task::ready(Vec::new())
120 }
121 }
122
123 Some(ContextPickerMode::Fetch) => {
124 if !query.is_empty() {
125 Task::ready(vec![Match::Fetch(query.into())])
126 } else {
127 Task::ready(Vec::new())
128 }
129 }
130
131 Some(ContextPickerMode::Rules) => {
132 if let Some(prompt_store) = prompt_store.as_ref() {
133 let search_rules_task = search_rules(query, cancellation_flag, prompt_store, cx);
134 cx.background_spawn(async move {
135 search_rules_task
136 .await
137 .into_iter()
138 .map(Match::Rules)
139 .collect::<Vec<_>>()
140 })
141 } else {
142 Task::ready(Vec::new())
143 }
144 }
145
146 None => {
147 if query.is_empty() {
148 let mut matches = recent_entries
149 .into_iter()
150 .map(|entry| match entry {
151 super::RecentEntry::File {
152 project_path,
153 path_prefix,
154 } => Match::File(FileMatch {
155 mat: fuzzy::PathMatch {
156 score: 1.,
157 positions: Vec::new(),
158 worktree_id: project_path.worktree_id.to_usize(),
159 path: project_path.path,
160 path_prefix,
161 is_dir: false,
162 distance_to_relative_ancestor: 0,
163 },
164 is_recent: true,
165 }),
166 super::RecentEntry::Thread(thread_context_entry) => {
167 Match::Thread(ThreadMatch {
168 thread: thread_context_entry,
169 is_recent: true,
170 })
171 }
172 })
173 .collect::<Vec<_>>();
174
175 matches.extend(
176 available_context_picker_entries(&prompt_store, &thread_store, &workspace, cx)
177 .into_iter()
178 .map(|mode| {
179 Match::Entry(EntryMatch {
180 entry: mode,
181 mat: None,
182 })
183 }),
184 );
185
186 Task::ready(matches)
187 } else {
188 let executor = cx.background_executor().clone();
189
190 let search_files_task =
191 search_files(query.clone(), cancellation_flag, &workspace, cx);
192
193 let entries =
194 available_context_picker_entries(&prompt_store, &thread_store, &workspace, cx);
195 let entry_candidates = entries
196 .iter()
197 .enumerate()
198 .map(|(ix, entry)| StringMatchCandidate::new(ix, entry.keyword()))
199 .collect::<Vec<_>>();
200
201 cx.background_spawn(async move {
202 let mut matches = search_files_task
203 .await
204 .into_iter()
205 .map(Match::File)
206 .collect::<Vec<_>>();
207
208 let entry_matches = fuzzy::match_strings(
209 &entry_candidates,
210 &query,
211 false,
212 true,
213 100,
214 &Arc::new(AtomicBool::default()),
215 executor,
216 )
217 .await;
218
219 matches.extend(entry_matches.into_iter().map(|mat| {
220 Match::Entry(EntryMatch {
221 entry: entries[mat.candidate_id],
222 mat: Some(mat),
223 })
224 }));
225
226 matches.sort_by(|a, b| {
227 b.score()
228 .partial_cmp(&a.score())
229 .unwrap_or(std::cmp::Ordering::Equal)
230 });
231
232 matches
233 })
234 }
235 }
236 }
237}
238
239pub struct ContextPickerCompletionProvider {
240 workspace: WeakEntity<Workspace>,
241 context_store: WeakEntity<ContextStore>,
242 thread_store: Option<WeakEntity<ThreadStore>>,
243 text_thread_store: Option<WeakEntity<TextThreadStore>>,
244 editor: WeakEntity<Editor>,
245 excluded_buffer: Option<WeakEntity<Buffer>>,
246}
247
248impl ContextPickerCompletionProvider {
249 pub fn new(
250 workspace: WeakEntity<Workspace>,
251 context_store: WeakEntity<ContextStore>,
252 thread_store: Option<WeakEntity<ThreadStore>>,
253 text_thread_store: Option<WeakEntity<TextThreadStore>>,
254 editor: WeakEntity<Editor>,
255 exclude_buffer: Option<WeakEntity<Buffer>>,
256 ) -> Self {
257 Self {
258 workspace,
259 context_store,
260 thread_store,
261 text_thread_store,
262 editor,
263 excluded_buffer: exclude_buffer,
264 }
265 }
266
267 fn completion_for_entry(
268 entry: ContextPickerEntry,
269 excerpt_id: ExcerptId,
270 source_range: Range<Anchor>,
271 editor: Entity<Editor>,
272 context_store: Entity<ContextStore>,
273 workspace: &Entity<Workspace>,
274 cx: &mut App,
275 ) -> Option<Completion> {
276 match entry {
277 ContextPickerEntry::Mode(mode) => Some(Completion {
278 replace_range: source_range,
279 new_text: format!("@{} ", mode.keyword()),
280 label: CodeLabel::plain(mode.label().to_string(), None),
281 icon_path: Some(mode.icon().path().into()),
282 documentation: None,
283 source: project::CompletionSource::Custom,
284 insert_text_mode: None,
285 // This ensures that when a user accepts this completion, the
286 // completion menu will still be shown after "@category " is
287 // inserted
288 confirm: Some(Arc::new(|_, _, _| true)),
289 }),
290 ContextPickerEntry::Action(action) => {
291 let (new_text, on_action) = match action {
292 ContextPickerAction::AddSelections => {
293 let selections = selection_ranges(workspace, cx);
294
295 let selection_infos = selections
296 .iter()
297 .map(|(buffer, range)| {
298 let full_path = buffer
299 .read(cx)
300 .file()
301 .map(|file| file.full_path(cx))
302 .unwrap_or_else(|| PathBuf::from("untitled"));
303 let file_name = full_path
304 .file_name()
305 .unwrap_or_default()
306 .to_string_lossy()
307 .to_string();
308 let line_range = range.to_point(&buffer.read(cx).snapshot());
309
310 let link = MentionLink::for_selection(
311 &file_name,
312 &full_path.to_string_lossy(),
313 line_range.start.row as usize..line_range.end.row as usize,
314 );
315 (file_name, link, line_range)
316 })
317 .collect::<Vec<_>>();
318
319 let new_text = format!(
320 "{} ",
321 selection_infos.iter().map(|(_, link, _)| link).join(" ")
322 );
323
324 let callback = Arc::new({
325 move |_, window: &mut Window, cx: &mut App| {
326 context_store.update(cx, |context_store, cx| {
327 for (buffer, range) in &selections {
328 context_store.add_selection(
329 buffer.clone(),
330 range.clone(),
331 cx,
332 );
333 }
334 });
335
336 let editor = editor.clone();
337 let selection_infos = selection_infos.clone();
338 window.defer(cx, move |window, cx| {
339 let mut current_offset = 0;
340 for (file_name, link, line_range) in selection_infos.iter() {
341 let snapshot =
342 editor.read(cx).buffer().read(cx).snapshot(cx);
343 let Some(start) = snapshot
344 .anchor_in_excerpt(excerpt_id, source_range.start)
345 else {
346 return;
347 };
348
349 let offset = start.to_offset(&snapshot) + current_offset;
350 let text_len = link.len();
351
352 let range = snapshot.anchor_after(offset)
353 ..snapshot.anchor_after(offset + text_len);
354
355 let crease = super::crease_for_mention(
356 format!(
357 "{} ({}-{})",
358 file_name,
359 line_range.start.row + 1,
360 line_range.end.row + 1
361 )
362 .into(),
363 IconName::Reader.path().into(),
364 range,
365 editor.downgrade(),
366 );
367
368 editor.update(cx, |editor, cx| {
369 editor.insert_creases(vec![crease.clone()], cx);
370 editor.fold_creases(vec![crease], false, window, cx);
371 });
372
373 current_offset += text_len + 1;
374 }
375 });
376
377 false
378 }
379 });
380
381 (new_text, callback)
382 }
383 };
384
385 Some(Completion {
386 replace_range: source_range.clone(),
387 new_text,
388 label: CodeLabel::plain(action.label().to_string(), None),
389 icon_path: Some(action.icon().path().into()),
390 documentation: None,
391 source: project::CompletionSource::Custom,
392 insert_text_mode: None,
393 // This ensures that when a user accepts this completion, the
394 // completion menu will still be shown after "@category " is
395 // inserted
396 confirm: Some(on_action),
397 })
398 }
399 }
400 }
401
402 fn completion_for_thread(
403 thread_entry: ThreadContextEntry,
404 excerpt_id: ExcerptId,
405 source_range: Range<Anchor>,
406 recent: bool,
407 editor: Entity<Editor>,
408 context_store: Entity<ContextStore>,
409 thread_store: Entity<ThreadStore>,
410 text_thread_store: Entity<TextThreadStore>,
411 ) -> Completion {
412 let icon_for_completion = if recent {
413 IconName::HistoryRerun
414 } else {
415 IconName::Thread
416 };
417 let new_text = format!("{} ", MentionLink::for_thread(&thread_entry));
418 let new_text_len = new_text.len();
419 Completion {
420 replace_range: source_range.clone(),
421 new_text,
422 label: CodeLabel::plain(thread_entry.title().to_string(), None),
423 documentation: None,
424 insert_text_mode: None,
425 source: project::CompletionSource::Custom,
426 icon_path: Some(icon_for_completion.path().into()),
427 confirm: Some(confirm_completion_callback(
428 IconName::Thread.path().into(),
429 thread_entry.title().clone(),
430 excerpt_id,
431 source_range.start,
432 new_text_len - 1,
433 editor,
434 context_store.clone(),
435 move |window, cx| match &thread_entry {
436 ThreadContextEntry::Thread { id, .. } => {
437 let thread_id = id.clone();
438 let context_store = context_store.clone();
439 let thread_store = thread_store.clone();
440 window.spawn::<_, Option<_>>(cx, async move |cx| {
441 let thread: Entity<Thread> = thread_store
442 .update_in(cx, |thread_store, window, cx| {
443 thread_store.open_thread(&thread_id, window, cx)
444 })
445 .ok()?
446 .await
447 .log_err()?;
448 let context = context_store
449 .update(cx, |context_store, cx| {
450 context_store.add_thread(thread, false, cx)
451 })
452 .ok()??;
453 Some(context)
454 })
455 }
456 ThreadContextEntry::Context { path, .. } => {
457 let path = path.clone();
458 let context_store = context_store.clone();
459 let text_thread_store = text_thread_store.clone();
460 cx.spawn::<_, Option<_>>(async move |cx| {
461 let thread = text_thread_store
462 .update(cx, |store, cx| store.open_local_context(path, cx))
463 .ok()?
464 .await
465 .log_err()?;
466 let context = context_store
467 .update(cx, |context_store, cx| {
468 context_store.add_text_thread(thread, false, cx)
469 })
470 .ok()??;
471 Some(context)
472 })
473 }
474 },
475 )),
476 }
477 }
478
479 fn completion_for_rules(
480 rules: RulesContextEntry,
481 excerpt_id: ExcerptId,
482 source_range: Range<Anchor>,
483 editor: Entity<Editor>,
484 context_store: Entity<ContextStore>,
485 ) -> Completion {
486 let new_text = format!("{} ", MentionLink::for_rule(&rules));
487 let new_text_len = new_text.len();
488 Completion {
489 replace_range: source_range.clone(),
490 new_text,
491 label: CodeLabel::plain(rules.title.to_string(), None),
492 documentation: None,
493 insert_text_mode: None,
494 source: project::CompletionSource::Custom,
495 icon_path: Some(RULES_ICON.path().into()),
496 confirm: Some(confirm_completion_callback(
497 RULES_ICON.path().into(),
498 rules.title.clone(),
499 excerpt_id,
500 source_range.start,
501 new_text_len - 1,
502 editor,
503 context_store.clone(),
504 move |_, cx| {
505 let user_prompt_id = rules.prompt_id;
506 let context = context_store.update(cx, |context_store, cx| {
507 context_store.add_rules(user_prompt_id, false, cx)
508 });
509 Task::ready(context)
510 },
511 )),
512 }
513 }
514
515 fn completion_for_fetch(
516 source_range: Range<Anchor>,
517 url_to_fetch: SharedString,
518 excerpt_id: ExcerptId,
519 editor: Entity<Editor>,
520 context_store: Entity<ContextStore>,
521 http_client: Arc<HttpClientWithUrl>,
522 ) -> Completion {
523 let new_text = format!("{} ", MentionLink::for_fetch(&url_to_fetch));
524 let new_text_len = new_text.len();
525 Completion {
526 replace_range: source_range.clone(),
527 new_text,
528 label: CodeLabel::plain(url_to_fetch.to_string(), None),
529 documentation: None,
530 source: project::CompletionSource::Custom,
531 icon_path: Some(IconName::ToolWeb.path().into()),
532 insert_text_mode: None,
533 confirm: Some(confirm_completion_callback(
534 IconName::ToolWeb.path().into(),
535 url_to_fetch.clone(),
536 excerpt_id,
537 source_range.start,
538 new_text_len - 1,
539 editor,
540 context_store.clone(),
541 move |_, cx| {
542 let context_store = context_store.clone();
543 let http_client = http_client.clone();
544 let url_to_fetch = url_to_fetch.clone();
545 cx.spawn(async move |cx| {
546 if let Some(context) = context_store
547 .read_with(cx, |context_store, _| {
548 context_store.get_url_context(url_to_fetch.clone())
549 })
550 .ok()?
551 {
552 return Some(context);
553 }
554 let content = cx
555 .background_spawn(fetch_url_content(
556 http_client,
557 url_to_fetch.to_string(),
558 ))
559 .await
560 .log_err()?;
561 context_store
562 .update(cx, |context_store, cx| {
563 context_store.add_fetched_url(url_to_fetch.to_string(), content, cx)
564 })
565 .ok()
566 })
567 },
568 )),
569 }
570 }
571
572 fn completion_for_path(
573 project_path: ProjectPath,
574 path_prefix: &str,
575 is_recent: bool,
576 is_directory: bool,
577 excerpt_id: ExcerptId,
578 source_range: Range<Anchor>,
579 editor: Entity<Editor>,
580 context_store: Entity<ContextStore>,
581 cx: &App,
582 ) -> Completion {
583 let (file_name, directory) = super::file_context_picker::extract_file_name_and_directory(
584 &project_path.path,
585 path_prefix,
586 );
587
588 let label =
589 build_code_label_for_full_path(&file_name, directory.as_ref().map(|s| s.as_ref()), cx);
590 let full_path = if let Some(directory) = directory {
591 format!("{}{}", directory, file_name)
592 } else {
593 file_name.to_string()
594 };
595
596 let crease_icon_path = if is_directory {
597 FileIcons::get_folder_icon(false, cx).unwrap_or_else(|| IconName::Folder.path().into())
598 } else {
599 FileIcons::get_icon(Path::new(&full_path), cx)
600 .unwrap_or_else(|| IconName::File.path().into())
601 };
602 let completion_icon_path = if is_recent {
603 IconName::HistoryRerun.path().into()
604 } else {
605 crease_icon_path.clone()
606 };
607
608 let new_text = format!("{} ", MentionLink::for_file(&file_name, &full_path));
609 let new_text_len = new_text.len();
610 Completion {
611 replace_range: source_range.clone(),
612 new_text,
613 label,
614 documentation: None,
615 source: project::CompletionSource::Custom,
616 icon_path: Some(completion_icon_path),
617 insert_text_mode: None,
618 confirm: Some(confirm_completion_callback(
619 crease_icon_path,
620 file_name,
621 excerpt_id,
622 source_range.start,
623 new_text_len - 1,
624 editor,
625 context_store.clone(),
626 move |_, cx| {
627 if is_directory {
628 Task::ready(
629 context_store
630 .update(cx, |context_store, cx| {
631 context_store.add_directory(&project_path, false, cx)
632 })
633 .log_err()
634 .flatten(),
635 )
636 } else {
637 let result = context_store.update(cx, |context_store, cx| {
638 context_store.add_file_from_path(project_path.clone(), false, cx)
639 });
640 cx.spawn(async move |_| result.await.log_err().flatten())
641 }
642 },
643 )),
644 }
645 }
646
647 fn completion_for_symbol(
648 symbol: Symbol,
649 excerpt_id: ExcerptId,
650 source_range: Range<Anchor>,
651 editor: Entity<Editor>,
652 context_store: Entity<ContextStore>,
653 workspace: Entity<Workspace>,
654 cx: &mut App,
655 ) -> Option<Completion> {
656 let path_prefix = workspace
657 .read(cx)
658 .project()
659 .read(cx)
660 .worktree_for_id(symbol.path.worktree_id, cx)?
661 .read(cx)
662 .root_name();
663
664 let (file_name, directory) = super::file_context_picker::extract_file_name_and_directory(
665 &symbol.path.path,
666 path_prefix,
667 );
668 let full_path = if let Some(directory) = directory {
669 format!("{}{}", directory, file_name)
670 } else {
671 file_name.to_string()
672 };
673
674 let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId);
675 let mut label = CodeLabel::plain(symbol.name.clone(), None);
676 label.push_str(" ", None);
677 label.push_str(&file_name, comment_id);
678 label.push_str(&format!(" L{}", symbol.range.start.0.row + 1), comment_id);
679
680 let new_text = format!("{} ", MentionLink::for_symbol(&symbol.name, &full_path));
681 let new_text_len = new_text.len();
682 Some(Completion {
683 replace_range: source_range.clone(),
684 new_text,
685 label,
686 documentation: None,
687 source: project::CompletionSource::Custom,
688 icon_path: Some(IconName::Code.path().into()),
689 insert_text_mode: None,
690 confirm: Some(confirm_completion_callback(
691 IconName::Code.path().into(),
692 symbol.name.clone().into(),
693 excerpt_id,
694 source_range.start,
695 new_text_len - 1,
696 editor,
697 context_store.clone(),
698 move |_, cx| {
699 let symbol = symbol.clone();
700 let context_store = context_store.clone();
701 let workspace = workspace.clone();
702 let result = super::symbol_context_picker::add_symbol(
703 symbol,
704 false,
705 workspace,
706 context_store.downgrade(),
707 cx,
708 );
709 cx.spawn(async move |_| result.await.log_err()?.0)
710 },
711 )),
712 })
713 }
714}
715
716fn build_code_label_for_full_path(file_name: &str, directory: Option<&str>, cx: &App) -> CodeLabel {
717 let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId);
718 let mut label = CodeLabel::default();
719
720 label.push_str(file_name, None);
721 label.push_str(" ", None);
722
723 if let Some(directory) = directory {
724 label.push_str(directory, comment_id);
725 }
726
727 label.filter_range = 0..label.text().len();
728
729 label
730}
731
732impl CompletionProvider for ContextPickerCompletionProvider {
733 fn completions(
734 &self,
735 excerpt_id: ExcerptId,
736 buffer: &Entity<Buffer>,
737 buffer_position: Anchor,
738 _trigger: CompletionContext,
739 _window: &mut Window,
740 cx: &mut Context<Editor>,
741 ) -> Task<Result<Vec<CompletionResponse>>> {
742 let state = buffer.update(cx, |buffer, _cx| {
743 let position = buffer_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 let line = lines.next()?;
748 MentionCompletion::try_parse(line, offset_to_line)
749 });
750 let Some(state) = state else {
751 return Task::ready(Ok(Vec::new()));
752 };
753
754 let Some((workspace, context_store)) =
755 self.workspace.upgrade().zip(self.context_store.upgrade())
756 else {
757 return Task::ready(Ok(Vec::new()));
758 };
759
760 let snapshot = buffer.read(cx).snapshot();
761 let source_range = snapshot.anchor_before(state.source_range.start)
762 ..snapshot.anchor_after(state.source_range.end);
763
764 let thread_store = self.thread_store.clone();
765 let text_thread_store = self.text_thread_store.clone();
766 let editor = self.editor.clone();
767 let http_client = workspace.read(cx).client().http_client();
768
769 let MentionCompletion { mode, argument, .. } = state;
770 let query = argument.unwrap_or_else(|| "".to_string());
771
772 let excluded_path = self
773 .excluded_buffer
774 .as_ref()
775 .and_then(WeakEntity::upgrade)
776 .and_then(|b| b.read(cx).file())
777 .map(|file| ProjectPath::from_file(file.as_ref(), cx));
778
779 let recent_entries = recent_context_picker_entries_with_store(
780 context_store.clone(),
781 thread_store.clone(),
782 text_thread_store.clone(),
783 workspace.clone(),
784 excluded_path.clone(),
785 cx,
786 );
787
788 let prompt_store = thread_store.as_ref().and_then(|thread_store| {
789 thread_store
790 .read_with(cx, |thread_store, _cx| thread_store.prompt_store().clone())
791 .ok()
792 .flatten()
793 });
794
795 let search_task = search(
796 mode,
797 query,
798 Arc::<AtomicBool>::default(),
799 recent_entries,
800 prompt_store,
801 thread_store.clone(),
802 text_thread_store.clone(),
803 workspace.clone(),
804 cx,
805 );
806
807 cx.spawn(async move |_, cx| {
808 let matches = search_task.await;
809 let Some(editor) = editor.upgrade() else {
810 return Ok(Vec::new());
811 };
812
813 let completions = cx.update(|cx| {
814 matches
815 .into_iter()
816 .filter_map(|mat| match mat {
817 Match::File(FileMatch { mat, is_recent }) => {
818 let project_path = ProjectPath {
819 worktree_id: WorktreeId::from_usize(mat.worktree_id),
820 path: mat.path.clone(),
821 };
822
823 if excluded_path.as_ref() == Some(&project_path) {
824 return None;
825 }
826
827 Some(Self::completion_for_path(
828 project_path,
829 &mat.path_prefix,
830 is_recent,
831 mat.is_dir,
832 excerpt_id,
833 source_range.clone(),
834 editor.clone(),
835 context_store.clone(),
836 cx,
837 ))
838 }
839
840 Match::Symbol(SymbolMatch { symbol, .. }) => Self::completion_for_symbol(
841 symbol,
842 excerpt_id,
843 source_range.clone(),
844 editor.clone(),
845 context_store.clone(),
846 workspace.clone(),
847 cx,
848 ),
849
850 Match::Thread(ThreadMatch {
851 thread, is_recent, ..
852 }) => {
853 let thread_store = thread_store.as_ref().and_then(|t| t.upgrade())?;
854 let text_thread_store =
855 text_thread_store.as_ref().and_then(|t| t.upgrade())?;
856 Some(Self::completion_for_thread(
857 thread,
858 excerpt_id,
859 source_range.clone(),
860 is_recent,
861 editor.clone(),
862 context_store.clone(),
863 thread_store,
864 text_thread_store,
865 ))
866 }
867
868 Match::Rules(user_rules) => Some(Self::completion_for_rules(
869 user_rules,
870 excerpt_id,
871 source_range.clone(),
872 editor.clone(),
873 context_store.clone(),
874 )),
875
876 Match::Fetch(url) => Some(Self::completion_for_fetch(
877 source_range.clone(),
878 url,
879 excerpt_id,
880 editor.clone(),
881 context_store.clone(),
882 http_client.clone(),
883 )),
884
885 Match::Entry(EntryMatch { entry, .. }) => Self::completion_for_entry(
886 entry,
887 excerpt_id,
888 source_range.clone(),
889 editor.clone(),
890 context_store.clone(),
891 &workspace,
892 cx,
893 ),
894 })
895 .collect()
896 })?;
897
898 Ok(vec![CompletionResponse {
899 completions,
900 // Since this does its own filtering (see `filter_completions()` returns false),
901 // there is no benefit to computing whether this set of completions is incomplete.
902 is_incomplete: true,
903 }])
904 })
905 }
906
907 fn is_completion_trigger(
908 &self,
909 buffer: &Entity<language::Buffer>,
910 position: language::Anchor,
911 _text: &str,
912 _trigger_in_words: bool,
913 _menu_is_open: bool,
914 cx: &mut Context<Editor>,
915 ) -> bool {
916 let buffer = buffer.read(cx);
917 let position = position.to_point(buffer);
918 let line_start = Point::new(position.row, 0);
919 let offset_to_line = buffer.point_to_offset(line_start);
920 let mut lines = buffer.text_for_range(line_start..position).lines();
921 if let Some(line) = lines.next() {
922 MentionCompletion::try_parse(line, offset_to_line)
923 .map(|completion| {
924 completion.source_range.start <= offset_to_line + position.column as usize
925 && completion.source_range.end >= offset_to_line + position.column as usize
926 })
927 .unwrap_or(false)
928 } else {
929 false
930 }
931 }
932
933 fn sort_completions(&self) -> bool {
934 false
935 }
936
937 fn filter_completions(&self) -> bool {
938 false
939 }
940}
941
942fn confirm_completion_callback(
943 crease_icon_path: SharedString,
944 crease_text: SharedString,
945 excerpt_id: ExcerptId,
946 start: Anchor,
947 content_len: usize,
948 editor: Entity<Editor>,
949 context_store: Entity<ContextStore>,
950 add_context_fn: impl Fn(&mut Window, &mut App) -> Task<Option<AgentContextHandle>>
951 + Send
952 + Sync
953 + 'static,
954) -> Arc<dyn Fn(CompletionIntent, &mut Window, &mut App) -> bool + Send + Sync> {
955 Arc::new(move |_, window, cx| {
956 let context = add_context_fn(window, cx);
957
958 let crease_text = crease_text.clone();
959 let crease_icon_path = crease_icon_path.clone();
960 let editor = editor.clone();
961 let context_store = context_store.clone();
962 window.defer(cx, move |window, cx| {
963 let crease_id = crate::context_picker::insert_crease_for_mention(
964 excerpt_id,
965 start,
966 content_len,
967 crease_text.clone(),
968 crease_icon_path,
969 editor.clone(),
970 window,
971 cx,
972 );
973 cx.spawn(async move |cx| {
974 let crease_id = crease_id?;
975 let context = context.await?;
976 editor
977 .update(cx, |editor, cx| {
978 if let Some(addon) = editor.addon_mut::<ContextCreasesAddon>() {
979 addon.add_creases(
980 &context_store,
981 AgentContextKey(context),
982 [(crease_id, crease_text)],
983 cx,
984 );
985 }
986 })
987 .ok()
988 })
989 .detach();
990 });
991 false
992 })
993}
994
995#[derive(Debug, Default, PartialEq)]
996struct MentionCompletion {
997 source_range: Range<usize>,
998 mode: Option<ContextPickerMode>,
999 argument: Option<String>,
1000}
1001
1002impl MentionCompletion {
1003 fn try_parse(line: &str, offset_to_line: usize) -> Option<Self> {
1004 let last_mention_start = line.rfind('@')?;
1005 if last_mention_start >= line.len() {
1006 return Some(Self::default());
1007 }
1008 if last_mention_start > 0
1009 && line
1010 .chars()
1011 .nth(last_mention_start - 1)
1012 .is_some_and(|c| !c.is_whitespace())
1013 {
1014 return None;
1015 }
1016
1017 let rest_of_line = &line[last_mention_start + 1..];
1018
1019 let mut mode = None;
1020 let mut argument = None;
1021
1022 let mut parts = rest_of_line.split_whitespace();
1023 let mut end = last_mention_start + 1;
1024 if let Some(mode_text) = parts.next() {
1025 end += mode_text.len();
1026
1027 if let Some(parsed_mode) = ContextPickerMode::try_from(mode_text).ok() {
1028 mode = Some(parsed_mode);
1029 } else {
1030 argument = Some(mode_text.to_string());
1031 }
1032 match rest_of_line[mode_text.len()..].find(|c: char| !c.is_whitespace()) {
1033 Some(whitespace_count) => {
1034 if let Some(argument_text) = parts.next() {
1035 argument = Some(argument_text.to_string());
1036 end += whitespace_count + argument_text.len();
1037 }
1038 }
1039 None => {
1040 // Rest of line is entirely whitespace
1041 end += rest_of_line.len() - mode_text.len();
1042 }
1043 }
1044 }
1045
1046 Some(Self {
1047 source_range: last_mention_start + offset_to_line..end + offset_to_line,
1048 mode,
1049 argument,
1050 })
1051 }
1052}
1053
1054#[cfg(test)]
1055mod tests {
1056 use super::*;
1057 use editor::AnchorRangeExt;
1058 use gpui::{EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext};
1059 use project::{Project, ProjectPath};
1060 use serde_json::json;
1061 use settings::SettingsStore;
1062 use std::{ops::Deref, rc::Rc};
1063 use util::path;
1064 use workspace::{AppState, Item};
1065
1066 #[test]
1067 fn test_mention_completion_parse() {
1068 assert_eq!(MentionCompletion::try_parse("Lorem Ipsum", 0), None);
1069
1070 assert_eq!(
1071 MentionCompletion::try_parse("Lorem @", 0),
1072 Some(MentionCompletion {
1073 source_range: 6..7,
1074 mode: None,
1075 argument: None,
1076 })
1077 );
1078
1079 assert_eq!(
1080 MentionCompletion::try_parse("Lorem @file", 0),
1081 Some(MentionCompletion {
1082 source_range: 6..11,
1083 mode: Some(ContextPickerMode::File),
1084 argument: None,
1085 })
1086 );
1087
1088 assert_eq!(
1089 MentionCompletion::try_parse("Lorem @file ", 0),
1090 Some(MentionCompletion {
1091 source_range: 6..12,
1092 mode: Some(ContextPickerMode::File),
1093 argument: None,
1094 })
1095 );
1096
1097 assert_eq!(
1098 MentionCompletion::try_parse("Lorem @file main.rs", 0),
1099 Some(MentionCompletion {
1100 source_range: 6..19,
1101 mode: Some(ContextPickerMode::File),
1102 argument: Some("main.rs".to_string()),
1103 })
1104 );
1105
1106 assert_eq!(
1107 MentionCompletion::try_parse("Lorem @file main.rs ", 0),
1108 Some(MentionCompletion {
1109 source_range: 6..19,
1110 mode: Some(ContextPickerMode::File),
1111 argument: Some("main.rs".to_string()),
1112 })
1113 );
1114
1115 assert_eq!(
1116 MentionCompletion::try_parse("Lorem @file main.rs Ipsum", 0),
1117 Some(MentionCompletion {
1118 source_range: 6..19,
1119 mode: Some(ContextPickerMode::File),
1120 argument: Some("main.rs".to_string()),
1121 })
1122 );
1123
1124 assert_eq!(
1125 MentionCompletion::try_parse("Lorem @main", 0),
1126 Some(MentionCompletion {
1127 source_range: 6..11,
1128 mode: None,
1129 argument: Some("main".to_string()),
1130 })
1131 );
1132
1133 assert_eq!(MentionCompletion::try_parse("test@", 0), None);
1134 }
1135
1136 struct AtMentionEditor(Entity<Editor>);
1137
1138 impl Item for AtMentionEditor {
1139 type Event = ();
1140
1141 fn include_in_nav_history() -> bool {
1142 false
1143 }
1144
1145 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1146 "Test".into()
1147 }
1148 }
1149
1150 impl EventEmitter<()> for AtMentionEditor {}
1151
1152 impl Focusable for AtMentionEditor {
1153 fn focus_handle(&self, cx: &App) -> FocusHandle {
1154 self.0.read(cx).focus_handle(cx)
1155 }
1156 }
1157
1158 impl Render for AtMentionEditor {
1159 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1160 self.0.clone().into_any_element()
1161 }
1162 }
1163
1164 #[gpui::test]
1165 async fn test_context_completion_provider(cx: &mut TestAppContext) {
1166 init_test(cx);
1167
1168 let app_state = cx.update(AppState::test);
1169
1170 cx.update(|cx| {
1171 language::init(cx);
1172 editor::init(cx);
1173 workspace::init(app_state.clone(), cx);
1174 Project::init_settings(cx);
1175 });
1176
1177 app_state
1178 .fs
1179 .as_fake()
1180 .insert_tree(
1181 path!("/dir"),
1182 json!({
1183 "editor": "",
1184 "a": {
1185 "one.txt": "",
1186 "two.txt": "",
1187 "three.txt": "",
1188 "four.txt": ""
1189 },
1190 "b": {
1191 "five.txt": "",
1192 "six.txt": "",
1193 "seven.txt": "",
1194 "eight.txt": "",
1195 }
1196 }),
1197 )
1198 .await;
1199
1200 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
1201 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1202 let workspace = window.root(cx).unwrap();
1203
1204 let worktree = project.update(cx, |project, cx| {
1205 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
1206 assert_eq!(worktrees.len(), 1);
1207 worktrees.pop().unwrap()
1208 });
1209 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
1210
1211 let mut cx = VisualTestContext::from_window(*window.deref(), cx);
1212
1213 let paths = vec![
1214 path!("a/one.txt"),
1215 path!("a/two.txt"),
1216 path!("a/three.txt"),
1217 path!("a/four.txt"),
1218 path!("b/five.txt"),
1219 path!("b/six.txt"),
1220 path!("b/seven.txt"),
1221 path!("b/eight.txt"),
1222 ];
1223
1224 let mut opened_editors = Vec::new();
1225 for path in paths {
1226 let buffer = workspace
1227 .update_in(&mut cx, |workspace, window, cx| {
1228 workspace.open_path(
1229 ProjectPath {
1230 worktree_id,
1231 path: Path::new(path).into(),
1232 },
1233 None,
1234 false,
1235 window,
1236 cx,
1237 )
1238 })
1239 .await
1240 .unwrap();
1241 opened_editors.push(buffer);
1242 }
1243
1244 let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
1245 let editor = cx.new(|cx| {
1246 Editor::new(
1247 editor::EditorMode::full(),
1248 multi_buffer::MultiBuffer::build_simple("", cx),
1249 None,
1250 window,
1251 cx,
1252 )
1253 });
1254 workspace.active_pane().update(cx, |pane, cx| {
1255 pane.add_item(
1256 Box::new(cx.new(|_| AtMentionEditor(editor.clone()))),
1257 true,
1258 true,
1259 None,
1260 window,
1261 cx,
1262 );
1263 });
1264 editor
1265 });
1266
1267 let context_store = cx.new(|_| ContextStore::new(project.downgrade(), None));
1268
1269 let editor_entity = editor.downgrade();
1270 editor.update_in(&mut cx, |editor, window, cx| {
1271 let last_opened_buffer = opened_editors.last().and_then(|editor| {
1272 editor
1273 .downcast::<Editor>()?
1274 .read(cx)
1275 .buffer()
1276 .read(cx)
1277 .as_singleton()
1278 .as_ref()
1279 .map(Entity::downgrade)
1280 });
1281 window.focus(&editor.focus_handle(cx));
1282 editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new(
1283 workspace.downgrade(),
1284 context_store.downgrade(),
1285 None,
1286 None,
1287 editor_entity,
1288 last_opened_buffer,
1289 ))));
1290 });
1291
1292 cx.simulate_input("Lorem ");
1293
1294 editor.update(&mut cx, |editor, cx| {
1295 assert_eq!(editor.text(cx), "Lorem ");
1296 assert!(!editor.has_visible_completions_menu());
1297 });
1298
1299 cx.simulate_input("@");
1300
1301 editor.update(&mut cx, |editor, cx| {
1302 assert_eq!(editor.text(cx), "Lorem @");
1303 assert!(editor.has_visible_completions_menu());
1304 assert_eq!(
1305 current_completion_labels(editor),
1306 &[
1307 "seven.txt dir/b/",
1308 "six.txt dir/b/",
1309 "five.txt dir/b/",
1310 "four.txt dir/a/",
1311 "Files & Directories",
1312 "Symbols",
1313 "Fetch"
1314 ]
1315 );
1316 });
1317
1318 // Select and confirm "File"
1319 editor.update_in(&mut cx, |editor, window, cx| {
1320 assert!(editor.has_visible_completions_menu());
1321 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1322 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1323 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1324 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1325 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1326 });
1327
1328 cx.run_until_parked();
1329
1330 editor.update(&mut cx, |editor, cx| {
1331 assert_eq!(editor.text(cx), "Lorem @file ");
1332 assert!(editor.has_visible_completions_menu());
1333 });
1334
1335 cx.simulate_input("one");
1336
1337 editor.update(&mut cx, |editor, cx| {
1338 assert_eq!(editor.text(cx), "Lorem @file one");
1339 assert!(editor.has_visible_completions_menu());
1340 assert_eq!(current_completion_labels(editor), vec!["one.txt dir/a/"]);
1341 });
1342
1343 editor.update_in(&mut cx, |editor, window, cx| {
1344 assert!(editor.has_visible_completions_menu());
1345 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1346 });
1347
1348 editor.update(&mut cx, |editor, cx| {
1349 assert_eq!(editor.text(cx), "Lorem [@one.txt](@file:dir/a/one.txt) ");
1350 assert!(!editor.has_visible_completions_menu());
1351 assert_eq!(
1352 fold_ranges(editor, cx),
1353 vec![Point::new(0, 6)..Point::new(0, 37)]
1354 );
1355 });
1356
1357 cx.simulate_input(" ");
1358
1359 editor.update(&mut cx, |editor, cx| {
1360 assert_eq!(editor.text(cx), "Lorem [@one.txt](@file:dir/a/one.txt) ");
1361 assert!(!editor.has_visible_completions_menu());
1362 assert_eq!(
1363 fold_ranges(editor, cx),
1364 vec![Point::new(0, 6)..Point::new(0, 37)]
1365 );
1366 });
1367
1368 cx.simulate_input("Ipsum ");
1369
1370 editor.update(&mut cx, |editor, cx| {
1371 assert_eq!(
1372 editor.text(cx),
1373 "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum ",
1374 );
1375 assert!(!editor.has_visible_completions_menu());
1376 assert_eq!(
1377 fold_ranges(editor, cx),
1378 vec![Point::new(0, 6)..Point::new(0, 37)]
1379 );
1380 });
1381
1382 cx.simulate_input("@file ");
1383
1384 editor.update(&mut cx, |editor, cx| {
1385 assert_eq!(
1386 editor.text(cx),
1387 "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum @file ",
1388 );
1389 assert!(editor.has_visible_completions_menu());
1390 assert_eq!(
1391 fold_ranges(editor, cx),
1392 vec![Point::new(0, 6)..Point::new(0, 37)]
1393 );
1394 });
1395
1396 editor.update_in(&mut cx, |editor, window, cx| {
1397 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1398 });
1399
1400 cx.run_until_parked();
1401
1402 editor.update(&mut cx, |editor, cx| {
1403 assert_eq!(
1404 editor.text(cx),
1405 "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@seven.txt](@file:dir/b/seven.txt) "
1406 );
1407 assert!(!editor.has_visible_completions_menu());
1408 assert_eq!(
1409 fold_ranges(editor, cx),
1410 vec![
1411 Point::new(0, 6)..Point::new(0, 37),
1412 Point::new(0, 45)..Point::new(0, 80)
1413 ]
1414 );
1415 });
1416
1417 cx.simulate_input("\n@");
1418
1419 editor.update(&mut cx, |editor, cx| {
1420 assert_eq!(
1421 editor.text(cx),
1422 "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@seven.txt](@file:dir/b/seven.txt) \n@"
1423 );
1424 assert!(editor.has_visible_completions_menu());
1425 assert_eq!(
1426 fold_ranges(editor, cx),
1427 vec![
1428 Point::new(0, 6)..Point::new(0, 37),
1429 Point::new(0, 45)..Point::new(0, 80)
1430 ]
1431 );
1432 });
1433
1434 editor.update_in(&mut cx, |editor, window, cx| {
1435 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1436 });
1437
1438 cx.run_until_parked();
1439
1440 editor.update(&mut cx, |editor, cx| {
1441 assert_eq!(
1442 editor.text(cx),
1443 "Lorem [@one.txt](@file:dir/a/one.txt) Ipsum [@seven.txt](@file:dir/b/seven.txt) \n[@six.txt](@file:dir/b/six.txt) "
1444 );
1445 assert!(!editor.has_visible_completions_menu());
1446 assert_eq!(
1447 fold_ranges(editor, cx),
1448 vec![
1449 Point::new(0, 6)..Point::new(0, 37),
1450 Point::new(0, 45)..Point::new(0, 80),
1451 Point::new(1, 0)..Point::new(1, 31)
1452 ]
1453 );
1454 });
1455 }
1456
1457 fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec<Range<Point>> {
1458 let snapshot = editor.buffer().read(cx).snapshot(cx);
1459 editor.display_map.update(cx, |display_map, cx| {
1460 display_map
1461 .snapshot(cx)
1462 .folds_in_range(0..snapshot.len())
1463 .map(|fold| fold.range.to_point(&snapshot))
1464 .collect()
1465 })
1466 }
1467
1468 fn current_completion_labels(editor: &Editor) -> Vec<String> {
1469 let completions = editor.current_completions().expect("Missing completions");
1470 completions
1471 .into_iter()
1472 .map(|completion| completion.label.text)
1473 .collect::<Vec<_>>()
1474 }
1475
1476 pub(crate) fn init_test(cx: &mut TestAppContext) {
1477 cx.update(|cx| {
1478 let store = SettingsStore::test(cx);
1479 cx.set_global(store);
1480 theme::init(theme::LoadThemes::JustBase, cx);
1481 client::init_settings(cx);
1482 language::init(cx);
1483 Project::init_settings(cx);
1484 workspace::init_settings(cx);
1485 editor::init_settings(cx);
1486 });
1487 }
1488}