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