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",
Smit Barmase created
This PR adds support to paste external files and directories in Agent
Panel, along with the existing image paste path.
Release Notes:
- Added support for pasting files and folders into the Agent Panel.
Cargo.lock | 1
crates/agent_ui/Cargo.toml | 1
crates/agent_ui/src/mention_set.rs | 66 +-
crates/agent_ui/src/message_editor.rs | 730 ++++++++++++++++++++----
crates/agent_ui/src/text_thread_editor.rs | 21
5 files changed, 643 insertions(+), 176 deletions(-)
@@ -337,7 +337,6 @@ dependencies = [
"assistant_slash_command",
"assistant_slash_commands",
"assistant_text_thread",
- "async-fs",
"audio",
"base64 0.22.1",
"buffer_diff",
@@ -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]
@@ -835,6 +835,36 @@ pub(crate) async fn insert_images_as_context(
}
}
+fn image_format_from_external_content(format: image::ImageFormat) -> Option<ImageFormat> {
+ 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<Editor>,
mention_set: Entity<MentionSet>,
@@ -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::<Vec<_>>()
})
.await,
);
@@ -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<MessageEditorEvent> 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<Editor>,
+ mention_set: &Entity<MentionSet>,
+ project: &Entity<Project>,
+ workspace: &Entity<Workspace>,
+ supports_images: bool,
+ window: &mut Window,
+ cx: &mut App,
+) -> Option<Task<()>> {
+ 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>,
+ project_is_local: bool,
+ supports_images: bool,
+ entries: Vec<ClipboardEntry>,
+ cx: &mut gpui::AsyncWindowContext,
+) -> (Vec<ResolvedPastedContextItem>, Vec<Entity<Worktree>>) {
+ 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<Editor>,
+ mention_set: Entity<MentionSet>,
+ workspace: WeakEntity<Workspace>,
+ supports_images: bool,
+ cx: &mut gpui::AsyncWindowContext,
+) -> Option<Task<()>> {
+ 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<ResolvedPastedContextItem>,
+ added_worktrees: Vec<Entity<Worktree>>,
+ editor: Entity<Editor>,
+ mention_set: Entity<MentionSet>,
+ workspace: WeakEntity<Workspace>,
+ 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<Workspace>,
@@ -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<Self>) -> 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::<Vec<_>>();
+
+ 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<project::ProjectPath>,
@@ -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::<Vec<_>>()
+ })
+ .await;
crate::mention_set::insert_images_as_context(
images,
@@ -1771,11 +1969,12 @@ fn find_matching_bracket(text: &str, open: char, close: char) -> Option<usize> {
#[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<MessageEditor>, Entity<Editor>, 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<MessageEditor>,
+ paths: Vec<PathBuf>,
+ 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<MessageEditor>,
+ 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::<Vec<_>>()
+ }
+
+ 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(
@@ -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