diff --git a/crates/agent_ui/Cargo.toml b/crates/agent_ui/Cargo.toml index fcb23fd17f..03cb331334 100644 --- a/crates/agent_ui/Cargo.toml +++ b/crates/agent_ui/Cargo.toml @@ -145,3 +145,4 @@ tempfile.workspace = true vim.workspace = true tree-sitter-md.workspace = true unindent.workspace = true +terminal = { workspace = true, features = ["test-support"] } diff --git a/crates/agent_ui/src/agent_panel.rs b/crates/agent_ui/src/agent_panel.rs index 4cab8aee37..88c5baa2b2 100644 --- a/crates/agent_ui/src/agent_panel.rs +++ b/crates/agent_ui/src/agent_panel.rs @@ -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::>(); - 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, + ) { + 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::>(); + 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, + ) { + 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, @@ -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::>() + }; + + 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| ::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; diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 449e1c67d7..55e6929d83 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -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);