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