completion_provider.rs

  1use std::ops::Range;
  2use std::path::{Path, PathBuf};
  3use std::sync::Arc;
  4use std::sync::atomic::AtomicBool;
  5
  6use acp_thread::MentionUri;
  7use anyhow::{Context as _, Result};
  8use collections::HashMap;
  9use editor::display_map::CreaseId;
 10use editor::{CompletionProvider, Editor, ExcerptId};
 11use file_icons::FileIcons;
 12use futures::future::try_join_all;
 13use gpui::{App, Entity, Task, WeakEntity};
 14use language::{Buffer, CodeLabel, HighlightId};
 15use lsp::CompletionContext;
 16use parking_lot::Mutex;
 17use project::{Completion, CompletionIntent, CompletionResponse, Project, ProjectPath, WorktreeId};
 18use rope::Point;
 19use text::{Anchor, ToPoint};
 20use ui::prelude::*;
 21use workspace::Workspace;
 22
 23use crate::context_picker::MentionLink;
 24use crate::context_picker::file_context_picker::{extract_file_name_and_directory, search_files};
 25
 26#[derive(Default)]
 27pub struct MentionSet {
 28    paths_by_crease_id: HashMap<CreaseId, MentionUri>,
 29}
 30
 31impl MentionSet {
 32    pub fn insert(&mut self, crease_id: CreaseId, path: PathBuf) {
 33        self.paths_by_crease_id
 34            .insert(crease_id, MentionUri::File(path));
 35    }
 36
 37    pub fn drain(&mut self) -> impl Iterator<Item = CreaseId> {
 38        self.paths_by_crease_id.drain().map(|(id, _)| id)
 39    }
 40
 41    pub fn clear(&mut self) {
 42        self.paths_by_crease_id.clear();
 43    }
 44
 45    pub fn contents(
 46        &self,
 47        project: Entity<Project>,
 48        cx: &mut App,
 49    ) -> Task<Result<HashMap<CreaseId, Mention>>> {
 50        let contents = self
 51            .paths_by_crease_id
 52            .iter()
 53            .map(|(crease_id, uri)| match uri {
 54                MentionUri::File(path) => {
 55                    let crease_id = *crease_id;
 56                    let uri = uri.clone();
 57                    let path = path.to_path_buf();
 58                    let buffer_task = project.update(cx, |project, cx| {
 59                        let path = project
 60                            .find_project_path(path, cx)
 61                            .context("Failed to find project path")?;
 62                        anyhow::Ok(project.open_buffer(path, cx))
 63                    });
 64
 65                    cx.spawn(async move |cx| {
 66                        let buffer = buffer_task?.await?;
 67                        let content = buffer.read_with(cx, |buffer, _cx| buffer.text())?;
 68
 69                        anyhow::Ok((crease_id, Mention { uri, content }))
 70                    })
 71                }
 72                _ => {
 73                    // TODO
 74                    unimplemented!()
 75                }
 76            })
 77            .collect::<Vec<_>>();
 78
 79        cx.spawn(async move |_cx| {
 80            let contents = try_join_all(contents).await?.into_iter().collect();
 81            anyhow::Ok(contents)
 82        })
 83    }
 84}
 85
 86pub struct Mention {
 87    pub uri: MentionUri,
 88    pub content: String,
 89}
 90
 91pub struct ContextPickerCompletionProvider {
 92    workspace: WeakEntity<Workspace>,
 93    editor: WeakEntity<Editor>,
 94    mention_set: Arc<Mutex<MentionSet>>,
 95}
 96
 97impl ContextPickerCompletionProvider {
 98    pub fn new(
 99        mention_set: Arc<Mutex<MentionSet>>,
100        workspace: WeakEntity<Workspace>,
101        editor: WeakEntity<Editor>,
102    ) -> Self {
103        Self {
104            mention_set,
105            workspace,
106            editor,
107        }
108    }
109
110    pub(crate) fn completion_for_path(
111        project_path: ProjectPath,
112        path_prefix: &str,
113        is_recent: bool,
114        is_directory: bool,
115        excerpt_id: ExcerptId,
116        source_range: Range<Anchor>,
117        editor: Entity<Editor>,
118        mention_set: Arc<Mutex<MentionSet>>,
119        project: Entity<Project>,
120        cx: &App,
121    ) -> Completion {
122        let (file_name, directory) =
123            extract_file_name_and_directory(&project_path.path, path_prefix);
124
125        let label =
126            build_code_label_for_full_path(&file_name, directory.as_ref().map(|s| s.as_ref()), cx);
127        let full_path = if let Some(directory) = directory {
128            format!("{}{}", directory, file_name)
129        } else {
130            file_name.to_string()
131        };
132
133        let crease_icon_path = if is_directory {
134            FileIcons::get_folder_icon(false, cx).unwrap_or_else(|| IconName::Folder.path().into())
135        } else {
136            FileIcons::get_icon(Path::new(&full_path), cx)
137                .unwrap_or_else(|| IconName::File.path().into())
138        };
139        let completion_icon_path = if is_recent {
140            IconName::HistoryRerun.path().into()
141        } else {
142            crease_icon_path.clone()
143        };
144
145        let new_text = format!("{} ", MentionLink::for_file(&file_name, &full_path));
146        let new_text_len = new_text.len();
147        Completion {
148            replace_range: source_range.clone(),
149            new_text,
150            label,
151            documentation: None,
152            source: project::CompletionSource::Custom,
153            icon_path: Some(completion_icon_path),
154            insert_text_mode: None,
155            confirm: Some(confirm_completion_callback(
156                crease_icon_path,
157                file_name,
158                project_path,
159                excerpt_id,
160                source_range.start,
161                new_text_len - 1,
162                editor,
163                mention_set,
164                project,
165            )),
166        }
167    }
168}
169
170fn build_code_label_for_full_path(file_name: &str, directory: Option<&str>, cx: &App) -> CodeLabel {
171    let comment_id = cx.theme().syntax().highlight_id("comment").map(HighlightId);
172    let mut label = CodeLabel::default();
173
174    label.push_str(&file_name, None);
175    label.push_str(" ", None);
176
177    if let Some(directory) = directory {
178        label.push_str(&directory, comment_id);
179    }
180
181    label.filter_range = 0..label.text().len();
182
183    label
184}
185
186impl CompletionProvider for ContextPickerCompletionProvider {
187    fn completions(
188        &self,
189        excerpt_id: ExcerptId,
190        buffer: &Entity<Buffer>,
191        buffer_position: Anchor,
192        _trigger: CompletionContext,
193        _window: &mut Window,
194        cx: &mut Context<Editor>,
195    ) -> Task<Result<Vec<CompletionResponse>>> {
196        let state = buffer.update(cx, |buffer, _cx| {
197            let position = buffer_position.to_point(buffer);
198            let line_start = Point::new(position.row, 0);
199            let offset_to_line = buffer.point_to_offset(line_start);
200            let mut lines = buffer.text_for_range(line_start..position).lines();
201            let line = lines.next()?;
202            MentionCompletion::try_parse(line, offset_to_line)
203        });
204        let Some(state) = state else {
205            return Task::ready(Ok(Vec::new()));
206        };
207
208        let Some(workspace) = self.workspace.upgrade() else {
209            return Task::ready(Ok(Vec::new()));
210        };
211
212        let project = workspace.read(cx).project().clone();
213        let snapshot = buffer.read(cx).snapshot();
214        let source_range = snapshot.anchor_before(state.source_range.start)
215            ..snapshot.anchor_after(state.source_range.end);
216
217        let editor = self.editor.clone();
218        let mention_set = self.mention_set.clone();
219        let MentionCompletion { argument, .. } = state;
220        let query = argument.unwrap_or_else(|| "".to_string());
221
222        let search_task = search_files(query.clone(), Arc::<AtomicBool>::default(), &workspace, cx);
223
224        cx.spawn(async move |_, cx| {
225            let matches = search_task.await;
226            let Some(editor) = editor.upgrade() else {
227                return Ok(Vec::new());
228            };
229
230            let completions = cx.update(|cx| {
231                matches
232                    .into_iter()
233                    .map(|mat| {
234                        let path_match = &mat.mat;
235                        let project_path = ProjectPath {
236                            worktree_id: WorktreeId::from_usize(path_match.worktree_id),
237                            path: path_match.path.clone(),
238                        };
239
240                        Self::completion_for_path(
241                            project_path,
242                            &path_match.path_prefix,
243                            mat.is_recent,
244                            path_match.is_dir,
245                            excerpt_id,
246                            source_range.clone(),
247                            editor.clone(),
248                            mention_set.clone(),
249                            project.clone(),
250                            cx,
251                        )
252                    })
253                    .collect()
254            })?;
255
256            Ok(vec![CompletionResponse {
257                completions,
258                // Since this does its own filtering (see `filter_completions()` returns false),
259                // there is no benefit to computing whether this set of completions is incomplete.
260                is_incomplete: true,
261            }])
262        })
263    }
264
265    fn is_completion_trigger(
266        &self,
267        buffer: &Entity<language::Buffer>,
268        position: language::Anchor,
269        _text: &str,
270        _trigger_in_words: bool,
271        _menu_is_open: bool,
272        cx: &mut Context<Editor>,
273    ) -> bool {
274        let buffer = buffer.read(cx);
275        let position = position.to_point(buffer);
276        let line_start = Point::new(position.row, 0);
277        let offset_to_line = buffer.point_to_offset(line_start);
278        let mut lines = buffer.text_for_range(line_start..position).lines();
279        if let Some(line) = lines.next() {
280            MentionCompletion::try_parse(line, offset_to_line)
281                .map(|completion| {
282                    completion.source_range.start <= offset_to_line + position.column as usize
283                        && completion.source_range.end >= offset_to_line + position.column as usize
284                })
285                .unwrap_or(false)
286        } else {
287            false
288        }
289    }
290
291    fn sort_completions(&self) -> bool {
292        false
293    }
294
295    fn filter_completions(&self) -> bool {
296        false
297    }
298}
299
300fn confirm_completion_callback(
301    crease_icon_path: SharedString,
302    crease_text: SharedString,
303    project_path: ProjectPath,
304    excerpt_id: ExcerptId,
305    start: Anchor,
306    content_len: usize,
307    editor: Entity<Editor>,
308    mention_set: Arc<Mutex<MentionSet>>,
309    project: Entity<Project>,
310) -> Arc<dyn Fn(CompletionIntent, &mut Window, &mut App) -> bool + Send + Sync> {
311    Arc::new(move |_, window, cx| {
312        let crease_text = crease_text.clone();
313        let crease_icon_path = crease_icon_path.clone();
314        let editor = editor.clone();
315        let project_path = project_path.clone();
316        let mention_set = mention_set.clone();
317        let project = project.clone();
318        window.defer(cx, move |window, cx| {
319            let crease_id = crate::context_picker::insert_crease_for_mention(
320                excerpt_id,
321                start,
322                content_len,
323                crease_text.clone(),
324                crease_icon_path,
325                editor.clone(),
326                window,
327                cx,
328            );
329
330            let Some(path) = project.read(cx).absolute_path(&project_path, cx) else {
331                return;
332            };
333
334            if let Some(crease_id) = crease_id {
335                mention_set.lock().insert(crease_id, path);
336            }
337        });
338        false
339    })
340}
341
342#[derive(Debug, Default, PartialEq)]
343struct MentionCompletion {
344    source_range: Range<usize>,
345    argument: Option<String>,
346}
347
348impl MentionCompletion {
349    fn try_parse(line: &str, offset_to_line: usize) -> Option<Self> {
350        let last_mention_start = line.rfind('@')?;
351        if last_mention_start >= line.len() {
352            return Some(Self::default());
353        }
354        if last_mention_start > 0
355            && line
356                .chars()
357                .nth(last_mention_start - 1)
358                .map_or(false, |c| !c.is_whitespace())
359        {
360            return None;
361        }
362
363        let rest_of_line = &line[last_mention_start + 1..];
364        let mut argument = None;
365
366        let mut parts = rest_of_line.split_whitespace();
367        let mut end = last_mention_start + 1;
368        if let Some(argument_text) = parts.next() {
369            end += argument_text.len();
370            argument = Some(argument_text.to_string());
371        }
372
373        Some(Self {
374            source_range: last_mention_start + offset_to_line..end + offset_to_line,
375            argument,
376        })
377    }
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383    use gpui::{EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext};
384    use project::{Project, ProjectPath};
385    use serde_json::json;
386    use settings::SettingsStore;
387    use std::{ops::Deref, rc::Rc};
388    use util::path;
389    use workspace::{AppState, Item};
390
391    #[test]
392    fn test_mention_completion_parse() {
393        assert_eq!(MentionCompletion::try_parse("Lorem Ipsum", 0), None);
394
395        assert_eq!(
396            MentionCompletion::try_parse("Lorem @", 0),
397            Some(MentionCompletion {
398                source_range: 6..7,
399                argument: None,
400            })
401        );
402
403        assert_eq!(
404            MentionCompletion::try_parse("Lorem @main", 0),
405            Some(MentionCompletion {
406                source_range: 6..11,
407                argument: Some("main".to_string()),
408            })
409        );
410
411        assert_eq!(MentionCompletion::try_parse("test@", 0), None);
412    }
413
414    struct AtMentionEditor(Entity<Editor>);
415
416    impl Item for AtMentionEditor {
417        type Event = ();
418
419        fn include_in_nav_history() -> bool {
420            false
421        }
422
423        fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
424            "Test".into()
425        }
426    }
427
428    impl EventEmitter<()> for AtMentionEditor {}
429
430    impl Focusable for AtMentionEditor {
431        fn focus_handle(&self, cx: &App) -> FocusHandle {
432            self.0.read(cx).focus_handle(cx).clone()
433        }
434    }
435
436    impl Render for AtMentionEditor {
437        fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
438            self.0.clone().into_any_element()
439        }
440    }
441
442    #[gpui::test]
443    async fn test_context_completion_provider(cx: &mut TestAppContext) {
444        init_test(cx);
445
446        let app_state = cx.update(AppState::test);
447
448        cx.update(|cx| {
449            language::init(cx);
450            editor::init(cx);
451            workspace::init(app_state.clone(), cx);
452            Project::init_settings(cx);
453        });
454
455        app_state
456            .fs
457            .as_fake()
458            .insert_tree(
459                path!("/dir"),
460                json!({
461                    "editor": "",
462                    "a": {
463                        "one.txt": "",
464                        "two.txt": "",
465                        "three.txt": "",
466                        "four.txt": ""
467                    },
468                    "b": {
469                        "five.txt": "",
470                        "six.txt": "",
471                        "seven.txt": "",
472                        "eight.txt": "",
473                    }
474                }),
475            )
476            .await;
477
478        let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
479        let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
480        let workspace = window.root(cx).unwrap();
481
482        let worktree = project.update(cx, |project, cx| {
483            let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
484            assert_eq!(worktrees.len(), 1);
485            worktrees.pop().unwrap()
486        });
487        let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
488
489        let mut cx = VisualTestContext::from_window(*window.deref(), cx);
490
491        let paths = vec![
492            path!("a/one.txt"),
493            path!("a/two.txt"),
494            path!("a/three.txt"),
495            path!("a/four.txt"),
496            path!("b/five.txt"),
497            path!("b/six.txt"),
498            path!("b/seven.txt"),
499            path!("b/eight.txt"),
500        ];
501
502        let mut opened_editors = Vec::new();
503        for path in paths {
504            let buffer = workspace
505                .update_in(&mut cx, |workspace, window, cx| {
506                    workspace.open_path(
507                        ProjectPath {
508                            worktree_id,
509                            path: Path::new(path).into(),
510                        },
511                        None,
512                        false,
513                        window,
514                        cx,
515                    )
516                })
517                .await
518                .unwrap();
519            opened_editors.push(buffer);
520        }
521
522        let editor = workspace.update_in(&mut cx, |workspace, window, cx| {
523            let editor = cx.new(|cx| {
524                Editor::new(
525                    editor::EditorMode::full(),
526                    multi_buffer::MultiBuffer::build_simple("", cx),
527                    None,
528                    window,
529                    cx,
530                )
531            });
532            workspace.active_pane().update(cx, |pane, cx| {
533                pane.add_item(
534                    Box::new(cx.new(|_| AtMentionEditor(editor.clone()))),
535                    true,
536                    true,
537                    None,
538                    window,
539                    cx,
540                );
541            });
542            editor
543        });
544
545        let mention_set = Arc::new(Mutex::new(MentionSet::default()));
546
547        let editor_entity = editor.downgrade();
548        editor.update_in(&mut cx, |editor, window, cx| {
549            window.focus(&editor.focus_handle(cx));
550            editor.set_completion_provider(Some(Rc::new(ContextPickerCompletionProvider::new(
551                mention_set.clone(),
552                workspace.downgrade(),
553                editor_entity,
554            ))));
555        });
556
557        cx.simulate_input("Lorem ");
558
559        editor.update(&mut cx, |editor, cx| {
560            assert_eq!(editor.text(cx), "Lorem ");
561            assert!(!editor.has_visible_completions_menu());
562        });
563
564        cx.simulate_input("@");
565
566        editor.update(&mut cx, |editor, cx| {
567            assert_eq!(editor.text(cx), "Lorem @");
568            assert!(editor.has_visible_completions_menu());
569            assert_eq!(
570                current_completion_labels(editor),
571                &[
572                    "eight.txt dir/b/",
573                    "seven.txt dir/b/",
574                    "six.txt dir/b/",
575                    "five.txt dir/b/",
576                    "four.txt dir/a/",
577                    "three.txt dir/a/",
578                    "two.txt dir/a/",
579                    "one.txt dir/a/",
580                    "dir ",
581                    "a dir/",
582                    "four.txt dir/a/",
583                    "one.txt dir/a/",
584                    "three.txt dir/a/",
585                    "two.txt dir/a/",
586                    "b dir/",
587                    "eight.txt dir/b/",
588                    "five.txt dir/b/",
589                    "seven.txt dir/b/",
590                    "six.txt dir/b/",
591                    "editor dir/"
592                ]
593            );
594        });
595
596        // Select and confirm "File"
597        editor.update_in(&mut cx, |editor, window, cx| {
598            assert!(editor.has_visible_completions_menu());
599            editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
600            editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
601            editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
602            editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
603            editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
604        });
605
606        cx.run_until_parked();
607
608        editor.update(&mut cx, |editor, cx| {
609            assert_eq!(editor.text(cx), "Lorem [@four.txt](@file:dir/a/four.txt) ");
610        });
611    }
612
613    fn current_completion_labels(editor: &Editor) -> Vec<String> {
614        let completions = editor.current_completions().expect("Missing completions");
615        completions
616            .into_iter()
617            .map(|completion| completion.label.text.to_string())
618            .collect::<Vec<_>>()
619    }
620
621    pub(crate) fn init_test(cx: &mut TestAppContext) {
622        cx.update(|cx| {
623            let store = SettingsStore::test(cx);
624            cx.set_global(store);
625            theme::init(theme::LoadThemes::JustBase, cx);
626            client::init_settings(cx);
627            language::init(cx);
628            Project::init_settings(cx);
629            workspace::init_settings(cx);
630            editor::init_settings(cx);
631        });
632    }
633}