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