From d7bce5468521791c40b2f4641c23c3ce878bfd70 Mon Sep 17 00:00:00 2001 From: David Baldwin Date: Tue, 13 Jan 2026 11:39:41 -0500 Subject: [PATCH] remote: Support local terminals in remote projects (#46532) This PR adds the ability to open local terminals when working in remote projects. When working in a remote (often actually remoting into a local container) I always need to run separate terminals outside Zed so I can run local build tools, scripts, agents, etc. for the related project. I'd like to be able to run all of these in the same Zed window and this adds that ability via one-off local terminals. ## Changes Adds an optional `local` parameter to terminal commands. When set to `true`, creates a local shell on your machine instead of connecting to the remote. ### Implementation - Added `force_local` parameter to terminal creation logic - Created `create_local_terminal()` method that bypasses remote client - Updated terminal actions (`NewTerminal`, `NewCenterTerminal`) to accept optional `local: bool` field (defaults to `false`) ### Usage **Via keybinding:** ```json { "bindings": { "cmd-t": "workspace::NewCenterTerminal", "cmd-T": ["workspace::NewCenterTerminal", { "local": true }] } }, { "context": "Terminal", "bindings": { "cmd-n": "workspace::NewTerminal", "cmd-N": ["workspace::NewTerminal", { "local": true }], }, }, ``` **Behavior:** - Default terminal commands continue to work as before (remote in remote projects, local in local projects) - The `local` parameter is optional and defaults to `false` Release Notes: - Added support for opening local terminals in remote projects via `local` parameter on terminal commands. --- crates/editor/src/editor.rs | 9 +- crates/editor/src/element.rs | 1 + crates/outline_panel/src/outline_panel.rs | 6 +- crates/project/src/terminals.rs | 37 ++++++- crates/project_panel/src/project_panel.rs | 6 +- crates/terminal_view/src/terminal_panel.rs | 107 +++++++++++++++++---- crates/terminal_view/src/terminal_view.rs | 15 ++- crates/workspace/src/pane.rs | 3 +- crates/workspace/src/workspace.rs | 27 +++++- 9 files changed, 175 insertions(+), 36 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 3b78bff39142bcf5e7f5011599d50925e632ff4a..d3c2b91c7f65a38c02dfcb9eee322aa2a8ff9a65 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -11283,7 +11283,14 @@ impl Editor { .to_path_buf(); Some(parent) }) { - window.dispatch_action(OpenTerminal { working_directory }.boxed_clone(), cx); + window.dispatch_action( + OpenTerminal { + working_directory, + local: false, + } + .boxed_clone(), + cx, + ); } } diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 764ba887bc25898456ca2f1b3656096c58d2ac4b..a1a4898a957c4b381f3da93d8a0cc9fff84931de 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -4386,6 +4386,7 @@ impl EditorElement { window.dispatch_action( OpenTerminal { working_directory: parent_abs_path.clone(), + local: false, } .boxed_clone(), cx, diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index d4ef31200542cfeb07a05c6c3705d5d4a1d10cbd..946be32c9717b9563853e222f6e66b340ad3ab81 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -2036,7 +2036,11 @@ impl OutlinePanel { if let Some(working_directory) = working_directory { window.dispatch_action( - workspace::OpenTerminal { working_directory }.boxed_clone(), + workspace::OpenTerminal { + working_directory, + local: false, + } + .boxed_clone(), cx, ) } diff --git a/crates/project/src/terminals.rs b/crates/project/src/terminals.rs index f928871f1eb56bdbc38109d8509acb9776edb994..f9f3672cf2f53516725108b761fe3c09b0064fee 100644 --- a/crates/project/src/terminals.rs +++ b/crates/project/src/terminals.rs @@ -281,9 +281,38 @@ impl Project { &mut self, cwd: Option, cx: &mut Context, + ) -> Task>> { + self.create_terminal_shell_internal(cwd, false, cx) + } + + /// Creates a local terminal even if the project is remote. + /// In remote projects: opens in Zed's launch directory (bypasses SSH). + /// In local projects: opens in the project directory (same as regular terminals). + pub fn create_local_terminal( + &mut self, + cx: &mut Context, + ) -> Task>> { + let working_directory = if self.remote_client.is_some() { + // Remote project: don't use remote paths, let shell use Zed's cwd + None + } else { + // Local project: use project directory like normal terminals + self.active_project_directory(cx).map(|p| p.to_path_buf()) + }; + self.create_terminal_shell_internal(working_directory, true, cx) + } + + /// Internal method for creating terminal shells. + /// If force_local is true, creates a local terminal even if the project has a remote client. + /// This allows "breaking out" to a local shell in remote projects. + fn create_terminal_shell_internal( + &mut self, + cwd: Option, + force_local: bool, + cx: &mut Context, ) -> Task>> { let path = cwd.map(|p| Arc::from(&*p)); - let is_via_remote = self.remote_client.is_some(); + let is_via_remote = !force_local && self.remote_client.is_some(); let mut settings_location = None; if let Some(path) = path.as_ref() @@ -314,7 +343,11 @@ impl Project { .filter(|_| detect_venv) .map(|p| self.active_toolchain(p, LanguageName::new_static("Python"), cx)) .collect::>(); - let remote_client = self.remote_client.clone(); + let remote_client = if force_local { + None + } else { + self.remote_client.clone() + }; let shell = match &remote_client { Some(remote_client) => remote_client .read(cx) diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index a8623a62ed2dcb8749eafeadf117f9851327b6f6..64b1ef92b1f7d2e42b0a549a76c61c0c196f9df9 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -3152,7 +3152,11 @@ impl ProjectPanel { }; if let Some(working_directory) = working_directory { window.dispatch_action( - workspace::OpenTerminal { working_directory }.boxed_clone(), + workspace::OpenTerminal { + working_directory, + local: false, + } + .boxed_clone(), cx, ) } diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index 0b58400a86ff40849d2e67b0533802ab25f2a88f..f3122983aad67f2f746ea85fb53bf0290f9f5e95 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -29,9 +29,9 @@ use util::{ResultExt, TryFutureExt}; use workspace::{ ActivateNextPane, ActivatePane, ActivatePaneDown, ActivatePaneLeft, ActivatePaneRight, ActivatePaneUp, ActivatePreviousPane, DraggedSelection, DraggedTab, ItemId, MoveItemToPane, - MoveItemToPaneInDirection, MovePaneDown, MovePaneLeft, MovePaneRight, MovePaneUp, NewTerminal, - Pane, PaneGroup, SplitDirection, SplitDown, SplitLeft, SplitMode, SplitRight, SplitUp, - SwapPaneDown, SwapPaneLeft, SwapPaneRight, SwapPaneUp, ToggleZoom, Workspace, + MoveItemToPaneInDirection, MovePaneDown, MovePaneLeft, MovePaneRight, MovePaneUp, Pane, + PaneGroup, SplitDirection, SplitDown, SplitLeft, SplitMode, SplitRight, SplitUp, SwapPaneDown, + SwapPaneLeft, SwapPaneRight, SwapPaneUp, ToggleZoom, Workspace, dock::{DockPosition, Panel, PanelEvent, PanelHandle}, item::SerializableItem, move_active_item, move_item, pane, @@ -161,7 +161,7 @@ impl TerminalPanel { menu.context(focus_handle.clone()) .action( "New Terminal", - workspace::NewTerminal.boxed_clone(), + workspace::NewTerminal::default().boxed_clone(), ) // We want the focus to go back to terminal panel once task modal is dismissed, // hence we focus that first. Otherwise, we'd end up without a focused element, as @@ -524,12 +524,16 @@ impl TerminalPanel { terminal_panel .update(cx, |panel, cx| { - panel.add_terminal_shell( - Some(action.working_directory.clone()), - RevealStrategy::Always, - window, - cx, - ) + if action.local { + panel.add_local_terminal_shell(RevealStrategy::Always, window, cx) + } else { + panel.add_terminal_shell( + Some(action.working_directory.clone()), + RevealStrategy::Always, + window, + cx, + ) + } }) .detach_and_log_err(cx); } @@ -649,7 +653,7 @@ impl TerminalPanel { /// Create a new Terminal in the current working directory or the user's home directory fn new_terminal( workspace: &mut Workspace, - _: &workspace::NewTerminal, + action: &workspace::NewTerminal, window: &mut Window, cx: &mut Context, ) { @@ -659,12 +663,16 @@ impl TerminalPanel { terminal_panel .update(cx, |this, cx| { - this.add_terminal_shell( - default_working_directory(workspace, cx), - RevealStrategy::Always, - window, - cx, - ) + if action.local { + this.add_local_terminal_shell(RevealStrategy::Always, window, cx) + } else { + this.add_terminal_shell( + default_working_directory(workspace, cx), + RevealStrategy::Always, + window, + cx, + ) + } }) .detach_and_log_err(cx); } @@ -824,6 +832,26 @@ impl TerminalPanel { reveal_strategy: RevealStrategy, window: &mut Window, cx: &mut Context, + ) -> Task>> { + self.add_terminal_shell_internal(false, cwd, reveal_strategy, window, cx) + } + + fn add_local_terminal_shell( + &mut self, + reveal_strategy: RevealStrategy, + window: &mut Window, + cx: &mut Context, + ) -> Task>> { + self.add_terminal_shell_internal(true, None, reveal_strategy, window, cx) + } + + fn add_terminal_shell_internal( + &mut self, + force_local: bool, + cwd: Option, + reveal_strategy: RevealStrategy, + window: &mut Window, + cx: &mut Context, ) -> Task>> { let workspace = self.workspace.clone(); @@ -836,9 +864,15 @@ impl TerminalPanel { terminal_panel.active_pane.clone() })?; let project = workspace.read_with(cx, |workspace, _| workspace.project().clone())?; - let terminal = project - .update(cx, |project, cx| project.create_terminal_shell(cwd, cx)) - .await; + let terminal = if force_local { + project + .update(cx, |project, cx| project.create_local_terminal(cx)) + .await + } else { + project + .update(cx, |project, cx| project.create_terminal_shell(cwd, cx)) + .await + }; match terminal { Ok(terminal) => { @@ -1120,7 +1154,7 @@ pub fn new_terminal_pane( project.clone(), Default::default(), None, - NewTerminal.boxed_clone(), + workspace::NewTerminal::default().boxed_clone(), false, window, cx, @@ -1980,6 +2014,37 @@ mod tests { .unwrap(); } + #[gpui::test] + async fn test_local_terminal_in_local_project(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.executor()); + let project = Project::test(fs, [], cx).await; + let workspace = cx.add_window(|window, cx| Workspace::test_new(project, window, cx)); + + let (window_handle, terminal_panel) = workspace + .update(cx, |workspace, window, cx| { + let window_handle = window.window_handle(); + let terminal_panel = cx.new(|cx| TerminalPanel::new(workspace, window, cx)); + (window_handle, terminal_panel) + }) + .unwrap(); + + let result = window_handle + .update(cx, |_, window, cx| { + terminal_panel.update(cx, |terminal_panel, cx| { + terminal_panel.add_local_terminal_shell(RevealStrategy::Always, window, cx) + }) + }) + .unwrap() + .await; + + assert!( + result.is_ok(), + "local terminal should successfully create in local project" + ); + } + fn set_max_tabs(cx: &mut TestAppContext, value: Option) { cx.update_global(|store: &mut SettingsStore, cx| { store.update_user_settings(cx, |settings| { diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 670b3db583d01dcf202090407b309803ec9232d2..82203a5b8ee0994885013dba0c58741f32c780b3 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -85,7 +85,7 @@ actions!( terminal, [ /// Reruns the last executed task in the terminal. - RerunTask + RerunTask, ] ); @@ -194,13 +194,18 @@ impl TerminalView { ///Create a new Terminal in the current working directory or the user's home directory pub fn deploy( workspace: &mut Workspace, - _: &NewCenterTerminal, + action: &NewCenterTerminal, window: &mut Window, cx: &mut Context, ) { + let local = action.local; let working_directory = default_working_directory(workspace, cx); - TerminalPanel::add_center_terminal(workspace, window, cx, |project, cx| { - project.create_terminal_shell(working_directory, cx) + TerminalPanel::add_center_terminal(workspace, window, cx, move |project, cx| { + if local { + project.create_local_terminal(cx) + } else { + project.create_terminal_shell(working_directory, cx) + } }) .detach_and_log_err(cx); } @@ -389,7 +394,7 @@ impl TerminalView { .is_some_and(|terminal_panel| terminal_panel.read(cx).assistant_enabled()); let context_menu = ContextMenu::build(window, cx, |menu, _, _| { menu.context(self.focus_handle.clone()) - .action("New Terminal", Box::new(NewTerminal)) + .action("New Terminal", Box::new(NewTerminal::default())) .separator() .action("Copy", Box::new(Copy)) .action("Paste", Box::new(Paste)) diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index d02140b90450b1936c9e4d03956f0acb535e13bc..318a790fdfaaaa2dc2d68ccde6d047f08e186a4e 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -3134,6 +3134,7 @@ impl Pane { window.dispatch_action( OpenTerminal { working_directory: parent_abs_path.clone(), + local: false, } .boxed_clone(), cx, @@ -4032,7 +4033,7 @@ fn default_render_tab_bar_buttons( ) .action("Search Symbols", ToggleProjectSymbols.boxed_clone()) .separator() - .action("New Terminal", NewTerminal.boxed_clone()) + .action("New Terminal", NewTerminal::default().boxed_clone()) })) }), ) diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index d5811f3528c1d9bbc65fc80b38e394d8b0a975ba..492f0bf70bbf848da5d690d8a548b3dbf6eb62de 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -231,8 +231,6 @@ actions!( FollowNextCollaborator, /// Moves the focused panel to the next position. MoveFocusedPanelToNextPosition, - /// Opens a new terminal in the center. - NewCenterTerminal, /// Creates a new file. NewFile, /// Creates a new file in a vertical split. @@ -241,8 +239,6 @@ actions!( NewFileSplitHorizontal, /// Opens a new search. NewSearch, - /// Opens a new terminal. - NewTerminal, /// Opens a new window. NewWindow, /// Opens a file or directory. @@ -406,6 +402,26 @@ pub struct ToggleFileFinder { pub separate_history: bool, } +/// Opens a new terminal in the center. +#[derive(Default, PartialEq, Eq, Clone, Deserialize, JsonSchema, Action)] +#[action(namespace = workspace)] +#[serde(deny_unknown_fields)] +pub struct NewCenterTerminal { + /// If true, creates a local terminal even in remote projects. + #[serde(default)] + pub local: bool, +} + +/// Opens a new terminal. +#[derive(Default, PartialEq, Eq, Clone, Deserialize, JsonSchema, Action)] +#[action(namespace = workspace)] +#[serde(deny_unknown_fields)] +pub struct NewTerminal { + /// If true, creates a local terminal even in remote projects. + #[serde(default)] + pub local: bool, +} + /// Increases size of a currently focused dock by a given amount of pixels. #[derive(Clone, PartialEq, Deserialize, JsonSchema, Action)] #[action(namespace = workspace)] @@ -535,6 +551,9 @@ impl PartialEq for Toast { #[serde(deny_unknown_fields)] pub struct OpenTerminal { pub working_directory: PathBuf, + /// If true, creates a local terminal even in remote projects. + #[serde(default)] + pub local: bool, } #[derive(Clone, Copy, Debug, Default, Hash, PartialEq, Eq, PartialOrd, Ord)]