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