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;
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::PastedImage => {
239 debug_panic!("pasted image URI should not be included in completions");
240 Task::ready(Err(anyhow!(
241 "pasted imaged URI should not be included in completions"
242 )))
243 }
244 MentionUri::Selection { .. } => {
245 debug_panic!("unexpected selection URI");
246 Task::ready(Err(anyhow!("unexpected selection URI")))
247 }
248 };
249 let task = cx
250 .spawn(async move |_, _| task.await.map_err(|e| e.to_string()))
251 .shared();
252 self.mentions.insert(crease_id, (mention_uri, task.clone()));
253
254 // Notify the user if we failed to load the mentioned context
255 cx.spawn_in(window, async move |this, cx| {
256 let result = task.await.notify_async_err(cx);
257 drop(tx);
258 if result.is_none() {
259 this.update(cx, |this, cx| {
260 editor.update(cx, |editor, cx| {
261 // Remove mention
262 editor.edit([(start_anchor..end_anchor, "")], cx);
263 });
264 this.mentions.remove(&crease_id);
265 })
266 .ok();
267 }
268 })
269 }
270
271 pub fn confirm_mention_for_file(
272 &self,
273 abs_path: PathBuf,
274 supports_images: bool,
275 cx: &mut Context<Self>,
276 ) -> Task<Result<Mention>> {
277 let Some(project) = self.project.upgrade() else {
278 return Task::ready(Err(anyhow!("project not found")));
279 };
280
281 let Some(project_path) = project
282 .read(cx)
283 .project_path_for_absolute_path(&abs_path, cx)
284 else {
285 return Task::ready(Err(anyhow!("project path not found")));
286 };
287 let extension = abs_path
288 .extension()
289 .and_then(OsStr::to_str)
290 .unwrap_or_default();
291
292 if Img::extensions().contains(&extension) && !extension.contains("svg") {
293 if !supports_images {
294 return Task::ready(Err(anyhow!("This model does not support images yet")));
295 }
296 let task = project.update(cx, |project, cx| project.open_image(project_path, cx));
297 return cx.spawn(async move |_, cx| {
298 let image = task.await?;
299 let image = image.update(cx, |image, _| image.image.clone());
300 let format = image.format;
301 let image = cx
302 .update(|cx| LanguageModelImage::from_image(image, cx))
303 .await;
304 if let Some(image) = image {
305 Ok(Mention::Image(MentionImage {
306 data: image.source,
307 format,
308 }))
309 } else {
310 Err(anyhow!("Failed to convert image"))
311 }
312 });
313 }
314
315 let buffer = project.update(cx, |project, cx| project.open_buffer(project_path, cx));
316 cx.spawn(async move |_, cx| {
317 let buffer = buffer.await?;
318 let buffer_content = outline::get_buffer_content_or_outline(
319 buffer.clone(),
320 Some(&abs_path.to_string_lossy()),
321 &cx,
322 )
323 .await?;
324
325 Ok(Mention::Text {
326 content: buffer_content.text,
327 tracked_buffers: vec![buffer],
328 })
329 })
330 }
331
332 fn confirm_mention_for_fetch(
333 &self,
334 url: url::Url,
335 http_client: Arc<HttpClientWithUrl>,
336 cx: &mut Context<Self>,
337 ) -> Task<Result<Mention>> {
338 cx.background_executor().spawn(async move {
339 let content = fetch_url_content(http_client, url.to_string()).await?;
340 Ok(Mention::Text {
341 content,
342 tracked_buffers: Vec::new(),
343 })
344 })
345 }
346
347 fn confirm_mention_for_symbol(
348 &self,
349 abs_path: PathBuf,
350 line_range: RangeInclusive<u32>,
351 cx: &mut Context<Self>,
352 ) -> Task<Result<Mention>> {
353 let Some(project) = self.project.upgrade() else {
354 return Task::ready(Err(anyhow!("project not found")));
355 };
356 let Some(project_path) = project
357 .read(cx)
358 .project_path_for_absolute_path(&abs_path, cx)
359 else {
360 return Task::ready(Err(anyhow!("project path not found")));
361 };
362 let buffer = project.update(cx, |project, cx| project.open_buffer(project_path, cx));
363 cx.spawn(async move |_, cx| {
364 let buffer = buffer.await?;
365 let mention = buffer.update(cx, |buffer, cx| {
366 let start = Point::new(*line_range.start(), 0).min(buffer.max_point());
367 let end = Point::new(*line_range.end() + 1, 0).min(buffer.max_point());
368 let content = buffer.text_for_range(start..end).collect();
369 Mention::Text {
370 content,
371 tracked_buffers: vec![cx.entity()],
372 }
373 });
374 Ok(mention)
375 })
376 }
377
378 fn confirm_mention_for_rule(
379 &mut self,
380 id: PromptId,
381 cx: &mut Context<Self>,
382 ) -> Task<Result<Mention>> {
383 let Some(prompt_store) = self.prompt_store.as_ref() else {
384 return Task::ready(Err(anyhow!("Missing prompt store")));
385 };
386 let prompt = prompt_store.read(cx).load(id, cx);
387 cx.spawn(async move |_, _| {
388 let prompt = prompt.await?;
389 Ok(Mention::Text {
390 content: prompt,
391 tracked_buffers: Vec::new(),
392 })
393 })
394 }
395
396 pub fn confirm_mention_for_selection(
397 &mut self,
398 source_range: Range<text::Anchor>,
399 selections: Vec<(Entity<Buffer>, Range<text::Anchor>, Range<usize>)>,
400 editor: Entity<Editor>,
401 window: &mut Window,
402 cx: &mut Context<Self>,
403 ) {
404 let Some(project) = self.project.upgrade() else {
405 return;
406 };
407
408 let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx);
409 let Some(start) = snapshot.as_singleton_anchor(source_range.start) else {
410 return;
411 };
412
413 let offset = start.to_offset(&snapshot);
414
415 for (buffer, selection_range, range_to_fold) in selections {
416 let range = snapshot.anchor_after(offset + range_to_fold.start)
417 ..snapshot.anchor_after(offset + range_to_fold.end);
418
419 let abs_path = buffer
420 .read(cx)
421 .project_path(cx)
422 .and_then(|project_path| project.read(cx).absolute_path(&project_path, cx));
423 let snapshot = buffer.read(cx).snapshot();
424
425 let text = snapshot
426 .text_for_range(selection_range.clone())
427 .collect::<String>();
428 let point_range = selection_range.to_point(&snapshot);
429 let line_range = point_range.start.row..=point_range.end.row;
430
431 let uri = MentionUri::Selection {
432 abs_path: abs_path.clone(),
433 line_range: line_range.clone(),
434 };
435 let crease = crease_for_mention(
436 selection_name(abs_path.as_deref(), &line_range).into(),
437 uri.icon_path(cx),
438 range,
439 editor.downgrade(),
440 );
441
442 let crease_id = editor.update(cx, |editor, cx| {
443 let crease_ids = editor.insert_creases(vec![crease.clone()], cx);
444 editor.fold_creases(vec![crease], false, window, cx);
445 crease_ids.first().copied().unwrap()
446 });
447
448 self.mentions.insert(
449 crease_id,
450 (
451 uri,
452 Task::ready(Ok(Mention::Text {
453 content: text,
454 tracked_buffers: vec![buffer],
455 }))
456 .shared(),
457 ),
458 );
459 }
460
461 // Take this explanation with a grain of salt but, with creases being
462 // inserted, GPUI's recomputes the editor layout in the next frames, so
463 // directly calling `editor.request_autoscroll` wouldn't work as
464 // expected. We're leveraging `cx.on_next_frame` to wait 2 frames and
465 // ensure that the layout has been recalculated so that the autoscroll
466 // request actually shows the cursor's new position.
467 cx.on_next_frame(window, move |_, window, cx| {
468 cx.on_next_frame(window, move |_, _, cx| {
469 editor.update(cx, |editor, cx| {
470 editor.request_autoscroll(Autoscroll::fit(), cx)
471 });
472 });
473 });
474 }
475
476 fn confirm_mention_for_thread(
477 &mut self,
478 id: acp::SessionId,
479 cx: &mut Context<Self>,
480 ) -> Task<Result<Mention>> {
481 let Some(thread_store) = self.thread_store.clone() else {
482 return Task::ready(Err(anyhow!(
483 "Thread mentions are only supported for the native agent"
484 )));
485 };
486 let Some(project) = self.project.upgrade() else {
487 return Task::ready(Err(anyhow!("project not found")));
488 };
489
490 let server = Rc::new(agent::NativeAgentServer::new(
491 project.read(cx).fs().clone(),
492 thread_store,
493 ));
494 let delegate = AgentServerDelegate::new(
495 project.read(cx).agent_server_store().clone(),
496 project.clone(),
497 None,
498 None,
499 );
500 let connection = server.connect(None, delegate, cx);
501 cx.spawn(async move |_, cx| {
502 let (agent, _) = connection.await?;
503 let agent = agent.downcast::<agent::NativeAgentConnection>().unwrap();
504 let summary = agent
505 .0
506 .update(cx, |agent, cx| agent.thread_summary(id, cx))
507 .await?;
508 Ok(Mention::Text {
509 content: summary.to_string(),
510 tracked_buffers: Vec::new(),
511 })
512 })
513 }
514}
515
516#[cfg(test)]
517mod tests {
518 use super::*;
519
520 use fs::FakeFs;
521 use gpui::TestAppContext;
522 use project::Project;
523 use prompt_store;
524 use release_channel;
525 use semver::Version;
526 use serde_json::json;
527 use settings::SettingsStore;
528 use std::path::Path;
529 use theme;
530 use util::path;
531
532 fn init_test(cx: &mut TestAppContext) {
533 let settings_store = cx.update(SettingsStore::test);
534 cx.set_global(settings_store);
535 cx.update(|cx| {
536 theme::init(theme::LoadThemes::JustBase, cx);
537 release_channel::init(Version::new(0, 0, 0), cx);
538 prompt_store::init(cx);
539 });
540 }
541
542 #[gpui::test]
543 async fn test_thread_mentions_disabled(cx: &mut TestAppContext) {
544 init_test(cx);
545
546 let fs = FakeFs::new(cx.executor());
547 fs.insert_tree("/project", json!({"file": ""})).await;
548 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
549 let thread_store = None;
550 let mention_set = cx.new(|_cx| MentionSet::new(project.downgrade(), thread_store, None));
551
552 let task = mention_set.update(cx, |mention_set, cx| {
553 mention_set.confirm_mention_for_thread(acp::SessionId::new("thread-1"), cx)
554 });
555
556 let error = task.await.unwrap_err();
557 assert!(
558 error
559 .to_string()
560 .contains("Thread mentions are only supported for the native agent"),
561 "Unexpected error: {error:#}"
562 );
563 }
564}
565
566pub(crate) fn paste_images_as_context(
567 editor: Entity<Editor>,
568 mention_set: Entity<MentionSet>,
569 window: &mut Window,
570 cx: &mut App,
571) -> Option<Task<()>> {
572 let clipboard = cx.read_from_clipboard()?;
573 Some(window.spawn(cx, async move |cx| {
574 use itertools::Itertools;
575 let (mut images, paths) = clipboard
576 .into_entries()
577 .filter_map(|entry| match entry {
578 ClipboardEntry::Image(image) => Some(Either::Left(image)),
579 ClipboardEntry::ExternalPaths(paths) => Some(Either::Right(paths)),
580 _ => None,
581 })
582 .partition_map::<Vec<_>, Vec<_>, _, _, _>(std::convert::identity);
583
584 if !paths.is_empty() {
585 images.extend(
586 cx.background_spawn(async move {
587 let mut images = vec![];
588 for path in paths.into_iter().flat_map(|paths| paths.paths().to_owned()) {
589 let Ok(content) = async_fs::read(path).await else {
590 continue;
591 };
592 let Ok(format) = image::guess_format(&content) else {
593 continue;
594 };
595 images.push(gpui::Image::from_bytes(
596 match format {
597 image::ImageFormat::Png => gpui::ImageFormat::Png,
598 image::ImageFormat::Jpeg => gpui::ImageFormat::Jpeg,
599 image::ImageFormat::WebP => gpui::ImageFormat::Webp,
600 image::ImageFormat::Gif => gpui::ImageFormat::Gif,
601 image::ImageFormat::Bmp => gpui::ImageFormat::Bmp,
602 image::ImageFormat::Tiff => gpui::ImageFormat::Tiff,
603 image::ImageFormat::Ico => gpui::ImageFormat::Ico,
604 _ => continue,
605 },
606 content,
607 ));
608 }
609 images
610 })
611 .await,
612 );
613 }
614
615 if images.is_empty() {
616 return;
617 }
618
619 let replacement_text = MentionUri::PastedImage.as_link().to_string();
620 cx.update(|_window, cx| {
621 cx.stop_propagation();
622 })
623 .ok();
624 for image in images {
625 let Some((excerpt_id, text_anchor, multibuffer_anchor)) = editor
626 .update_in(cx, |message_editor, window, cx| {
627 let snapshot = message_editor.snapshot(window, cx);
628 let (excerpt_id, _, buffer_snapshot) =
629 snapshot.buffer_snapshot().as_singleton().unwrap();
630
631 let text_anchor = buffer_snapshot.anchor_before(buffer_snapshot.len());
632 let multibuffer_anchor = snapshot
633 .buffer_snapshot()
634 .anchor_in_excerpt(*excerpt_id, text_anchor);
635 message_editor.edit(
636 [(
637 multi_buffer::Anchor::max()..multi_buffer::Anchor::max(),
638 format!("{replacement_text} "),
639 )],
640 cx,
641 );
642 (*excerpt_id, text_anchor, multibuffer_anchor)
643 })
644 .ok()
645 else {
646 break;
647 };
648
649 let content_len = replacement_text.len();
650 let Some(start_anchor) = multibuffer_anchor else {
651 continue;
652 };
653 let end_anchor = editor.update(cx, |editor, cx| {
654 let snapshot = editor.buffer().read(cx).snapshot(cx);
655 snapshot.anchor_before(start_anchor.to_offset(&snapshot) + content_len)
656 });
657 let image = Arc::new(image);
658 let Ok(Some((crease_id, tx))) = cx.update(|window, cx| {
659 insert_crease_for_mention(
660 excerpt_id,
661 text_anchor,
662 content_len,
663 MentionUri::PastedImage.name().into(),
664 IconName::Image.path().into(),
665 Some(Task::ready(Ok(image.clone())).shared()),
666 editor.clone(),
667 window,
668 cx,
669 )
670 }) else {
671 continue;
672 };
673 let task = cx
674 .spawn(async move |cx| {
675 let format = image.format;
676 let image = cx
677 .update(|_, cx| LanguageModelImage::from_image(image, cx))
678 .map_err(|e| e.to_string())?
679 .await;
680 drop(tx);
681 if let Some(image) = image {
682 Ok(Mention::Image(MentionImage {
683 data: image.source,
684 format,
685 }))
686 } else {
687 Err("Failed to convert image".into())
688 }
689 })
690 .shared();
691
692 mention_set.update(cx, |mention_set, _cx| {
693 mention_set.insert_mention(crease_id, MentionUri::PastedImage, task.clone())
694 });
695
696 if task.await.notify_async_err(cx).is_none() {
697 editor.update(cx, |editor, cx| {
698 editor.edit([(start_anchor..end_anchor, "")], cx);
699 });
700 mention_set.update(cx, |mention_set, _cx| {
701 mention_set.remove_mention(&crease_id)
702 });
703 }
704 }
705 }))
706}
707
708pub(crate) fn insert_crease_for_mention(
709 excerpt_id: ExcerptId,
710 anchor: text::Anchor,
711 content_len: usize,
712 crease_label: SharedString,
713 crease_icon: SharedString,
714 // abs_path: Option<Arc<Path>>,
715 image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
716 editor: Entity<Editor>,
717 window: &mut Window,
718 cx: &mut App,
719) -> Option<(CreaseId, postage::barrier::Sender)> {
720 let (tx, rx) = postage::barrier::channel();
721
722 let crease_id = editor.update(cx, |editor, cx| {
723 let snapshot = editor.buffer().read(cx).snapshot(cx);
724
725 let start = snapshot.anchor_in_excerpt(excerpt_id, anchor)?;
726
727 let start = start.bias_right(&snapshot);
728 let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
729
730 let placeholder = FoldPlaceholder {
731 render: render_mention_fold_button(
732 crease_label.clone(),
733 crease_icon.clone(),
734 start..end,
735 rx,
736 image,
737 cx.weak_entity(),
738 cx,
739 ),
740 merge_adjacent: false,
741 ..Default::default()
742 };
743
744 let crease = Crease::Inline {
745 range: start..end,
746 placeholder,
747 render_toggle: None,
748 render_trailer: None,
749 metadata: Some(CreaseMetadata {
750 label: crease_label,
751 icon_path: crease_icon,
752 }),
753 };
754
755 let ids = editor.insert_creases(vec![crease.clone()], cx);
756 editor.fold_creases(vec![crease], false, window, cx);
757
758 Some(ids[0])
759 })?;
760
761 Some((crease_id, tx))
762}
763
764pub(crate) fn crease_for_mention(
765 label: SharedString,
766 icon_path: SharedString,
767 range: Range<Anchor>,
768 editor_entity: WeakEntity<Editor>,
769) -> Crease<Anchor> {
770 let placeholder = FoldPlaceholder {
771 render: render_fold_icon_button(icon_path.clone(), label.clone(), editor_entity),
772 merge_adjacent: false,
773 ..Default::default()
774 };
775
776 let render_trailer = move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any();
777
778 Crease::inline(range, placeholder, fold_toggle("mention"), render_trailer)
779 .with_metadata(CreaseMetadata { icon_path, label })
780}
781
782fn render_fold_icon_button(
783 icon_path: SharedString,
784 label: SharedString,
785 editor: WeakEntity<Editor>,
786) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
787 Arc::new({
788 move |fold_id, fold_range, cx| {
789 let is_in_text_selection = editor
790 .update(cx, |editor, cx| editor.is_range_selected(&fold_range, cx))
791 .unwrap_or_default();
792
793 MentionCrease::new(fold_id, icon_path.clone(), label.clone())
794 .is_toggled(is_in_text_selection)
795 .into_any_element()
796 }
797 })
798}
799
800fn fold_toggle(
801 name: &'static str,
802) -> impl Fn(
803 MultiBufferRow,
804 bool,
805 Arc<dyn Fn(bool, &mut Window, &mut App) + Send + Sync>,
806 &mut Window,
807 &mut App,
808) -> AnyElement {
809 move |row, is_folded, fold, _window, _cx| {
810 Disclosure::new((name, row.0 as u64), !is_folded)
811 .toggle_state(is_folded)
812 .on_click(move |_e, window, cx| fold(!is_folded, window, cx))
813 .into_any_element()
814 }
815}
816
817fn full_mention_for_directory(
818 project: &Entity<Project>,
819 abs_path: &Path,
820 cx: &mut App,
821) -> Task<Result<Mention>> {
822 fn collect_files_in_path(worktree: &Worktree, path: &RelPath) -> Vec<(Arc<RelPath>, String)> {
823 let mut files = Vec::new();
824
825 for entry in worktree.child_entries(path) {
826 if entry.is_dir() {
827 files.extend(collect_files_in_path(worktree, &entry.path));
828 } else if entry.is_file() {
829 files.push((
830 entry.path.clone(),
831 worktree
832 .full_path(&entry.path)
833 .to_string_lossy()
834 .to_string(),
835 ));
836 }
837 }
838
839 files
840 }
841
842 let Some(project_path) = project
843 .read(cx)
844 .project_path_for_absolute_path(&abs_path, cx)
845 else {
846 return Task::ready(Err(anyhow!("project path not found")));
847 };
848 let Some(entry) = project.read(cx).entry_for_path(&project_path, cx) else {
849 return Task::ready(Err(anyhow!("project entry not found")));
850 };
851 let directory_path = entry.path.clone();
852 let worktree_id = project_path.worktree_id;
853 let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) else {
854 return Task::ready(Err(anyhow!("worktree not found")));
855 };
856 let project = project.clone();
857 cx.spawn(async move |cx| {
858 let file_paths = worktree.read_with(cx, |worktree, _cx| {
859 collect_files_in_path(worktree, &directory_path)
860 });
861 let descendants_future = cx.update(|cx| {
862 futures::future::join_all(file_paths.into_iter().map(
863 |(worktree_path, full_path): (Arc<RelPath>, String)| {
864 let rel_path = worktree_path
865 .strip_prefix(&directory_path)
866 .log_err()
867 .map_or_else(|| worktree_path.clone(), |rel_path| rel_path.into());
868
869 let open_task = project.update(cx, |project, cx| {
870 project.buffer_store().update(cx, |buffer_store, cx| {
871 let project_path = ProjectPath {
872 worktree_id,
873 path: worktree_path,
874 };
875 buffer_store.open_buffer(project_path, cx)
876 })
877 });
878
879 cx.spawn(async move |cx| {
880 let buffer = open_task.await.log_err()?;
881 let buffer_content = outline::get_buffer_content_or_outline(
882 buffer.clone(),
883 Some(&full_path),
884 &cx,
885 )
886 .await
887 .ok()?;
888
889 Some((rel_path, full_path, buffer_content.text, buffer))
890 })
891 },
892 ))
893 });
894
895 let contents = cx
896 .background_spawn(async move {
897 let (contents, tracked_buffers): (Vec<_>, Vec<_>) = descendants_future
898 .await
899 .into_iter()
900 .flatten()
901 .map(|(rel_path, full_path, rope, buffer)| {
902 ((rel_path, full_path, rope), buffer)
903 })
904 .unzip();
905 Mention::Text {
906 content: render_directory_contents(contents),
907 tracked_buffers,
908 }
909 })
910 .await;
911 anyhow::Ok(contents)
912 })
913}
914
915fn render_directory_contents(entries: Vec<(Arc<RelPath>, String, String)>) -> String {
916 let mut output = String::new();
917 for (_relative_path, full_path, content) in entries {
918 let fence = codeblock_fence_for_path(Some(&full_path), None);
919 write!(output, "\n{fence}\n{content}\n```").unwrap();
920 }
921 output
922}
923
924fn render_mention_fold_button(
925 label: SharedString,
926 icon: SharedString,
927 range: Range<Anchor>,
928 mut loading_finished: postage::barrier::Receiver,
929 image_task: Option<Shared<Task<Result<Arc<Image>, String>>>>,
930 editor: WeakEntity<Editor>,
931 cx: &mut App,
932) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
933 let loading = cx.new(|cx| {
934 let loading = cx.spawn(async move |this, cx| {
935 loading_finished.recv().await;
936 this.update(cx, |this: &mut LoadingContext, cx| {
937 this.loading = None;
938 cx.notify();
939 })
940 .ok();
941 });
942 LoadingContext {
943 id: cx.entity_id(),
944 label,
945 icon,
946 range,
947 editor,
948 loading: Some(loading),
949 image: image_task.clone(),
950 }
951 });
952 Arc::new(move |_fold_id, _fold_range, _cx| loading.clone().into_any_element())
953}
954
955struct LoadingContext {
956 id: EntityId,
957 label: SharedString,
958 icon: SharedString,
959 range: Range<Anchor>,
960 editor: WeakEntity<Editor>,
961 loading: Option<Task<()>>,
962 image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
963}
964
965impl Render for LoadingContext {
966 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
967 let is_in_text_selection = self
968 .editor
969 .update(cx, |editor, cx| editor.is_range_selected(&self.range, cx))
970 .unwrap_or_default();
971
972 let id = ElementId::from(("loading_context", self.id));
973
974 MentionCrease::new(id, self.icon.clone(), self.label.clone())
975 .is_toggled(is_in_text_selection)
976 .is_loading(self.loading.is_some())
977 .when_some(self.image.clone(), |this, image_task| {
978 this.image_preview(move |_, cx| {
979 let image = image_task.peek().cloned().transpose().ok().flatten();
980 let image_task = image_task.clone();
981 cx.new::<ImageHover>(|cx| ImageHover {
982 image,
983 _task: cx.spawn(async move |this, cx| {
984 if let Ok(image) = image_task.clone().await {
985 this.update(cx, |this, cx| {
986 if this.image.replace(image).is_none() {
987 cx.notify();
988 }
989 })
990 .ok();
991 }
992 }),
993 })
994 .into()
995 })
996 })
997 }
998}
999
1000struct ImageHover {
1001 image: Option<Arc<Image>>,
1002 _task: Task<()>,
1003}
1004
1005impl Render for ImageHover {
1006 fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
1007 if let Some(image) = self.image.clone() {
1008 gpui::img(image).max_w_96().max_h_96().into_any_element()
1009 } else {
1010 gpui::Empty.into_any_element()
1011 }
1012 }
1013}
1014
1015async fn fetch_url_content(http_client: Arc<HttpClientWithUrl>, url: String) -> Result<String> {
1016 #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
1017 enum ContentType {
1018 Html,
1019 Plaintext,
1020 Json,
1021 }
1022 use html_to_markdown::{TagHandler, convert_html_to_markdown, markdown};
1023
1024 let url = if !url.starts_with("https://") && !url.starts_with("http://") {
1025 format!("https://{url}")
1026 } else {
1027 url
1028 };
1029
1030 let mut response = http_client.get(&url, AsyncBody::default(), true).await?;
1031 let mut body = Vec::new();
1032 response
1033 .body_mut()
1034 .read_to_end(&mut body)
1035 .await
1036 .context("error reading response body")?;
1037
1038 if response.status().is_client_error() {
1039 let text = String::from_utf8_lossy(body.as_slice());
1040 anyhow::bail!(
1041 "status error {}, response: {text:?}",
1042 response.status().as_u16()
1043 );
1044 }
1045
1046 let Some(content_type) = response.headers().get("content-type") else {
1047 anyhow::bail!("missing Content-Type header");
1048 };
1049 let content_type = content_type
1050 .to_str()
1051 .context("invalid Content-Type header")?;
1052 let content_type = match content_type {
1053 "text/html" => ContentType::Html,
1054 "text/plain" => ContentType::Plaintext,
1055 "application/json" => ContentType::Json,
1056 _ => ContentType::Html,
1057 };
1058
1059 match content_type {
1060 ContentType::Html => {
1061 let mut handlers: Vec<TagHandler> = vec![
1062 Rc::new(RefCell::new(markdown::WebpageChromeRemover)),
1063 Rc::new(RefCell::new(markdown::ParagraphHandler)),
1064 Rc::new(RefCell::new(markdown::HeadingHandler)),
1065 Rc::new(RefCell::new(markdown::ListHandler)),
1066 Rc::new(RefCell::new(markdown::TableHandler::new())),
1067 Rc::new(RefCell::new(markdown::StyledTextHandler)),
1068 ];
1069 if url.contains("wikipedia.org") {
1070 use html_to_markdown::structure::wikipedia;
1071
1072 handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover)));
1073 handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaInfoboxHandler)));
1074 handlers.push(Rc::new(
1075 RefCell::new(wikipedia::WikipediaCodeHandler::new()),
1076 ));
1077 } else {
1078 handlers.push(Rc::new(RefCell::new(markdown::CodeHandler)));
1079 }
1080 convert_html_to_markdown(&body[..], &mut handlers)
1081 }
1082 ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()),
1083 ContentType::Json => {
1084 let json: serde_json::Value = serde_json::from_slice(&body)?;
1085
1086 Ok(format!(
1087 "```json\n{}\n```",
1088 serde_json::to_string_pretty(&json)?
1089 ))
1090 }
1091 }
1092}