agent_ui: Paste dropped paths into agent terminals (#56686)

Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content is consistent with the [UI/UX
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

Release Notes:

- N/A
This commit is contained in:
Ben Brandt 2026-05-17 19:15:09 +02:00 committed by GitHub
parent 23231879cd
commit 43585afc29
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 269 additions and 27 deletions

View file

@ -145,3 +145,4 @@ tempfile.workspace = true
vim.workspace = true
tree-sitter-md.workspace = true
unindent.workspace = true
terminal = { workspace = true, features = ["test-support"] }

View file

@ -4905,32 +4905,79 @@ impl AgentPanel {
}),
)
.on_drop(cx.listener(move |this, paths: &ExternalPaths, window, cx| {
let tasks = paths
.paths()
.iter()
.map(|path| {
Workspace::project_path_for_path(this.project.clone(), path, false, cx)
})
.collect::<Vec<_>>();
cx.spawn_in(window, async move |this, cx| {
let mut paths = vec![];
let mut added_worktrees = vec![];
let opened_paths = futures::future::join_all(tasks).await;
for entry in opened_paths {
if let Some((worktree, project_path)) = entry.log_err() {
added_worktrees.push(worktree);
paths.push(project_path);
}
}
this.update_in(cx, |this, window, cx| {
this.handle_drop(paths, added_worktrees, window, cx);
})
.ok();
})
.detach();
this.handle_external_paths_drop(paths, window, cx);
}))
}
fn handle_external_paths_drop(
&mut self,
paths: &ExternalPaths,
window: &mut Window,
cx: &mut Context<Self>,
) {
if matches!(&self.base_view, BaseView::Terminal { .. }) {
// Terminal drops should match normal terminal views by pasting raw OS paths.
// The agent-thread path below converts paths to project paths, which can add
// worktrees and is only needed when attaching files to a conversation.
self.paste_external_paths_into_active_terminal(paths, window, cx);
return;
}
let BaseView::AgentThread { conversation_view } = &self.base_view else {
return;
};
let conversation_view = conversation_view.clone();
let tasks = paths
.paths()
.iter()
.map(|path| Workspace::project_path_for_path(self.project.clone(), path, false, cx))
.collect::<Vec<_>>();
cx.spawn_in(window, async move |_this, cx| {
let mut paths = vec![];
let mut added_worktrees = vec![];
let opened_paths = futures::future::join_all(tasks).await;
for entry in opened_paths {
if let Some((worktree, project_path)) = entry.log_err() {
added_worktrees.push(worktree);
paths.push(project_path);
}
}
conversation_view
.update_in(cx, |conversation_view, window, cx| {
conversation_view.insert_dragged_files(paths, added_worktrees, window, cx);
})
.log_err();
})
.detach();
}
fn paste_external_paths_into_active_terminal(
&mut self,
paths: &ExternalPaths,
window: &mut Window,
cx: &mut Context<Self>,
) {
let BaseView::Terminal { terminal_id } = &self.base_view else {
return;
};
if !self.project.read(cx).is_local() {
return;
}
let Some(terminal_view) = self
.terminals
.get(terminal_id)
.map(|terminal| terminal.view.clone())
else {
return;
};
terminal_view.update(cx, |terminal_view, cx| {
terminal_view.add_paths_to_terminal(paths.paths(), window, cx);
});
}
fn handle_drop(
&mut self,
paths: Vec<ProjectPath>,
@ -4944,7 +4991,30 @@ impl AgentPanel {
conversation_view.insert_dragged_files(paths, added_worktrees, window, cx);
});
}
BaseView::Terminal { .. } | BaseView::Uninitialized => {}
BaseView::Terminal { terminal_id } => {
let paths = {
let project = self.project.read(cx);
paths
.iter()
.filter_map(|project_path| project.absolute_path(project_path, cx))
.collect::<Vec<_>>()
};
if paths.is_empty() {
return;
}
if let Some(terminal_view) = self
.terminals
.get(terminal_id)
.map(|terminal| terminal.view.clone())
{
terminal_view.update(cx, |terminal_view, cx| {
terminal_view.add_paths_to_terminal(&paths, window, cx);
});
}
}
BaseView::Uninitialized => {}
}
}
@ -5003,7 +5073,9 @@ impl Render for AgentPanel {
VisibleSurface::AgentThread(conversation_view) => parent
.child(conversation_view.clone())
.child(self.render_drag_target(cx)),
VisibleSurface::Terminal(terminal_view) => parent.child(terminal_view.clone()),
VisibleSurface::Terminal(terminal_view) => parent
.child(terminal_view.clone())
.child(self.render_drag_target(cx)),
VisibleSurface::Configuration(configuration) => {
parent.children(configuration.cloned())
}
@ -5279,7 +5351,7 @@ mod tests {
use std::any::Any;
use serde_json::json;
use std::path::Path;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Instant;
@ -6669,6 +6741,175 @@ mod tests {
(panel, cx)
}
fn expected_terminal_drop_text(paths: &[PathBuf]) -> String {
let mut text = String::new();
for path in paths {
text.push(' ');
text.push_str(&format!("{path:?}"));
}
text.push(' ');
text
}
#[gpui::test]
async fn test_terminal_external_image_drop_writes_path(cx: &mut TestAppContext) {
let (panel, mut cx) = setup_panel(cx).await;
cx.update(|_, cx| {
cx.update_flags(true, vec!["agent-panel-terminal".to_string()]);
});
let terminal_id = panel
.update_in(&mut cx, |panel, window, cx| {
panel.insert_test_terminal("Image Upload", true, window, cx)
})
.expect("test terminal should be inserted");
cx.run_until_parked();
let terminal = panel.read_with(&cx, |panel, cx| {
panel
.terminals
.get(&terminal_id)
.expect("terminal should remain in the panel")
.view
.read(cx)
.terminal()
.clone()
});
terminal.update(&mut cx, |terminal, _cx| terminal.take_input_log());
let image_path = PathBuf::from("/tmp/dropped-image.png");
panel.update_in(&mut cx, |panel, window, cx| {
let external_paths = ExternalPaths(vec![image_path.clone()].into());
panel.paste_external_paths_into_active_terminal(&external_paths, window, cx);
});
let mut input_log = terminal.update(&mut cx, |terminal, _cx| terminal.take_input_log());
assert_eq!(input_log.len(), 1, "expected one write to the terminal");
let written =
String::from_utf8(input_log.remove(0)).expect("terminal write should be valid UTF-8");
assert_eq!(
written,
expected_terminal_drop_text(std::slice::from_ref(&image_path))
);
}
#[gpui::test]
async fn test_terminal_external_paths_drop_handler_writes_image_path(cx: &mut TestAppContext) {
let (panel, mut cx) = setup_panel(cx).await;
cx.update(|_, cx| {
cx.update_flags(true, vec!["agent-panel-terminal".to_string()]);
});
let terminal_id = panel
.update_in(&mut cx, |panel, window, cx| {
panel.insert_test_terminal("Image Upload", true, window, cx)
})
.expect("test terminal should be inserted");
cx.run_until_parked();
let terminal = panel.read_with(&cx, |panel, cx| {
panel
.terminals
.get(&terminal_id)
.expect("terminal should remain in the panel")
.view
.read(cx)
.terminal()
.clone()
});
terminal.update(&mut cx, |terminal, _cx| terminal.take_input_log());
let image_path = PathBuf::from("/tmp/dropped-image.png");
panel.update_in(&mut cx, |panel, window, cx| {
let external_paths = ExternalPaths(vec![image_path.clone()].into());
panel.handle_external_paths_drop(&external_paths, window, cx);
});
let mut input_log = terminal.update(&mut cx, |terminal, _cx| terminal.take_input_log());
assert_eq!(input_log.len(), 1, "expected one write to the terminal");
let written =
String::from_utf8(input_log.remove(0)).expect("terminal write should be valid UTF-8");
assert_eq!(
written,
expected_terminal_drop_text(std::slice::from_ref(&image_path))
);
}
#[gpui::test]
async fn test_external_file_drop_on_thread_does_not_paste_into_later_terminal(
cx: &mut TestAppContext,
) {
init_test(cx);
cx.update(|cx| {
agent::ThreadStore::init_global(cx);
language_model::LanguageModelRegistry::test(cx);
cx.update_flags(true, vec!["agent-panel-terminal".to_string()]);
});
let fs = FakeFs::new(cx.executor());
cx.update(|cx| <dyn fs::Fs>::set_global(fs.clone(), cx));
fs.insert_tree("/project", json!({ "file.txt": "content" }))
.await;
let project = Project::test(fs.clone(), [Path::new("/project")], cx).await;
let multi_workspace =
cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let workspace = multi_workspace
.read_with(cx, |multi_workspace, _cx| {
multi_workspace.workspace().clone()
})
.unwrap();
let mut cx = VisualTestContext::from_window(multi_workspace.into(), cx);
let panel = workspace.update_in(&mut cx, |workspace, window, cx| {
let panel = cx.new(|cx| AgentPanel::new(workspace, None, window, cx));
workspace.add_panel(panel.clone(), window, cx);
panel
});
open_thread_with_connection(&panel, StubAgentConnection::new(), &mut cx);
let thread_id = active_thread_id(&panel, &cx);
let file_path = PathBuf::from("/project/file.txt");
panel.update_in(&mut cx, |panel, window, cx| {
let external_paths = ExternalPaths(vec![file_path.clone()].into());
panel.handle_external_paths_drop(&external_paths, window, cx);
});
let terminal_id = panel
.update_in(&mut cx, |panel, window, cx| {
panel.insert_test_terminal("Drop Target", true, window, cx)
})
.expect("test terminal should be inserted");
let terminal = panel.read_with(&cx, |panel, cx| {
panel
.terminals
.get(&terminal_id)
.expect("terminal should remain in the panel")
.view
.read(cx)
.terminal()
.clone()
});
terminal.update(&mut cx, |terminal, _cx| terminal.take_input_log());
cx.run_until_parked();
let input_log = terminal.update(&mut cx, |terminal, _cx| terminal.take_input_log());
assert!(
input_log.is_empty(),
"thread drop completion should not write to the active terminal"
);
let expected_uri = MentionUri::File {
abs_path: file_path,
}
.to_uri()
.to_string();
let expected_text = format!("[@file.txt]({expected_uri}) ");
let actual_text = panel.read_with(&cx, |panel, cx| panel.editor_text(thread_id, cx));
assert_eq!(actual_text.as_deref(), Some(expected_text.as_str()));
}
#[gpui::test]
async fn test_terminal_entry_kind_controls_new_entry(cx: &mut TestAppContext) {
let (panel, mut cx) = setup_panel(cx).await;

View file

@ -855,7 +855,7 @@ impl TerminalView {
});
}
fn add_paths_to_terminal(&self, paths: &[PathBuf], window: &mut Window, cx: &mut App) {
pub fn add_paths_to_terminal(&self, paths: &[PathBuf], window: &mut Window, cx: &mut App) {
let mut text = paths.iter().map(|path| format!(" {path:?}")).join("");
text.push(' ');
window.focus(&self.focus_handle(cx), cx);