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