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