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