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