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, 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        _ => None,
 843    }
 844}
 845
 846pub(crate) fn load_external_image_from_path(
 847    path: &Path,
 848    default_name: &SharedString,
 849) -> Option<(Image, SharedString)> {
 850    let content = std::fs::read(path).ok()?;
 851    let format = image::guess_format(&content)
 852        .ok()
 853        .and_then(image_format_from_external_content)?;
 854    let name = path
 855        .file_name()
 856        .and_then(|name| name.to_str())
 857        .map(|name| SharedString::from(name.to_owned()))
 858        .unwrap_or_else(|| default_name.clone());
 859
 860    Some((Image::from_bytes(format, content), name))
 861}
 862
 863pub(crate) fn paste_images_as_context(
 864    editor: Entity<Editor>,
 865    mention_set: Entity<MentionSet>,
 866    workspace: WeakEntity<Workspace>,
 867    window: &mut Window,
 868    cx: &mut App,
 869) -> Option<Task<()>> {
 870    let clipboard = cx.read_from_clipboard()?;
 871
 872    // Only handle paste if the first clipboard entry is an image or file path.
 873    // If text comes first, return None so the caller falls through to text paste.
 874    // This respects the priority order set by the source application.
 875    if matches!(
 876        clipboard.entries().first(),
 877        Some(ClipboardEntry::String(_)) | None
 878    ) {
 879        return None;
 880    }
 881
 882    Some(window.spawn(cx, async move |mut cx| {
 883        use itertools::Itertools;
 884        let default_name: SharedString = "Image".into();
 885        let (mut images, paths): (Vec<(gpui::Image, SharedString)>, Vec<_>) = clipboard
 886            .into_entries()
 887            .filter_map(|entry| match entry {
 888                ClipboardEntry::Image(image) => Some(Either::Left((image, default_name.clone()))),
 889                ClipboardEntry::ExternalPaths(paths) => Some(Either::Right(paths)),
 890                _ => None,
 891            })
 892            .partition_map::<Vec<_>, Vec<_>, _, _, _>(std::convert::identity);
 893
 894        if !paths.is_empty() {
 895            images.extend(
 896                cx.background_spawn(async move {
 897                    paths
 898                        .into_iter()
 899                        .flat_map(|paths| paths.paths().to_owned())
 900                        .filter_map(|path| load_external_image_from_path(&path, &default_name))
 901                        .collect::<Vec<_>>()
 902                })
 903                .await,
 904            );
 905        }
 906
 907        if !images.is_empty() {
 908            insert_images_as_context(images, editor, mention_set, workspace, &mut cx).await;
 909        }
 910    }))
 911}
 912
 913pub(crate) fn insert_crease_for_mention(
 914    anchor: text::Anchor,
 915    content_len: usize,
 916    crease_label: SharedString,
 917    crease_icon: SharedString,
 918    crease_tooltip: Option<SharedString>,
 919    mention_uri: Option<MentionUri>,
 920    workspace: Option<WeakEntity<Workspace>>,
 921    image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
 922    editor: Entity<Editor>,
 923    window: &mut Window,
 924    cx: &mut App,
 925) -> Option<(CreaseId, postage::barrier::Sender)> {
 926    let (tx, rx) = postage::barrier::channel();
 927
 928    let crease_id = editor.update(cx, |editor, cx| {
 929        let snapshot = editor.buffer().read(cx).snapshot(cx);
 930
 931        let start = snapshot.anchor_in_excerpt(anchor)?;
 932
 933        let start = start.bias_right(&snapshot);
 934        let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
 935
 936        let placeholder = FoldPlaceholder {
 937            render: render_mention_fold_button(
 938                crease_label.clone(),
 939                crease_icon.clone(),
 940                crease_tooltip,
 941                mention_uri.clone(),
 942                workspace.clone(),
 943                start..end,
 944                rx,
 945                image,
 946                cx.weak_entity(),
 947                cx,
 948            ),
 949            merge_adjacent: false,
 950            ..Default::default()
 951        };
 952
 953        let crease = Crease::Inline {
 954            range: start..end,
 955            placeholder,
 956            render_toggle: None,
 957            render_trailer: None,
 958            metadata: Some(CreaseMetadata {
 959                label: crease_label,
 960                icon_path: crease_icon,
 961            }),
 962        };
 963
 964        let ids = editor.insert_creases(vec![crease.clone()], cx);
 965        editor.fold_creases(vec![crease], false, window, cx);
 966
 967        Some(ids[0])
 968    })?;
 969
 970    Some((crease_id, tx))
 971}
 972
 973pub(crate) fn crease_for_mention(
 974    label: SharedString,
 975    icon_path: SharedString,
 976    tooltip: Option<SharedString>,
 977    range: Range<Anchor>,
 978    editor_entity: WeakEntity<Editor>,
 979) -> Crease<Anchor> {
 980    let placeholder = FoldPlaceholder {
 981        render: render_fold_icon_button(icon_path.clone(), label.clone(), tooltip, editor_entity),
 982        merge_adjacent: false,
 983        ..Default::default()
 984    };
 985
 986    let render_trailer = move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any();
 987
 988    Crease::inline(range, placeholder, fold_toggle("mention"), render_trailer)
 989        .with_metadata(CreaseMetadata { icon_path, label })
 990}
 991
 992fn render_fold_icon_button(
 993    icon_path: SharedString,
 994    label: SharedString,
 995    tooltip: Option<SharedString>,
 996    editor: WeakEntity<Editor>,
 997) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
 998    Arc::new({
 999        move |fold_id, fold_range, cx| {
1000            let is_in_text_selection = editor
1001                .update(cx, |editor, cx| editor.is_range_selected(&fold_range, cx))
1002                .unwrap_or_default();
1003
1004            MentionCrease::new(fold_id, icon_path.clone(), label.clone())
1005                .is_toggled(is_in_text_selection)
1006                .when_some(tooltip.clone(), |this, tooltip_text| {
1007                    this.tooltip(tooltip_text)
1008                })
1009                .into_any_element()
1010        }
1011    })
1012}
1013
1014fn fold_toggle(
1015    name: &'static str,
1016) -> impl Fn(
1017    MultiBufferRow,
1018    bool,
1019    Arc<dyn Fn(bool, &mut Window, &mut App) + Send + Sync>,
1020    &mut Window,
1021    &mut App,
1022) -> AnyElement {
1023    move |row, is_folded, fold, _window, _cx| {
1024        Disclosure::new((name, row.0 as u64), !is_folded)
1025            .toggle_state(is_folded)
1026            .on_click(move |_e, window, cx| fold(!is_folded, window, cx))
1027            .into_any_element()
1028    }
1029}
1030
1031fn full_mention_for_directory(
1032    project: &Entity<Project>,
1033    abs_path: &Path,
1034    cx: &mut App,
1035) -> Task<Result<Mention>> {
1036    fn collect_files_in_path(worktree: &Worktree, path: &RelPath) -> Vec<(Arc<RelPath>, String)> {
1037        let mut files = Vec::new();
1038
1039        for entry in worktree.child_entries(path) {
1040            if entry.is_dir() {
1041                files.extend(collect_files_in_path(worktree, &entry.path));
1042            } else if entry.is_file() {
1043                files.push((
1044                    entry.path.clone(),
1045                    worktree
1046                        .full_path(&entry.path)
1047                        .to_string_lossy()
1048                        .to_string(),
1049                ));
1050            }
1051        }
1052
1053        files
1054    }
1055
1056    let Some(project_path) = project
1057        .read(cx)
1058        .project_path_for_absolute_path(&abs_path, cx)
1059    else {
1060        return Task::ready(Err(anyhow!("project path not found")));
1061    };
1062    let Some(entry) = project.read(cx).entry_for_path(&project_path, cx) else {
1063        return Task::ready(Err(anyhow!("project entry not found")));
1064    };
1065    let directory_path = entry.path.clone();
1066    let worktree_id = project_path.worktree_id;
1067    let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) else {
1068        return Task::ready(Err(anyhow!("worktree not found")));
1069    };
1070    let project = project.clone();
1071    cx.spawn(async move |cx| {
1072        let file_paths = worktree.read_with(cx, |worktree, _cx| {
1073            collect_files_in_path(worktree, &directory_path)
1074        });
1075        let descendants_future = cx.update(|cx| {
1076            futures::future::join_all(file_paths.into_iter().map(
1077                |(worktree_path, full_path): (Arc<RelPath>, String)| {
1078                    let rel_path = worktree_path
1079                        .strip_prefix(&directory_path)
1080                        .log_err()
1081                        .map_or_else(|| worktree_path.clone(), |rel_path| rel_path.into());
1082
1083                    let open_task = project.update(cx, |project, cx| {
1084                        project.buffer_store().update(cx, |buffer_store, cx| {
1085                            let project_path = ProjectPath {
1086                                worktree_id,
1087                                path: worktree_path,
1088                            };
1089                            buffer_store.open_buffer(project_path, cx)
1090                        })
1091                    });
1092
1093                    cx.spawn(async move |cx| {
1094                        let buffer = open_task.await.log_err()?;
1095                        let buffer_content = outline::get_buffer_content_or_outline(
1096                            buffer.clone(),
1097                            Some(&full_path),
1098                            &cx,
1099                        )
1100                        .await
1101                        .ok()?;
1102
1103                        Some((rel_path, full_path, buffer_content.text, buffer))
1104                    })
1105                },
1106            ))
1107        });
1108
1109        let contents = cx
1110            .background_spawn(async move {
1111                let (contents, tracked_buffers): (Vec<_>, Vec<_>) = descendants_future
1112                    .await
1113                    .into_iter()
1114                    .flatten()
1115                    .map(|(rel_path, full_path, rope, buffer)| {
1116                        ((rel_path, full_path, rope), buffer)
1117                    })
1118                    .unzip();
1119                Mention::Text {
1120                    content: render_directory_contents(contents),
1121                    tracked_buffers,
1122                }
1123            })
1124            .await;
1125        anyhow::Ok(contents)
1126    })
1127}
1128
1129fn render_directory_contents(entries: Vec<(Arc<RelPath>, String, String)>) -> String {
1130    let mut output = String::new();
1131    for (_relative_path, full_path, content) in entries {
1132        let fence = codeblock_fence_for_path(Some(&full_path), None);
1133        write!(output, "\n{fence}\n{content}\n```").unwrap();
1134    }
1135    output
1136}
1137
1138fn render_mention_fold_button(
1139    label: SharedString,
1140    icon: SharedString,
1141    tooltip: Option<SharedString>,
1142    mention_uri: Option<MentionUri>,
1143    workspace: Option<WeakEntity<Workspace>>,
1144    range: Range<Anchor>,
1145    mut loading_finished: postage::barrier::Receiver,
1146    image_task: Option<Shared<Task<Result<Arc<Image>, String>>>>,
1147    editor: WeakEntity<Editor>,
1148    cx: &mut App,
1149) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
1150    let loading = cx.new(|cx| {
1151        let loading = cx.spawn(async move |this, cx| {
1152            loading_finished.recv().await;
1153            this.update(cx, |this: &mut LoadingContext, cx| {
1154                this.loading = None;
1155                cx.notify();
1156            })
1157            .ok();
1158        });
1159        LoadingContext {
1160            id: cx.entity_id(),
1161            label,
1162            icon,
1163            tooltip,
1164            mention_uri: mention_uri.clone(),
1165            workspace: workspace.clone(),
1166            range,
1167            editor,
1168            loading: Some(loading),
1169            image: image_task.clone(),
1170        }
1171    });
1172    Arc::new(move |_fold_id, _fold_range, _cx| loading.clone().into_any_element())
1173}
1174
1175struct LoadingContext {
1176    id: EntityId,
1177    label: SharedString,
1178    icon: SharedString,
1179    tooltip: Option<SharedString>,
1180    mention_uri: Option<MentionUri>,
1181    workspace: Option<WeakEntity<Workspace>>,
1182    range: Range<Anchor>,
1183    editor: WeakEntity<Editor>,
1184    loading: Option<Task<()>>,
1185    image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
1186}
1187
1188impl Render for LoadingContext {
1189    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1190        let is_in_text_selection = self
1191            .editor
1192            .update(cx, |editor, cx| editor.is_range_selected(&self.range, cx))
1193            .unwrap_or_default();
1194
1195        let id = ElementId::from(("loading_context", self.id));
1196
1197        MentionCrease::new(id, self.icon.clone(), self.label.clone())
1198            .mention_uri(self.mention_uri.clone())
1199            .workspace(self.workspace.clone())
1200            .is_toggled(is_in_text_selection)
1201            .is_loading(self.loading.is_some())
1202            .when_some(self.tooltip.clone(), |this, tooltip_text| {
1203                this.tooltip(tooltip_text)
1204            })
1205            .when_some(self.image.clone(), |this, image_task| {
1206                this.image_preview(move |_, cx| {
1207                    let image = image_task.peek().cloned().transpose().ok().flatten();
1208                    let image_task = image_task.clone();
1209                    cx.new::<ImageHover>(|cx| ImageHover {
1210                        image,
1211                        _task: cx.spawn(async move |this, cx| {
1212                            if let Ok(image) = image_task.clone().await {
1213                                this.update(cx, |this, cx| {
1214                                    if this.image.replace(image).is_none() {
1215                                        cx.notify();
1216                                    }
1217                                })
1218                                .ok();
1219                            }
1220                        }),
1221                    })
1222                    .into()
1223                })
1224            })
1225    }
1226}
1227
1228struct ImageHover {
1229    image: Option<Arc<Image>>,
1230    _task: Task<()>,
1231}
1232
1233impl Render for ImageHover {
1234    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1235        if let Some(image) = self.image.clone() {
1236            div()
1237                .p_1p5()
1238                .elevation_2(cx)
1239                .child(gpui::img(image).h_auto().max_w_96().rounded_sm())
1240                .into_any_element()
1241        } else {
1242            gpui::Empty.into_any_element()
1243        }
1244    }
1245}
1246
1247async fn fetch_url_content(http_client: Arc<HttpClientWithUrl>, url: String) -> Result<String> {
1248    #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
1249    enum ContentType {
1250        Html,
1251        Plaintext,
1252        Json,
1253    }
1254    use html_to_markdown::{TagHandler, convert_html_to_markdown, markdown};
1255
1256    let url = if !url.starts_with("https://") && !url.starts_with("http://") {
1257        format!("https://{url}")
1258    } else {
1259        url
1260    };
1261
1262    let mut response = http_client.get(&url, AsyncBody::default(), true).await?;
1263    let mut body = Vec::new();
1264    response
1265        .body_mut()
1266        .read_to_end(&mut body)
1267        .await
1268        .context("error reading response body")?;
1269
1270    if response.status().is_client_error() {
1271        let text = String::from_utf8_lossy(body.as_slice());
1272        anyhow::bail!(
1273            "status error {}, response: {text:?}",
1274            response.status().as_u16()
1275        );
1276    }
1277
1278    let Some(content_type) = response.headers().get("content-type") else {
1279        anyhow::bail!("missing Content-Type header");
1280    };
1281    let content_type = content_type
1282        .to_str()
1283        .context("invalid Content-Type header")?;
1284    let content_type = match content_type {
1285        "text/html" => ContentType::Html,
1286        "text/plain" => ContentType::Plaintext,
1287        "application/json" => ContentType::Json,
1288        _ => ContentType::Html,
1289    };
1290
1291    match content_type {
1292        ContentType::Html => {
1293            let mut handlers: Vec<TagHandler> = vec![
1294                Rc::new(RefCell::new(markdown::WebpageChromeRemover)),
1295                Rc::new(RefCell::new(markdown::ParagraphHandler)),
1296                Rc::new(RefCell::new(markdown::HeadingHandler)),
1297                Rc::new(RefCell::new(markdown::ListHandler)),
1298                Rc::new(RefCell::new(markdown::TableHandler::new())),
1299                Rc::new(RefCell::new(markdown::StyledTextHandler)),
1300            ];
1301            if url.contains("wikipedia.org") {
1302                use html_to_markdown::structure::wikipedia;
1303
1304                handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover)));
1305                handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaInfoboxHandler)));
1306                handlers.push(Rc::new(
1307                    RefCell::new(wikipedia::WikipediaCodeHandler::new()),
1308                ));
1309            } else {
1310                handlers.push(Rc::new(RefCell::new(markdown::CodeHandler)));
1311            }
1312            convert_html_to_markdown(&body[..], &mut handlers)
1313        }
1314        ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()),
1315        ContentType::Json => {
1316            let json: serde_json::Value = serde_json::from_slice(&body)?;
1317
1318            Ok(format!(
1319                "```json\n{}\n```",
1320                serde_json::to_string_pretty(&json)?
1321            ))
1322        }
1323    }
1324}