diff --git a/Cargo.lock b/Cargo.lock index af79c407f796fbce380508a391c63b3b682707c5..1921eb4b689b3b389d77953125a44625f3f71a34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -337,7 +337,6 @@ dependencies = [ "assistant_slash_command", "assistant_slash_commands", "assistant_text_thread", - "async-fs", "audio", "base64 0.22.1", "buffer_diff", diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index b60f2a6b136c5e4dbb131603d95623a719ce7134..662d983e544389d22821fcf14ae28293e81e7d91 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -113,7 +113,6 @@ watch.workspace = true workspace.workspace = true zed_actions.workspace = true image.workspace = true -async-fs.workspace = true reqwest_client = { workspace = true, optional = true } [dev-dependencies] diff --git a/crates/agent_ui/src/mention_set.rs b/crates/agent_ui/src/mention_set.rs index c0aee0fc323977d9aa2822b592db0621c7061bba..97adcb6e39092a892c5e56429b9d446f5ee0be68 100644 --- a/crates/agent_ui/src/mention_set.rs +++ b/crates/agent_ui/src/mention_set.rs @@ -835,6 +835,36 @@ pub(crate) async fn insert_images_as_context( } } +fn image_format_from_external_content(format: image::ImageFormat) -> Option { + match format { + image::ImageFormat::Png => Some(ImageFormat::Png), + image::ImageFormat::Jpeg => Some(ImageFormat::Jpeg), + image::ImageFormat::WebP => Some(ImageFormat::Webp), + image::ImageFormat::Gif => Some(ImageFormat::Gif), + image::ImageFormat::Bmp => Some(ImageFormat::Bmp), + image::ImageFormat::Tiff => Some(ImageFormat::Tiff), + image::ImageFormat::Ico => Some(ImageFormat::Ico), + _ => None, + } +} + +pub(crate) fn load_external_image_from_path( + path: &Path, + default_name: &SharedString, +) -> Option<(Image, SharedString)> { + let content = std::fs::read(path).ok()?; + let format = image::guess_format(&content) + .ok() + .and_then(image_format_from_external_content)?; + let name = path + .file_name() + .and_then(|name| name.to_str()) + .map(|name| SharedString::from(name.to_owned())) + .unwrap_or_else(|| default_name.clone()); + + Some((Image::from_bytes(format, content), name)) +} + pub(crate) fn paste_images_as_context( editor: Entity, mention_set: Entity, @@ -869,37 +899,11 @@ pub(crate) fn paste_images_as_context( if !paths.is_empty() { images.extend( cx.background_spawn(async move { - let mut images = vec![]; - for path in paths.into_iter().flat_map(|paths| paths.paths().to_owned()) { - let Ok(content) = async_fs::read(&path).await else { - continue; - }; - let Ok(format) = image::guess_format(&content) else { - continue; - }; - let name: SharedString = path - .file_name() - .and_then(|n| n.to_str()) - .map(|s| SharedString::from(s.to_owned())) - .unwrap_or_else(|| default_name.clone()); - images.push(( - gpui::Image::from_bytes( - match format { - image::ImageFormat::Png => gpui::ImageFormat::Png, - image::ImageFormat::Jpeg => gpui::ImageFormat::Jpeg, - image::ImageFormat::WebP => gpui::ImageFormat::Webp, - image::ImageFormat::Gif => gpui::ImageFormat::Gif, - image::ImageFormat::Bmp => gpui::ImageFormat::Bmp, - image::ImageFormat::Tiff => gpui::ImageFormat::Tiff, - image::ImageFormat::Ico => gpui::ImageFormat::Ico, - _ => continue, - }, - content, - ), - name, - )); - } - images + paths + .into_iter() + .flat_map(|paths| paths.paths().to_owned()) + .filter_map(|path| load_external_image_from_path(&path, &default_name)) + .collect::>() }) .await, ); diff --git a/crates/agent_ui/src/message_editor.rs b/crates/agent_ui/src/message_editor.rs index df8ab3d08aaaa77f9490603efe03ade5d1ecff4d..a82b5c26fe003e01a58358cf75f6a00e7a983b40 100644 --- a/crates/agent_ui/src/message_editor.rs +++ b/crates/agent_ui/src/message_editor.rs @@ -7,9 +7,7 @@ use crate::{ PromptCompletionProvider, PromptCompletionProviderDelegate, PromptContextAction, PromptContextType, SlashCommandCompletion, }, - mention_set::{ - Mention, MentionImage, MentionSet, insert_crease_for_mention, paste_images_as_context, - }, + mention_set::{Mention, MentionImage, MentionSet, insert_crease_for_mention}, }; use acp_thread::MentionUri; use agent::ThreadStore; @@ -28,7 +26,9 @@ use gpui::{ use language::{Buffer, language_settings::InlayHintKind}; use parking_lot::RwLock; use project::AgentId; -use project::{CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, Worktree}; +use project::{ + CompletionIntent, InlayHint, InlayHintLabel, InlayId, Project, ProjectPath, Worktree, +}; use prompt_store::PromptStore; use rope::Point; use settings::Settings; @@ -161,6 +161,236 @@ impl EventEmitter for MessageEditor {} const COMMAND_HINT_INLAY_ID: InlayId = InlayId::Hint(0); +enum MentionInsertPosition { + AtCursor, + EndOfBuffer, +} + +fn insert_mention_for_project_path( + project_path: &ProjectPath, + position: MentionInsertPosition, + editor: &Entity, + mention_set: &Entity, + project: &Entity, + workspace: &Entity, + supports_images: bool, + window: &mut Window, + cx: &mut App, +) -> Option> { + let (file_name, mention_uri) = { + let project = project.read(cx); + let path_style = project.path_style(cx); + let entry = project.entry_for_path(project_path, cx)?; + let worktree = project.worktree_for_id(project_path.worktree_id, cx)?; + let abs_path = worktree.read(cx).absolutize(&project_path.path); + let (file_name, _) = crate::completion_provider::extract_file_name_and_directory( + &project_path.path, + worktree.read(cx).root_name(), + path_style, + ); + let mention_uri = if entry.is_dir() { + MentionUri::Directory { abs_path } + } else { + MentionUri::File { abs_path } + }; + (file_name, mention_uri) + }; + + let mention_text = mention_uri.as_link().to_string(); + let content_len = mention_text.len(); + + let text_anchor = match position { + MentionInsertPosition::AtCursor => editor.update(cx, |editor, cx| { + let buffer = editor.buffer().read(cx); + let snapshot = buffer.snapshot(cx); + let (_, _, buffer_snapshot) = snapshot.as_singleton()?; + let text_anchor = editor + .selections + .newest_anchor() + .start + .text_anchor + .bias_left(&buffer_snapshot); + + editor.insert(&mention_text, window, cx); + editor.insert(" ", window, cx); + + Some(text_anchor) + }), + MentionInsertPosition::EndOfBuffer => { + let multi_buffer = editor.read(cx).buffer().clone(); + let buffer = multi_buffer.read(cx).as_singleton()?; + let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len())); + let new_text = format!("{mention_text} "); + editor.update(cx, |editor, cx| { + editor.edit( + [( + multi_buffer::Anchor::max()..multi_buffer::Anchor::max(), + new_text, + )], + cx, + ); + }); + Some(anchor) + } + }?; + + Some(mention_set.update(cx, |mention_set, cx| { + mention_set.confirm_mention_completion( + file_name, + text_anchor, + content_len, + mention_uri, + supports_images, + editor.clone(), + workspace, + window, + cx, + ) + })) +} + +enum ResolvedPastedContextItem { + Image(gpui::Image, gpui::SharedString), + ProjectPath(ProjectPath), +} + +async fn resolve_pasted_context_items( + project: Entity, + project_is_local: bool, + supports_images: bool, + entries: Vec, + cx: &mut gpui::AsyncWindowContext, +) -> (Vec, Vec>) { + let mut items = Vec::new(); + let mut added_worktrees = Vec::new(); + let default_image_name: SharedString = MentionUri::PastedImage.name().into(); + + for entry in entries { + match entry { + ClipboardEntry::String(_) => {} + ClipboardEntry::Image(image) => { + if supports_images { + items.push(ResolvedPastedContextItem::Image( + image, + default_image_name.clone(), + )); + } + } + ClipboardEntry::ExternalPaths(paths) => { + for path in paths.paths().iter() { + if let Some((image, name)) = cx + .background_spawn({ + let path = path.clone(); + let default_image_name = default_image_name.clone(); + async move { + crate::mention_set::load_external_image_from_path( + &path, + &default_image_name, + ) + } + }) + .await + { + if supports_images { + items.push(ResolvedPastedContextItem::Image(image, name)); + } + continue; + } + + if !project_is_local { + continue; + } + + let path = path.clone(); + let Ok(resolve_task) = cx.update({ + let project = project.clone(); + move |_, cx| Workspace::project_path_for_path(project, &path, false, cx) + }) else { + continue; + }; + + if let Some((worktree, project_path)) = resolve_task.await.log_err() { + added_worktrees.push(worktree); + items.push(ResolvedPastedContextItem::ProjectPath(project_path)); + } + } + } + } + } + + (items, added_worktrees) +} + +fn insert_project_path_as_context( + project_path: ProjectPath, + editor: Entity, + mention_set: Entity, + workspace: WeakEntity, + supports_images: bool, + cx: &mut gpui::AsyncWindowContext, +) -> Option> { + let workspace = workspace.upgrade()?; + + cx.update(move |window, cx| { + let project = workspace.read(cx).project().clone(); + insert_mention_for_project_path( + &project_path, + MentionInsertPosition::AtCursor, + &editor, + &mention_set, + &project, + &workspace, + supports_images, + window, + cx, + ) + }) + .ok() + .flatten() +} + +async fn insert_resolved_pasted_context_items( + items: Vec, + added_worktrees: Vec>, + editor: Entity, + mention_set: Entity, + workspace: WeakEntity, + supports_images: bool, + cx: &mut gpui::AsyncWindowContext, +) { + let mut path_mention_tasks = Vec::new(); + + for item in items { + match item { + ResolvedPastedContextItem::Image(image, name) => { + crate::mention_set::insert_images_as_context( + vec![(image, name)], + editor.clone(), + mention_set.clone(), + workspace.clone(), + cx, + ) + .await; + } + ResolvedPastedContextItem::ProjectPath(project_path) => { + if let Some(task) = insert_project_path_as_context( + project_path, + editor.clone(), + mention_set.clone(), + workspace.clone(), + supports_images, + cx, + ) { + path_mention_tasks.push(task); + } + } + } + } + + join_all(path_mention_tasks).await; + drop(added_worktrees); +} + impl MessageEditor { pub fn new( workspace: WeakEntity, @@ -859,9 +1089,8 @@ impl MessageEditor { } return; } - // Handle text paste with potential markdown mention links. - // This must be checked BEFORE paste_images_as_context because that function - // returns a task even when there are no images in the clipboard. + // Handle text paste with potential markdown mention links before + // clipboard context entries so markdown text still pastes as text. if let Some(clipboard_text) = cx.read_from_clipboard().and_then(|item| { item.entries().iter().find_map(|entry| match entry { ClipboardEntry::String(text) => Some(text.text().to_string()), @@ -958,30 +1187,7 @@ impl MessageEditor { } } - let has_non_text_content = cx - .read_from_clipboard() - .map(|item| { - item.entries().iter().any(|entry| { - matches!( - entry, - ClipboardEntry::Image(_) | ClipboardEntry::ExternalPaths(_) - ) - }) - }) - .unwrap_or(false); - - if self.session_capabilities.read().supports_images() - && has_non_text_content - && let Some(task) = paste_images_as_context( - self.editor.clone(), - self.mention_set.clone(), - self.workspace.clone(), - window, - cx, - ) - { - cx.stop_propagation(); - task.detach(); + if self.handle_pasted_context(window, cx) { return; } @@ -996,6 +1202,61 @@ impl MessageEditor { }); } + fn handle_pasted_context(&mut self, window: &mut Window, cx: &mut Context) -> bool { + let Some(clipboard) = cx.read_from_clipboard() else { + return false; + }; + + if matches!( + clipboard.entries().first(), + Some(ClipboardEntry::String(_)) | None + ) { + return false; + } + + let Some(workspace) = self.workspace.upgrade() else { + return false; + }; + let project = workspace.read(cx).project().clone(); + let project_is_local = project.read(cx).is_local(); + let supports_images = self.session_capabilities.read().supports_images(); + if !project_is_local && !supports_images { + return false; + } + let editor = self.editor.clone(); + let mention_set = self.mention_set.clone(); + let workspace = self.workspace.clone(); + let entries = clipboard.into_entries().collect::>(); + + cx.stop_propagation(); + + window + .spawn(cx, async move |mut cx| { + let (items, added_worktrees) = resolve_pasted_context_items( + project, + project_is_local, + supports_images, + entries, + &mut cx, + ) + .await; + insert_resolved_pasted_context_items( + items, + added_worktrees, + editor, + mention_set, + workspace, + supports_images, + &mut cx, + ) + .await; + Ok::<(), anyhow::Error>(()) + }) + .detach_and_log_err(cx); + + true + } + pub fn insert_dragged_files( &mut self, paths: Vec, @@ -1007,60 +1268,22 @@ impl MessageEditor { return; }; let project = workspace.read(cx).project().clone(); - let path_style = project.read(cx).path_style(cx); - let buffer = self.editor.read(cx).buffer().clone(); - let Some(buffer) = buffer.read(cx).as_singleton() else { - return; - }; + let supports_images = self.session_capabilities.read().supports_images(); let mut tasks = Vec::new(); for path in paths { - let Some(entry) = project.read(cx).entry_for_path(&path, cx) else { - continue; - }; - let Some(worktree) = project.read(cx).worktree_for_id(path.worktree_id, cx) else { - continue; - }; - let abs_path = worktree.read(cx).absolutize(&path.path); - let (file_name, _) = crate::completion_provider::extract_file_name_and_directory( - &path.path, - worktree.read(cx).root_name(), - path_style, - ); - - let uri = if entry.is_dir() { - MentionUri::Directory { abs_path } - } else { - MentionUri::File { abs_path } - }; - - let new_text = format!("{} ", uri.as_link()); - let content_len = new_text.len() - 1; - - let anchor = buffer.update(cx, |buffer, _cx| buffer.anchor_before(buffer.len())); - - self.editor.update(cx, |message_editor, cx| { - message_editor.edit( - [( - multi_buffer::Anchor::max()..multi_buffer::Anchor::max(), - new_text, - )], - cx, - ); - }); - let supports_images = self.session_capabilities.read().supports_images(); - tasks.push(self.mention_set.update(cx, |mention_set, cx| { - mention_set.confirm_mention_completion( - file_name, - anchor, - content_len, - uri, - supports_images, - self.editor.clone(), - &workspace, - window, - cx, - ) - })); + if let Some(task) = insert_mention_for_project_path( + &path, + MentionInsertPosition::EndOfBuffer, + &self.editor, + &self.mention_set, + &project, + &workspace, + supports_images, + window, + cx, + ) { + tasks.push(task); + } } cx.spawn(async move |_, _| { join_all(tasks).await; @@ -1346,45 +1569,20 @@ impl MessageEditor { _ => return Ok::<(), anyhow::Error>(()), }; - let supported_formats = [ - ("png", gpui::ImageFormat::Png), - ("jpg", gpui::ImageFormat::Jpeg), - ("jpeg", gpui::ImageFormat::Jpeg), - ("webp", gpui::ImageFormat::Webp), - ("gif", gpui::ImageFormat::Gif), - ("bmp", gpui::ImageFormat::Bmp), - ("tiff", gpui::ImageFormat::Tiff), - ("tif", gpui::ImageFormat::Tiff), - ("ico", gpui::ImageFormat::Ico), - ]; - - let mut images = Vec::new(); - for path in paths { - let extension = path - .extension() - .and_then(|ext| ext.to_str()) - .map(|s| s.to_lowercase()); - - let Some(format) = extension.and_then(|ext| { - supported_formats - .iter() - .find(|(e, _)| *e == ext) - .map(|(_, f)| *f) - }) else { - continue; - }; - - let Ok(content) = async_fs::read(&path).await else { - continue; - }; - - let name: gpui::SharedString = path - .file_name() - .and_then(|n| n.to_str()) - .map(|s| gpui::SharedString::from(s.to_owned())) - .unwrap_or_else(|| "Image".into()); - images.push((gpui::Image::from_bytes(format, content), name)); - } + let default_image_name: SharedString = "Image".into(); + let images = cx + .background_spawn(async move { + paths + .into_iter() + .filter_map(|path| { + crate::mention_set::load_external_image_from_path( + &path, + &default_image_name, + ) + }) + .collect::>() + }) + .await; crate::mention_set::insert_images_as_context( images, @@ -1771,11 +1969,12 @@ fn find_matching_bracket(text: &str, open: char, close: char) -> Option { #[cfg(test)] mod tests { - use std::{ops::Range, path::Path, sync::Arc}; + use std::{ops::Range, path::Path, path::PathBuf, sync::Arc}; use acp_thread::MentionUri; use agent::{ThreadStore, outline}; use agent_client_protocol as acp; + use base64::Engine as _; use editor::{ AnchorRangeExt as _, Editor, EditorMode, MultiBufferOffset, SelectionEffects, actions::Paste, @@ -1784,14 +1983,14 @@ mod tests { use fs::FakeFs; use futures::StreamExt as _; use gpui::{ - AppContext, ClipboardItem, Entity, EventEmitter, FocusHandle, Focusable, TestAppContext, - VisualTestContext, + AppContext, ClipboardEntry, ClipboardItem, Entity, EventEmitter, ExternalPaths, + FocusHandle, Focusable, TestAppContext, VisualTestContext, }; use language_model::LanguageModelRegistry; use lsp::{CompletionContext, CompletionTriggerKind}; use parking_lot::RwLock; use project::{CompletionIntent, Project, ProjectPath}; - use serde_json::json; + use serde_json::{Value, json}; use text::Point; use ui::{App, Context, IntoElement, Render, SharedString, Window}; @@ -3819,6 +4018,285 @@ mod tests { }); } + #[gpui::test] + async fn test_paste_external_file_path_inserts_file_mention(cx: &mut TestAppContext) { + init_test(cx); + let (message_editor, editor, mut cx) = + setup_paste_test_message_editor(json!({"file.txt": "content"}), cx).await; + paste_external_paths( + &message_editor, + vec![PathBuf::from(path!("/project/file.txt"))], + &mut cx, + ); + + let expected_uri = MentionUri::File { + abs_path: path!("/project/file.txt").into(), + } + .to_uri() + .to_string(); + + editor.update(&mut cx, |editor, cx| { + assert_eq!(editor.text(cx), format!("[@file.txt]({expected_uri}) ")); + }); + + let contents = mention_contents(&message_editor, &mut cx).await; + + let [(uri, Mention::Text { content, .. })] = contents.as_slice() else { + panic!("Unexpected mentions"); + }; + assert_eq!(content, "content"); + assert_eq!( + uri, + &MentionUri::File { + abs_path: path!("/project/file.txt").into(), + } + ); + } + + #[gpui::test] + async fn test_paste_external_directory_path_inserts_directory_mention(cx: &mut TestAppContext) { + init_test(cx); + let (message_editor, editor, mut cx) = setup_paste_test_message_editor( + json!({ + "src": { + "main.rs": "fn main() {}\n", + } + }), + cx, + ) + .await; + paste_external_paths( + &message_editor, + vec![PathBuf::from(path!("/project/src"))], + &mut cx, + ); + + let expected_uri = MentionUri::Directory { + abs_path: path!("/project/src").into(), + } + .to_uri() + .to_string(); + + editor.update(&mut cx, |editor, cx| { + assert_eq!(editor.text(cx), format!("[@src]({expected_uri}) ")); + }); + + let contents = mention_contents(&message_editor, &mut cx).await; + + let [(uri, Mention::Link)] = contents.as_slice() else { + panic!("Unexpected mentions"); + }; + assert_eq!( + uri, + &MentionUri::Directory { + abs_path: path!("/project/src").into(), + } + ); + } + + #[gpui::test] + async fn test_paste_external_file_path_inserts_at_cursor(cx: &mut TestAppContext) { + init_test(cx); + let (message_editor, editor, mut cx) = + setup_paste_test_message_editor(json!({"file.txt": "content"}), cx).await; + + editor.update_in(&mut cx, |editor, window, cx| { + editor.set_text("Hello world", window, cx); + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |selections| { + selections.select_ranges([MultiBufferOffset(6)..MultiBufferOffset(6)]); + }); + }); + + paste_external_paths( + &message_editor, + vec![PathBuf::from(path!("/project/file.txt"))], + &mut cx, + ); + + let expected_uri = MentionUri::File { + abs_path: path!("/project/file.txt").into(), + } + .to_uri() + .to_string(); + + editor.update(&mut cx, |editor, cx| { + assert_eq!( + editor.text(cx), + format!("Hello [@file.txt]({expected_uri}) world") + ); + }); + } + + #[gpui::test] + async fn test_paste_mixed_external_image_without_extension_and_file_path( + cx: &mut TestAppContext, + ) { + init_test(cx); + let (message_editor, editor, mut cx) = + setup_paste_test_message_editor(json!({"file.txt": "content"}), cx).await; + + message_editor.update(&mut cx, |message_editor, _cx| { + message_editor + .session_capabilities + .write() + .set_prompt_capabilities(acp::PromptCapabilities::new().image(true)); + }); + + let temporary_image_path = write_test_png_file(None); + paste_external_paths( + &message_editor, + vec![ + temporary_image_path.clone(), + PathBuf::from(path!("/project/file.txt")), + ], + &mut cx, + ); + + std::fs::remove_file(&temporary_image_path).expect("remove temp png"); + + let expected_file_uri = MentionUri::File { + abs_path: path!("/project/file.txt").into(), + } + .to_uri() + .to_string(); + let expected_image_uri = MentionUri::PastedImage.to_uri().to_string(); + + editor.update(&mut cx, |editor, cx| { + assert_eq!( + editor.text(cx), + format!("[@Image]({expected_image_uri}) [@file.txt]({expected_file_uri}) ") + ); + }); + + let contents = mention_contents(&message_editor, &mut cx).await; + + assert_eq!(contents.len(), 2); + assert!(contents.iter().any(|(uri, mention)| { + *uri == MentionUri::PastedImage && matches!(mention, Mention::Image(_)) + })); + assert!(contents.iter().any(|(uri, mention)| { + *uri == MentionUri::File { + abs_path: path!("/project/file.txt").into(), + } && matches!( + mention, + Mention::Text { + content, + tracked_buffers: _, + } if content == "content" + ) + })); + } + + async fn setup_paste_test_message_editor( + project_tree: Value, + cx: &mut TestAppContext, + ) -> (Entity, Entity, VisualTestContext) { + let app_state = cx.update(AppState::test); + + cx.update(|cx| { + editor::init(cx); + workspace::init(app_state.clone(), cx); + }); + + app_state + .fs + .as_fake() + .insert_tree(path!("/project"), project_tree) + .await; + + let project = Project::test(app_state.fs.clone(), [path!("/project").as_ref()], cx).await; + let window = + cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx)); + let workspace = window + .read_with(cx, |mw, _| mw.workspace().clone()) + .unwrap(); + + let mut cx = VisualTestContext::from_window(window.into(), cx); + + let thread_store = cx.new(|cx| ThreadStore::new(cx)); + + let (message_editor, editor) = workspace.update_in(&mut cx, |workspace, window, cx| { + let workspace_handle = cx.weak_entity(); + let message_editor = cx.new(|cx| { + MessageEditor::new( + workspace_handle, + project.downgrade(), + Some(thread_store), + None, + None, + Default::default(), + "Test Agent".into(), + "Test", + EditorMode::AutoHeight { + max_lines: None, + min_lines: 1, + }, + window, + cx, + ) + }); + workspace.active_pane().update(cx, |pane, cx| { + pane.add_item( + Box::new(cx.new(|_| MessageEditorItem(message_editor.clone()))), + true, + true, + None, + window, + cx, + ); + }); + message_editor.read(cx).focus_handle(cx).focus(window, cx); + let editor = message_editor.read(cx).editor().clone(); + (message_editor, editor) + }); + + (message_editor, editor, cx) + } + + fn paste_external_paths( + message_editor: &Entity, + paths: Vec, + cx: &mut VisualTestContext, + ) { + cx.write_to_clipboard(ClipboardItem { + entries: vec![ClipboardEntry::ExternalPaths(ExternalPaths(paths.into()))], + }); + + message_editor.update_in(cx, |message_editor, window, cx| { + message_editor.paste(&Paste, window, cx); + }); + cx.run_until_parked(); + } + + async fn mention_contents( + message_editor: &Entity, + cx: &mut VisualTestContext, + ) -> Vec<(MentionUri, Mention)> { + message_editor + .update(cx, |message_editor, cx| { + message_editor + .mention_set() + .update(cx, |mention_set, cx| mention_set.contents(false, cx)) + }) + .await + .unwrap() + .into_values() + .collect::>() + } + + fn write_test_png_file(extension: Option<&str>) -> PathBuf { + let bytes = base64::prelude::BASE64_STANDARD + .decode("iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==") + .expect("decode png"); + let file_name = match extension { + Some(extension) => format!("zed-agent-ui-test-{}.{}", uuid::Uuid::new_v4(), extension), + None => format!("zed-agent-ui-test-{}", uuid::Uuid::new_v4()), + }; + let path = std::env::temp_dir().join(file_name); + std::fs::write(&path, bytes).expect("write temp png"); + path + } + // Helper that creates a minimal MessageEditor inside a window, returning both // the entity and the underlying VisualTestContext so callers can drive updates. async fn setup_message_editor( diff --git a/crates/agent_ui/src/text_thread_editor.rs b/crates/agent_ui/src/text_thread_editor.rs index ce1a7300c9fe0910822a720563e64be3ed2b0087..14aec38e481768b5482a2cbf67df3d59304a915c 100644 --- a/crates/agent_ui/src/text_thread_editor.rs +++ b/crates/agent_ui/src/text_thread_editor.rs @@ -1,5 +1,6 @@ use crate::{ language_model_selector::{LanguageModelSelector, language_model_selector}, + mention_set::load_external_image_from_path, ui::ModelSelectorTooltip, }; use anyhow::Result; @@ -1900,26 +1901,12 @@ impl TextThreadEditor { } } + let default_image_name: SharedString = "Image".into(); for path in paths { - let Ok(content) = std::fs::read(path) else { + let Some((image, _)) = load_external_image_from_path(&path, &default_image_name) else { continue; }; - let Ok(format) = image::guess_format(&content) else { - continue; - }; - images.push(gpui::Image::from_bytes( - match format { - image::ImageFormat::Png => gpui::ImageFormat::Png, - image::ImageFormat::Jpeg => gpui::ImageFormat::Jpeg, - image::ImageFormat::WebP => gpui::ImageFormat::Webp, - image::ImageFormat::Gif => gpui::ImageFormat::Gif, - image::ImageFormat::Bmp => gpui::ImageFormat::Bmp, - image::ImageFormat::Tiff => gpui::ImageFormat::Tiff, - image::ImageFormat::Ico => gpui::ImageFormat::Ico, - _ => continue, - }, - content, - )); + images.push(image); } // Respect entry priority order — if the first entry is text, the source