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}