agent_ui: Add support for pasting external files and directories (#52300)

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.
This commit is contained in:
Smit Barmase 2026-03-25 04:17:45 +05:30 committed by GitHub
parent 1358e42d26
commit c7951fa368
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 643 additions and 176 deletions

1
Cargo.lock generated
View file

@ -337,7 +337,6 @@ dependencies = [
"assistant_slash_command",
"assistant_slash_commands",
"assistant_text_thread",
"async-fs",
"audio",
"base64 0.22.1",
"buffer_diff",

View file

@ -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]

View file

@ -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,
);

View file

@ -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(

View file

@ -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