Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T04:08:33.536Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

mention_set.rs

1501 lines · 53.6 KB · rust
1use crate::diagnostics::{DiagnosticsOptions, codeblock_fence_for_path, collect_diagnostics};
2use acp_thread::{MentionUri, selection_name};
3use agent::{ThreadStore, outline};
4use agent_client_protocol::schema::v1 as acp;
5use agent_servers::{AgentServer, AgentServerDelegate};
6use anyhow::{Context as _, Result, anyhow};
7use collections::{HashMap, HashSet};
8use editor::{
9    Anchor, Editor, EditorSnapshot, FoldPlaceholder, ToOffset,
10    display_map::{Crease, CreaseId, CreaseMetadata, FoldId},
11    scroll::Autoscroll,
12};
13use futures::{AsyncReadExt as _, FutureExt as _, future::Shared};
14use gpui::{
15    AppContext, ClipboardEntry, Context, Empty, Entity, EntityId, Image, ImageFormat, Img,
16    SharedString, Task, WeakEntity,
17};
18use http_client::{AsyncBody, HttpClientWithUrl};
19use itertools::Either;
20use language::Buffer;
21use language_model::{LanguageModelImage, LanguageModelImageExt};
22use multi_buffer::MultiBufferRow;
23use postage::stream::Stream as _;
24use project::{Project, ProjectItem, ProjectPath, Worktree};
25use rope::Point;
26use std::{
27    cell::RefCell,
28    ffi::OsStr,
29    fmt::Write,
30    ops::{Range, RangeInclusive},
31    path::{Path, PathBuf},
32    rc::Rc,
33    sync::Arc,
34};
35use text::OffsetRangeExt;
36use ui::{Disclosure, Toggleable, prelude::*};
37use util::{ResultExt, debug_panic, rel_path::RelPath};
38use workspace::{Workspace, notifications::NotifyResultExt as _};
39
40use crate::ui::MentionCrease;
41
42pub type MentionTask = Shared<Task<Result<Mention, String>>>;
43
44#[derive(Debug, Clone, Eq, PartialEq)]
45pub enum Mention {
46    Text {
47        content: String,
48        tracked_buffers: Vec<Entity<Buffer>>,
49    },
50    Image(MentionImage),
51    Link,
52}
53
54#[derive(Clone, Debug, Eq, PartialEq)]
55pub struct MentionImage {
56    pub data: SharedString,
57    pub format: ImageFormat,
58}
59
60pub struct MentionSet {
61    project: WeakEntity<Project>,
62    thread_store: Option<Entity<ThreadStore>>,
63    mentions: HashMap<CreaseId, (MentionUri, MentionTask)>,
64    crease_entities: HashMap<CreaseId, Entity<LoadingContext>>,
65}
66
67impl MentionSet {
68    pub fn new(project: WeakEntity<Project>, thread_store: Option<Entity<ThreadStore>>) -> Self {
69        Self {
70            project,
71            thread_store,
72            mentions: HashMap::default(),
73            crease_entities: HashMap::default(),
74        }
75    }
76
77    pub fn contents(
78        &self,
79        full_mention_content: bool,
80        cx: &mut App,
81    ) -> Task<Result<HashMap<CreaseId, (MentionUri, Mention)>>> {
82        let Some(project) = self.project.upgrade() else {
83            return Task::ready(Err(anyhow!("Project not found")));
84        };
85        let mentions = self.mentions.clone();
86        cx.spawn(async move |cx| {
87            let mut contents = HashMap::default();
88            for (crease_id, (mention_uri, task)) in mentions {
89                let content = if full_mention_content
90                    && let MentionUri::Directory { abs_path } = &mention_uri
91                {
92                    cx.update(|cx| full_mention_for_directory(&project, abs_path, cx))
93                        .await?
94                } else {
95                    task.await.map_err(|e| anyhow!("{e}"))?
96                };
97
98                contents.insert(crease_id, (mention_uri, content));
99            }
100            Ok(contents)
101        })
102    }
103
104    pub fn remove_invalid(&mut self, snapshot: &EditorSnapshot) {
105        for (crease_id, crease) in snapshot.crease_snapshot.creases() {
106            if !crease.range().start.is_valid(snapshot.buffer_snapshot()) {
107                self.mentions.remove(&crease_id);
108                self.crease_entities.remove(&crease_id);
109            }
110        }
111    }
112
113    pub fn insert_mention(
114        &mut self,
115        crease_id: CreaseId,
116        uri: MentionUri,
117        task: MentionTask,
118        crease_entity: Option<Entity<LoadingContext>>,
119        cx: &mut App,
120    ) {
121        self.mentions.insert(crease_id, (uri, task));
122        if let Some(entity) = crease_entity {
123            self.crease_entities.insert(crease_id, entity);
124        }
125        self.recompute_disambiguation(cx);
126    }
127
128    /// Creates the appropriate confirmation task for a mention based on its URI type.
129    /// This is used when pasting mention links to properly load their content.
130    pub fn confirm_mention_for_uri(
131        &mut self,
132        mention_uri: MentionUri,
133        supports_images: bool,
134        http_client: Arc<HttpClientWithUrl>,
135        cx: &mut Context<Self>,
136    ) -> Task<Result<Mention>> {
137        match mention_uri {
138            MentionUri::Fetch { url } => self.confirm_mention_for_fetch(url, http_client, cx),
139            MentionUri::Directory { .. } => Task::ready(Ok(Mention::Link)),
140            MentionUri::Thread { id, .. } => self.confirm_mention_for_thread(id, cx),
141            MentionUri::File { abs_path } => {
142                self.confirm_mention_for_file(abs_path, supports_images, cx)
143            }
144            MentionUri::Symbol {
145                abs_path,
146                line_range,
147                ..
148            } => self.confirm_mention_for_symbol(abs_path, line_range, cx),
149            MentionUri::Skill {
150                skill_file_path, ..
151            } => self.confirm_mention_for_skill(skill_file_path, cx),
152            MentionUri::Diagnostics {
153                include_errors,
154                include_warnings,
155            } => self.confirm_mention_for_diagnostics(include_errors, include_warnings, cx),
156            MentionUri::GitDiff { base_ref } => {
157                self.confirm_mention_for_git_diff(base_ref.into(), cx)
158            }
159            MentionUri::Selection {
160                abs_path: Some(abs_path),
161                line_range,
162                ..
163            } => self.confirm_mention_for_symbol(abs_path, line_range, cx),
164            MentionUri::Selection { abs_path: None, .. } => Task::ready(Err(anyhow!(
165                "Untitled buffer selection mentions are not supported for paste"
166            ))),
167            MentionUri::PastedImage { .. }
168            | MentionUri::TerminalSelection { .. }
169            | MentionUri::MergeConflict { .. }
170            | MentionUri::Rule { .. } => {
171                Task::ready(Err(anyhow!("Unsupported mention URI type for paste")))
172            }
173        }
174    }
175
176    pub fn remove_mention(&mut self, crease_id: &CreaseId, cx: &mut App) {
177        self.mentions.remove(crease_id);
178        self.crease_entities.remove(crease_id);
179        self.recompute_disambiguation(cx);
180    }
181
182    pub fn creases(&self) -> HashSet<CreaseId> {
183        self.mentions.keys().cloned().collect()
184    }
185
186    pub fn is_empty(&self) -> bool {
187        self.mentions.is_empty()
188    }
189
190    pub fn mentions(&self) -> HashSet<MentionUri> {
191        self.mentions.values().map(|(uri, _)| uri.clone()).collect()
192    }
193
194    pub fn mention_uri_for_crease(&self, crease_id: &CreaseId) -> Option<MentionUri> {
195        self.mentions.get(crease_id).map(|(uri, _)| uri.clone())
196    }
197
198    /// Returns the resolved mention for a crease, if any.
199    pub fn resolved_mention_for_crease(
200        &self,
201        crease_id: &CreaseId,
202    ) -> Option<(MentionUri, Option<Mention>)> {
203        let (uri, task) = self.mentions.get(crease_id)?;
204        let mention = task.clone().now_or_never().and_then(|result| result.ok());
205        Some((uri.clone(), mention))
206    }
207
208    pub fn set_mentions(&mut self, mentions: HashMap<CreaseId, (MentionUri, MentionTask)>) {
209        self.crease_entities
210            .retain(|id, _| mentions.contains_key(id));
211        self.mentions = mentions;
212    }
213
214    pub fn clear(&mut self) -> impl Iterator<Item = (CreaseId, (MentionUri, MentionTask))> {
215        self.crease_entities.clear();
216        self.mentions.drain()
217    }
218
219    fn recompute_disambiguation(&self, cx: &mut App) {
220        let labels =
221            compute_disambiguated_labels(self.mentions.iter().map(|(id, (uri, _))| (*id, uri)));
222
223        for (crease_id, new_label) in labels {
224            if let Some(entity) = self.crease_entities.get(&crease_id) {
225                entity.update(cx, |loading_ctx, cx| {
226                    if loading_ctx.label != new_label {
227                        loading_ctx.label = new_label;
228                        cx.notify();
229                    }
230                });
231            }
232        }
233    }
234
235    pub fn confirm_mention_completion(
236        &mut self,
237        crease_text: SharedString,
238        start: text::Anchor,
239        content_len: usize,
240        mention_uri: MentionUri,
241        supports_images: bool,
242        editor: Entity<Editor>,
243        workspace: &Entity<Workspace>,
244        window: &mut Window,
245        cx: &mut Context<Self>,
246    ) -> Task<()> {
247        let Some(project) = self.project.upgrade() else {
248            return Task::ready(());
249        };
250
251        let snapshot = editor.update(cx, |editor, cx| editor.snapshot(window, cx));
252        let Some(start_anchor) = snapshot.buffer_snapshot().anchor_in_excerpt(start) else {
253            return Task::ready(());
254        };
255        let end_anchor = snapshot.buffer_snapshot().anchor_before(
256            start_anchor.to_offset(&snapshot.buffer_snapshot()) + content_len + 1usize,
257        );
258
259        let crease = if let MentionUri::File { abs_path } = &mention_uri
260            && is_raster_image_path(abs_path)
261        {
262            let Some(project_path) = project
263                .read(cx)
264                .project_path_for_absolute_path(&abs_path, cx)
265            else {
266                log::error!("project path not found for image mention {abs_path:?}");
267                return Task::ready(());
268            };
269            let image_task = project.update(cx, |project, cx| project.open_image(project_path, cx));
270            let image = cx
271                .spawn(async move |_, cx| {
272                    let image = image_task.await.map_err(|e| e.to_string())?;
273                    let image = image.update(cx, |image, _| image.image.clone());
274                    Ok(image)
275                })
276                .shared();
277            insert_crease_for_mention(
278                start,
279                content_len,
280                mention_uri.name().into(),
281                IconName::Image.path().into(),
282                mention_uri.tooltip_text(),
283                Some(mention_uri.clone()),
284                Some(workspace.downgrade()),
285                Some(image),
286                editor.clone(),
287                window,
288                cx,
289            )
290        } else {
291            insert_crease_for_mention(
292                start,
293                content_len,
294                crease_text,
295                mention_uri.icon_path(cx),
296                mention_uri.tooltip_text(),
297                Some(mention_uri.clone()),
298                Some(workspace.downgrade()),
299                None,
300                editor.clone(),
301                window,
302                cx,
303            )
304        };
305        let Some((crease_id, tx, crease_entity)) = crease else {
306            return Task::ready(());
307        };
308
309        let task = match mention_uri.clone() {
310            MentionUri::Fetch { url } => {
311                self.confirm_mention_for_fetch(url, workspace.read(cx).client().http_client(), cx)
312            }
313            MentionUri::Directory { .. } => Task::ready(Ok(Mention::Link)),
314            MentionUri::Thread { id, .. } => self.confirm_mention_for_thread(id, cx),
315            MentionUri::File { abs_path } => {
316                self.confirm_mention_for_file(abs_path, supports_images, cx)
317            }
318            MentionUri::Symbol {
319                abs_path,
320                line_range,
321                ..
322            } => self.confirm_mention_for_symbol(abs_path, line_range, cx),
323            MentionUri::Skill {
324                skill_file_path, ..
325            } => self.confirm_mention_for_skill(skill_file_path, cx),
326            MentionUri::Diagnostics {
327                include_errors,
328                include_warnings,
329            } => self.confirm_mention_for_diagnostics(include_errors, include_warnings, cx),
330            MentionUri::PastedImage { .. } => {
331                debug_panic!("pasted image URI should not be included in completions");
332                Task::ready(Err(anyhow!(
333                    "pasted imaged URI should not be included in completions"
334                )))
335            }
336            MentionUri::Selection { .. } => {
337                debug_panic!("unexpected selection URI");
338                Task::ready(Err(anyhow!("unexpected selection URI")))
339            }
340            MentionUri::TerminalSelection { .. } => {
341                debug_panic!("unexpected terminal URI");
342                Task::ready(Err(anyhow!("unexpected terminal URI")))
343            }
344            MentionUri::GitDiff { base_ref } => {
345                self.confirm_mention_for_git_diff(base_ref.into(), cx)
346            }
347            MentionUri::MergeConflict { .. } => {
348                debug_panic!("unexpected merge conflict URI");
349                Task::ready(Err(anyhow!("unexpected merge conflict URI")))
350            }
351            MentionUri::Rule { .. } => {
352                debug_panic!("unexpected rule URI");
353                Task::ready(Err(anyhow!("unexpected rule URI")))
354            }
355        };
356        let task = cx
357            .spawn(async move |_, _| task.await.map_err(|e| e.to_string()))
358            .shared();
359        self.mentions.insert(crease_id, (mention_uri, task.clone()));
360        if let Some(entity) = crease_entity {
361            self.crease_entities.insert(crease_id, entity);
362        }
363        self.recompute_disambiguation(cx);
364
365        // Notify the user if we failed to load the mentioned context
366        let workspace = workspace.downgrade();
367        cx.spawn(async move |this, mut cx| {
368            let result = task.await.notify_workspace_async_err(workspace, &mut cx);
369            drop(tx);
370            if result.is_none() {
371                this.update(cx, |this, cx| {
372                    editor.update(cx, |editor, cx| {
373                        // Remove mention
374                        editor.edit([(start_anchor..end_anchor, "")], cx);
375                    });
376                    this.mentions.remove(&crease_id);
377                    this.crease_entities.remove(&crease_id);
378                })
379                .ok();
380            }
381        })
382    }
383
384    pub fn confirm_mention_for_file(
385        &self,
386        abs_path: PathBuf,
387        supports_images: bool,
388        cx: &mut Context<Self>,
389    ) -> Task<Result<Mention>> {
390        let Some(project) = self.project.upgrade() else {
391            return Task::ready(Err(anyhow!("project not found")));
392        };
393
394        let Some(project_path) = project
395            .read(cx)
396            .project_path_for_absolute_path(&abs_path, cx)
397        else {
398            return Task::ready(Err(anyhow!(
399                "project path not found for file mention {abs_path:?}"
400            )));
401        };
402
403        if is_raster_image_path(&abs_path) {
404            if !supports_images {
405                return Task::ready(Err(anyhow!("This model does not support images yet")));
406            }
407            let task = project.update(cx, |project, cx| project.open_image(project_path, cx));
408            return cx.spawn(async move |_, cx| {
409                let image = task.await?;
410                let image = image.update(cx, |image, _| image.image.clone());
411                let image = cx
412                    .update(|cx| LanguageModelImage::from_image(image, cx))
413                    .await;
414                if let Some(image) = image {
415                    Ok(Mention::Image(MentionImage {
416                        data: image.source,
417                        format: LanguageModelImage::FORMAT,
418                    }))
419                } else {
420                    Err(anyhow!("Failed to convert image"))
421                }
422            });
423        }
424
425        let buffer = project.update(cx, |project, cx| project.open_buffer(project_path, cx));
426        cx.spawn(async move |_, cx| {
427            let buffer = buffer.await?;
428            let buffer_content = outline::get_buffer_content_or_outline(
429                buffer.clone(),
430                Some(&abs_path.to_string_lossy()),
431                &cx,
432            )
433            .await?;
434
435            Ok(Mention::Text {
436                content: buffer_content.text,
437                tracked_buffers: vec![buffer],
438            })
439        })
440    }
441
442    fn confirm_mention_for_fetch(
443        &self,
444        url: url::Url,
445        http_client: Arc<HttpClientWithUrl>,
446        cx: &mut Context<Self>,
447    ) -> Task<Result<Mention>> {
448        cx.background_executor().spawn(async move {
449            let content = fetch_url_content(http_client, url.to_string()).await?;
450            Ok(Mention::Text {
451                content,
452                tracked_buffers: Vec::new(),
453            })
454        })
455    }
456
457    fn confirm_mention_for_symbol(
458        &self,
459        abs_path: PathBuf,
460        line_range: RangeInclusive<u32>,
461        cx: &mut Context<Self>,
462    ) -> Task<Result<Mention>> {
463        let Some(project) = self.project.upgrade() else {
464            return Task::ready(Err(anyhow!("project not found")));
465        };
466        let Some(project_path) = project
467            .read(cx)
468            .project_path_for_absolute_path(&abs_path, cx)
469        else {
470            return Task::ready(Err(anyhow!(
471                "project path not found for symbol mention {abs_path:?}"
472            )));
473        };
474        let buffer = project.update(cx, |project, cx| project.open_buffer(project_path, cx));
475        cx.spawn(async move |_, cx| {
476            let buffer = buffer.await?;
477            let mention = buffer.update(cx, |buffer, cx| {
478                let start = Point::new(*line_range.start(), 0).min(buffer.max_point());
479                let end = Point::new(*line_range.end() + 1, 0).min(buffer.max_point());
480                let content = buffer.text_for_range(start..end).collect();
481                Mention::Text {
482                    content,
483                    tracked_buffers: vec![cx.entity()],
484                }
485            });
486            Ok(mention)
487        })
488    }
489
490    fn confirm_mention_for_skill(
491        &self,
492        skill_file_path: PathBuf,
493        cx: &mut Context<Self>,
494    ) -> Task<Result<Mention>> {
495        // Built-in skills have synthetic paths that don't exist on disk;
496        // serve their content directly from the compiled-in data.
497        if let Some(content) = agent_skills::builtin_skill_content(&skill_file_path) {
498            return Task::ready(Ok(Mention::Text {
499                content: content.to_string(),
500                tracked_buffers: Vec::new(),
501            }));
502        }
503        cx.background_spawn(async move {
504            let content = std::fs::read_to_string(&skill_file_path).map_err(|e| {
505                anyhow!(
506                    "Failed to read skill file {}: {}",
507                    skill_file_path.display(),
508                    e
509                )
510            })?;
511            Ok(Mention::Text {
512                content,
513                tracked_buffers: Vec::new(),
514            })
515        })
516    }
517
518    pub fn confirm_mention_for_selection(
519        &mut self,
520        source_range: Range<text::Anchor>,
521        selections: Vec<(Entity<Buffer>, Range<text::Anchor>, Range<usize>)>,
522        editor: Entity<Editor>,
523        window: &mut Window,
524        cx: &mut Context<Self>,
525    ) {
526        let Some(project) = self.project.upgrade() else {
527            return;
528        };
529
530        let snapshot = editor.read(cx).buffer().read(cx).snapshot(cx);
531        let Some(start) = snapshot.anchor_in_excerpt(source_range.start) else {
532            return;
533        };
534
535        let offset = start.to_offset(&snapshot);
536
537        for (buffer, selection_range, range_to_fold) in selections {
538            let range = snapshot.anchor_after(offset + range_to_fold.start)
539                ..snapshot.anchor_after(offset + range_to_fold.end);
540
541            let abs_path = buffer
542                .read(cx)
543                .project_path(cx)
544                .and_then(|project_path| project.read(cx).absolute_path(&project_path, cx));
545            let snapshot = buffer.read(cx).snapshot();
546
547            let text = snapshot
548                .text_for_range(selection_range.clone())
549                .collect::<String>();
550            let point_range = selection_range.to_point(&snapshot);
551            let line_range = point_range.start.row..=point_range.end.row;
552
553            let uri = MentionUri::Selection {
554                abs_path: abs_path.clone(),
555                line_range: line_range.clone(),
556                column: None,
557            };
558            let crease = crease_for_mention(
559                selection_name(abs_path.as_deref(), &line_range).into(),
560                uri.icon_path(cx),
561                uri.tooltip_text(),
562                range,
563                editor.downgrade(),
564            );
565
566            let crease_id = editor.update(cx, |editor, cx| {
567                let crease_ids = editor.insert_creases(vec![crease.clone()], cx);
568                editor.fold_creases(vec![crease], false, window, cx);
569                crease_ids.first().copied().unwrap()
570            });
571
572            self.mentions.insert(
573                crease_id,
574                (
575                    uri,
576                    Task::ready(Ok(Mention::Text {
577                        content: text,
578                        tracked_buffers: vec![buffer],
579                    }))
580                    .shared(),
581                ),
582            );
583        }
584
585        // Take this explanation with a grain of salt but, with creases being
586        // inserted, GPUI's recomputes the editor layout in the next frames, so
587        // directly calling `editor.request_autoscroll` wouldn't work as
588        // expected. We're leveraging `cx.on_next_frame` to wait 2 frames and
589        // ensure that the layout has been recalculated so that the autoscroll
590        // request actually shows the cursor's new position.
591        cx.on_next_frame(window, move |_, window, cx| {
592            cx.on_next_frame(window, move |_, _, cx| {
593                editor.update(cx, |editor, cx| {
594                    editor.request_autoscroll(Autoscroll::fit(), cx)
595                });
596            });
597        });
598    }
599
600    fn confirm_mention_for_thread(
601        &mut self,
602        id: acp::SessionId,
603        cx: &mut Context<Self>,
604    ) -> Task<Result<Mention>> {
605        let Some(thread_store) = self.thread_store.clone() else {
606            return Task::ready(Err(anyhow!(
607                "Thread mentions are only supported for the native agent"
608            )));
609        };
610        let Some(project) = self.project.upgrade() else {
611            return Task::ready(Err(anyhow!("project not found")));
612        };
613
614        let server = Rc::new(agent::NativeAgentServer::new(
615            project.read(cx).fs().clone(),
616            thread_store,
617        ));
618        let delegate =
619            AgentServerDelegate::new(project.read(cx).agent_server_store().clone(), None, None);
620        let connection = server.connect(delegate, project.clone(), cx);
621        cx.spawn(async move |_, cx| {
622            let agent = connection.await?;
623            let agent = agent.downcast::<agent::NativeAgentConnection>().unwrap();
624            let summary = agent
625                .0
626                .update(cx, |agent, cx| {
627                    agent.thread_summary(id, project.clone(), cx)
628                })
629                .await?;
630            Ok(Mention::Text {
631                content: summary.to_string(),
632                tracked_buffers: Vec::new(),
633            })
634        })
635    }
636
637    fn confirm_mention_for_diagnostics(
638        &self,
639        include_errors: bool,
640        include_warnings: bool,
641        cx: &mut Context<Self>,
642    ) -> Task<Result<Mention>> {
643        let Some(project) = self.project.upgrade() else {
644            return Task::ready(Err(anyhow!("project not found")));
645        };
646
647        let diagnostics_task = collect_diagnostics(
648            project,
649            DiagnosticsOptions {
650                include_errors,
651                include_warnings,
652                path_matcher: None,
653            },
654            cx,
655        );
656        cx.spawn(async move |_, _| {
657            let content = diagnostics_task
658                .await?
659                .unwrap_or_else(|| "No diagnostics found.".into());
660            Ok(Mention::Text {
661                content,
662                tracked_buffers: Vec::new(),
663            })
664        })
665    }
666
667    pub fn confirm_mention_for_git_diff(
668        &self,
669        base_ref: SharedString,
670        cx: &mut Context<Self>,
671    ) -> Task<Result<Mention>> {
672        let Some(project) = self.project.upgrade() else {
673            return Task::ready(Err(anyhow!("project not found")));
674        };
675
676        let Some(repo) = project.read(cx).active_repository(cx) else {
677            return Task::ready(Err(anyhow!("no active repository")));
678        };
679
680        let diff_receiver = repo.update(cx, |repo, cx| {
681            repo.diff(
682                git::repository::DiffType::MergeBase { base_ref: base_ref },
683                cx,
684            )
685        });
686
687        cx.spawn(async move |_, _| {
688            let diff_text = diff_receiver.await??;
689            if diff_text.is_empty() {
690                Ok(Mention::Text {
691                    content: "No changes found in branch diff.".into(),
692                    tracked_buffers: Vec::new(),
693                })
694            } else {
695                Ok(Mention::Text {
696                    content: diff_text,
697                    tracked_buffers: Vec::new(),
698                })
699            }
700        })
701    }
702}
703
704/// Computes disambiguated labels for a set of mentions, so that mentions sharing
705/// a base name get extra context (parent path components, skill source) to tell
706/// them apart. Same approach as buffer tab titles and the sidebar.
707fn compute_disambiguated_labels<'a>(
708    mentions: impl Iterator<Item = (CreaseId, &'a MentionUri)>,
709) -> HashMap<CreaseId, SharedString> {
710    let (ids, uris): (Vec<CreaseId>, Vec<&MentionUri>) = mentions.unzip();
711    ids.into_iter()
712        .zip(disambiguated_labels_for_uris(&uris))
713        .collect()
714}
715
716/// Labels for each URI, in input order. Duplicate URIs are collapsed first, so a
717/// mention added twice keeps its base name instead of being escalated to its
718/// full path by the collision-resolution loop.
719fn disambiguated_labels_for_uris(uris: &[&MentionUri]) -> Vec<SharedString> {
720    let mut seen: HashSet<&MentionUri> = HashSet::default();
721    let unique_uris: Vec<&MentionUri> = uris
722        .iter()
723        .copied()
724        .filter(|&uri| seen.insert(uri))
725        .collect();
726
727    let details =
728        util::disambiguate::compute_disambiguation_details(&unique_uris, |uri, detail| {
729            uri.disambiguated_name(detail)
730        });
731
732    let uri_to_detail: HashMap<&MentionUri, usize> = unique_uris.into_iter().zip(details).collect();
733
734    uris.iter()
735        .map(|uri| {
736            let detail = uri_to_detail.get(uri).copied().unwrap_or(0);
737            uri.disambiguated_name(detail).into()
738        })
739        .collect()
740}
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745
746    use fs::FakeFs;
747    use gpui::TestAppContext;
748    use project::Project;
749    use prompt_store;
750    use release_channel;
751    use semver::Version;
752    use serde_json::json;
753    use settings::SettingsStore;
754    use std::path::Path;
755    use theme;
756    use util::path;
757
758    fn init_test(cx: &mut TestAppContext) {
759        let settings_store = cx.update(SettingsStore::test);
760        cx.set_global(settings_store);
761        cx.update(|cx| {
762            theme_settings::init(theme::LoadThemes::JustBase, cx);
763            release_channel::init(Version::new(0, 0, 0), cx);
764            prompt_store::init(cx);
765        });
766    }
767
768    #[gpui::test]
769    async fn test_thread_mentions_disabled(cx: &mut TestAppContext) {
770        init_test(cx);
771
772        let fs = FakeFs::new(cx.executor());
773        fs.insert_tree("/project", json!({"file": ""})).await;
774        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
775        let thread_store = None;
776        let mention_set = cx.new(|_cx| MentionSet::new(project.downgrade(), thread_store));
777
778        let task = mention_set.update(cx, |mention_set, cx| {
779            mention_set.confirm_mention_for_thread(acp::SessionId::new("thread-1"), cx)
780        });
781
782        let error = task.await.unwrap_err();
783        assert!(
784            error
785                .to_string()
786                .contains("Thread mentions are only supported for the native agent"),
787            "Unexpected error: {error:#}"
788        );
789    }
790
791    #[gpui::test]
792    async fn test_selection_mentions_supported_for_paste(cx: &mut TestAppContext) {
793        init_test(cx);
794
795        let fs = FakeFs::new(cx.executor());
796        fs.insert_tree(
797            "/project",
798            json!({"file.rs": "line 1\nline 2\nline 3\nline 4\n"}),
799        )
800        .await;
801        let project = Project::test(fs, [Path::new(path!("/project"))], cx).await;
802        let mention_set = cx.new(|_cx| MentionSet::new(project.downgrade(), None));
803
804        let mention_task = mention_set.update(cx, |mention_set, cx| {
805            let http_client = project.read(cx).client().http_client();
806            mention_set.confirm_mention_for_uri(
807                MentionUri::Selection {
808                    abs_path: Some(path!("/project/file.rs").into()),
809                    line_range: 1..=2,
810                    column: None,
811                },
812                false,
813                http_client,
814                cx,
815            )
816        });
817
818        let mention = mention_task.await.unwrap();
819        match mention {
820            Mention::Text {
821                content,
822                tracked_buffers,
823            } => {
824                assert_eq!(content, "line 2\nline 3\n");
825                assert_eq!(tracked_buffers.len(), 1);
826            }
827            other => panic!("Expected selection mention to resolve as text, got {other:?}"),
828        }
829    }
830
831    #[test]
832    fn test_is_raster_image_path_is_case_insensitive() {
833        // Regression test for #54308: drag-and-dropping a file whose extension
834        // is uppercase (e.g. `.PNG`) used to be treated as a non-image file.
835        assert!(is_raster_image_path(Path::new("/tmp/image.png")));
836        assert!(is_raster_image_path(Path::new("/tmp/image.PNG")));
837        assert!(is_raster_image_path(Path::new("/tmp/image.Png")));
838        assert!(is_raster_image_path(Path::new("/tmp/photo.JPEG")));
839        assert!(is_raster_image_path(Path::new("/tmp/animation.GIF")));
840
841        // SVG is handled via a different code path and must not be reported here.
842        assert!(!is_raster_image_path(Path::new("/tmp/icon.svg")));
843        assert!(!is_raster_image_path(Path::new("/tmp/icon.SVG")));
844
845        // Non-image extensions and paths with no extension.
846        assert!(!is_raster_image_path(Path::new("/tmp/notes.txt")));
847        assert!(!is_raster_image_path(Path::new("/tmp/README")));
848    }
849
850    #[test]
851    fn test_disambiguated_labels_dedupe_identical_uris() {
852        // Mentioning the same file twice must not escalate the duplicates to
853        // their full path. Distinct files sharing a base name still disambiguate.
854        let foo_a = MentionUri::File {
855            abs_path: path!("/project/a/foo.rs").into(),
856        };
857
858        let foo_b = MentionUri::File {
859            abs_path: path!("/project/b/foo.rs").into(),
860        };
861
862        let uris = vec![&foo_a, &foo_a, &foo_b];
863        let labels = disambiguated_labels_for_uris(&uris);
864
865        assert_eq!(labels[0].as_ref(), "a/foo.rs");
866        assert_eq!(labels[2].as_ref(), "b/foo.rs");
867        // The duplicate keeps the same label rather than escalating to full path.
868        assert_eq!(labels[1].as_ref(), "a/foo.rs");
869    }
870}
871
872/// Inserts a list of images into the editor as context mentions.
873/// This is the shared implementation used by both paste and file picker operations.
874pub(crate) async fn insert_images_as_context(
875    images: Vec<(gpui::Image, SharedString)>,
876    editor: Entity<Editor>,
877    mention_set: Entity<MentionSet>,
878    workspace: WeakEntity<Workspace>,
879    cx: &mut gpui::AsyncWindowContext,
880) {
881    if images.is_empty() {
882        return;
883    }
884
885    for (image, name) in images {
886        let mention_uri = MentionUri::PastedImage {
887            name: name.to_string(),
888        };
889        let replacement_text = mention_uri.as_link().to_string();
890        let Some((text_anchor, multibuffer_anchor)) = editor
891            .update_in(cx, |editor, window, cx| {
892                let snapshot = editor.snapshot(window, cx);
893                let (cursor_anchor, buffer_snapshot) = snapshot
894                    .buffer_snapshot()
895                    .anchor_to_buffer_anchor(editor.selections.newest_anchor().start)
896                    .unwrap();
897                let text_anchor = cursor_anchor.bias_left(buffer_snapshot);
898                let multibuffer_anchor = snapshot.buffer_snapshot().anchor_in_excerpt(text_anchor);
899                editor.insert(&format!("{replacement_text} "), window, cx);
900                (text_anchor, multibuffer_anchor)
901            })
902            .ok()
903        else {
904            break;
905        };
906
907        let content_len = replacement_text.len();
908        let Some(start_anchor) = multibuffer_anchor else {
909            continue;
910        };
911        let end_anchor = editor.update(cx, |editor, cx| {
912            let snapshot = editor.buffer().read(cx).snapshot(cx);
913            snapshot.anchor_before(start_anchor.to_offset(&snapshot) + content_len)
914        });
915        let image = Arc::new(image);
916        let Ok(Some((crease_id, tx, crease_entity))) = cx.update(|window, cx| {
917            insert_crease_for_mention(
918                text_anchor,
919                content_len,
920                name.clone(),
921                IconName::Image.path().into(),
922                None,
923                None,
924                None,
925                Some(Task::ready(Ok(image.clone())).shared()),
926                editor.clone(),
927                window,
928                cx,
929            )
930        }) else {
931            continue;
932        };
933        let task = cx
934            .spawn(async move |cx| {
935                let image = cx
936                    .update(|_, cx| LanguageModelImage::from_image(image, cx))
937                    .map_err(|e| e.to_string())?
938                    .await;
939                drop(tx);
940                if let Some(image) = image {
941                    Ok(Mention::Image(MentionImage {
942                        data: image.source,
943                        format: LanguageModelImage::FORMAT,
944                    }))
945                } else {
946                    Err("Failed to convert image".into())
947                }
948            })
949            .shared();
950
951        mention_set.update(cx, |mention_set, cx| {
952            mention_set.insert_mention(
953                crease_id,
954                MentionUri::PastedImage {
955                    name: name.to_string(),
956                },
957                task.clone(),
958                crease_entity,
959                cx,
960            )
961        });
962
963        if task
964            .await
965            .notify_workspace_async_err(workspace.clone(), cx)
966            .is_none()
967        {
968            editor.update(cx, |editor, cx| {
969                editor.edit([(start_anchor..end_anchor, "")], cx);
970            });
971            mention_set.update(cx, |mention_set, cx| {
972                mention_set.remove_mention(&crease_id, cx)
973            });
974        }
975    }
976}
977
978fn image_format_from_external_content(format: image::ImageFormat) -> Option<ImageFormat> {
979    match format {
980        image::ImageFormat::Png => Some(ImageFormat::Png),
981        image::ImageFormat::Jpeg => Some(ImageFormat::Jpeg),
982        image::ImageFormat::WebP => Some(ImageFormat::Webp),
983        image::ImageFormat::Gif => Some(ImageFormat::Gif),
984        image::ImageFormat::Bmp => Some(ImageFormat::Bmp),
985        image::ImageFormat::Tiff => Some(ImageFormat::Tiff),
986        image::ImageFormat::Ico => Some(ImageFormat::Ico),
987        image::ImageFormat::Pnm => Some(ImageFormat::Pnm),
988        _ => {
989            debug_panic!("An unhandled image format: {format:?}");
990            None
991        }
992    }
993}
994
995// Case-insensitive so that e.g. `foo.PNG` is recognized the same as `foo.png`.
996// SVG is excluded because it is handled separately.
997fn is_raster_image_path(path: &Path) -> bool {
998    let Some(extension) = path.extension().and_then(OsStr::to_str) else {
999        return false;
1000    };
1001    if extension.eq_ignore_ascii_case("svg") {
1002        return false;
1003    }
1004    Img::extensions()
1005        .iter()
1006        .any(|known| known.eq_ignore_ascii_case(extension))
1007}
1008
1009pub(crate) fn load_external_image_from_path(
1010    path: &Path,
1011    default_name: &SharedString,
1012) -> Option<(Image, SharedString)> {
1013    let content = std::fs::read(path).ok()?;
1014    let format = image::guess_format(&content)
1015        .ok()
1016        .and_then(image_format_from_external_content)?;
1017    let name = path
1018        .file_name()
1019        .and_then(|name| name.to_str())
1020        .map(|name| SharedString::from(name.to_owned()))
1021        .unwrap_or_else(|| default_name.clone());
1022
1023    Some((Image::from_bytes(format, content), name))
1024}
1025
1026pub(crate) fn paste_images_as_context(
1027    editor: Entity<Editor>,
1028    mention_set: Entity<MentionSet>,
1029    workspace: WeakEntity<Workspace>,
1030    window: &mut Window,
1031    cx: &mut App,
1032) -> Option<Task<()>> {
1033    let clipboard = cx.read_from_clipboard()?;
1034
1035    // Only handle paste if the first clipboard entry is an image or file path.
1036    // If text comes first, return None so the caller falls through to text paste.
1037    // This respects the priority order set by the source application.
1038    if matches!(
1039        clipboard.entries().first(),
1040        Some(ClipboardEntry::String(_)) | None
1041    ) {
1042        return None;
1043    }
1044
1045    Some(window.spawn(cx, async move |mut cx| {
1046        use itertools::Itertools;
1047        let default_name: SharedString = "Image".into();
1048        let (mut images, paths): (Vec<(gpui::Image, SharedString)>, Vec<_>) = clipboard
1049            .into_entries()
1050            .filter_map(|entry| match entry {
1051                ClipboardEntry::Image(image) => Some(Either::Left((image, default_name.clone()))),
1052                ClipboardEntry::ExternalPaths(paths) => Some(Either::Right(paths)),
1053                _ => None,
1054            })
1055            .partition_map::<Vec<_>, Vec<_>, _, _, _>(std::convert::identity);
1056
1057        if !paths.is_empty() {
1058            images.extend(
1059                cx.background_spawn(async move {
1060                    paths
1061                        .into_iter()
1062                        .flat_map(|paths| paths.paths().to_owned())
1063                        .filter_map(|path| load_external_image_from_path(&path, &default_name))
1064                        .collect::<Vec<_>>()
1065                })
1066                .await,
1067            );
1068        }
1069
1070        if !images.is_empty() {
1071            insert_images_as_context(images, editor, mention_set, workspace, &mut cx).await;
1072        }
1073    }))
1074}
1075
1076pub(crate) fn insert_crease_for_mention(
1077    anchor: text::Anchor,
1078    content_len: usize,
1079    crease_label: SharedString,
1080    crease_icon: SharedString,
1081    crease_tooltip: Option<SharedString>,
1082    mention_uri: Option<MentionUri>,
1083    workspace: Option<WeakEntity<Workspace>>,
1084    image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
1085    editor: Entity<Editor>,
1086    window: &mut Window,
1087    cx: &mut App,
1088) -> Option<(
1089    CreaseId,
1090    postage::barrier::Sender,
1091    Option<Entity<LoadingContext>>,
1092)> {
1093    let (tx, rx) = postage::barrier::channel();
1094
1095    let crease_id = editor.update(cx, |editor, cx| {
1096        let snapshot = editor.buffer().read(cx).snapshot(cx);
1097
1098        let start = snapshot.anchor_in_excerpt(anchor)?;
1099
1100        let start = start.bias_right(&snapshot);
1101        let end = snapshot.anchor_before(start.to_offset(&snapshot) + content_len);
1102
1103        let (render, crease_entity) = render_mention_fold_button(
1104            crease_label.clone(),
1105            crease_icon.clone(),
1106            crease_tooltip,
1107            mention_uri.clone(),
1108            workspace.clone(),
1109            start..end,
1110            rx,
1111            image,
1112            cx.weak_entity(),
1113            cx,
1114        );
1115        let placeholder = FoldPlaceholder {
1116            render,
1117            merge_adjacent: false,
1118            ..Default::default()
1119        };
1120
1121        let crease = Crease::Inline {
1122            range: start..end,
1123            placeholder,
1124            render_toggle: None,
1125            render_trailer: None,
1126            metadata: Some(CreaseMetadata {
1127                label: crease_label,
1128                icon_path: crease_icon,
1129            }),
1130        };
1131
1132        let ids = editor.insert_creases(vec![crease.clone()], cx);
1133        editor.fold_creases(vec![crease], false, window, cx);
1134
1135        Some((ids[0], crease_entity))
1136    })?;
1137
1138    let (crease_id, crease_entity) = crease_id;
1139    Some((crease_id, tx, Some(crease_entity)))
1140}
1141
1142pub(crate) fn crease_for_mention(
1143    label: SharedString,
1144    icon_path: SharedString,
1145    tooltip: Option<SharedString>,
1146    range: Range<Anchor>,
1147    editor_entity: WeakEntity<Editor>,
1148) -> Crease<Anchor> {
1149    let placeholder = FoldPlaceholder {
1150        render: render_fold_icon_button(icon_path.clone(), label.clone(), tooltip, editor_entity),
1151        merge_adjacent: false,
1152        ..Default::default()
1153    };
1154
1155    let render_trailer = move |_row, _unfold, _window: &mut Window, _cx: &mut App| Empty.into_any();
1156
1157    Crease::inline(range, placeholder, fold_toggle("mention"), render_trailer)
1158        .with_metadata(CreaseMetadata { icon_path, label })
1159}
1160
1161fn render_fold_icon_button(
1162    icon_path: SharedString,
1163    label: SharedString,
1164    tooltip: Option<SharedString>,
1165    editor: WeakEntity<Editor>,
1166) -> Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> {
1167    Arc::new({
1168        move |fold_id, fold_range, cx| {
1169            let is_in_text_selection = editor
1170                .update(cx, |editor, cx| editor.is_range_selected(&fold_range, cx))
1171                .unwrap_or_default();
1172
1173            MentionCrease::new(fold_id, icon_path.clone(), label.clone())
1174                .is_toggled(is_in_text_selection)
1175                .when_some(tooltip.clone(), |this, tooltip_text| {
1176                    this.tooltip(tooltip_text)
1177                })
1178                .into_any_element()
1179        }
1180    })
1181}
1182
1183fn fold_toggle(
1184    name: &'static str,
1185) -> impl Fn(
1186    MultiBufferRow,
1187    bool,
1188    Arc<dyn Fn(bool, &mut Window, &mut App) + Send + Sync>,
1189    &mut Window,
1190    &mut App,
1191) -> AnyElement {
1192    move |row, is_folded, fold, _window, _cx| {
1193        Disclosure::new((name, row.0 as u64), !is_folded)
1194            .toggle_state(is_folded)
1195            .on_click(move |_e, window, cx| fold(!is_folded, window, cx))
1196            .into_any_element()
1197    }
1198}
1199
1200fn full_mention_for_directory(
1201    project: &Entity<Project>,
1202    abs_path: &Path,
1203    cx: &mut App,
1204) -> Task<Result<Mention>> {
1205    fn collect_files_in_path(worktree: &Worktree, path: &RelPath) -> Vec<(Arc<RelPath>, String)> {
1206        let mut files = Vec::new();
1207
1208        for entry in worktree.child_entries(path) {
1209            if entry.is_dir() {
1210                files.extend(collect_files_in_path(worktree, &entry.path));
1211            } else if entry.is_file() {
1212                files.push((
1213                    entry.path.clone(),
1214                    worktree
1215                        .full_path(&entry.path)
1216                        .to_string_lossy()
1217                        .to_string(),
1218                ));
1219            }
1220        }
1221
1222        files
1223    }
1224
1225    let Some(project_path) = project
1226        .read(cx)
1227        .project_path_for_absolute_path(&abs_path, cx)
1228    else {
1229        return Task::ready(Err(anyhow!(
1230            "project path not found for directory mention {abs_path:?}"
1231        )));
1232    };
1233    let Some(entry) = project.read(cx).entry_for_path(&project_path, cx) else {
1234        return Task::ready(Err(anyhow!("project entry not found")));
1235    };
1236    let directory_path = entry.path.clone();
1237    let worktree_id = project_path.worktree_id;
1238    let Some(worktree) = project.read(cx).worktree_for_id(worktree_id, cx) else {
1239        return Task::ready(Err(anyhow!("worktree not found")));
1240    };
1241    let project = project.clone();
1242    cx.spawn(async move |cx| {
1243        let file_paths = worktree.read_with(cx, |worktree, _cx| {
1244            collect_files_in_path(worktree, &directory_path)
1245        });
1246        let descendants_future = cx.update(|cx| {
1247            futures::future::join_all(file_paths.into_iter().map(
1248                |(worktree_path, full_path): (Arc<RelPath>, String)| {
1249                    let rel_path = worktree_path
1250                        .strip_prefix(&directory_path)
1251                        .map_or_else(|_| worktree_path.clone(), |rel_path| rel_path.into());
1252
1253                    let open_task = project.update(cx, |project, cx| {
1254                        project.buffer_store().update(cx, |buffer_store, cx| {
1255                            let project_path = ProjectPath {
1256                                worktree_id,
1257                                path: worktree_path,
1258                            };
1259                            buffer_store.open_buffer(project_path, cx)
1260                        })
1261                    });
1262
1263                    cx.spawn(async move |cx| {
1264                        let buffer = open_task.await.log_err()?;
1265                        let buffer_content = outline::get_buffer_content_or_outline(
1266                            buffer.clone(),
1267                            Some(&full_path),
1268                            &cx,
1269                        )
1270                        .await
1271                        .ok()?;
1272
1273                        Some((rel_path, full_path, buffer_content.text, buffer))
1274                    })
1275                },
1276            ))
1277        });
1278
1279        let contents = cx
1280            .background_spawn(async move {
1281                let (contents, tracked_buffers): (Vec<_>, Vec<_>) = descendants_future
1282                    .await
1283                    .into_iter()
1284                    .flatten()
1285                    .map(|(rel_path, full_path, rope, buffer)| {
1286                        ((rel_path, full_path, rope), buffer)
1287                    })
1288                    .unzip();
1289                Mention::Text {
1290                    content: render_directory_contents(contents),
1291                    tracked_buffers,
1292                }
1293            })
1294            .await;
1295        anyhow::Ok(contents)
1296    })
1297}
1298
1299fn render_directory_contents(entries: Vec<(Arc<RelPath>, String, String)>) -> String {
1300    let mut output = String::new();
1301    for (_relative_path, full_path, content) in entries {
1302        let fence = codeblock_fence_for_path(Some(&full_path), None);
1303        write!(output, "\n{fence}\n{content}\n```").unwrap();
1304    }
1305    output
1306}
1307
1308fn render_mention_fold_button(
1309    label: SharedString,
1310    icon: SharedString,
1311    tooltip: Option<SharedString>,
1312    mention_uri: Option<MentionUri>,
1313    workspace: Option<WeakEntity<Workspace>>,
1314    range: Range<Anchor>,
1315    mut loading_finished: postage::barrier::Receiver,
1316    image_task: Option<Shared<Task<Result<Arc<Image>, String>>>>,
1317    editor: WeakEntity<Editor>,
1318    cx: &mut App,
1319) -> (
1320    Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement>,
1321    Entity<LoadingContext>,
1322) {
1323    let loading = cx.new(|cx| {
1324        let loading = cx.spawn(async move |this, cx| {
1325            loading_finished.recv().await;
1326            this.update(cx, |this: &mut LoadingContext, cx| {
1327                this.loading = None;
1328                cx.notify();
1329            })
1330            .ok();
1331        });
1332        LoadingContext {
1333            id: cx.entity_id(),
1334            label,
1335            icon,
1336            tooltip,
1337            mention_uri: mention_uri.clone(),
1338            workspace: workspace.clone(),
1339            range,
1340            editor,
1341            loading: Some(loading),
1342            image: image_task.clone(),
1343        }
1344    });
1345    let loading_clone = loading.clone();
1346    let render: Arc<dyn Send + Sync + Fn(FoldId, Range<Anchor>, &mut App) -> AnyElement> =
1347        Arc::new(move |_fold_id, _fold_range, _cx| loading_clone.clone().into_any_element());
1348    (render, loading)
1349}
1350
1351pub struct LoadingContext {
1352    id: EntityId,
1353    label: SharedString,
1354    icon: SharedString,
1355    tooltip: Option<SharedString>,
1356    mention_uri: Option<MentionUri>,
1357    workspace: Option<WeakEntity<Workspace>>,
1358    range: Range<Anchor>,
1359    editor: WeakEntity<Editor>,
1360    loading: Option<Task<()>>,
1361    image: Option<Shared<Task<Result<Arc<Image>, String>>>>,
1362}
1363
1364impl Render for LoadingContext {
1365    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1366        let is_in_text_selection = self
1367            .editor
1368            .update(cx, |editor, cx| editor.is_range_selected(&self.range, cx))
1369            .unwrap_or_default();
1370
1371        let id = ElementId::from(("loading_context", self.id));
1372
1373        MentionCrease::new(id, self.icon.clone(), self.label.clone())
1374            .mention_uri(self.mention_uri.clone())
1375            .workspace(self.workspace.clone())
1376            .is_toggled(is_in_text_selection)
1377            .is_loading(self.loading.is_some())
1378            .when_some(self.tooltip.clone(), |this, tooltip_text| {
1379                this.tooltip(tooltip_text)
1380            })
1381            .when_some(self.image.clone(), |this, image_task| {
1382                this.image_preview(move |_, cx| {
1383                    let image = image_task.peek().cloned().transpose().ok().flatten();
1384                    let image_task = image_task.clone();
1385                    cx.new::<ImageHover>(|cx| ImageHover {
1386                        image,
1387                        _task: cx.spawn(async move |this, cx| {
1388                            if let Ok(image) = image_task.clone().await {
1389                                this.update(cx, |this, cx| {
1390                                    if this.image.replace(image).is_none() {
1391                                        cx.notify();
1392                                    }
1393                                })
1394                                .ok();
1395                            }
1396                        }),
1397                    })
1398                    .into()
1399                })
1400            })
1401    }
1402}
1403
1404struct ImageHover {
1405    image: Option<Arc<Image>>,
1406    _task: Task<()>,
1407}
1408
1409impl Render for ImageHover {
1410    fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
1411        if let Some(image) = self.image.clone() {
1412            div()
1413                .p_1p5()
1414                .elevation_2(cx)
1415                .child(gpui::img(image).h_auto().max_w_96().rounded_sm())
1416                .into_any_element()
1417        } else {
1418            gpui::Empty.into_any_element()
1419        }
1420    }
1421}
1422
1423async fn fetch_url_content(http_client: Arc<HttpClientWithUrl>, url: String) -> Result<String> {
1424    #[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy)]
1425    enum ContentType {
1426        Html,
1427        Plaintext,
1428        Json,
1429    }
1430    use html_to_markdown::{TagHandler, convert_html_to_markdown, markdown};
1431
1432    let url = if !url.starts_with("https://") && !url.starts_with("http://") {
1433        format!("https://{url}")
1434    } else {
1435        url
1436    };
1437
1438    let mut response = http_client.get(&url, AsyncBody::default(), true).await?;
1439    let mut body = Vec::new();
1440    response
1441        .body_mut()
1442        .read_to_end(&mut body)
1443        .await
1444        .context("error reading response body")?;
1445
1446    if response.status().is_client_error() {
1447        let text = String::from_utf8_lossy(body.as_slice());
1448        anyhow::bail!(
1449            "status error {}, response: {text:?}",
1450            response.status().as_u16()
1451        );
1452    }
1453
1454    let Some(content_type) = response.headers().get("content-type") else {
1455        anyhow::bail!("missing Content-Type header");
1456    };
1457    let content_type = content_type
1458        .to_str()
1459        .context("invalid Content-Type header")?;
1460    let content_type = match content_type {
1461        "text/html" => ContentType::Html,
1462        "text/plain" => ContentType::Plaintext,
1463        "application/json" => ContentType::Json,
1464        _ => ContentType::Html,
1465    };
1466
1467    match content_type {
1468        ContentType::Html => {
1469            let mut handlers: Vec<TagHandler> = vec![
1470                Rc::new(RefCell::new(markdown::WebpageChromeRemover)),
1471                Rc::new(RefCell::new(markdown::ParagraphHandler)),
1472                Rc::new(RefCell::new(markdown::HeadingHandler)),
1473                Rc::new(RefCell::new(markdown::ListHandler)),
1474                Rc::new(RefCell::new(markdown::TableHandler::new())),
1475                Rc::new(RefCell::new(markdown::StyledTextHandler)),
1476            ];
1477            if url.contains("wikipedia.org") {
1478                use html_to_markdown::structure::wikipedia;
1479
1480                handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaChromeRemover)));
1481                handlers.push(Rc::new(RefCell::new(wikipedia::WikipediaInfoboxHandler)));
1482                handlers.push(Rc::new(
1483                    RefCell::new(wikipedia::WikipediaCodeHandler::new()),
1484                ));
1485            } else {
1486                handlers.push(Rc::new(RefCell::new(markdown::CodeHandler)));
1487            }
1488            convert_html_to_markdown(&body[..], &mut handlers)
1489        }
1490        ContentType::Plaintext => Ok(std::str::from_utf8(&body)?.to_owned()),
1491        ContentType::Json => {
1492            let json: serde_json::Value = serde_json::from_slice(&body)?;
1493
1494            Ok(format!(
1495                "```json\n{}\n```",
1496                serde_json::to_string_pretty(&json)?
1497            ))
1498        }
1499    }
1500}
1501
Served at tenant.openagents/omega Member data and write actions are omitted.