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