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