mirror of
https://github.com/zed-industries/zed.git
synced 2026-06-01 03:14:56 +07:00
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:
parent
1358e42d26
commit
c7951fa368
5 changed files with 643 additions and 176 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue