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 =
557 AgentServerDelegate::new(project.read(cx).agent_server_store().clone(), None);
558 let connection = server.connect(delegate, cx);
559 cx.spawn(async move |_, cx| {
560 let agent = connection.await?;
561 let agent = agent.downcast::<agent::NativeAgentConnection>().unwrap();
562 let summary = agent
563 .0
564 .update(cx, |agent, cx| {
565 agent.thread_summary(id, project.clone(), cx)
566 })
567 .await?;
568 Ok(Mention::Text {
569 content: summary.to_string(),
570 tracked_buffers: Vec::new(),
571 })
572 })
573 }
574
575 fn confirm_mention_for_diagnostics(
576 &self,
577 include_errors: bool,
578 include_warnings: bool,
579 cx: &mut Context<Self>,
580 ) -> Task<Result<Mention>> {
581 let Some(project) = self.project.upgrade() else {
582 return Task::ready(Err(anyhow!("project not found")));
583 };
584
585 let diagnostics_task = collect_diagnostics_output(
586 project,
587 assistant_slash_commands::Options {
588 include_errors,
589 include_warnings,
590 path_matcher: None,
591 },
592 cx,
593 );
594 cx.spawn(async move |_, _| {
595 let output = diagnostics_task.await?;
596 let content = output
597 .map(|output| output.text)
598 .unwrap_or_else(|| "No diagnostics found.".into());
599 Ok(Mention::Text {
600 content,
601 tracked_buffers: Vec::new(),
602 })
603 })
604 }
605}
606
607#[cfg(test)]
608mod tests {
609 use super::*;
610
611 use fs::FakeFs;
612 use gpui::TestAppContext;
613 use project::Project;
614 use prompt_store;
615 use release_channel;
616 use semver::Version;
617 use serde_json::json;
618 use settings::SettingsStore;
619 use std::path::Path;
620 use theme;
621 use util::path;
622
623 fn init_test(cx: &mut TestAppContext) {
624 let settings_store = cx.update(SettingsStore::test);
625 cx.set_global(settings_store);
626 cx.update(|cx| {
627 theme::init(theme::LoadThemes::JustBase, cx);
628 release_channel::init(Version::new(0, 0, 0), cx);
629 prompt_store::init(cx);
630 });
631 }
632
633 #[gpui::test]
634 async fn test_thread_mentions_disabled(cx: &mut TestAppContext) {
635 init_test(cx);
636
637 let fs = FakeFs::new(cx.executor());
638 fs.insert_tree("/project", json!({"file": ""})).await;
639 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
640 let thread_store = None;
641 let mention_set = cx.new(|_cx| MentionSet::new(project.downgrade(), thread_store, None));
642
643 let task = mention_set.update(cx, |mention_set, cx| {
644 mention_set.confirm_mention_for_thread(acp::SessionId::new("thread-1"), cx)
645 });
646
647 let error = task.await.unwrap_err();
648 assert!(
649 error
650 .to_string()
651 .contains("Thread mentions are only supported for the native agent"),
652 "Unexpected error: {error:#}"
653 );
654 }
655}
656
657/// Inserts a list of images into the editor as context mentions.
658/// This is the shared implementation used by both paste and file picker operations.
659pub(crate) async fn insert_images_as_context(
660 images: Vec<gpui::Image>,
661 editor: Entity<Editor>,
662 mention_set: Entity<MentionSet>,
663 workspace: WeakEntity<Workspace>,
664 cx: &mut gpui::AsyncWindowContext,
665) {
666 if images.is_empty() {
667 return;
668 }
669
670 let replacement_text = MentionUri::PastedImage.as_link().to_string();
671
672 for image in images {
673 let Some((excerpt_id, text_anchor, multibuffer_anchor)) = editor
674 .update_in(cx, |editor, window, cx| {
675 let snapshot = editor.snapshot(window, cx);
676 let (excerpt_id, _, buffer_snapshot) =
677 snapshot.buffer_snapshot().as_singleton().unwrap();
678
679 let cursor_anchor = editor.selections.newest_anchor().start.text_anchor;
680 let text_anchor = cursor_anchor.bias_left(&buffer_snapshot);
681 let multibuffer_anchor = snapshot
682 .buffer_snapshot()
683 .anchor_in_excerpt(excerpt_id, text_anchor);
684 editor.insert(&format!("{replacement_text} "), window, cx);
685 (excerpt_id, text_anchor, multibuffer_anchor)
686 })
687 .ok()
688 else {
689 break;
690 };
691
692 let content_len = replacement_text.len();
693 let Some(start_anchor) = multibuffer_anchor else {
694 continue;
695 };
696 let end_anchor = editor.update(cx, |editor, cx| {
697 let snapshot = editor.buffer().read(cx).snapshot(cx);
698 snapshot.anchor_before(start_anchor.to_offset(&snapshot) + content_len)
699 });
700 let image = Arc::new(image);
701 let Ok(Some((crease_id, tx))) = cx.update(|window, cx| {
702 insert_crease_for_mention(
703 excerpt_id,
704 text_anchor,
705 content_len,
706 MentionUri::PastedImage.name().into(),
707 IconName::Image.path().into(),
708 None,
709 None,
710 None,
711 Some(Task::ready(Ok(image.clone())).shared()),
712 editor.clone(),
713 window,
714 cx,
715 )
716 }) else {
717 continue;
718 };
719 let task = cx
720 .spawn(async move |cx| {
721 let image = cx
722 .update(|_, cx| LanguageModelImage::from_image(image, cx))
723 .map_err(|e| e.to_string())?
724 .await;
725 drop(tx);
726 if let Some(image) = image {
727 Ok(Mention::Image(MentionImage {
728 data: image.source,
729 format: LanguageModelImage::FORMAT,
730 }))
731 } else {
732 Err("Failed to convert image".into())
733 }
734 })
735 .shared();
736
737 mention_set.update(cx, |mention_set, _cx| {
738 mention_set.insert_mention(crease_id, MentionUri::PastedImage, task.clone())
739 });
740
741 if task
742 .await
743 .notify_workspace_async_err(workspace.clone(), cx)
744 .is_none()
745 {
746 editor.update(cx, |editor, cx| {
747 editor.edit([(start_anchor..end_anchor, "")], cx);
748 });
749 mention_set.update(cx, |mention_set, _cx| {
750 mention_set.remove_mention(&crease_id)
751 });
752 }
753 }
754}
755
756pub(crate) fn paste_images_as_context(
757 editor: Entity<Editor>,
758 mention_set: Entity<MentionSet>,
759 workspace: WeakEntity<Workspace>,
760 window: &mut Window,
761 cx: &mut App,
762) -> Option<Task<()>> {
763 let clipboard = cx.read_from_clipboard()?;
764 Some(window.spawn(cx, async move |mut cx| {
765 use itertools::Itertools;
766 let (mut images, paths) = clipboard
767 .into_entries()
768 .filter_map(|entry| match entry {
769 ClipboardEntry::Image(image) => Some(Either::Left(image)),
770 ClipboardEntry::ExternalPaths(paths) => Some(Either::Right(paths)),
771 _ => None,
772 })
773 .partition_map::<Vec<_>, Vec<_>, _, _, _>(std::convert::identity);
774
775 if !paths.is_empty() {
776 images.extend(
777 cx.background_spawn(async move {
778 let mut images = vec![];
779 for path in paths.into_iter().flat_map(|paths| paths.paths().to_owned()) {
780 let Ok(content) = async_fs::read(path).await else {
781 continue;
782 };
783 let Ok(format) = image::guess_format(&content) else {
784 continue;
785 };
786 images.push(gpui::Image::from_bytes(
787 match format {
788 image::ImageFormat::Png => gpui::ImageFormat::Png,
789 image::ImageFormat::Jpeg => gpui::ImageFormat::Jpeg,
790 image::ImageFormat::WebP => gpui::ImageFormat::Webp,
791 image::ImageFormat::Gif => gpui::ImageFormat::Gif,
792 image::ImageFormat::Bmp => gpui::ImageFormat::Bmp,
793 image::ImageFormat::Tiff => gpui::ImageFormat::Tiff,
794 image::ImageFormat::Ico => gpui::ImageFormat::Ico,
795 _ => continue,
796 },
797 content,
798 ));
799 }
800 images
801 })
802 .await,
803 );
804 }
805
806 cx.update(|_window, cx| {
807 cx.stop_propagation();
808 })
809 .ok();
810
811 insert_images_as_context(images, editor, mention_set, workspace, &mut cx).await;
812 }))
813}
814
815pub(crate) fn insert_crease_for_mention(
816 excerpt_id: ExcerptId,
817 anchor: text::Anchor,
818 content_len: usize,
819 crease_label: SharedString,
820 crease_icon: SharedString,
821 crease_tooltip: Option<SharedString>,
822 mention_uri: Option<MentionUri>,
823 workspace: Option<WeakEntity<Workspace>>,
824 image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
825 editor: Entity<Editor>,
826 window: &mut Window,
827 cx: &mut App,
828) -> Option<(CreaseId, postage::barrier::Sender)> {
829 let (tx, rx) = postage::barrier::channel();
830
831 let crease_id = editor.update(cx, |editor, cx| {
832 let snapshot = editor.buffer().read(cx).snapshot(cx);
833
834 let start = snapshot.anchor_in_excerpt(excerpt_id, anchor)?;
835
836 let start = start.bias_right(&snapshot);
837 let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
838
839 let placeholder = FoldPlaceholder {
840 render: render_mention_fold_button(
841 crease_label.clone(),
842 crease_icon.clone(),
843 crease_tooltip,
844 mention_uri.clone(),
845 workspace.clone(),
846 start..end,
847 rx,
848 image,
849 cx.weak_entity(),
850 cx,
851 ),
852 merge_adjacent: false,
853 ..Default::default()
854 };
855
856 let crease = Crease::Inline {
857 range: start..end,
858 placeholder,
859 render_toggle: None,
860 render_trailer: None,
861 metadata: Some(CreaseMetadata {
862 label: crease_label,
863 icon_path: crease_icon,
864 }),
865 };
866
867 let ids = editor.insert_creases(vec![crease.clone()], cx);
868 editor.fold_creases(vec![crease], false, window, cx);
869
870 Some(ids[0])
871 })?;
872
873 Some((crease_id, tx))
874}
875
876pub(crate) fn crease_for_mention(
877 label: SharedString,
878 icon_path: SharedString,
879 tooltip: Option<SharedString>,
880 range: Range<Anchor>,
881 editor_entity: WeakEntity<Editor>,
882) -> Crease<Anchor> {
883 let placeholder = FoldPlaceholder {
884 render: render_fold_icon_button(icon_path.clone(), label.clone(), tooltip, editor_entity),
885 merge_adjacent: false,
886 ..Default::default()
887 };
888
889 let render_trailer = move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any();
890
891 Crease::inline(range, placeholder, fold_toggle("mention"), render_trailer)
892 .with_metadata(CreaseMetadata { icon_path, label })
893}
894
895fn render_fold_icon_button(
896 icon_path: SharedString,
897 label: SharedString,
898 tooltip: Option<SharedString>,
899 editor: WeakEntity<Editor>,
900) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
901 Arc::new({
902 move |fold_id, fold_range, cx| {
903 let is_in_text_selection = editor
904 .update(cx, |editor, cx| editor.is_range_selected(&fold_range, cx))
905 .unwrap_or_default();
906
907 MentionCrease::new(fold_id, icon_path.clone(), label.clone())
908 .is_toggled(is_in_text_selection)
909 .when_some(tooltip.clone(), |this, tooltip_text| {
910 this.tooltip(tooltip_text)
911 })
912 .into_any_element()
913 }
914 })
915}
916
917fn fold_toggle(
918 name: &'static str,
919) -> impl Fn(
920 MultiBufferRow,
921 bool,
922 Arc<dyn Fn(bool, &mut Window, &mut App) + Send + Sync>,
923 &mut Window,
924 &mut App,
925) -> AnyElement {
926 move |row, is_folded, fold, _window, _cx| {
927 Disclosure::new((name, row.0 as u64), !is_folded)
928 .toggle_state(is_folded)
929 .on_click(move |_e, window, cx| fold(!is_folded, window, cx))
930 .into_any_element()
931 }
932}
933
934fn full_mention_for_directory(
935 project: &Entity<Project>,
936 abs_path: &Path,
937 cx: &mut App,
938) -> Task<Result<Mention>> {
939 fn collect_files_in_path(worktree: &Worktree, path: &RelPath) -> Vec<(Arc<RelPath>, String)> {
940 let mut files = Vec::new();
941
942 for entry in worktree.child_entries(path) {
943 if entry.is_dir() {
944 files.extend(collect_files_in_path(worktree, &entry.path));
945 } else if entry.is_file() {
946 files.push((
947 entry.path.clone(),
948 worktree
949 .full_path(&entry.path)
950 .to_string_lossy()
951 .to_string(),
952 ));
953 }
954 }
955
956 files
957 }
958
959 let Some(project_path) = project
960 .read(cx)
961 .project_path_for_absolute_path(&abs_path, cx)
962 else {
963 return Task::ready(Err(anyhow!("project path not found")));
964 };
965 let Some(entry) = project.read(cx).entry_for_path(&project_path, cx) else {
966 return Task::ready(Err(anyhow!("project entry not found")));
967 };
968 let directory_path = entry.path.clone();
969 let worktree_id = project_path.worktree_id;
970 let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) else {
971 return Task::ready(Err(anyhow!("worktree not found")));
972 };
973 let project = project.clone();
974 cx.spawn(async move |cx| {
975 let file_paths = worktree.read_with(cx, |worktree, _cx| {
976 collect_files_in_path(worktree, &directory_path)
977 });
978 let descendants_future = cx.update(|cx| {
979 futures::future::join_all(file_paths.into_iter().map(
980 |(worktree_path, full_path): (Arc<RelPath>, String)| {
981 let rel_path = worktree_path
982 .strip_prefix(&directory_path)
983 .log_err()
984 .map_or_else(|| worktree_path.clone(), |rel_path| rel_path.into());
985
986 let open_task = project.update(cx, |project, cx| {
987 project.buffer_store().update(cx, |buffer_store, cx| {
988 let project_path = ProjectPath {
989 worktree_id,
990 path: worktree_path,
991 };
992 buffer_store.open_buffer(project_path, cx)
993 })
994 });
995
996 cx.spawn(async move |cx| {
997 let buffer = open_task.await.log_err()?;
998 let buffer_content = outline::get_buffer_content_or_outline(
999 buffer.clone(),
1000 Some(&full_path),
1001 &cx,
1002 )
1003 .await
1004 .ok()?;
1005
1006 Some((rel_path, full_path, buffer_content.text, buffer))
1007 })
1008 },
1009 ))
1010 });
1011
1012 let contents = cx
1013 .background_spawn(async move {
1014 let (contents, tracked_buffers): (Vec<_>, Vec<_>) = descendants_future
1015 .await
1016 .into_iter()
1017 .flatten()
1018 .map(|(rel_path, full_path, rope, buffer)| {
1019 ((rel_path, full_path, rope), buffer)
1020 })
1021 .unzip();
1022 Mention::Text {
1023 content: render_directory_contents(contents),
1024 tracked_buffers,
1025 }
1026 })
1027 .await;
1028 anyhow::Ok(contents)
1029 })
1030}
1031
1032fn render_directory_contents(entries: Vec<(Arc<RelPath>, String, String)>) -> String {
1033 let mut output = String::new();
1034 for (_relative_path, full_path, content) in entries {
1035 let fence = codeblock_fence_for_path(Some(&full_path), None);
1036 write!(output, "\n{fence}\n{content}\n```").unwrap();
1037 }
1038 output
1039}
1040
1041fn render_mention_fold_button(
1042 label: SharedString,
1043 icon: SharedString,
1044 tooltip: Option<SharedString>,
1045 mention_uri: Option<MentionUri>,
1046 workspace: Option<WeakEntity<Workspace>>,
1047 range: Range<Anchor>,
1048 mut loading_finished: postage::barrier::Receiver,
1049 image_task: Option<Shared<Task<Result<Arc<Image>, String>>>>,
1050 editor: WeakEntity<Editor>,
1051 cx: &mut App,
1052) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
1053 let loading = cx.new(|cx| {
1054 let loading = cx.spawn(async move |this, cx| {
1055 loading_finished.recv().await;
1056 this.update(cx, |this: &mut LoadingContext, cx| {
1057 this.loading = None;
1058 cx.notify();
1059 })
1060 .ok();
1061 });
1062 LoadingContext {
1063 id: cx.entity_id(),
1064 label,
1065 icon,
1066 tooltip,
1067 mention_uri: mention_uri.clone(),
1068 workspace: workspace.clone(),
1069 range,
1070 editor,
1071 loading: Some(loading),
1072 image: image_task.clone(),
1073 }
1074 });
1075 Arc::new(move |_fold_id, _fold_range, _cx| loading.clone().into_any_element())
1076}
1077
1078struct LoadingContext {
1079 id: EntityId,
1080 label: SharedString,
1081 icon: SharedString,
1082 tooltip: Option<SharedString>,
1083 mention_uri: Option<MentionUri>,
1084 workspace: Option<WeakEntity<Workspace>>,
1085 range: Range<Anchor>,
1086 editor: WeakEntity<Editor>,
1087 loading: Option<Task<()>>,
1088 image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
1089}
1090
1091impl Render for LoadingContext {
1092 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1093 let is_in_text_selection = self
1094 .editor
1095 .update(cx, |editor, cx| editor.is_range_selected(&self.range, cx))
1096 .unwrap_or_default();
1097
1098 let id = ElementId::from(("loading_context", self.id));
1099
1100 MentionCrease::new(id, self.icon.clone(), self.label.clone())
1101 .mention_uri(self.mention_uri.clone())
1102 .workspace(self.workspace.clone())
1103 .is_toggled(is_in_text_selection)
1104 .is_loading(self.loading.is_some())
1105 .when_some(self.tooltip.clone(), |this, tooltip_text| {
1106 this.tooltip(tooltip_text)
1107 })
1108 .when_some(self.image.clone(), |this, image_task| {
1109 this.image_preview(move |_, cx| {
1110 let image = image_task.peek().cloned().transpose().ok().flatten();
1111 let image_task = image_task.clone();
1112 cx.new::<ImageHover>(|cx| ImageHover {
1113 image,
1114 _task: cx.spawn(async move |this, cx| {
1115 if let Ok(image) = image_task.clone().await {
1116 this.update(cx, |this, cx| {
1117 if this.image.replace(image).is_none() {
1118 cx.notify();
1119 }
1120 })
1121 .ok();
1122 }
1123 }),
1124 })
1125 .into()
1126 })
1127 })
1128 }
1129}
1130
1131struct ImageHover {
1132 image: Option<Arc<Image>>,
1133 _task: Task<()>,
1134}
1135
1136impl Render for ImageHover {
1137 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1138 if let Some(image) = self.image.clone() {
1139 div()
1140 .p_1p5()
1141 .elevation_2(cx)
1142 .child(gpui::img(image).h_auto().max_w_96().rounded_sm())
1143 .into_any_element()
1144 } else {
1145 gpui::Empty.into_any_element()
1146 }
1147 }
1148}
1149
1150async fn fetch_url_content(http_client: Arc<HttpClientWithUrl>, url: String) -> Result<String> {
1151 #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
1152 enum ContentType {
1153 Html,
1154 Plaintext,
1155 Json,
1156 }
1157 use html_to_markdown::{TagHandler, convert_html_to_markdown, markdown};
1158
1159 let url = if !url.starts_with("https://") && !url.starts_with("http://") {
1160 format!("https://{url}")
1161 } else {
1162 url
1163 };
1164
1165 let mut response = http_client.get(&url, AsyncBody::default(), true).await?;
1166 let mut body = Vec::new();
1167 response
1168 .body_mut()
1169 .read_to_end(&mut body)
1170 .await
1171 .context("error reading response body")?;
1172
1173 if response.status().is_client_error() {
1174 let text = String::from_utf8_lossy(body.as_slice());
1175 anyhow::bail!(
1176 "status error {}, response: {text:?}",
1177 response.status().as_u16()
1178 );
1179 }
1180
1181 let Some(content_type) = response.headers().get("content-type") else {
1182 anyhow::bail!("missing Content-Type header");
1183 };
1184 let content_type = content_type
1185 .to_str()
1186 .context("invalid Content-Type header")?;
1187 let content_type = match content_type {
1188 "text/html" => ContentType::Html,
1189 "text/plain" => ContentType::Plaintext,
1190 "application/json" => ContentType::Json,
1191 _ => ContentType::Html,
1192 };
1193
1194 match content_type {
1195 ContentType::Html => {
1196 let mut handlers: Vec<TagHandler> = vec![
1197 Rc::new(RefCell::new(markdown::WebpageChromeRemover)),
1198 Rc::new(RefCell::new(markdown::ParagraphHandler)),
1199 Rc::new(RefCell::new(markdown::HeadingHandler)),
1200 Rc::new(RefCell::new(markdown::ListHandler)),
1201 Rc::new(RefCell::new(markdown::TableHandler::new())),
1202 Rc::new(RefCell::new(markdown::StyledTextHandler)),
1203 ];
1204 if url.contains("wikipedia.org") {
1205 use html_to_markdown::structure::wikipedia;
1206
1207 handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover)));
1208 handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaInfoboxHandler)));
1209 handlers.push(Rc::new(
1210 RefCell::new(wikipedia::WikipediaCodeHandler::new()),
1211 ));
1212 } else {
1213 handlers.push(Rc::new(RefCell::new(markdown::CodeHandler)));
1214 }
1215 convert_html_to_markdown(&body[..], &mut handlers)
1216 }
1217 ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()),
1218 ContentType::Json => {
1219 let json: serde_json::Value = serde_json::from_slice(&body)?;
1220
1221 Ok(format!(
1222 "```json\n{}\n```",
1223 serde_json::to_string_pretty(&json)?
1224 ))
1225 }
1226 }
1227}