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