1use acp_thread::{MentionUri, selection_name};
2use agent::{ThreadStore, outline};
3use agent_client_protocol as acp;
4use agent_servers::{AgentServer, AgentServerDelegate};
5use anyhow::{Context as _, Result, anyhow};
6use assistant_slash_commands::{codeblock_fence_for_path, collect_diagnostics_output};
7use collections::{HashMap, HashSet};
8use editor::{
9 Anchor, Editor, EditorSnapshot, ExcerptId, FoldPlaceholder, ToOffset,
10 display_map::{Crease, CreaseId, CreaseMetadata, FoldId},
11 scroll::Autoscroll,
12};
13use futures::{AsyncReadExt as _, FutureExt as _, future::Shared};
14use gpui::{
15 AppContext, ClipboardEntry, Context, Empty, Entity, EntityId, Image, ImageFormat, Img,
16 SharedString, Task, WeakEntity,
17};
18use http_client::{AsyncBody, HttpClientWithUrl};
19use itertools::Either;
20use language::Buffer;
21use language_model::LanguageModelImage;
22use multi_buffer::MultiBufferRow;
23use postage::stream::Stream as _;
24use project::{Project, ProjectItem, ProjectPath, Worktree};
25use prompt_store::{PromptId, PromptStore};
26use rope::Point;
27use std::{
28 cell::RefCell,
29 ffi::OsStr,
30 fmt::Write,
31 ops::{Range, RangeInclusive},
32 path::{Path, PathBuf},
33 rc::Rc,
34 sync::Arc,
35};
36use text::OffsetRangeExt;
37use ui::{Disclosure, Toggleable, prelude::*};
38use util::{ResultExt, debug_panic, rel_path::RelPath};
39use workspace::{Workspace, notifications::NotifyResultExt as _};
40
41use crate::ui::MentionCrease;
42
43pub type MentionTask = Shared<Task<Result<Mention, String>>>;
44
45#[derive(Debug, Clone, Eq, PartialEq)]
46pub enum Mention {
47 Text {
48 content: String,
49 tracked_buffers: Vec<Entity<Buffer>>,
50 },
51 Image(MentionImage),
52 Link,
53}
54
55#[derive(Clone, Debug, Eq, PartialEq)]
56pub struct MentionImage {
57 pub data: SharedString,
58 pub format: ImageFormat,
59}
60
61pub struct MentionSet {
62 project: WeakEntity<Project>,
63 thread_store: Option<Entity<ThreadStore>>,
64 prompt_store: Option<Entity<PromptStore>>,
65 mentions: HashMap<CreaseId, (MentionUri, MentionTask)>,
66}
67
68impl MentionSet {
69 pub fn new(
70 project: WeakEntity<Project>,
71 thread_store: Option<Entity<ThreadStore>>,
72 prompt_store: Option<Entity<PromptStore>>,
73 ) -> Self {
74 Self {
75 project,
76 thread_store,
77 prompt_store,
78 mentions: HashMap::default(),
79 }
80 }
81
82 pub fn contents(
83 &self,
84 full_mention_content: bool,
85 cx: &mut App,
86 ) -> Task<Result<HashMap<CreaseId, (MentionUri, Mention)>>> {
87 let Some(project) = self.project.upgrade() else {
88 return Task::ready(Err(anyhow!("Project not found")));
89 };
90 let mentions = self.mentions.clone();
91 cx.spawn(async move |cx| {
92 let mut contents = HashMap::default();
93 for (crease_id, (mention_uri, task)) in mentions {
94 let content = if full_mention_content
95 && let MentionUri::Directory { abs_path } = &mention_uri
96 {
97 cx.update(|cx| full_mention_for_directory(&project, abs_path, cx))
98 .await?
99 } else {
100 task.await.map_err(|e| anyhow!("{e}"))?
101 };
102
103 contents.insert(crease_id, (mention_uri, content));
104 }
105 Ok(contents)
106 })
107 }
108
109 pub fn remove_invalid(&mut self, snapshot: &EditorSnapshot) {
110 for (crease_id, crease) in snapshot.crease_snapshot.creases() {
111 if !crease.range().start.is_valid(snapshot.buffer_snapshot()) {
112 self.mentions.remove(&crease_id);
113 }
114 }
115 }
116
117 pub fn insert_mention(&mut self, crease_id: CreaseId, uri: MentionUri, task: MentionTask) {
118 self.mentions.insert(crease_id, (uri, task));
119 }
120
121 pub fn remove_mention(&mut self, crease_id: &CreaseId) {
122 self.mentions.remove(crease_id);
123 }
124
125 pub fn creases(&self) -> HashSet<CreaseId> {
126 self.mentions.keys().cloned().collect()
127 }
128
129 pub fn mentions(&self) -> HashSet<MentionUri> {
130 self.mentions.values().map(|(uri, _)| uri.clone()).collect()
131 }
132
133 pub fn set_mentions(&mut self, mentions: HashMap<CreaseId, (MentionUri, MentionTask)>) {
134 self.mentions = mentions;
135 }
136
137 pub fn clear(&mut self) -> impl Iterator<Item = (CreaseId, (MentionUri, MentionTask))> {
138 self.mentions.drain()
139 }
140
141 #[cfg(test)]
142 pub fn has_thread_store(&self) -> bool {
143 self.thread_store.is_some()
144 }
145
146 pub fn confirm_mention_completion(
147 &mut self,
148 crease_text: SharedString,
149 start: text::Anchor,
150 content_len: usize,
151 mention_uri: MentionUri,
152 supports_images: bool,
153 editor: Entity<Editor>,
154 workspace: &Entity<Workspace>,
155 window: &mut Window,
156 cx: &mut Context<Self>,
157 ) -> Task<()> {
158 let Some(project) = self.project.upgrade() else {
159 return Task::ready(());
160 };
161
162 let snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx));
163 let Some(start_anchor) = snapshot.buffer_snapshot().as_singleton_anchor(start) else {
164 return Task::ready(());
165 };
166 let excerpt_id = start_anchor.excerpt_id;
167 let end_anchor = snapshot.buffer_snapshot().anchor_before(
168 start_anchor.to_offset(&snapshot.buffer_snapshot()) + content_len + 1usize,
169 );
170
171 let crease = if let MentionUri::File { abs_path } = &mention_uri
172 && let Some(extension) = abs_path.extension()
173 && let Some(extension) = extension.to_str()
174 && Img::extensions().contains(&extension)
175 && !extension.contains("svg")
176 {
177 let Some(project_path) = project
178 .read(cx)
179 .project_path_for_absolute_path(&abs_path, cx)
180 else {
181 log::error!("project path not found");
182 return Task::ready(());
183 };
184 let image_task = project.update(cx, |project, cx| project.open_image(project_path, cx));
185 let image = cx
186 .spawn(async move |_, cx| {
187 let image = image_task.await.map_err(|e| e.to_string())?;
188 let image = image.update(cx, |image, _| image.image.clone());
189 Ok(image)
190 })
191 .shared();
192 insert_crease_for_mention(
193 excerpt_id,
194 start,
195 content_len,
196 mention_uri.name().into(),
197 IconName::Image.path().into(),
198 Some(image),
199 editor.clone(),
200 window,
201 cx,
202 )
203 } else {
204 insert_crease_for_mention(
205 excerpt_id,
206 start,
207 content_len,
208 crease_text,
209 mention_uri.icon_path(cx),
210 None,
211 editor.clone(),
212 window,
213 cx,
214 )
215 };
216 let Some((crease_id, tx)) = crease else {
217 return Task::ready(());
218 };
219
220 let task = match mention_uri.clone() {
221 MentionUri::Fetch { url } => {
222 self.confirm_mention_for_fetch(url, workspace.read(cx).client().http_client(), cx)
223 }
224 MentionUri::Directory { .. } => Task::ready(Ok(Mention::Link)),
225 MentionUri::Thread { id, .. } => self.confirm_mention_for_thread(id, cx),
226 MentionUri::TextThread { .. } => {
227 Task::ready(Err(anyhow!("Text thread mentions are no longer supported")))
228 }
229 MentionUri::File { abs_path } => {
230 self.confirm_mention_for_file(abs_path, supports_images, cx)
231 }
232 MentionUri::Symbol {
233 abs_path,
234 line_range,
235 ..
236 } => self.confirm_mention_for_symbol(abs_path, line_range, cx),
237 MentionUri::Rule { id, .. } => self.confirm_mention_for_rule(id, cx),
238 MentionUri::Diagnostics {
239 include_errors,
240 include_warnings,
241 } => self.confirm_mention_for_diagnostics(include_errors, include_warnings, cx),
242 MentionUri::PastedImage => {
243 debug_panic!("pasted image URI should not be included in completions");
244 Task::ready(Err(anyhow!(
245 "pasted imaged URI should not be included in completions"
246 )))
247 }
248 MentionUri::Selection { .. } => {
249 debug_panic!("unexpected selection URI");
250 Task::ready(Err(anyhow!("unexpected selection URI")))
251 }
252 MentionUri::TerminalSelection { .. } => {
253 debug_panic!("unexpected terminal URI");
254 Task::ready(Err(anyhow!("unexpected terminal URI")))
255 }
256 };
257 let task = cx
258 .spawn(async move |_, _| task.await.map_err(|e| e.to_string()))
259 .shared();
260 self.mentions.insert(crease_id, (mention_uri, task.clone()));
261
262 // Notify the user if we failed to load the mentioned context
263 cx.spawn_in(window, async move |this, cx| {
264 let result = task.await.notify_async_err(cx);
265 drop(tx);
266 if result.is_none() {
267 this.update(cx, |this, cx| {
268 editor.update(cx, |editor, cx| {
269 // Remove mention
270 editor.edit([(start_anchor..end_anchor, "")], cx);
271 });
272 this.mentions.remove(&crease_id);
273 })
274 .ok();
275 }
276 })
277 }
278
279 pub fn confirm_mention_for_file(
280 &self,
281 abs_path: PathBuf,
282 supports_images: bool,
283 cx: &mut Context<Self>,
284 ) -> Task<Result<Mention>> {
285 let Some(project) = self.project.upgrade() else {
286 return Task::ready(Err(anyhow!("project not found")));
287 };
288
289 let Some(project_path) = project
290 .read(cx)
291 .project_path_for_absolute_path(&abs_path, cx)
292 else {
293 return Task::ready(Err(anyhow!("project path not found")));
294 };
295 let extension = abs_path
296 .extension()
297 .and_then(OsStr::to_str)
298 .unwrap_or_default();
299
300 if Img::extensions().contains(&extension) && !extension.contains("svg") {
301 if !supports_images {
302 return Task::ready(Err(anyhow!("This model does not support images yet")));
303 }
304 let task = project.update(cx, |project, cx| project.open_image(project_path, cx));
305 return cx.spawn(async move |_, cx| {
306 let image = task.await?;
307 let image = image.update(cx, |image, _| image.image.clone());
308 let image = cx
309 .update(|cx| LanguageModelImage::from_image(image, cx))
310 .await;
311 if let Some(image) = image {
312 Ok(Mention::Image(MentionImage {
313 data: image.source,
314 format: LanguageModelImage::FORMAT,
315 }))
316 } else {
317 Err(anyhow!("Failed to convert image"))
318 }
319 });
320 }
321
322 let buffer = project.update(cx, |project, cx| project.open_buffer(project_path, cx));
323 cx.spawn(async move |_, cx| {
324 let buffer = buffer.await?;
325 let buffer_content = outline::get_buffer_content_or_outline(
326 buffer.clone(),
327 Some(&abs_path.to_string_lossy()),
328 &cx,
329 )
330 .await?;
331
332 Ok(Mention::Text {
333 content: buffer_content.text,
334 tracked_buffers: vec![buffer],
335 })
336 })
337 }
338
339 fn confirm_mention_for_fetch(
340 &self,
341 url: url::Url,
342 http_client: Arc<HttpClientWithUrl>,
343 cx: &mut Context<Self>,
344 ) -> Task<Result<Mention>> {
345 cx.background_executor().spawn(async move {
346 let content = fetch_url_content(http_client, url.to_string()).await?;
347 Ok(Mention::Text {
348 content,
349 tracked_buffers: Vec::new(),
350 })
351 })
352 }
353
354 fn confirm_mention_for_symbol(
355 &self,
356 abs_path: PathBuf,
357 line_range: RangeInclusive<u32>,
358 cx: &mut Context<Self>,
359 ) -> Task<Result<Mention>> {
360 let Some(project) = self.project.upgrade() else {
361 return Task::ready(Err(anyhow!("project not found")));
362 };
363 let Some(project_path) = project
364 .read(cx)
365 .project_path_for_absolute_path(&abs_path, cx)
366 else {
367 return Task::ready(Err(anyhow!("project path not found")));
368 };
369 let buffer = project.update(cx, |project, cx| project.open_buffer(project_path, cx));
370 cx.spawn(async move |_, cx| {
371 let buffer = buffer.await?;
372 let mention = buffer.update(cx, |buffer, cx| {
373 let start = Point::new(*line_range.start(), 0).min(buffer.max_point());
374 let end = Point::new(*line_range.end() + 1, 0).min(buffer.max_point());
375 let content = buffer.text_for_range(start..end).collect();
376 Mention::Text {
377 content,
378 tracked_buffers: vec![cx.entity()],
379 }
380 });
381 Ok(mention)
382 })
383 }
384
385 fn confirm_mention_for_rule(
386 &mut self,
387 id: PromptId,
388 cx: &mut Context<Self>,
389 ) -> Task<Result<Mention>> {
390 let Some(prompt_store) = self.prompt_store.as_ref() else {
391 return Task::ready(Err(anyhow!("Missing prompt store")));
392 };
393 let prompt = prompt_store.read(cx).load(id, cx);
394 cx.spawn(async move |_, _| {
395 let prompt = prompt.await?;
396 Ok(Mention::Text {
397 content: prompt,
398 tracked_buffers: Vec::new(),
399 })
400 })
401 }
402
403 pub fn confirm_mention_for_selection(
404 &mut self,
405 source_range: Range<text::Anchor>,
406 selections: Vec<(Entity<Buffer>, Range<text::Anchor>, Range<usize>)>,
407 editor: Entity<Editor>,
408 window: &mut Window,
409 cx: &mut Context<Self>,
410 ) {
411 let Some(project) = self.project.upgrade() else {
412 return;
413 };
414
415 let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx);
416 let Some(start) = snapshot.as_singleton_anchor(source_range.start) else {
417 return;
418 };
419
420 let offset = start.to_offset(&snapshot);
421
422 for (buffer, selection_range, range_to_fold) in selections {
423 let range = snapshot.anchor_after(offset + range_to_fold.start)
424 ..snapshot.anchor_after(offset + range_to_fold.end);
425
426 let abs_path = buffer
427 .read(cx)
428 .project_path(cx)
429 .and_then(|project_path| project.read(cx).absolute_path(&project_path, cx));
430 let snapshot = buffer.read(cx).snapshot();
431
432 let text = snapshot
433 .text_for_range(selection_range.clone())
434 .collect::<String>();
435 let point_range = selection_range.to_point(&snapshot);
436 let line_range = point_range.start.row..=point_range.end.row;
437
438 let uri = MentionUri::Selection {
439 abs_path: abs_path.clone(),
440 line_range: line_range.clone(),
441 };
442 let crease = crease_for_mention(
443 selection_name(abs_path.as_deref(), &line_range).into(),
444 uri.icon_path(cx),
445 range,
446 editor.downgrade(),
447 );
448
449 let crease_id = editor.update(cx, |editor, cx| {
450 let crease_ids = editor.insert_creases(vec![crease.clone()], cx);
451 editor.fold_creases(vec![crease], false, window, cx);
452 crease_ids.first().copied().unwrap()
453 });
454
455 self.mentions.insert(
456 crease_id,
457 (
458 uri,
459 Task::ready(Ok(Mention::Text {
460 content: text,
461 tracked_buffers: vec![buffer],
462 }))
463 .shared(),
464 ),
465 );
466 }
467
468 // Take this explanation with a grain of salt but, with creases being
469 // inserted, GPUI's recomputes the editor layout in the next frames, so
470 // directly calling `editor.request_autoscroll` wouldn't work as
471 // expected. We're leveraging `cx.on_next_frame` to wait 2 frames and
472 // ensure that the layout has been recalculated so that the autoscroll
473 // request actually shows the cursor's new position.
474 cx.on_next_frame(window, move |_, window, cx| {
475 cx.on_next_frame(window, move |_, _, cx| {
476 editor.update(cx, |editor, cx| {
477 editor.request_autoscroll(Autoscroll::fit(), cx)
478 });
479 });
480 });
481 }
482
483 fn confirm_mention_for_thread(
484 &mut self,
485 id: acp::SessionId,
486 cx: &mut Context<Self>,
487 ) -> Task<Result<Mention>> {
488 let Some(thread_store) = self.thread_store.clone() else {
489 return Task::ready(Err(anyhow!(
490 "Thread mentions are only supported for the native agent"
491 )));
492 };
493 let Some(project) = self.project.upgrade() else {
494 return Task::ready(Err(anyhow!("project not found")));
495 };
496
497 let server = Rc::new(agent::NativeAgentServer::new(
498 project.read(cx).fs().clone(),
499 thread_store,
500 ));
501 let delegate = AgentServerDelegate::new(
502 project.read(cx).agent_server_store().clone(),
503 project.clone(),
504 None,
505 None,
506 );
507 let connection = server.connect(None, delegate, cx);
508 cx.spawn(async move |_, cx| {
509 let (agent, _) = connection.await?;
510 let agent = agent.downcast::<agent::NativeAgentConnection>().unwrap();
511 let summary = agent
512 .0
513 .update(cx, |agent, cx| agent.thread_summary(id, cx))
514 .await?;
515 Ok(Mention::Text {
516 content: summary.to_string(),
517 tracked_buffers: Vec::new(),
518 })
519 })
520 }
521
522 fn confirm_mention_for_diagnostics(
523 &self,
524 include_errors: bool,
525 include_warnings: bool,
526 cx: &mut Context<Self>,
527 ) -> Task<Result<Mention>> {
528 let Some(project) = self.project.upgrade() else {
529 return Task::ready(Err(anyhow!("project not found")));
530 };
531
532 let diagnostics_task = collect_diagnostics_output(
533 project,
534 assistant_slash_commands::Options {
535 include_errors,
536 include_warnings,
537 path_matcher: None,
538 },
539 cx,
540 );
541 cx.spawn(async move |_, _| {
542 let output = diagnostics_task.await?;
543 let content = output
544 .map(|output| output.text)
545 .unwrap_or_else(|| "No diagnostics found.".into());
546 Ok(Mention::Text {
547 content,
548 tracked_buffers: Vec::new(),
549 })
550 })
551 }
552}
553
554#[cfg(test)]
555mod tests {
556 use super::*;
557
558 use fs::FakeFs;
559 use gpui::TestAppContext;
560 use project::Project;
561 use prompt_store;
562 use release_channel;
563 use semver::Version;
564 use serde_json::json;
565 use settings::SettingsStore;
566 use std::path::Path;
567 use theme;
568 use util::path;
569
570 fn init_test(cx: &mut TestAppContext) {
571 let settings_store = cx.update(SettingsStore::test);
572 cx.set_global(settings_store);
573 cx.update(|cx| {
574 theme::init(theme::LoadThemes::JustBase, cx);
575 release_channel::init(Version::new(0, 0, 0), cx);
576 prompt_store::init(cx);
577 });
578 }
579
580 #[gpui::test]
581 async fn test_thread_mentions_disabled(cx: &mut TestAppContext) {
582 init_test(cx);
583
584 let fs = FakeFs::new(cx.executor());
585 fs.insert_tree("/project", json!({"file": ""})).await;
586 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
587 let thread_store = None;
588 let mention_set = cx.new(|_cx| MentionSet::new(project.downgrade(), thread_store, None));
589
590 let task = mention_set.update(cx, |mention_set, cx| {
591 mention_set.confirm_mention_for_thread(acp::SessionId::new("thread-1"), cx)
592 });
593
594 let error = task.await.unwrap_err();
595 assert!(
596 error
597 .to_string()
598 .contains("Thread mentions are only supported for the native agent"),
599 "Unexpected error: {error:#}"
600 );
601 }
602}
603
604/// Inserts a list of images into the editor as context mentions.
605/// This is the shared implementation used by both paste and file picker operations.
606pub(crate) async fn insert_images_as_context(
607 images: Vec<gpui::Image>,
608 editor: Entity<Editor>,
609 mention_set: Entity<MentionSet>,
610 cx: &mut gpui::AsyncWindowContext,
611) {
612 if images.is_empty() {
613 return;
614 }
615
616 let replacement_text = MentionUri::PastedImage.as_link().to_string();
617
618 for image in images {
619 let Some((excerpt_id, text_anchor, multibuffer_anchor)) = editor
620 .update_in(cx, |editor, window, cx| {
621 let snapshot = editor.snapshot(window, cx);
622 let (excerpt_id, _, buffer_snapshot) =
623 snapshot.buffer_snapshot().as_singleton().unwrap();
624
625 let text_anchor = buffer_snapshot.anchor_before(buffer_snapshot.len());
626 let multibuffer_anchor = snapshot
627 .buffer_snapshot()
628 .anchor_in_excerpt(*excerpt_id, text_anchor);
629 editor.edit(
630 [(
631 multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
632 format!("{replacement_text} "),
633 )],
634 cx,
635 );
636 (*excerpt_id, text_anchor, multibuffer_anchor)
637 })
638 .ok()
639 else {
640 break;
641 };
642
643 let content_len = replacement_text.len();
644 let Some(start_anchor) = multibuffer_anchor else {
645 continue;
646 };
647 let end_anchor = editor.update(cx, |editor, cx| {
648 let snapshot = editor.buffer().read(cx).snapshot(cx);
649 snapshot.anchor_before(start_anchor.to_offset(&snapshot) + content_len)
650 });
651 let image = Arc::new(image);
652 let Ok(Some((crease_id, tx))) = cx.update(|window, cx| {
653 insert_crease_for_mention(
654 excerpt_id,
655 text_anchor,
656 content_len,
657 MentionUri::PastedImage.name().into(),
658 IconName::Image.path().into(),
659 Some(Task::ready(Ok(image.clone())).shared()),
660 editor.clone(),
661 window,
662 cx,
663 )
664 }) else {
665 continue;
666 };
667 let task = cx
668 .spawn(async move |cx| {
669 let image = cx
670 .update(|_, cx| LanguageModelImage::from_image(image, cx))
671 .map_err(|e| e.to_string())?
672 .await;
673 drop(tx);
674 if let Some(image) = image {
675 Ok(Mention::Image(MentionImage {
676 data: image.source,
677 format: LanguageModelImage::FORMAT,
678 }))
679 } else {
680 Err("Failed to convert image".into())
681 }
682 })
683 .shared();
684
685 mention_set.update(cx, |mention_set, _cx| {
686 mention_set.insert_mention(crease_id, MentionUri::PastedImage, task.clone())
687 });
688
689 if task.await.notify_async_err(cx).is_none() {
690 editor.update(cx, |editor, cx| {
691 editor.edit([(start_anchor..end_anchor, "")], cx);
692 });
693 mention_set.update(cx, |mention_set, _cx| {
694 mention_set.remove_mention(&crease_id)
695 });
696 }
697 }
698}
699
700pub(crate) fn paste_images_as_context(
701 editor: Entity<Editor>,
702 mention_set: Entity<MentionSet>,
703 window: &mut Window,
704 cx: &mut App,
705) -> Option<Task<()>> {
706 let clipboard = cx.read_from_clipboard()?;
707 Some(window.spawn(cx, async move |cx| {
708 use itertools::Itertools;
709 let (mut images, paths) = clipboard
710 .into_entries()
711 .filter_map(|entry| match entry {
712 ClipboardEntry::Image(image) => Some(Either::Left(image)),
713 ClipboardEntry::ExternalPaths(paths) => Some(Either::Right(paths)),
714 _ => None,
715 })
716 .partition_map::<Vec<_>, Vec<_>, _, _, _>(std::convert::identity);
717
718 if !paths.is_empty() {
719 images.extend(
720 cx.background_spawn(async move {
721 let mut images = vec![];
722 for path in paths.into_iter().flat_map(|paths| paths.paths().to_owned()) {
723 let Ok(content) = async_fs::read(path).await else {
724 continue;
725 };
726 let Ok(format) = image::guess_format(&content) else {
727 continue;
728 };
729 images.push(gpui::Image::from_bytes(
730 match format {
731 image::ImageFormat::Png => gpui::ImageFormat::Png,
732 image::ImageFormat::Jpeg => gpui::ImageFormat::Jpeg,
733 image::ImageFormat::WebP => gpui::ImageFormat::Webp,
734 image::ImageFormat::Gif => gpui::ImageFormat::Gif,
735 image::ImageFormat::Bmp => gpui::ImageFormat::Bmp,
736 image::ImageFormat::Tiff => gpui::ImageFormat::Tiff,
737 image::ImageFormat::Ico => gpui::ImageFormat::Ico,
738 _ => continue,
739 },
740 content,
741 ));
742 }
743 images
744 })
745 .await,
746 );
747 }
748
749 cx.update(|_window, cx| {
750 cx.stop_propagation();
751 })
752 .ok();
753
754 insert_images_as_context(images, editor, mention_set, cx).await;
755 }))
756}
757
758pub(crate) fn insert_crease_for_mention(
759 excerpt_id: ExcerptId,
760 anchor: text::Anchor,
761 content_len: usize,
762 crease_label: SharedString,
763 crease_icon: SharedString,
764 // abs_path: Option<Arc<Path>>,
765 image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
766 editor: Entity<Editor>,
767 window: &mut Window,
768 cx: &mut App,
769) -> Option<(CreaseId, postage::barrier::Sender)> {
770 let (tx, rx) = postage::barrier::channel();
771
772 let crease_id = editor.update(cx, |editor, cx| {
773 let snapshot = editor.buffer().read(cx).snapshot(cx);
774
775 let start = snapshot.anchor_in_excerpt(excerpt_id, anchor)?;
776
777 let start = start.bias_right(&snapshot);
778 let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
779
780 let placeholder = FoldPlaceholder {
781 render: render_mention_fold_button(
782 crease_label.clone(),
783 crease_icon.clone(),
784 start..end,
785 rx,
786 image,
787 cx.weak_entity(),
788 cx,
789 ),
790 merge_adjacent: false,
791 ..Default::default()
792 };
793
794 let crease = Crease::Inline {
795 range: start..end,
796 placeholder,
797 render_toggle: None,
798 render_trailer: None,
799 metadata: Some(CreaseMetadata {
800 label: crease_label,
801 icon_path: crease_icon,
802 }),
803 };
804
805 let ids = editor.insert_creases(vec![crease.clone()], cx);
806 editor.fold_creases(vec![crease], false, window, cx);
807
808 Some(ids[0])
809 })?;
810
811 Some((crease_id, tx))
812}
813
814pub(crate) fn crease_for_mention(
815 label: SharedString,
816 icon_path: SharedString,
817 range: Range<Anchor>,
818 editor_entity: WeakEntity<Editor>,
819) -> Crease<Anchor> {
820 let placeholder = FoldPlaceholder {
821 render: render_fold_icon_button(icon_path.clone(), label.clone(), editor_entity),
822 merge_adjacent: false,
823 ..Default::default()
824 };
825
826 let render_trailer = move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any();
827
828 Crease::inline(range, placeholder, fold_toggle("mention"), render_trailer)
829 .with_metadata(CreaseMetadata { icon_path, label })
830}
831
832fn render_fold_icon_button(
833 icon_path: SharedString,
834 label: SharedString,
835 editor: WeakEntity<Editor>,
836) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
837 Arc::new({
838 move |fold_id, fold_range, cx| {
839 let is_in_text_selection = editor
840 .update(cx, |editor, cx| editor.is_range_selected(&fold_range, cx))
841 .unwrap_or_default();
842
843 MentionCrease::new(fold_id, icon_path.clone(), label.clone())
844 .is_toggled(is_in_text_selection)
845 .into_any_element()
846 }
847 })
848}
849
850fn fold_toggle(
851 name: &'static str,
852) -> impl Fn(
853 MultiBufferRow,
854 bool,
855 Arc<dyn Fn(bool, &mut Window, &mut App) + Send + Sync>,
856 &mut Window,
857 &mut App,
858) -> AnyElement {
859 move |row, is_folded, fold, _window, _cx| {
860 Disclosure::new((name, row.0 as u64), !is_folded)
861 .toggle_state(is_folded)
862 .on_click(move |_e, window, cx| fold(!is_folded, window, cx))
863 .into_any_element()
864 }
865}
866
867fn full_mention_for_directory(
868 project: &Entity<Project>,
869 abs_path: &Path,
870 cx: &mut App,
871) -> Task<Result<Mention>> {
872 fn collect_files_in_path(worktree: &Worktree, path: &RelPath) -> Vec<(Arc<RelPath>, String)> {
873 let mut files = Vec::new();
874
875 for entry in worktree.child_entries(path) {
876 if entry.is_dir() {
877 files.extend(collect_files_in_path(worktree, &entry.path));
878 } else if entry.is_file() {
879 files.push((
880 entry.path.clone(),
881 worktree
882 .full_path(&entry.path)
883 .to_string_lossy()
884 .to_string(),
885 ));
886 }
887 }
888
889 files
890 }
891
892 let Some(project_path) = project
893 .read(cx)
894 .project_path_for_absolute_path(&abs_path, cx)
895 else {
896 return Task::ready(Err(anyhow!("project path not found")));
897 };
898 let Some(entry) = project.read(cx).entry_for_path(&project_path, cx) else {
899 return Task::ready(Err(anyhow!("project entry not found")));
900 };
901 let directory_path = entry.path.clone();
902 let worktree_id = project_path.worktree_id;
903 let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) else {
904 return Task::ready(Err(anyhow!("worktree not found")));
905 };
906 let project = project.clone();
907 cx.spawn(async move |cx| {
908 let file_paths = worktree.read_with(cx, |worktree, _cx| {
909 collect_files_in_path(worktree, &directory_path)
910 });
911 let descendants_future = cx.update(|cx| {
912 futures::future::join_all(file_paths.into_iter().map(
913 |(worktree_path, full_path): (Arc<RelPath>, String)| {
914 let rel_path = worktree_path
915 .strip_prefix(&directory_path)
916 .log_err()
917 .map_or_else(|| worktree_path.clone(), |rel_path| rel_path.into());
918
919 let open_task = project.update(cx, |project, cx| {
920 project.buffer_store().update(cx, |buffer_store, cx| {
921 let project_path = ProjectPath {
922 worktree_id,
923 path: worktree_path,
924 };
925 buffer_store.open_buffer(project_path, cx)
926 })
927 });
928
929 cx.spawn(async move |cx| {
930 let buffer = open_task.await.log_err()?;
931 let buffer_content = outline::get_buffer_content_or_outline(
932 buffer.clone(),
933 Some(&full_path),
934 &cx,
935 )
936 .await
937 .ok()?;
938
939 Some((rel_path, full_path, buffer_content.text, buffer))
940 })
941 },
942 ))
943 });
944
945 let contents = cx
946 .background_spawn(async move {
947 let (contents, tracked_buffers): (Vec<_>, Vec<_>) = descendants_future
948 .await
949 .into_iter()
950 .flatten()
951 .map(|(rel_path, full_path, rope, buffer)| {
952 ((rel_path, full_path, rope), buffer)
953 })
954 .unzip();
955 Mention::Text {
956 content: render_directory_contents(contents),
957 tracked_buffers,
958 }
959 })
960 .await;
961 anyhow::Ok(contents)
962 })
963}
964
965fn render_directory_contents(entries: Vec<(Arc<RelPath>, String, String)>) -> String {
966 let mut output = String::new();
967 for (_relative_path, full_path, content) in entries {
968 let fence = codeblock_fence_for_path(Some(&full_path), None);
969 write!(output, "\n{fence}\n{content}\n```").unwrap();
970 }
971 output
972}
973
974fn render_mention_fold_button(
975 label: SharedString,
976 icon: SharedString,
977 range: Range<Anchor>,
978 mut loading_finished: postage::barrier::Receiver,
979 image_task: Option<Shared<Task<Result<Arc<Image>, String>>>>,
980 editor: WeakEntity<Editor>,
981 cx: &mut App,
982) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
983 let loading = cx.new(|cx| {
984 let loading = cx.spawn(async move |this, cx| {
985 loading_finished.recv().await;
986 this.update(cx, |this: &mut LoadingContext, cx| {
987 this.loading = None;
988 cx.notify();
989 })
990 .ok();
991 });
992 LoadingContext {
993 id: cx.entity_id(),
994 label,
995 icon,
996 range,
997 editor,
998 loading: Some(loading),
999 image: image_task.clone(),
1000 }
1001 });
1002 Arc::new(move |_fold_id, _fold_range, _cx| loading.clone().into_any_element())
1003}
1004
1005struct LoadingContext {
1006 id: EntityId,
1007 label: SharedString,
1008 icon: SharedString,
1009 range: Range<Anchor>,
1010 editor: WeakEntity<Editor>,
1011 loading: Option<Task<()>>,
1012 image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
1013}
1014
1015impl Render for LoadingContext {
1016 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1017 let is_in_text_selection = self
1018 .editor
1019 .update(cx, |editor, cx| editor.is_range_selected(&self.range, cx))
1020 .unwrap_or_default();
1021
1022 let id = ElementId::from(("loading_context", self.id));
1023
1024 MentionCrease::new(id, self.icon.clone(), self.label.clone())
1025 .is_toggled(is_in_text_selection)
1026 .is_loading(self.loading.is_some())
1027 .when_some(self.image.clone(), |this, image_task| {
1028 this.image_preview(move |_, cx| {
1029 let image = image_task.peek().cloned().transpose().ok().flatten();
1030 let image_task = image_task.clone();
1031 cx.new::<ImageHover>(|cx| ImageHover {
1032 image,
1033 _task: cx.spawn(async move |this, cx| {
1034 if let Ok(image) = image_task.clone().await {
1035 this.update(cx, |this, cx| {
1036 if this.image.replace(image).is_none() {
1037 cx.notify();
1038 }
1039 })
1040 .ok();
1041 }
1042 }),
1043 })
1044 .into()
1045 })
1046 })
1047 }
1048}
1049
1050struct ImageHover {
1051 image: Option<Arc<Image>>,
1052 _task: Task<()>,
1053}
1054
1055impl Render for ImageHover {
1056 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1057 if let Some(image) = self.image.clone() {
1058 div()
1059 .p_1p5()
1060 .elevation_2(cx)
1061 .child(gpui::img(image).h_auto().max_w_96().rounded_sm())
1062 .into_any_element()
1063 } else {
1064 gpui::Empty.into_any_element()
1065 }
1066 }
1067}
1068
1069async fn fetch_url_content(http_client: Arc<HttpClientWithUrl>, url: String) -> Result<String> {
1070 #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
1071 enum ContentType {
1072 Html,
1073 Plaintext,
1074 Json,
1075 }
1076 use html_to_markdown::{TagHandler, convert_html_to_markdown, markdown};
1077
1078 let url = if !url.starts_with("https://") && !url.starts_with("http://") {
1079 format!("https://{url}")
1080 } else {
1081 url
1082 };
1083
1084 let mut response = http_client.get(&url, AsyncBody::default(), true).await?;
1085 let mut body = Vec::new();
1086 response
1087 .body_mut()
1088 .read_to_end(&mut body)
1089 .await
1090 .context("error reading response body")?;
1091
1092 if response.status().is_client_error() {
1093 let text = String::from_utf8_lossy(body.as_slice());
1094 anyhow::bail!(
1095 "status error {}, response: {text:?}",
1096 response.status().as_u16()
1097 );
1098 }
1099
1100 let Some(content_type) = response.headers().get("content-type") else {
1101 anyhow::bail!("missing Content-Type header");
1102 };
1103 let content_type = content_type
1104 .to_str()
1105 .context("invalid Content-Type header")?;
1106 let content_type = match content_type {
1107 "text/html" => ContentType::Html,
1108 "text/plain" => ContentType::Plaintext,
1109 "application/json" => ContentType::Json,
1110 _ => ContentType::Html,
1111 };
1112
1113 match content_type {
1114 ContentType::Html => {
1115 let mut handlers: Vec<TagHandler> = vec![
1116 Rc::new(RefCell::new(markdown::WebpageChromeRemover)),
1117 Rc::new(RefCell::new(markdown::ParagraphHandler)),
1118 Rc::new(RefCell::new(markdown::HeadingHandler)),
1119 Rc::new(RefCell::new(markdown::ListHandler)),
1120 Rc::new(RefCell::new(markdown::TableHandler::new())),
1121 Rc::new(RefCell::new(markdown::StyledTextHandler)),
1122 ];
1123 if url.contains("wikipedia.org") {
1124 use html_to_markdown::structure::wikipedia;
1125
1126 handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover)));
1127 handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaInfoboxHandler)));
1128 handlers.push(Rc::new(
1129 RefCell::new(wikipedia::WikipediaCodeHandler::new()),
1130 ));
1131 } else {
1132 handlers.push(Rc::new(RefCell::new(markdown::CodeHandler)));
1133 }
1134 convert_html_to_markdown(&body[..], &mut handlers)
1135 }
1136 ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()),
1137 ContentType::Json => {
1138 let json: serde_json::Value = serde_json::from_slice(&body)?;
1139
1140 Ok(format!(
1141 "```json\n{}\n```",
1142 serde_json::to_string_pretty(&json)?
1143 ))
1144 }
1145 }
1146}