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