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