mention_set.rs

   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}