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