1use crate::diagnostics::{DiagnosticsOptions, codeblock_fence_for_path, collect_diagnostics};
2use acp_thread::{MentionUri, selection_name};
3use agent::{ThreadStore, outline};
4use agent_client_protocol as acp;
5use agent_servers::{AgentServer, AgentServerDelegate};
6use anyhow::{Context as _, Result, anyhow};
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::File { abs_path } => {
135 self.confirm_mention_for_file(abs_path, supports_images, cx)
136 }
137 MentionUri::Symbol {
138 abs_path,
139 line_range,
140 ..
141 } => self.confirm_mention_for_symbol(abs_path, line_range, cx),
142 MentionUri::Rule { id, .. } => self.confirm_mention_for_rule(id, cx),
143 MentionUri::Diagnostics {
144 include_errors,
145 include_warnings,
146 } => self.confirm_mention_for_diagnostics(include_errors, include_warnings, cx),
147 MentionUri::GitDiff { base_ref } => {
148 self.confirm_mention_for_git_diff(base_ref.into(), cx)
149 }
150 MentionUri::Selection {
151 abs_path: Some(abs_path),
152 line_range,
153 } => self.confirm_mention_for_symbol(abs_path, line_range, cx),
154 MentionUri::Selection { abs_path: None, .. } => Task::ready(Err(anyhow!(
155 "Untitled buffer selection mentions are not supported for paste"
156 ))),
157 MentionUri::PastedImage
158 | MentionUri::TerminalSelection { .. }
159 | MentionUri::MergeConflict { .. } => {
160 Task::ready(Err(anyhow!("Unsupported mention URI type for paste")))
161 }
162 }
163 }
164
165 pub fn remove_mention(&mut self, crease_id: &CreaseId) {
166 self.mentions.remove(crease_id);
167 }
168
169 pub fn creases(&self) -> HashSet<CreaseId> {
170 self.mentions.keys().cloned().collect()
171 }
172
173 pub fn mentions(&self) -> HashSet<MentionUri> {
174 self.mentions.values().map(|(uri, _)| uri.clone()).collect()
175 }
176
177 pub fn set_mentions(&mut self, mentions: HashMap<CreaseId, (MentionUri, MentionTask)>) {
178 self.mentions = mentions;
179 }
180
181 pub fn clear(&mut self) -> impl Iterator<Item = (CreaseId, (MentionUri, MentionTask))> {
182 self.mentions.drain()
183 }
184
185 #[cfg(test)]
186 pub fn has_thread_store(&self) -> bool {
187 self.thread_store.is_some()
188 }
189
190 pub fn confirm_mention_completion(
191 &mut self,
192 crease_text: SharedString,
193 start: text::Anchor,
194 content_len: usize,
195 mention_uri: MentionUri,
196 supports_images: bool,
197 editor: Entity<Editor>,
198 workspace: &Entity<Workspace>,
199 window: &mut Window,
200 cx: &mut Context<Self>,
201 ) -> Task<()> {
202 let Some(project) = self.project.upgrade() else {
203 return Task::ready(());
204 };
205
206 let snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx));
207 let Some(start_anchor) = snapshot.buffer_snapshot().as_singleton_anchor(start) else {
208 return Task::ready(());
209 };
210 let excerpt_id = start_anchor.excerpt_id;
211 let end_anchor = snapshot.buffer_snapshot().anchor_before(
212 start_anchor.to_offset(&snapshot.buffer_snapshot()) + content_len + 1usize,
213 );
214
215 let crease = if let MentionUri::File { abs_path } = &mention_uri
216 && let Some(extension) = abs_path.extension()
217 && let Some(extension) = extension.to_str()
218 && Img::extensions().contains(&extension)
219 && !extension.contains("svg")
220 {
221 let Some(project_path) = project
222 .read(cx)
223 .project_path_for_absolute_path(&abs_path, cx)
224 else {
225 log::error!("project path not found");
226 return Task::ready(());
227 };
228 let image_task = project.update(cx, |project, cx| project.open_image(project_path, cx));
229 let image = cx
230 .spawn(async move |_, cx| {
231 let image = image_task.await.map_err(|e| e.to_string())?;
232 let image = image.update(cx, |image, _| image.image.clone());
233 Ok(image)
234 })
235 .shared();
236 insert_crease_for_mention(
237 excerpt_id,
238 start,
239 content_len,
240 mention_uri.name().into(),
241 IconName::Image.path().into(),
242 mention_uri.tooltip_text(),
243 Some(mention_uri.clone()),
244 Some(workspace.downgrade()),
245 Some(image),
246 editor.clone(),
247 window,
248 cx,
249 )
250 } else {
251 insert_crease_for_mention(
252 excerpt_id,
253 start,
254 content_len,
255 crease_text,
256 mention_uri.icon_path(cx),
257 mention_uri.tooltip_text(),
258 Some(mention_uri.clone()),
259 Some(workspace.downgrade()),
260 None,
261 editor.clone(),
262 window,
263 cx,
264 )
265 };
266 let Some((crease_id, tx)) = crease else {
267 return Task::ready(());
268 };
269
270 let task = match mention_uri.clone() {
271 MentionUri::Fetch { url } => {
272 self.confirm_mention_for_fetch(url, workspace.read(cx).client().http_client(), cx)
273 }
274 MentionUri::Directory { .. } => Task::ready(Ok(Mention::Link)),
275 MentionUri::Thread { id, .. } => self.confirm_mention_for_thread(id, cx),
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, project.clone(), 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(
587 project,
588 DiagnosticsOptions {
589 include_errors,
590 include_warnings,
591 path_matcher: None,
592 },
593 cx,
594 );
595 cx.spawn(async move |_, _| {
596 let content = diagnostics_task
597 .await?
598 .unwrap_or_else(|| "No diagnostics found.".into());
599 Ok(Mention::Text {
600 content,
601 tracked_buffers: Vec::new(),
602 })
603 })
604 }
605
606 pub fn confirm_mention_for_git_diff(
607 &self,
608 base_ref: SharedString,
609 cx: &mut Context<Self>,
610 ) -> Task<Result<Mention>> {
611 let Some(project) = self.project.upgrade() else {
612 return Task::ready(Err(anyhow!("project not found")));
613 };
614
615 let Some(repo) = project.read(cx).active_repository(cx) else {
616 return Task::ready(Err(anyhow!("no active repository")));
617 };
618
619 let diff_receiver = repo.update(cx, |repo, cx| {
620 repo.diff(
621 git::repository::DiffType::MergeBase { base_ref: base_ref },
622 cx,
623 )
624 });
625
626 cx.spawn(async move |_, _| {
627 let diff_text = diff_receiver.await??;
628 if diff_text.is_empty() {
629 Ok(Mention::Text {
630 content: "No changes found in branch diff.".into(),
631 tracked_buffers: Vec::new(),
632 })
633 } else {
634 Ok(Mention::Text {
635 content: diff_text,
636 tracked_buffers: Vec::new(),
637 })
638 }
639 })
640 }
641}
642
643#[cfg(test)]
644mod tests {
645 use super::*;
646
647 use fs::FakeFs;
648 use gpui::TestAppContext;
649 use project::Project;
650 use prompt_store;
651 use release_channel;
652 use semver::Version;
653 use serde_json::json;
654 use settings::SettingsStore;
655 use std::path::Path;
656 use theme;
657 use util::path;
658
659 fn init_test(cx: &mut TestAppContext) {
660 let settings_store = cx.update(SettingsStore::test);
661 cx.set_global(settings_store);
662 cx.update(|cx| {
663 theme_settings::init(theme::LoadThemes::JustBase, cx);
664 release_channel::init(Version::new(0, 0, 0), cx);
665 prompt_store::init(cx);
666 });
667 }
668
669 #[gpui::test]
670 async fn test_thread_mentions_disabled(cx: &mut TestAppContext) {
671 init_test(cx);
672
673 let fs = FakeFs::new(cx.executor());
674 fs.insert_tree("/project", json!({"file": ""})).await;
675 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
676 let thread_store = None;
677 let mention_set = cx.new(|_cx| MentionSet::new(project.downgrade(), thread_store, None));
678
679 let task = mention_set.update(cx, |mention_set, cx| {
680 mention_set.confirm_mention_for_thread(acp::SessionId::new("thread-1"), cx)
681 });
682
683 let error = task.await.unwrap_err();
684 assert!(
685 error
686 .to_string()
687 .contains("Thread mentions are only supported for the native agent"),
688 "Unexpected error: {error:#}"
689 );
690 }
691
692 #[gpui::test]
693 async fn test_selection_mentions_supported_for_paste(cx: &mut TestAppContext) {
694 init_test(cx);
695
696 let fs = FakeFs::new(cx.executor());
697 fs.insert_tree(
698 "/project",
699 json!({"file.rs": "line 1\nline 2\nline 3\nline 4\n"}),
700 )
701 .await;
702 let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
703 let mention_set = cx.new(|_cx| MentionSet::new(project.downgrade(), None, None));
704
705 let mention_task = mention_set.update(cx, |mention_set, cx| {
706 let http_client = project.read(cx).client().http_client();
707 mention_set.confirm_mention_for_uri(
708 MentionUri::Selection {
709 abs_path: Some(path!("/project/file.rs").into()),
710 line_range: 1..=2,
711 },
712 false,
713 http_client,
714 cx,
715 )
716 });
717
718 let mention = mention_task.await.unwrap();
719 match mention {
720 Mention::Text {
721 content,
722 tracked_buffers,
723 } => {
724 assert_eq!(content, "line 2\nline 3\n");
725 assert_eq!(tracked_buffers.len(), 1);
726 }
727 other => panic!("Expected selection mention to resolve as text, got {other:?}"),
728 }
729 }
730}
731
732/// Inserts a list of images into the editor as context mentions.
733/// This is the shared implementation used by both paste and file picker operations.
734pub(crate) async fn insert_images_as_context(
735 images: Vec<(gpui::Image, SharedString)>,
736 editor: Entity<Editor>,
737 mention_set: Entity<MentionSet>,
738 workspace: WeakEntity<Workspace>,
739 cx: &mut gpui::AsyncWindowContext,
740) {
741 if images.is_empty() {
742 return;
743 }
744
745 let replacement_text = MentionUri::PastedImage.as_link().to_string();
746
747 for (image, name) in images {
748 let Some((excerpt_id, text_anchor, multibuffer_anchor)) = editor
749 .update_in(cx, |editor, window, cx| {
750 let snapshot = editor.snapshot(window, cx);
751 let (excerpt_id, _, buffer_snapshot) =
752 snapshot.buffer_snapshot().as_singleton().unwrap();
753
754 let cursor_anchor = editor.selections.newest_anchor().start.text_anchor;
755 let text_anchor = cursor_anchor.bias_left(&buffer_snapshot);
756 let multibuffer_anchor = snapshot
757 .buffer_snapshot()
758 .anchor_in_excerpt(excerpt_id, text_anchor);
759 editor.insert(&format!("{replacement_text} "), window, cx);
760 (excerpt_id, text_anchor, multibuffer_anchor)
761 })
762 .ok()
763 else {
764 break;
765 };
766
767 let content_len = replacement_text.len();
768 let Some(start_anchor) = multibuffer_anchor else {
769 continue;
770 };
771 let end_anchor = editor.update(cx, |editor, cx| {
772 let snapshot = editor.buffer().read(cx).snapshot(cx);
773 snapshot.anchor_before(start_anchor.to_offset(&snapshot) + content_len)
774 });
775 let image = Arc::new(image);
776 let Ok(Some((crease_id, tx))) = cx.update(|window, cx| {
777 insert_crease_for_mention(
778 excerpt_id,
779 text_anchor,
780 content_len,
781 name.clone(),
782 IconName::Image.path().into(),
783 None,
784 None,
785 None,
786 Some(Task::ready(Ok(image.clone())).shared()),
787 editor.clone(),
788 window,
789 cx,
790 )
791 }) else {
792 continue;
793 };
794 let task = cx
795 .spawn(async move |cx| {
796 let image = cx
797 .update(|_, cx| LanguageModelImage::from_image(image, cx))
798 .map_err(|e| e.to_string())?
799 .await;
800 drop(tx);
801 if let Some(image) = image {
802 Ok(Mention::Image(MentionImage {
803 data: image.source,
804 format: LanguageModelImage::FORMAT,
805 }))
806 } else {
807 Err("Failed to convert image".into())
808 }
809 })
810 .shared();
811
812 mention_set.update(cx, |mention_set, _cx| {
813 mention_set.insert_mention(crease_id, MentionUri::PastedImage, task.clone())
814 });
815
816 if task
817 .await
818 .notify_workspace_async_err(workspace.clone(), cx)
819 .is_none()
820 {
821 editor.update(cx, |editor, cx| {
822 editor.edit([(start_anchor..end_anchor, "")], cx);
823 });
824 mention_set.update(cx, |mention_set, _cx| {
825 mention_set.remove_mention(&crease_id)
826 });
827 }
828 }
829}
830
831fn image_format_from_external_content(format: image::ImageFormat) -> Option<ImageFormat> {
832 match format {
833 image::ImageFormat::Png => Some(ImageFormat::Png),
834 image::ImageFormat::Jpeg => Some(ImageFormat::Jpeg),
835 image::ImageFormat::WebP => Some(ImageFormat::Webp),
836 image::ImageFormat::Gif => Some(ImageFormat::Gif),
837 image::ImageFormat::Bmp => Some(ImageFormat::Bmp),
838 image::ImageFormat::Tiff => Some(ImageFormat::Tiff),
839 image::ImageFormat::Ico => Some(ImageFormat::Ico),
840 _ => None,
841 }
842}
843
844pub(crate) fn load_external_image_from_path(
845 path: &Path,
846 default_name: &SharedString,
847) -> Option<(Image, SharedString)> {
848 let content = std::fs::read(path).ok()?;
849 let format = image::guess_format(&content)
850 .ok()
851 .and_then(image_format_from_external_content)?;
852 let name = path
853 .file_name()
854 .and_then(|name| name.to_str())
855 .map(|name| SharedString::from(name.to_owned()))
856 .unwrap_or_else(|| default_name.clone());
857
858 Some((Image::from_bytes(format, content), name))
859}
860
861pub(crate) fn paste_images_as_context(
862 editor: Entity<Editor>,
863 mention_set: Entity<MentionSet>,
864 workspace: WeakEntity<Workspace>,
865 window: &mut Window,
866 cx: &mut App,
867) -> Option<Task<()>> {
868 let clipboard = cx.read_from_clipboard()?;
869
870 // Only handle paste if the first clipboard entry is an image or file path.
871 // If text comes first, return None so the caller falls through to text paste.
872 // This respects the priority order set by the source application.
873 if matches!(
874 clipboard.entries().first(),
875 Some(ClipboardEntry::String(_)) | None
876 ) {
877 return None;
878 }
879
880 Some(window.spawn(cx, async move |mut cx| {
881 use itertools::Itertools;
882 let default_name: SharedString = MentionUri::PastedImage.name().into();
883 let (mut images, paths): (Vec<(gpui::Image, SharedString)>, Vec<_>) = clipboard
884 .into_entries()
885 .filter_map(|entry| match entry {
886 ClipboardEntry::Image(image) => Some(Either::Left((image, default_name.clone()))),
887 ClipboardEntry::ExternalPaths(paths) => Some(Either::Right(paths)),
888 _ => None,
889 })
890 .partition_map::<Vec<_>, Vec<_>, _, _, _>(std::convert::identity);
891
892 if !paths.is_empty() {
893 images.extend(
894 cx.background_spawn(async move {
895 paths
896 .into_iter()
897 .flat_map(|paths| paths.paths().to_owned())
898 .filter_map(|path| load_external_image_from_path(&path, &default_name))
899 .collect::<Vec<_>>()
900 })
901 .await,
902 );
903 }
904
905 if !images.is_empty() {
906 insert_images_as_context(images, editor, mention_set, workspace, &mut cx).await;
907 }
908 }))
909}
910
911pub(crate) fn insert_crease_for_mention(
912 excerpt_id: ExcerptId,
913 anchor: text::Anchor,
914 content_len: usize,
915 crease_label: SharedString,
916 crease_icon: SharedString,
917 crease_tooltip: Option<SharedString>,
918 mention_uri: Option<MentionUri>,
919 workspace: Option<WeakEntity<Workspace>>,
920 image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
921 editor: Entity<Editor>,
922 window: &mut Window,
923 cx: &mut App,
924) -> Option<(CreaseId, postage::barrier::Sender)> {
925 let (tx, rx) = postage::barrier::channel();
926
927 let crease_id = editor.update(cx, |editor, cx| {
928 let snapshot = editor.buffer().read(cx).snapshot(cx);
929
930 let start = snapshot.anchor_in_excerpt(excerpt_id, anchor)?;
931
932 let start = start.bias_right(&snapshot);
933 let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
934
935 let placeholder = FoldPlaceholder {
936 render: render_mention_fold_button(
937 crease_label.clone(),
938 crease_icon.clone(),
939 crease_tooltip,
940 mention_uri.clone(),
941 workspace.clone(),
942 start..end,
943 rx,
944 image,
945 cx.weak_entity(),
946 cx,
947 ),
948 merge_adjacent: false,
949 ..Default::default()
950 };
951
952 let crease = Crease::Inline {
953 range: start..end,
954 placeholder,
955 render_toggle: None,
956 render_trailer: None,
957 metadata: Some(CreaseMetadata {
958 label: crease_label,
959 icon_path: crease_icon,
960 }),
961 };
962
963 let ids = editor.insert_creases(vec![crease.clone()], cx);
964 editor.fold_creases(vec![crease], false, window, cx);
965
966 Some(ids[0])
967 })?;
968
969 Some((crease_id, tx))
970}
971
972pub(crate) fn crease_for_mention(
973 label: SharedString,
974 icon_path: SharedString,
975 tooltip: Option<SharedString>,
976 range: Range<Anchor>,
977 editor_entity: WeakEntity<Editor>,
978) -> Crease<Anchor> {
979 let placeholder = FoldPlaceholder {
980 render: render_fold_icon_button(icon_path.clone(), label.clone(), tooltip, editor_entity),
981 merge_adjacent: false,
982 ..Default::default()
983 };
984
985 let render_trailer = move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any();
986
987 Crease::inline(range, placeholder, fold_toggle("mention"), render_trailer)
988 .with_metadata(CreaseMetadata { icon_path, label })
989}
990
991fn render_fold_icon_button(
992 icon_path: SharedString,
993 label: SharedString,
994 tooltip: Option<SharedString>,
995 editor: WeakEntity<Editor>,
996) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
997 Arc::new({
998 move |fold_id, fold_range, cx| {
999 let is_in_text_selection = editor
1000 .update(cx, |editor, cx| editor.is_range_selected(&fold_range, cx))
1001 .unwrap_or_default();
1002
1003 MentionCrease::new(fold_id, icon_path.clone(), label.clone())
1004 .is_toggled(is_in_text_selection)
1005 .when_some(tooltip.clone(), |this, tooltip_text| {
1006 this.tooltip(tooltip_text)
1007 })
1008 .into_any_element()
1009 }
1010 })
1011}
1012
1013fn fold_toggle(
1014 name: &'static str,
1015) -> impl Fn(
1016 MultiBufferRow,
1017 bool,
1018 Arc<dyn Fn(bool, &mut Window, &mut App) + Send + Sync>,
1019 &mut Window,
1020 &mut App,
1021) -> AnyElement {
1022 move |row, is_folded, fold, _window, _cx| {
1023 Disclosure::new((name, row.0 as u64), !is_folded)
1024 .toggle_state(is_folded)
1025 .on_click(move |_e, window, cx| fold(!is_folded, window, cx))
1026 .into_any_element()
1027 }
1028}
1029
1030fn full_mention_for_directory(
1031 project: &Entity<Project>,
1032 abs_path: &Path,
1033 cx: &mut App,
1034) -> Task<Result<Mention>> {
1035 fn collect_files_in_path(worktree: &Worktree, path: &RelPath) -> Vec<(Arc<RelPath>, String)> {
1036 let mut files = Vec::new();
1037
1038 for entry in worktree.child_entries(path) {
1039 if entry.is_dir() {
1040 files.extend(collect_files_in_path(worktree, &entry.path));
1041 } else if entry.is_file() {
1042 files.push((
1043 entry.path.clone(),
1044 worktree
1045 .full_path(&entry.path)
1046 .to_string_lossy()
1047 .to_string(),
1048 ));
1049 }
1050 }
1051
1052 files
1053 }
1054
1055 let Some(project_path) = project
1056 .read(cx)
1057 .project_path_for_absolute_path(&abs_path, cx)
1058 else {
1059 return Task::ready(Err(anyhow!("project path not found")));
1060 };
1061 let Some(entry) = project.read(cx).entry_for_path(&project_path, cx) else {
1062 return Task::ready(Err(anyhow!("project entry not found")));
1063 };
1064 let directory_path = entry.path.clone();
1065 let worktree_id = project_path.worktree_id;
1066 let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) else {
1067 return Task::ready(Err(anyhow!("worktree not found")));
1068 };
1069 let project = project.clone();
1070 cx.spawn(async move |cx| {
1071 let file_paths = worktree.read_with(cx, |worktree, _cx| {
1072 collect_files_in_path(worktree, &directory_path)
1073 });
1074 let descendants_future = cx.update(|cx| {
1075 futures::future::join_all(file_paths.into_iter().map(
1076 |(worktree_path, full_path): (Arc<RelPath>, String)| {
1077 let rel_path = worktree_path
1078 .strip_prefix(&directory_path)
1079 .log_err()
1080 .map_or_else(|| worktree_path.clone(), |rel_path| rel_path.into());
1081
1082 let open_task = project.update(cx, |project, cx| {
1083 project.buffer_store().update(cx, |buffer_store, cx| {
1084 let project_path = ProjectPath {
1085 worktree_id,
1086 path: worktree_path,
1087 };
1088 buffer_store.open_buffer(project_path, cx)
1089 })
1090 });
1091
1092 cx.spawn(async move |cx| {
1093 let buffer = open_task.await.log_err()?;
1094 let buffer_content = outline::get_buffer_content_or_outline(
1095 buffer.clone(),
1096 Some(&full_path),
1097 &cx,
1098 )
1099 .await
1100 .ok()?;
1101
1102 Some((rel_path, full_path, buffer_content.text, buffer))
1103 })
1104 },
1105 ))
1106 });
1107
1108 let contents = cx
1109 .background_spawn(async move {
1110 let (contents, tracked_buffers): (Vec<_>, Vec<_>) = descendants_future
1111 .await
1112 .into_iter()
1113 .flatten()
1114 .map(|(rel_path, full_path, rope, buffer)| {
1115 ((rel_path, full_path, rope), buffer)
1116 })
1117 .unzip();
1118 Mention::Text {
1119 content: render_directory_contents(contents),
1120 tracked_buffers,
1121 }
1122 })
1123 .await;
1124 anyhow::Ok(contents)
1125 })
1126}
1127
1128fn render_directory_contents(entries: Vec<(Arc<RelPath>, String, String)>) -> String {
1129 let mut output = String::new();
1130 for (_relative_path, full_path, content) in entries {
1131 let fence = codeblock_fence_for_path(Some(&full_path), None);
1132 write!(output, "\n{fence}\n{content}\n```").unwrap();
1133 }
1134 output
1135}
1136
1137fn render_mention_fold_button(
1138 label: SharedString,
1139 icon: SharedString,
1140 tooltip: Option<SharedString>,
1141 mention_uri: Option<MentionUri>,
1142 workspace: Option<WeakEntity<Workspace>>,
1143 range: Range<Anchor>,
1144 mut loading_finished: postage::barrier::Receiver,
1145 image_task: Option<Shared<Task<Result<Arc<Image>, String>>>>,
1146 editor: WeakEntity<Editor>,
1147 cx: &mut App,
1148) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
1149 let loading = cx.new(|cx| {
1150 let loading = cx.spawn(async move |this, cx| {
1151 loading_finished.recv().await;
1152 this.update(cx, |this: &mut LoadingContext, cx| {
1153 this.loading = None;
1154 cx.notify();
1155 })
1156 .ok();
1157 });
1158 LoadingContext {
1159 id: cx.entity_id(),
1160 label,
1161 icon,
1162 tooltip,
1163 mention_uri: mention_uri.clone(),
1164 workspace: workspace.clone(),
1165 range,
1166 editor,
1167 loading: Some(loading),
1168 image: image_task.clone(),
1169 }
1170 });
1171 Arc::new(move |_fold_id, _fold_range, _cx| loading.clone().into_any_element())
1172}
1173
1174struct LoadingContext {
1175 id: EntityId,
1176 label: SharedString,
1177 icon: SharedString,
1178 tooltip: Option<SharedString>,
1179 mention_uri: Option<MentionUri>,
1180 workspace: Option<WeakEntity<Workspace>>,
1181 range: Range<Anchor>,
1182 editor: WeakEntity<Editor>,
1183 loading: Option<Task<()>>,
1184 image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
1185}
1186
1187impl Render for LoadingContext {
1188 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1189 let is_in_text_selection = self
1190 .editor
1191 .update(cx, |editor, cx| editor.is_range_selected(&self.range, cx))
1192 .unwrap_or_default();
1193
1194 let id = ElementId::from(("loading_context", self.id));
1195
1196 MentionCrease::new(id, self.icon.clone(), self.label.clone())
1197 .mention_uri(self.mention_uri.clone())
1198 .workspace(self.workspace.clone())
1199 .is_toggled(is_in_text_selection)
1200 .is_loading(self.loading.is_some())
1201 .when_some(self.tooltip.clone(), |this, tooltip_text| {
1202 this.tooltip(tooltip_text)
1203 })
1204 .when_some(self.image.clone(), |this, image_task| {
1205 this.image_preview(move |_, cx| {
1206 let image = image_task.peek().cloned().transpose().ok().flatten();
1207 let image_task = image_task.clone();
1208 cx.new::<ImageHover>(|cx| ImageHover {
1209 image,
1210 _task: cx.spawn(async move |this, cx| {
1211 if let Ok(image) = image_task.clone().await {
1212 this.update(cx, |this, cx| {
1213 if this.image.replace(image).is_none() {
1214 cx.notify();
1215 }
1216 })
1217 .ok();
1218 }
1219 }),
1220 })
1221 .into()
1222 })
1223 })
1224 }
1225}
1226
1227struct ImageHover {
1228 image: Option<Arc<Image>>,
1229 _task: Task<()>,
1230}
1231
1232impl Render for ImageHover {
1233 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1234 if let Some(image) = self.image.clone() {
1235 div()
1236 .p_1p5()
1237 .elevation_2(cx)
1238 .child(gpui::img(image).h_auto().max_w_96().rounded_sm())
1239 .into_any_element()
1240 } else {
1241 gpui::Empty.into_any_element()
1242 }
1243 }
1244}
1245
1246async fn fetch_url_content(http_client: Arc<HttpClientWithUrl>, url: String) -> Result<String> {
1247 #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
1248 enum ContentType {
1249 Html,
1250 Plaintext,
1251 Json,
1252 }
1253 use html_to_markdown::{TagHandler, convert_html_to_markdown, markdown};
1254
1255 let url = if !url.starts_with("https://") && !url.starts_with("http://") {
1256 format!("https://{url}")
1257 } else {
1258 url
1259 };
1260
1261 let mut response = http_client.get(&url, AsyncBody::default(), true).await?;
1262 let mut body = Vec::new();
1263 response
1264 .body_mut()
1265 .read_to_end(&mut body)
1266 .await
1267 .context("error reading response body")?;
1268
1269 if response.status().is_client_error() {
1270 let text = String::from_utf8_lossy(body.as_slice());
1271 anyhow::bail!(
1272 "status error {}, response: {text:?}",
1273 response.status().as_u16()
1274 );
1275 }
1276
1277 let Some(content_type) = response.headers().get("content-type") else {
1278 anyhow::bail!("missing Content-Type header");
1279 };
1280 let content_type = content_type
1281 .to_str()
1282 .context("invalid Content-Type header")?;
1283 let content_type = match content_type {
1284 "text/html" => ContentType::Html,
1285 "text/plain" => ContentType::Plaintext,
1286 "application/json" => ContentType::Json,
1287 _ => ContentType::Html,
1288 };
1289
1290 match content_type {
1291 ContentType::Html => {
1292 let mut handlers: Vec<TagHandler> = vec![
1293 Rc::new(RefCell::new(markdown::WebpageChromeRemover)),
1294 Rc::new(RefCell::new(markdown::ParagraphHandler)),
1295 Rc::new(RefCell::new(markdown::HeadingHandler)),
1296 Rc::new(RefCell::new(markdown::ListHandler)),
1297 Rc::new(RefCell::new(markdown::TableHandler::new())),
1298 Rc::new(RefCell::new(markdown::StyledTextHandler)),
1299 ];
1300 if url.contains("wikipedia.org") {
1301 use html_to_markdown::structure::wikipedia;
1302
1303 handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover)));
1304 handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaInfoboxHandler)));
1305 handlers.push(Rc::new(
1306 RefCell::new(wikipedia::WikipediaCodeHandler::new()),
1307 ));
1308 } else {
1309 handlers.push(Rc::new(RefCell::new(markdown::CodeHandler)));
1310 }
1311 convert_html_to_markdown(&body[..], &mut handlers)
1312 }
1313 ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()),
1314 ContentType::Json => {
1315 let json: serde_json::Value = serde_json::from_slice(&body)?;
1316
1317 Ok(format!(
1318 "```json\n{}\n```",
1319 serde_json::to_string_pretty(&json)?
1320 ))
1321 }
1322 }
1323}