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