1use crate::{
2 acp::completion_provider::ContextPickerCompletionProvider,
3 context_picker::fetch_context_picker::fetch_url_content,
4};
5use acp_thread::{MentionUri, selection_name};
6use agent::{TextThreadStore, ThreadId, ThreadStore};
7use agent_client_protocol as acp;
8use anyhow::{Context as _, Result, anyhow};
9use collections::{HashMap, HashSet};
10use editor::{
11 Anchor, AnchorRangeExt, ContextMenuOptions, ContextMenuPlacement, Editor, EditorElement,
12 EditorMode, EditorStyle, ExcerptId, FoldPlaceholder, MultiBuffer, ToOffset,
13 actions::Paste,
14 display_map::{Crease, CreaseId, FoldId},
15};
16use futures::{
17 FutureExt as _, TryFutureExt as _,
18 future::{Shared, try_join_all},
19};
20use gpui::{
21 AppContext, ClipboardEntry, Context, Entity, EventEmitter, FocusHandle, Focusable, Image,
22 ImageFormat, Img, Task, TextStyle, WeakEntity,
23};
24use language::{Buffer, Language};
25use language_model::LanguageModelImage;
26use project::{CompletionIntent, Project};
27use rope::Point;
28use settings::Settings;
29use std::{
30 ffi::OsStr,
31 fmt::Write,
32 ops::Range,
33 path::{Path, PathBuf},
34 rc::Rc,
35 sync::Arc,
36};
37use text::OffsetRangeExt;
38use theme::ThemeSettings;
39use ui::{
40 ActiveTheme, AnyElement, App, ButtonCommon, ButtonLike, ButtonStyle, Color, Icon, IconName,
41 IconSize, InteractiveElement, IntoElement, Label, LabelCommon, LabelSize, ParentElement,
42 Render, SelectableButton, SharedString, Styled, TextSize, TintColor, Toggleable, Window, div,
43 h_flex,
44};
45use url::Url;
46use util::ResultExt;
47use workspace::{Workspace, notifications::NotifyResultExt as _};
48use zed_actions::agent::Chat;
49
50pub struct MessageEditor {
51 mention_set: MentionSet,
52 editor: Entity<Editor>,
53 project: Entity<Project>,
54 workspace: WeakEntity<Workspace>,
55 thread_store: Entity<ThreadStore>,
56 text_thread_store: Entity<TextThreadStore>,
57}
58
59#[derive(Clone, Copy)]
60pub enum MessageEditorEvent {
61 Send,
62 Cancel,
63 Focus,
64}
65
66impl EventEmitter<MessageEditorEvent> for MessageEditor {}
67
68impl MessageEditor {
69 pub fn new(
70 workspace: WeakEntity<Workspace>,
71 project: Entity<Project>,
72 thread_store: Entity<ThreadStore>,
73 text_thread_store: Entity<TextThreadStore>,
74 placeholder: impl Into<Arc<str>>,
75 mode: EditorMode,
76 window: &mut Window,
77 cx: &mut Context<Self>,
78 ) -> Self {
79 let language = Language::new(
80 language::LanguageConfig {
81 completion_query_characters: HashSet::from_iter(['.', '-', '_', '@']),
82 ..Default::default()
83 },
84 None,
85 );
86 let completion_provider = ContextPickerCompletionProvider::new(
87 workspace.clone(),
88 thread_store.downgrade(),
89 text_thread_store.downgrade(),
90 cx.weak_entity(),
91 );
92 let mention_set = MentionSet::default();
93 let editor = cx.new(|cx| {
94 let buffer = cx.new(|cx| Buffer::local("", cx).with_language(Arc::new(language), cx));
95 let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
96
97 let mut editor = Editor::new(mode, buffer, None, window, cx);
98 editor.set_placeholder_text(placeholder, cx);
99 editor.set_show_indent_guides(false, cx);
100 editor.set_soft_wrap();
101 editor.set_use_modal_editing(true);
102 editor.set_completion_provider(Some(Rc::new(completion_provider)));
103 editor.set_context_menu_options(ContextMenuOptions {
104 min_entries_visible: 12,
105 max_entries_visible: 12,
106 placement: Some(ContextMenuPlacement::Above),
107 });
108 editor
109 });
110
111 cx.on_focus(&editor.focus_handle(cx), window, |_, _, cx| {
112 cx.emit(MessageEditorEvent::Focus)
113 })
114 .detach();
115
116 Self {
117 editor,
118 project,
119 mention_set,
120 thread_store,
121 text_thread_store,
122 workspace,
123 }
124 }
125
126 #[cfg(test)]
127 pub(crate) fn editor(&self) -> &Entity<Editor> {
128 &self.editor
129 }
130
131 #[cfg(test)]
132 pub(crate) fn mention_set(&mut self) -> &mut MentionSet {
133 &mut self.mention_set
134 }
135
136 pub fn is_empty(&self, cx: &App) -> bool {
137 self.editor.read(cx).is_empty(cx)
138 }
139
140 pub fn mentioned_path_and_threads(&self) -> (HashSet<PathBuf>, HashSet<ThreadId>) {
141 let mut excluded_paths = HashSet::default();
142 let mut excluded_threads = HashSet::default();
143
144 for uri in self.mention_set.uri_by_crease_id.values() {
145 match uri {
146 MentionUri::File { abs_path, .. } => {
147 excluded_paths.insert(abs_path.clone());
148 }
149 MentionUri::Thread { id, .. } => {
150 excluded_threads.insert(id.clone());
151 }
152 _ => {}
153 }
154 }
155
156 (excluded_paths, excluded_threads)
157 }
158
159 pub fn confirm_completion(
160 &mut self,
161 crease_text: SharedString,
162 start: text::Anchor,
163 content_len: usize,
164 mention_uri: MentionUri,
165 window: &mut Window,
166 cx: &mut Context<Self>,
167 ) {
168 let snapshot = self
169 .editor
170 .update(cx, |editor, cx| editor.snapshot(window, cx));
171 let Some((excerpt_id, _, _)) = snapshot.buffer_snapshot.as_singleton() else {
172 return;
173 };
174 let Some(anchor) = snapshot
175 .buffer_snapshot
176 .anchor_in_excerpt(*excerpt_id, start)
177 else {
178 return;
179 };
180
181 if let MentionUri::File { abs_path, .. } = &mention_uri {
182 let extension = abs_path
183 .extension()
184 .and_then(OsStr::to_str)
185 .unwrap_or_default();
186
187 if Img::extensions().contains(&extension) && !extension.contains("svg") {
188 let project = self.project.clone();
189 let Some(project_path) = project
190 .read(cx)
191 .project_path_for_absolute_path(abs_path, cx)
192 else {
193 return;
194 };
195 let image = cx
196 .spawn(async move |_, cx| {
197 let image = project
198 .update(cx, |project, cx| project.open_image(project_path, cx))
199 .map_err(|e| e.to_string())?
200 .await
201 .map_err(|e| e.to_string())?;
202 image
203 .read_with(cx, |image, _cx| image.image.clone())
204 .map_err(|e| e.to_string())
205 })
206 .shared();
207 let Some(crease_id) = insert_crease_for_image(
208 *excerpt_id,
209 start,
210 content_len,
211 Some(abs_path.as_path().into()),
212 image.clone(),
213 self.editor.clone(),
214 window,
215 cx,
216 ) else {
217 return;
218 };
219 self.confirm_mention_for_image(
220 crease_id,
221 anchor,
222 Some(abs_path.clone()),
223 image,
224 window,
225 cx,
226 );
227 return;
228 }
229 }
230
231 let Some(crease_id) = crate::context_picker::insert_crease_for_mention(
232 *excerpt_id,
233 start,
234 content_len,
235 crease_text.clone(),
236 mention_uri.icon_path(cx),
237 self.editor.clone(),
238 window,
239 cx,
240 ) else {
241 return;
242 };
243
244 match mention_uri {
245 MentionUri::Fetch { url } => {
246 self.confirm_mention_for_fetch(crease_id, anchor, url, window, cx);
247 }
248 MentionUri::Thread { id, name } => {
249 self.confirm_mention_for_thread(crease_id, anchor, id, name, window, cx);
250 }
251 MentionUri::TextThread { path, name } => {
252 self.confirm_mention_for_text_thread(crease_id, anchor, path, name, window, cx);
253 }
254 MentionUri::File { .. }
255 | MentionUri::Symbol { .. }
256 | MentionUri::Rule { .. }
257 | MentionUri::Selection { .. } => {
258 self.mention_set.insert_uri(crease_id, mention_uri.clone());
259 }
260 }
261 }
262
263 fn confirm_mention_for_fetch(
264 &mut self,
265 crease_id: CreaseId,
266 anchor: Anchor,
267 url: url::Url,
268 window: &mut Window,
269 cx: &mut Context<Self>,
270 ) {
271 let Some(http_client) = self
272 .workspace
273 .update(cx, |workspace, _cx| workspace.client().http_client())
274 .ok()
275 else {
276 return;
277 };
278
279 let url_string = url.to_string();
280 let fetch = cx
281 .background_executor()
282 .spawn(async move {
283 fetch_url_content(http_client, url_string)
284 .map_err(|e| e.to_string())
285 .await
286 })
287 .shared();
288 self.mention_set
289 .add_fetch_result(url.clone(), fetch.clone());
290
291 cx.spawn_in(window, async move |this, cx| {
292 let fetch = fetch.await.notify_async_err(cx);
293 this.update(cx, |this, cx| {
294 let mention_uri = MentionUri::Fetch { url };
295 if fetch.is_some() {
296 this.mention_set.insert_uri(crease_id, mention_uri.clone());
297 } else {
298 // Remove crease if we failed to fetch
299 this.editor.update(cx, |editor, cx| {
300 editor.display_map.update(cx, |display_map, cx| {
301 display_map.unfold_intersecting(vec![anchor..anchor], true, cx);
302 });
303 editor.remove_creases([crease_id], cx);
304 });
305 }
306 })
307 .ok();
308 })
309 .detach();
310 }
311
312 pub fn confirm_mention_for_selection(
313 &mut self,
314 source_range: Range<text::Anchor>,
315 selections: Vec<(Entity<Buffer>, Range<text::Anchor>, Range<usize>)>,
316 window: &mut Window,
317 cx: &mut Context<Self>,
318 ) {
319 let snapshot = self.editor.read(cx).buffer().read(cx).snapshot(cx);
320 let Some((&excerpt_id, _, _)) = snapshot.as_singleton() else {
321 return;
322 };
323 let Some(start) = snapshot.anchor_in_excerpt(excerpt_id, source_range.start) else {
324 return;
325 };
326
327 let offset = start.to_offset(&snapshot);
328
329 for (buffer, selection_range, range_to_fold) in selections {
330 let range = snapshot.anchor_after(offset + range_to_fold.start)
331 ..snapshot.anchor_after(offset + range_to_fold.end);
332
333 let path = buffer
334 .read(cx)
335 .file()
336 .map_or(PathBuf::from("untitled"), |file| file.path().to_path_buf());
337 let snapshot = buffer.read(cx).snapshot();
338
339 let point_range = selection_range.to_point(&snapshot);
340 let line_range = point_range.start.row..point_range.end.row;
341
342 let uri = MentionUri::Selection {
343 path: path.clone(),
344 line_range: line_range.clone(),
345 };
346 let crease = crate::context_picker::crease_for_mention(
347 selection_name(&path, &line_range).into(),
348 uri.icon_path(cx),
349 range,
350 self.editor.downgrade(),
351 );
352
353 let crease_id = self.editor.update(cx, |editor, cx| {
354 let crease_ids = editor.insert_creases(vec![crease.clone()], cx);
355 editor.fold_creases(vec![crease], false, window, cx);
356 crease_ids.first().copied().unwrap()
357 });
358
359 self.mention_set
360 .insert_uri(crease_id, MentionUri::Selection { path, line_range });
361 }
362 }
363
364 pub fn contents(
365 &self,
366 window: &mut Window,
367 cx: &mut Context<Self>,
368 ) -> Task<Result<Vec<acp::ContentBlock>>> {
369 let contents =
370 self.mention_set
371 .contents(self.project.clone(), self.thread_store.clone(), window, cx);
372 let editor = self.editor.clone();
373
374 cx.spawn(async move |_, cx| {
375 let contents = contents.await?;
376
377 editor.update(cx, |editor, cx| {
378 let mut ix = 0;
379 let mut chunks: Vec<acp::ContentBlock> = Vec::new();
380 let text = editor.text(cx);
381 editor.display_map.update(cx, |map, cx| {
382 let snapshot = map.snapshot(cx);
383 for (crease_id, crease) in snapshot.crease_snapshot.creases() {
384 // Skip creases that have been edited out of the message buffer.
385 if !crease.range().start.is_valid(&snapshot.buffer_snapshot) {
386 continue;
387 }
388
389 let Some(mention) = contents.get(&crease_id) else {
390 continue;
391 };
392
393 let crease_range = crease.range().to_offset(&snapshot.buffer_snapshot);
394 if crease_range.start > ix {
395 chunks.push(text[ix..crease_range.start].into());
396 }
397 let chunk = match mention {
398 Mention::Text { uri, content } => {
399 acp::ContentBlock::Resource(acp::EmbeddedResource {
400 annotations: None,
401 resource: acp::EmbeddedResourceResource::TextResourceContents(
402 acp::TextResourceContents {
403 mime_type: None,
404 text: content.clone(),
405 uri: uri.to_uri().to_string(),
406 },
407 ),
408 })
409 }
410 Mention::Image(mention_image) => {
411 acp::ContentBlock::Image(acp::ImageContent {
412 annotations: None,
413 data: mention_image.data.to_string(),
414 mime_type: mention_image.format.mime_type().into(),
415 uri: mention_image
416 .abs_path
417 .as_ref()
418 .map(|path| format!("file://{}", path.display())),
419 })
420 }
421 };
422 chunks.push(chunk);
423 ix = crease_range.end;
424 }
425
426 if ix < text.len() {
427 let last_chunk = text[ix..].trim_end();
428 if !last_chunk.is_empty() {
429 chunks.push(last_chunk.into());
430 }
431 }
432 });
433
434 chunks
435 })
436 })
437 }
438
439 pub fn clear(&mut self, window: &mut Window, cx: &mut Context<Self>) {
440 self.editor.update(cx, |editor, cx| {
441 editor.clear(window, cx);
442 editor.remove_creases(self.mention_set.drain(), cx)
443 });
444 }
445
446 fn send(&mut self, _: &Chat, _: &mut Window, cx: &mut Context<Self>) {
447 cx.emit(MessageEditorEvent::Send)
448 }
449
450 fn cancel(&mut self, _: &editor::actions::Cancel, _: &mut Window, cx: &mut Context<Self>) {
451 cx.emit(MessageEditorEvent::Cancel)
452 }
453
454 fn paste(&mut self, _: &Paste, window: &mut Window, cx: &mut Context<Self>) {
455 let images = cx
456 .read_from_clipboard()
457 .map(|item| {
458 item.into_entries()
459 .filter_map(|entry| {
460 if let ClipboardEntry::Image(image) = entry {
461 Some(image)
462 } else {
463 None
464 }
465 })
466 .collect::<Vec<_>>()
467 })
468 .unwrap_or_default();
469
470 if images.is_empty() {
471 return;
472 }
473 cx.stop_propagation();
474
475 let replacement_text = "image";
476 for image in images {
477 let (excerpt_id, text_anchor, multibuffer_anchor) =
478 self.editor.update(cx, |message_editor, cx| {
479 let snapshot = message_editor.snapshot(window, cx);
480 let (excerpt_id, _, buffer_snapshot) =
481 snapshot.buffer_snapshot.as_singleton().unwrap();
482
483 let text_anchor = buffer_snapshot.anchor_before(buffer_snapshot.len());
484 let multibuffer_anchor = snapshot
485 .buffer_snapshot
486 .anchor_in_excerpt(*excerpt_id, text_anchor);
487 message_editor.edit(
488 [(
489 multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
490 format!("{replacement_text} "),
491 )],
492 cx,
493 );
494 (*excerpt_id, text_anchor, multibuffer_anchor)
495 });
496
497 let content_len = replacement_text.len();
498 let Some(anchor) = multibuffer_anchor else {
499 return;
500 };
501 let task = Task::ready(Ok(Arc::new(image))).shared();
502 let Some(crease_id) = insert_crease_for_image(
503 excerpt_id,
504 text_anchor,
505 content_len,
506 None.clone(),
507 task.clone(),
508 self.editor.clone(),
509 window,
510 cx,
511 ) else {
512 return;
513 };
514 self.confirm_mention_for_image(crease_id, anchor, None, task, window, cx);
515 }
516 }
517
518 pub fn insert_dragged_files(
519 &self,
520 paths: Vec<project::ProjectPath>,
521 window: &mut Window,
522 cx: &mut Context<Self>,
523 ) {
524 let buffer = self.editor.read(cx).buffer().clone();
525 let Some(buffer) = buffer.read(cx).as_singleton() else {
526 return;
527 };
528 for path in paths {
529 let Some(entry) = self.project.read(cx).entry_for_path(&path, cx) else {
530 continue;
531 };
532 let Some(abs_path) = self.project.read(cx).absolute_path(&path, cx) else {
533 continue;
534 };
535
536 let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len()));
537 let path_prefix = abs_path
538 .file_name()
539 .unwrap_or(path.path.as_os_str())
540 .display()
541 .to_string();
542 let Some(completion) = ContextPickerCompletionProvider::completion_for_path(
543 path,
544 &path_prefix,
545 false,
546 entry.is_dir(),
547 anchor..anchor,
548 cx.weak_entity(),
549 self.project.clone(),
550 cx,
551 ) else {
552 continue;
553 };
554
555 self.editor.update(cx, |message_editor, cx| {
556 message_editor.edit(
557 [(
558 multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
559 completion.new_text,
560 )],
561 cx,
562 );
563 });
564 if let Some(confirm) = completion.confirm.clone() {
565 confirm(CompletionIntent::Complete, window, cx);
566 }
567 }
568 }
569
570 pub fn set_read_only(&mut self, read_only: bool, cx: &mut Context<Self>) {
571 self.editor.update(cx, |message_editor, cx| {
572 message_editor.set_read_only(read_only);
573 cx.notify()
574 })
575 }
576
577 fn confirm_mention_for_image(
578 &mut self,
579 crease_id: CreaseId,
580 anchor: Anchor,
581 abs_path: Option<PathBuf>,
582 image: Shared<Task<Result<Arc<Image>, String>>>,
583 window: &mut Window,
584 cx: &mut Context<Self>,
585 ) {
586 let editor = self.editor.clone();
587 let task = cx
588 .spawn_in(window, {
589 let abs_path = abs_path.clone();
590 async move |_, cx| {
591 let image = image.await.map_err(|e| e.to_string())?;
592 let format = image.format;
593 let image = cx
594 .update(|_, cx| LanguageModelImage::from_image(image, cx))
595 .map_err(|e| e.to_string())?
596 .await;
597 if let Some(image) = image {
598 Ok(MentionImage {
599 abs_path,
600 data: image.source,
601 format,
602 })
603 } else {
604 Err("Failed to convert image".into())
605 }
606 }
607 })
608 .shared();
609
610 self.mention_set.insert_image(crease_id, task.clone());
611
612 cx.spawn_in(window, async move |this, cx| {
613 if task.await.notify_async_err(cx).is_some() {
614 if let Some(abs_path) = abs_path.clone() {
615 this.update(cx, |this, _cx| {
616 this.mention_set.insert_uri(
617 crease_id,
618 MentionUri::File {
619 abs_path,
620 is_directory: false,
621 },
622 );
623 })
624 .ok();
625 }
626 } else {
627 editor
628 .update(cx, |editor, cx| {
629 editor.display_map.update(cx, |display_map, cx| {
630 display_map.unfold_intersecting(vec![anchor..anchor], true, cx);
631 });
632 editor.remove_creases([crease_id], cx);
633 })
634 .ok();
635 }
636 })
637 .detach();
638 }
639
640 fn confirm_mention_for_thread(
641 &mut self,
642 crease_id: CreaseId,
643 anchor: Anchor,
644 id: ThreadId,
645 name: String,
646 window: &mut Window,
647 cx: &mut Context<Self>,
648 ) {
649 let uri = MentionUri::Thread {
650 id: id.clone(),
651 name,
652 };
653 let open_task = self.thread_store.update(cx, |thread_store, cx| {
654 thread_store.open_thread(&id, window, cx)
655 });
656 let task = cx
657 .spawn(async move |_, cx| {
658 let thread = open_task.await.map_err(|e| e.to_string())?;
659 let content = thread
660 .read_with(cx, |thread, _cx| thread.latest_detailed_summary_or_text())
661 .map_err(|e| e.to_string())?;
662 Ok(content)
663 })
664 .shared();
665
666 self.mention_set.insert_thread(id, task.clone());
667
668 let editor = self.editor.clone();
669 cx.spawn_in(window, async move |this, cx| {
670 if task.await.notify_async_err(cx).is_some() {
671 this.update(cx, |this, _| {
672 this.mention_set.insert_uri(crease_id, uri);
673 })
674 .ok();
675 } else {
676 editor
677 .update(cx, |editor, cx| {
678 editor.display_map.update(cx, |display_map, cx| {
679 display_map.unfold_intersecting(vec![anchor..anchor], true, cx);
680 });
681 editor.remove_creases([crease_id], cx);
682 })
683 .ok();
684 }
685 })
686 .detach();
687 }
688
689 fn confirm_mention_for_text_thread(
690 &mut self,
691 crease_id: CreaseId,
692 anchor: Anchor,
693 path: PathBuf,
694 name: String,
695 window: &mut Window,
696 cx: &mut Context<Self>,
697 ) {
698 let uri = MentionUri::TextThread {
699 path: path.clone(),
700 name,
701 };
702 let context = self.text_thread_store.update(cx, |text_thread_store, cx| {
703 text_thread_store.open_local_context(path.as_path().into(), cx)
704 });
705 let task = cx
706 .spawn(async move |_, cx| {
707 let context = context.await.map_err(|e| e.to_string())?;
708 let xml = context
709 .update(cx, |context, cx| context.to_xml(cx))
710 .map_err(|e| e.to_string())?;
711 Ok(xml)
712 })
713 .shared();
714
715 self.mention_set.insert_text_thread(path, task.clone());
716
717 let editor = self.editor.clone();
718 cx.spawn_in(window, async move |this, cx| {
719 if task.await.notify_async_err(cx).is_some() {
720 this.update(cx, |this, _| {
721 this.mention_set.insert_uri(crease_id, uri);
722 })
723 .ok();
724 } else {
725 editor
726 .update(cx, |editor, cx| {
727 editor.display_map.update(cx, |display_map, cx| {
728 display_map.unfold_intersecting(vec![anchor..anchor], true, cx);
729 });
730 editor.remove_creases([crease_id], cx);
731 })
732 .ok();
733 }
734 })
735 .detach();
736 }
737
738 pub fn set_mode(&mut self, mode: EditorMode, cx: &mut Context<Self>) {
739 self.editor.update(cx, |editor, cx| {
740 editor.set_mode(mode);
741 cx.notify()
742 });
743 }
744
745 pub fn set_message(
746 &mut self,
747 message: Vec<acp::ContentBlock>,
748 window: &mut Window,
749 cx: &mut Context<Self>,
750 ) {
751 self.clear(window, cx);
752
753 let mut text = String::new();
754 let mut mentions = Vec::new();
755 let mut images = Vec::new();
756
757 for chunk in message {
758 match chunk {
759 acp::ContentBlock::Text(text_content) => {
760 text.push_str(&text_content.text);
761 }
762 acp::ContentBlock::Resource(acp::EmbeddedResource {
763 resource: acp::EmbeddedResourceResource::TextResourceContents(resource),
764 ..
765 }) => {
766 if let Some(mention_uri) = MentionUri::parse(&resource.uri).log_err() {
767 let start = text.len();
768 write!(&mut text, "{}", mention_uri.as_link()).ok();
769 let end = text.len();
770 mentions.push((start..end, mention_uri, resource.text));
771 }
772 }
773 acp::ContentBlock::Image(content) => {
774 let start = text.len();
775 text.push_str("image");
776 let end = text.len();
777 images.push((start..end, content));
778 }
779 acp::ContentBlock::Audio(_)
780 | acp::ContentBlock::Resource(_)
781 | acp::ContentBlock::ResourceLink(_) => {}
782 }
783 }
784
785 let snapshot = self.editor.update(cx, |editor, cx| {
786 editor.set_text(text, window, cx);
787 editor.buffer().read(cx).snapshot(cx)
788 });
789
790 for (range, mention_uri, text) in mentions {
791 let anchor = snapshot.anchor_before(range.start);
792 let crease_id = crate::context_picker::insert_crease_for_mention(
793 anchor.excerpt_id,
794 anchor.text_anchor,
795 range.end - range.start,
796 mention_uri.name().into(),
797 mention_uri.icon_path(cx),
798 self.editor.clone(),
799 window,
800 cx,
801 );
802
803 if let Some(crease_id) = crease_id {
804 self.mention_set.insert_uri(crease_id, mention_uri.clone());
805 }
806
807 match mention_uri {
808 MentionUri::Thread { id, .. } => {
809 self.mention_set
810 .insert_thread(id, Task::ready(Ok(text.into())).shared());
811 }
812 MentionUri::TextThread { path, .. } => {
813 self.mention_set
814 .insert_text_thread(path, Task::ready(Ok(text)).shared());
815 }
816 MentionUri::Fetch { url } => {
817 self.mention_set
818 .add_fetch_result(url, Task::ready(Ok(text)).shared());
819 }
820 MentionUri::File { .. }
821 | MentionUri::Symbol { .. }
822 | MentionUri::Rule { .. }
823 | MentionUri::Selection { .. } => {}
824 }
825 }
826 for (range, content) in images {
827 let Some(format) = ImageFormat::from_mime_type(&content.mime_type) else {
828 continue;
829 };
830 let anchor = snapshot.anchor_before(range.start);
831 let abs_path = content
832 .uri
833 .as_ref()
834 .and_then(|uri| uri.strip_prefix("file://").map(|s| Path::new(s).into()));
835
836 let name = content
837 .uri
838 .as_ref()
839 .and_then(|uri| {
840 uri.strip_prefix("file://")
841 .and_then(|path| Path::new(path).file_name())
842 })
843 .map(|name| name.to_string_lossy().to_string())
844 .unwrap_or("Image".to_owned());
845 let crease_id = crate::context_picker::insert_crease_for_mention(
846 anchor.excerpt_id,
847 anchor.text_anchor,
848 range.end - range.start,
849 name.into(),
850 IconName::Image.path().into(),
851 self.editor.clone(),
852 window,
853 cx,
854 );
855 let data: SharedString = content.data.to_string().into();
856
857 if let Some(crease_id) = crease_id {
858 self.mention_set.insert_image(
859 crease_id,
860 Task::ready(Ok(MentionImage {
861 abs_path,
862 data,
863 format,
864 }))
865 .shared(),
866 );
867 }
868 }
869 cx.notify();
870 }
871
872 #[cfg(test)]
873 pub fn set_text(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
874 self.editor.update(cx, |editor, cx| {
875 editor.set_text(text, window, cx);
876 });
877 }
878
879 #[cfg(test)]
880 pub fn text(&self, cx: &App) -> String {
881 self.editor.read(cx).text(cx)
882 }
883}
884
885impl Focusable for MessageEditor {
886 fn focus_handle(&self, cx: &App) -> FocusHandle {
887 self.editor.focus_handle(cx)
888 }
889}
890
891impl Render for MessageEditor {
892 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
893 div()
894 .key_context("MessageEditor")
895 .on_action(cx.listener(Self::send))
896 .on_action(cx.listener(Self::cancel))
897 .capture_action(cx.listener(Self::paste))
898 .flex_1()
899 .child({
900 let settings = ThemeSettings::get_global(cx);
901 let font_size = TextSize::Small
902 .rems(cx)
903 .to_pixels(settings.agent_font_size(cx));
904 let line_height = settings.buffer_line_height.value() * font_size;
905
906 let text_style = TextStyle {
907 color: cx.theme().colors().text,
908 font_family: settings.buffer_font.family.clone(),
909 font_fallbacks: settings.buffer_font.fallbacks.clone(),
910 font_features: settings.buffer_font.features.clone(),
911 font_size: font_size.into(),
912 line_height: line_height.into(),
913 ..Default::default()
914 };
915
916 EditorElement::new(
917 &self.editor,
918 EditorStyle {
919 background: cx.theme().colors().editor_background,
920 local_player: cx.theme().players().local(),
921 text: text_style,
922 syntax: cx.theme().syntax().clone(),
923 ..Default::default()
924 },
925 )
926 })
927 }
928}
929
930pub(crate) fn insert_crease_for_image(
931 excerpt_id: ExcerptId,
932 anchor: text::Anchor,
933 content_len: usize,
934 abs_path: Option<Arc<Path>>,
935 image: Shared<Task<Result<Arc<Image>, String>>>,
936 editor: Entity<Editor>,
937 window: &mut Window,
938 cx: &mut App,
939) -> Option<CreaseId> {
940 let crease_label = abs_path
941 .as_ref()
942 .and_then(|path| path.file_name())
943 .map(|name| name.to_string_lossy().to_string().into())
944 .unwrap_or(SharedString::from("Image"));
945
946 editor.update(cx, |editor, cx| {
947 let snapshot = editor.buffer().read(cx).snapshot(cx);
948
949 let start = snapshot.anchor_in_excerpt(excerpt_id, anchor)?;
950
951 let start = start.bias_right(&snapshot);
952 let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
953
954 let placeholder = FoldPlaceholder {
955 render: render_image_fold_icon_button(crease_label, image, cx.weak_entity()),
956 merge_adjacent: false,
957 ..Default::default()
958 };
959
960 let crease = Crease::Inline {
961 range: start..end,
962 placeholder,
963 render_toggle: None,
964 render_trailer: None,
965 metadata: None,
966 };
967
968 let ids = editor.insert_creases(vec![crease.clone()], cx);
969 editor.fold_creases(vec![crease], false, window, cx);
970
971 Some(ids[0])
972 })
973}
974
975fn render_image_fold_icon_button(
976 label: SharedString,
977 image_task: Shared<Task<Result<Arc<Image>, String>>>,
978 editor: WeakEntity<Editor>,
979) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
980 Arc::new({
981 let image_task = image_task.clone();
982 move |fold_id, fold_range, cx| {
983 let is_in_text_selection = editor
984 .update(cx, |editor, cx| editor.is_range_selected(&fold_range, cx))
985 .unwrap_or_default();
986
987 ButtonLike::new(fold_id)
988 .style(ButtonStyle::Filled)
989 .selected_style(ButtonStyle::Tinted(TintColor::Accent))
990 .toggle_state(is_in_text_selection)
991 .child(
992 h_flex()
993 .gap_1()
994 .child(
995 Icon::new(IconName::Image)
996 .size(IconSize::XSmall)
997 .color(Color::Muted),
998 )
999 .child(
1000 Label::new(label.clone())
1001 .size(LabelSize::Small)
1002 .buffer_font(cx)
1003 .single_line(),
1004 ),
1005 )
1006 .hoverable_tooltip({
1007 let image_task = image_task.clone();
1008 move |_, cx| {
1009 let image = image_task.peek().cloned().transpose().ok().flatten();
1010 let image_task = image_task.clone();
1011 cx.new::<ImageHover>(|cx| ImageHover {
1012 image,
1013 _task: cx.spawn(async move |this, cx| {
1014 if let Ok(image) = image_task.clone().await {
1015 this.update(cx, |this, cx| {
1016 if this.image.replace(image).is_none() {
1017 cx.notify();
1018 }
1019 })
1020 .ok();
1021 }
1022 }),
1023 })
1024 .into()
1025 }
1026 })
1027 .into_any_element()
1028 }
1029 })
1030}
1031
1032struct ImageHover {
1033 image: Option<Arc<Image>>,
1034 _task: Task<()>,
1035}
1036
1037impl Render for ImageHover {
1038 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1039 if let Some(image) = self.image.clone() {
1040 gpui::img(image).max_w_96().max_h_96().into_any_element()
1041 } else {
1042 gpui::Empty.into_any_element()
1043 }
1044 }
1045}
1046
1047#[derive(Debug, Eq, PartialEq)]
1048pub enum Mention {
1049 Text { uri: MentionUri, content: String },
1050 Image(MentionImage),
1051}
1052
1053#[derive(Clone, Debug, Eq, PartialEq)]
1054pub struct MentionImage {
1055 pub abs_path: Option<PathBuf>,
1056 pub data: SharedString,
1057 pub format: ImageFormat,
1058}
1059
1060#[derive(Default)]
1061pub struct MentionSet {
1062 uri_by_crease_id: HashMap<CreaseId, MentionUri>,
1063 fetch_results: HashMap<Url, Shared<Task<Result<String, String>>>>,
1064 images: HashMap<CreaseId, Shared<Task<Result<MentionImage, String>>>>,
1065 thread_summaries: HashMap<ThreadId, Shared<Task<Result<SharedString, String>>>>,
1066 text_thread_summaries: HashMap<PathBuf, Shared<Task<Result<String, String>>>>,
1067}
1068
1069impl MentionSet {
1070 pub fn insert_uri(&mut self, crease_id: CreaseId, uri: MentionUri) {
1071 self.uri_by_crease_id.insert(crease_id, uri);
1072 }
1073
1074 pub fn add_fetch_result(&mut self, url: Url, content: Shared<Task<Result<String, String>>>) {
1075 self.fetch_results.insert(url, content);
1076 }
1077
1078 pub fn insert_image(
1079 &mut self,
1080 crease_id: CreaseId,
1081 task: Shared<Task<Result<MentionImage, String>>>,
1082 ) {
1083 self.images.insert(crease_id, task);
1084 }
1085
1086 fn insert_thread(&mut self, id: ThreadId, task: Shared<Task<Result<SharedString, String>>>) {
1087 self.thread_summaries.insert(id, task);
1088 }
1089
1090 fn insert_text_thread(&mut self, path: PathBuf, task: Shared<Task<Result<String, String>>>) {
1091 self.text_thread_summaries.insert(path, task);
1092 }
1093
1094 pub fn drain(&mut self) -> impl Iterator<Item = CreaseId> {
1095 self.fetch_results.clear();
1096 self.thread_summaries.clear();
1097 self.text_thread_summaries.clear();
1098 self.uri_by_crease_id
1099 .drain()
1100 .map(|(id, _)| id)
1101 .chain(self.images.drain().map(|(id, _)| id))
1102 }
1103
1104 pub fn contents(
1105 &self,
1106 project: Entity<Project>,
1107 thread_store: Entity<ThreadStore>,
1108 _window: &mut Window,
1109 cx: &mut App,
1110 ) -> Task<Result<HashMap<CreaseId, Mention>>> {
1111 let mut processed_image_creases = HashSet::default();
1112
1113 let mut contents = self
1114 .uri_by_crease_id
1115 .iter()
1116 .map(|(&crease_id, uri)| {
1117 match uri {
1118 MentionUri::File { abs_path, .. } => {
1119 // TODO directories
1120 let uri = uri.clone();
1121 let abs_path = abs_path.to_path_buf();
1122
1123 if let Some(task) = self.images.get(&crease_id).cloned() {
1124 processed_image_creases.insert(crease_id);
1125 return cx.spawn(async move |_| {
1126 let image = task.await.map_err(|e| anyhow!("{e}"))?;
1127 anyhow::Ok((crease_id, Mention::Image(image)))
1128 });
1129 }
1130
1131 let buffer_task = project.update(cx, |project, cx| {
1132 let path = project
1133 .find_project_path(abs_path, cx)
1134 .context("Failed to find project path")?;
1135 anyhow::Ok(project.open_buffer(path, cx))
1136 });
1137 cx.spawn(async move |cx| {
1138 let buffer = buffer_task?.await?;
1139 let content = buffer.read_with(cx, |buffer, _cx| buffer.text())?;
1140
1141 anyhow::Ok((crease_id, Mention::Text { uri, content }))
1142 })
1143 }
1144 MentionUri::Symbol {
1145 path, line_range, ..
1146 }
1147 | MentionUri::Selection {
1148 path, line_range, ..
1149 } => {
1150 let uri = uri.clone();
1151 let path_buf = path.clone();
1152 let line_range = line_range.clone();
1153
1154 let buffer_task = project.update(cx, |project, cx| {
1155 let path = project
1156 .find_project_path(&path_buf, cx)
1157 .context("Failed to find project path")?;
1158 anyhow::Ok(project.open_buffer(path, cx))
1159 });
1160
1161 cx.spawn(async move |cx| {
1162 let buffer = buffer_task?.await?;
1163 let content = buffer.read_with(cx, |buffer, _cx| {
1164 buffer
1165 .text_for_range(
1166 Point::new(line_range.start, 0)
1167 ..Point::new(
1168 line_range.end,
1169 buffer.line_len(line_range.end),
1170 ),
1171 )
1172 .collect()
1173 })?;
1174
1175 anyhow::Ok((crease_id, Mention::Text { uri, content }))
1176 })
1177 }
1178 MentionUri::Thread { id, .. } => {
1179 let Some(content) = self.thread_summaries.get(id).cloned() else {
1180 return Task::ready(Err(anyhow!("missing thread summary")));
1181 };
1182 let uri = uri.clone();
1183 cx.spawn(async move |_| {
1184 Ok((
1185 crease_id,
1186 Mention::Text {
1187 uri,
1188 content: content
1189 .await
1190 .map_err(|e| anyhow::anyhow!("{e}"))?
1191 .to_string(),
1192 },
1193 ))
1194 })
1195 }
1196 MentionUri::TextThread { path, .. } => {
1197 let Some(content) = self.text_thread_summaries.get(path).cloned() else {
1198 return Task::ready(Err(anyhow!("missing text thread summary")));
1199 };
1200 let uri = uri.clone();
1201 cx.spawn(async move |_| {
1202 Ok((
1203 crease_id,
1204 Mention::Text {
1205 uri,
1206 content: content
1207 .await
1208 .map_err(|e| anyhow::anyhow!("{e}"))?
1209 .to_string(),
1210 },
1211 ))
1212 })
1213 }
1214 MentionUri::Rule { id: prompt_id, .. } => {
1215 let Some(prompt_store) = thread_store.read(cx).prompt_store().clone()
1216 else {
1217 return Task::ready(Err(anyhow!("missing prompt store")));
1218 };
1219 let text_task = prompt_store.read(cx).load(*prompt_id, cx);
1220 let uri = uri.clone();
1221 cx.spawn(async move |_| {
1222 // TODO: report load errors instead of just logging
1223 let text = text_task.await?;
1224 anyhow::Ok((crease_id, Mention::Text { uri, content: text }))
1225 })
1226 }
1227 MentionUri::Fetch { url } => {
1228 let Some(content) = self.fetch_results.get(url).cloned() else {
1229 return Task::ready(Err(anyhow!("missing fetch result")));
1230 };
1231 let uri = uri.clone();
1232 cx.spawn(async move |_| {
1233 Ok((
1234 crease_id,
1235 Mention::Text {
1236 uri,
1237 content: content.await.map_err(|e| anyhow::anyhow!("{e}"))?,
1238 },
1239 ))
1240 })
1241 }
1242 }
1243 })
1244 .collect::<Vec<_>>();
1245
1246 // Handle images that didn't have a mention URI (because they were added by the paste handler).
1247 contents.extend(self.images.iter().filter_map(|(crease_id, image)| {
1248 if processed_image_creases.contains(crease_id) {
1249 return None;
1250 }
1251 let crease_id = *crease_id;
1252 let image = image.clone();
1253 Some(cx.spawn(async move |_| {
1254 Ok((
1255 crease_id,
1256 Mention::Image(image.await.map_err(|e| anyhow::anyhow!("{e}"))?),
1257 ))
1258 }))
1259 }));
1260
1261 cx.spawn(async move |_cx| {
1262 let contents = try_join_all(contents).await?.into_iter().collect();
1263 anyhow::Ok(contents)
1264 })
1265 }
1266}
1267
1268#[cfg(test)]
1269mod tests {
1270 use std::{ops::Range, path::Path, sync::Arc};
1271
1272 use agent::{TextThreadStore, ThreadStore};
1273 use agent_client_protocol as acp;
1274 use editor::{AnchorRangeExt as _, Editor, EditorMode};
1275 use fs::FakeFs;
1276 use futures::StreamExt as _;
1277 use gpui::{
1278 AppContext, Entity, EventEmitter, FocusHandle, Focusable, TestAppContext, VisualTestContext,
1279 };
1280 use lsp::{CompletionContext, CompletionTriggerKind};
1281 use project::{CompletionIntent, Project, ProjectPath};
1282 use serde_json::json;
1283 use text::Point;
1284 use ui::{App, Context, IntoElement, Render, SharedString, Window};
1285 use util::path;
1286 use workspace::{AppState, Item, Workspace};
1287
1288 use crate::acp::{
1289 message_editor::{Mention, MessageEditor},
1290 thread_view::tests::init_test,
1291 };
1292
1293 #[gpui::test]
1294 async fn test_at_mention_removal(cx: &mut TestAppContext) {
1295 init_test(cx);
1296
1297 let fs = FakeFs::new(cx.executor());
1298 fs.insert_tree("/project", json!({"file": ""})).await;
1299 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
1300
1301 let (workspace, cx) =
1302 cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
1303
1304 let thread_store = cx.new(|cx| ThreadStore::fake(project.clone(), cx));
1305 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
1306
1307 let message_editor = cx.update(|window, cx| {
1308 cx.new(|cx| {
1309 MessageEditor::new(
1310 workspace.downgrade(),
1311 project.clone(),
1312 thread_store.clone(),
1313 text_thread_store.clone(),
1314 "Test",
1315 EditorMode::AutoHeight {
1316 min_lines: 1,
1317 max_lines: None,
1318 },
1319 window,
1320 cx,
1321 )
1322 })
1323 });
1324 let editor = message_editor.update(cx, |message_editor, _| message_editor.editor.clone());
1325
1326 cx.run_until_parked();
1327
1328 let excerpt_id = editor.update(cx, |editor, cx| {
1329 editor
1330 .buffer()
1331 .read(cx)
1332 .excerpt_ids()
1333 .into_iter()
1334 .next()
1335 .unwrap()
1336 });
1337 let completions = editor.update_in(cx, |editor, window, cx| {
1338 editor.set_text("Hello @file ", window, cx);
1339 let buffer = editor.buffer().read(cx).as_singleton().unwrap();
1340 let completion_provider = editor.completion_provider().unwrap();
1341 completion_provider.completions(
1342 excerpt_id,
1343 &buffer,
1344 text::Anchor::MAX,
1345 CompletionContext {
1346 trigger_kind: CompletionTriggerKind::TRIGGER_CHARACTER,
1347 trigger_character: Some("@".into()),
1348 },
1349 window,
1350 cx,
1351 )
1352 });
1353 let [_, completion]: [_; 2] = completions
1354 .await
1355 .unwrap()
1356 .into_iter()
1357 .flat_map(|response| response.completions)
1358 .collect::<Vec<_>>()
1359 .try_into()
1360 .unwrap();
1361
1362 editor.update_in(cx, |editor, window, cx| {
1363 let snapshot = editor.buffer().read(cx).snapshot(cx);
1364 let start = snapshot
1365 .anchor_in_excerpt(excerpt_id, completion.replace_range.start)
1366 .unwrap();
1367 let end = snapshot
1368 .anchor_in_excerpt(excerpt_id, completion.replace_range.end)
1369 .unwrap();
1370 editor.edit([(start..end, completion.new_text)], cx);
1371 (completion.confirm.unwrap())(CompletionIntent::Complete, window, cx);
1372 });
1373
1374 cx.run_until_parked();
1375
1376 // Backspace over the inserted crease (and the following space).
1377 editor.update_in(cx, |editor, window, cx| {
1378 editor.backspace(&Default::default(), window, cx);
1379 editor.backspace(&Default::default(), window, cx);
1380 });
1381
1382 let content = message_editor
1383 .update_in(cx, |message_editor, window, cx| {
1384 message_editor.contents(window, cx)
1385 })
1386 .await
1387 .unwrap();
1388
1389 // We don't send a resource link for the deleted crease.
1390 pretty_assertions::assert_matches!(content.as_slice(), [acp::ContentBlock::Text { .. }]);
1391 }
1392
1393 struct MessageEditorItem(Entity<MessageEditor>);
1394
1395 impl Item for MessageEditorItem {
1396 type Event = ();
1397
1398 fn include_in_nav_history() -> bool {
1399 false
1400 }
1401
1402 fn tab_content_text(&self, _detail: usize, _cx: &App) -> SharedString {
1403 "Test".into()
1404 }
1405 }
1406
1407 impl EventEmitter<()> for MessageEditorItem {}
1408
1409 impl Focusable for MessageEditorItem {
1410 fn focus_handle(&self, cx: &App) -> FocusHandle {
1411 self.0.read(cx).focus_handle(cx).clone()
1412 }
1413 }
1414
1415 impl Render for MessageEditorItem {
1416 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1417 self.0.clone().into_any_element()
1418 }
1419 }
1420
1421 #[gpui::test]
1422 async fn test_context_completion_provider(cx: &mut TestAppContext) {
1423 init_test(cx);
1424
1425 let app_state = cx.update(AppState::test);
1426
1427 cx.update(|cx| {
1428 language::init(cx);
1429 editor::init(cx);
1430 workspace::init(app_state.clone(), cx);
1431 Project::init_settings(cx);
1432 });
1433
1434 app_state
1435 .fs
1436 .as_fake()
1437 .insert_tree(
1438 path!("/dir"),
1439 json!({
1440 "editor": "",
1441 "a": {
1442 "one.txt": "1",
1443 "two.txt": "2",
1444 "three.txt": "3",
1445 "four.txt": "4"
1446 },
1447 "b": {
1448 "five.txt": "5",
1449 "six.txt": "6",
1450 "seven.txt": "7",
1451 "eight.txt": "8",
1452 }
1453 }),
1454 )
1455 .await;
1456
1457 let project = Project::test(app_state.fs.clone(), [path!("/dir").as_ref()], cx).await;
1458 let window = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
1459 let workspace = window.root(cx).unwrap();
1460
1461 let worktree = project.update(cx, |project, cx| {
1462 let mut worktrees = project.worktrees(cx).collect::<Vec<_>>();
1463 assert_eq!(worktrees.len(), 1);
1464 worktrees.pop().unwrap()
1465 });
1466 let worktree_id = worktree.read_with(cx, |worktree, _| worktree.id());
1467
1468 let mut cx = VisualTestContext::from_window(*window, cx);
1469
1470 let paths = vec![
1471 path!("a/one.txt"),
1472 path!("a/two.txt"),
1473 path!("a/three.txt"),
1474 path!("a/four.txt"),
1475 path!("b/five.txt"),
1476 path!("b/six.txt"),
1477 path!("b/seven.txt"),
1478 path!("b/eight.txt"),
1479 ];
1480
1481 let mut opened_editors = Vec::new();
1482 for path in paths {
1483 let buffer = workspace
1484 .update_in(&mut cx, |workspace, window, cx| {
1485 workspace.open_path(
1486 ProjectPath {
1487 worktree_id,
1488 path: Path::new(path).into(),
1489 },
1490 None,
1491 false,
1492 window,
1493 cx,
1494 )
1495 })
1496 .await
1497 .unwrap();
1498 opened_editors.push(buffer);
1499 }
1500
1501 let thread_store = cx.new(|cx| ThreadStore::fake(project.clone(), cx));
1502 let text_thread_store = cx.new(|cx| TextThreadStore::fake(project.clone(), cx));
1503
1504 let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| {
1505 let workspace_handle = cx.weak_entity();
1506 let message_editor = cx.new(|cx| {
1507 MessageEditor::new(
1508 workspace_handle,
1509 project.clone(),
1510 thread_store.clone(),
1511 text_thread_store.clone(),
1512 "Test",
1513 EditorMode::AutoHeight {
1514 max_lines: None,
1515 min_lines: 1,
1516 },
1517 window,
1518 cx,
1519 )
1520 });
1521 workspace.active_pane().update(cx, |pane, cx| {
1522 pane.add_item(
1523 Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))),
1524 true,
1525 true,
1526 None,
1527 window,
1528 cx,
1529 );
1530 });
1531 message_editor.read(cx).focus_handle(cx).focus(window);
1532 let editor = message_editor.read(cx).editor().clone();
1533 (message_editor, editor)
1534 });
1535
1536 cx.simulate_input("Lorem ");
1537
1538 editor.update(&mut cx, |editor, cx| {
1539 assert_eq!(editor.text(cx), "Lorem ");
1540 assert!(!editor.has_visible_completions_menu());
1541 });
1542
1543 cx.simulate_input("@");
1544
1545 editor.update(&mut cx, |editor, cx| {
1546 assert_eq!(editor.text(cx), "Lorem @");
1547 assert!(editor.has_visible_completions_menu());
1548 assert_eq!(
1549 current_completion_labels(editor),
1550 &[
1551 "eight.txt dir/b/",
1552 "seven.txt dir/b/",
1553 "six.txt dir/b/",
1554 "five.txt dir/b/",
1555 "Files & Directories",
1556 "Symbols",
1557 "Threads",
1558 "Fetch"
1559 ]
1560 );
1561 });
1562
1563 // Select and confirm "File"
1564 editor.update_in(&mut cx, |editor, window, cx| {
1565 assert!(editor.has_visible_completions_menu());
1566 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1567 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1568 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1569 editor.context_menu_next(&editor::actions::ContextMenuNext, window, cx);
1570 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1571 });
1572
1573 cx.run_until_parked();
1574
1575 editor.update(&mut cx, |editor, cx| {
1576 assert_eq!(editor.text(cx), "Lorem @file ");
1577 assert!(editor.has_visible_completions_menu());
1578 });
1579
1580 cx.simulate_input("one");
1581
1582 editor.update(&mut cx, |editor, cx| {
1583 assert_eq!(editor.text(cx), "Lorem @file one");
1584 assert!(editor.has_visible_completions_menu());
1585 assert_eq!(current_completion_labels(editor), vec!["one.txt dir/a/"]);
1586 });
1587
1588 editor.update_in(&mut cx, |editor, window, cx| {
1589 assert!(editor.has_visible_completions_menu());
1590 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1591 });
1592
1593 editor.update(&mut cx, |editor, cx| {
1594 assert_eq!(editor.text(cx), "Lorem [@one.txt](file:///dir/a/one.txt) ");
1595 assert!(!editor.has_visible_completions_menu());
1596 assert_eq!(
1597 fold_ranges(editor, cx),
1598 vec![Point::new(0, 6)..Point::new(0, 39)]
1599 );
1600 });
1601
1602 let contents = message_editor
1603 .update_in(&mut cx, |message_editor, window, cx| {
1604 message_editor.mention_set().contents(
1605 project.clone(),
1606 thread_store.clone(),
1607 window,
1608 cx,
1609 )
1610 })
1611 .await
1612 .unwrap()
1613 .into_values()
1614 .collect::<Vec<_>>();
1615
1616 pretty_assertions::assert_eq!(
1617 contents,
1618 [Mention::Text {
1619 content: "1".into(),
1620 uri: "file:///dir/a/one.txt".parse().unwrap()
1621 }]
1622 );
1623
1624 cx.simulate_input(" ");
1625
1626 editor.update(&mut cx, |editor, cx| {
1627 assert_eq!(editor.text(cx), "Lorem [@one.txt](file:///dir/a/one.txt) ");
1628 assert!(!editor.has_visible_completions_menu());
1629 assert_eq!(
1630 fold_ranges(editor, cx),
1631 vec![Point::new(0, 6)..Point::new(0, 39)]
1632 );
1633 });
1634
1635 cx.simulate_input("Ipsum ");
1636
1637 editor.update(&mut cx, |editor, cx| {
1638 assert_eq!(
1639 editor.text(cx),
1640 "Lorem [@one.txt](file:///dir/a/one.txt) Ipsum ",
1641 );
1642 assert!(!editor.has_visible_completions_menu());
1643 assert_eq!(
1644 fold_ranges(editor, cx),
1645 vec![Point::new(0, 6)..Point::new(0, 39)]
1646 );
1647 });
1648
1649 cx.simulate_input("@file ");
1650
1651 editor.update(&mut cx, |editor, cx| {
1652 assert_eq!(
1653 editor.text(cx),
1654 "Lorem [@one.txt](file:///dir/a/one.txt) Ipsum @file ",
1655 );
1656 assert!(editor.has_visible_completions_menu());
1657 assert_eq!(
1658 fold_ranges(editor, cx),
1659 vec![Point::new(0, 6)..Point::new(0, 39)]
1660 );
1661 });
1662
1663 editor.update_in(&mut cx, |editor, window, cx| {
1664 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1665 });
1666
1667 cx.run_until_parked();
1668
1669 let contents = message_editor
1670 .update_in(&mut cx, |message_editor, window, cx| {
1671 message_editor.mention_set().contents(
1672 project.clone(),
1673 thread_store.clone(),
1674 window,
1675 cx,
1676 )
1677 })
1678 .await
1679 .unwrap()
1680 .into_values()
1681 .collect::<Vec<_>>();
1682
1683 assert_eq!(contents.len(), 2);
1684 pretty_assertions::assert_eq!(
1685 contents[1],
1686 Mention::Text {
1687 content: "8".to_string(),
1688 uri: "file:///dir/b/eight.txt".parse().unwrap(),
1689 }
1690 );
1691
1692 editor.update(&mut cx, |editor, cx| {
1693 assert_eq!(
1694 editor.text(cx),
1695 "Lorem [@one.txt](file:///dir/a/one.txt) Ipsum [@eight.txt](file:///dir/b/eight.txt) "
1696 );
1697 assert!(!editor.has_visible_completions_menu());
1698 assert_eq!(
1699 fold_ranges(editor, cx),
1700 vec![
1701 Point::new(0, 6)..Point::new(0, 39),
1702 Point::new(0, 47)..Point::new(0, 84)
1703 ]
1704 );
1705 });
1706
1707 let plain_text_language = Arc::new(language::Language::new(
1708 language::LanguageConfig {
1709 name: "Plain Text".into(),
1710 matcher: language::LanguageMatcher {
1711 path_suffixes: vec!["txt".to_string()],
1712 ..Default::default()
1713 },
1714 ..Default::default()
1715 },
1716 None,
1717 ));
1718
1719 // Register the language and fake LSP
1720 let language_registry = project.read_with(&cx, |project, _| project.languages().clone());
1721 language_registry.add(plain_text_language);
1722
1723 let mut fake_language_servers = language_registry.register_fake_lsp(
1724 "Plain Text",
1725 language::FakeLspAdapter {
1726 capabilities: lsp::ServerCapabilities {
1727 workspace_symbol_provider: Some(lsp::OneOf::Left(true)),
1728 ..Default::default()
1729 },
1730 ..Default::default()
1731 },
1732 );
1733
1734 // Open the buffer to trigger LSP initialization
1735 let buffer = project
1736 .update(&mut cx, |project, cx| {
1737 project.open_local_buffer(path!("/dir/a/one.txt"), cx)
1738 })
1739 .await
1740 .unwrap();
1741
1742 // Register the buffer with language servers
1743 let _handle = project.update(&mut cx, |project, cx| {
1744 project.register_buffer_with_language_servers(&buffer, cx)
1745 });
1746
1747 cx.run_until_parked();
1748
1749 let fake_language_server = fake_language_servers.next().await.unwrap();
1750 fake_language_server.set_request_handler::<lsp::WorkspaceSymbolRequest, _, _>(
1751 |_, _| async move {
1752 Ok(Some(lsp::WorkspaceSymbolResponse::Flat(vec![
1753 #[allow(deprecated)]
1754 lsp::SymbolInformation {
1755 name: "MySymbol".into(),
1756 location: lsp::Location {
1757 uri: lsp::Url::from_file_path(path!("/dir/a/one.txt")).unwrap(),
1758 range: lsp::Range::new(
1759 lsp::Position::new(0, 0),
1760 lsp::Position::new(0, 1),
1761 ),
1762 },
1763 kind: lsp::SymbolKind::CONSTANT,
1764 tags: None,
1765 container_name: None,
1766 deprecated: None,
1767 },
1768 ])))
1769 },
1770 );
1771
1772 cx.simulate_input("@symbol ");
1773
1774 editor.update(&mut cx, |editor, cx| {
1775 assert_eq!(
1776 editor.text(cx),
1777 "Lorem [@one.txt](file:///dir/a/one.txt) Ipsum [@eight.txt](file:///dir/b/eight.txt) @symbol "
1778 );
1779 assert!(editor.has_visible_completions_menu());
1780 assert_eq!(
1781 current_completion_labels(editor),
1782 &[
1783 "MySymbol",
1784 ]
1785 );
1786 });
1787
1788 editor.update_in(&mut cx, |editor, window, cx| {
1789 editor.confirm_completion(&editor::actions::ConfirmCompletion::default(), window, cx);
1790 });
1791
1792 let contents = message_editor
1793 .update_in(&mut cx, |message_editor, window, cx| {
1794 message_editor
1795 .mention_set()
1796 .contents(project.clone(), thread_store, window, cx)
1797 })
1798 .await
1799 .unwrap()
1800 .into_values()
1801 .collect::<Vec<_>>();
1802
1803 assert_eq!(contents.len(), 3);
1804 pretty_assertions::assert_eq!(
1805 contents[2],
1806 Mention::Text {
1807 content: "1".into(),
1808 uri: "file:///dir/a/one.txt?symbol=MySymbol#L1:1"
1809 .parse()
1810 .unwrap(),
1811 }
1812 );
1813
1814 cx.run_until_parked();
1815
1816 editor.read_with(&mut cx, |editor, cx| {
1817 assert_eq!(
1818 editor.text(cx),
1819 "Lorem [@one.txt](file:///dir/a/one.txt) Ipsum [@eight.txt](file:///dir/b/eight.txt) [@MySymbol](file:///dir/a/one.txt?symbol=MySymbol#L1:1) "
1820 );
1821 });
1822 }
1823
1824 fn fold_ranges(editor: &Editor, cx: &mut App) -> Vec<Range<Point>> {
1825 let snapshot = editor.buffer().read(cx).snapshot(cx);
1826 editor.display_map.update(cx, |display_map, cx| {
1827 display_map
1828 .snapshot(cx)
1829 .folds_in_range(0..snapshot.len())
1830 .map(|fold| fold.range.to_point(&snapshot))
1831 .collect()
1832 })
1833 }
1834
1835 fn current_completion_labels(editor: &Editor) -> Vec<String> {
1836 let completions = editor.current_completions().expect("Missing completions");
1837 completions
1838 .into_iter()
1839 .map(|completion| completion.label.text.to_string())
1840 .collect::<Vec<_>>()
1841 }
1842}