Skip to repository content1501 lines · 53.6 KB · rust
tenant.openagents/omega
No repository description is available.
OpenAgents Git authority 2026-07-28T04:08:33.536Z Public web read
NIP-34 coordinate
30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omegaMaintainersHidden in public view
References2 branches · 1 tag
Read-only clone
git clone https://openagents.com/git/tenant.openagents/omega.gitBrowse files
mention_set.rs
1use crate::diagnostics::{DiagnosticsOptions, codeblock_fence_for_path, collect_diagnostics};
2use acp_thread::{MentionUri, selection_name};
3use agent::{ThreadStore, outline};
4use agent_client_protocol::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