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