mention_set.rs

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