Allow to reuse windows in open remote projects dialogue (#32138)

Closes https://github.com/zed-industries/zed/issues/26276

Same as other "open window" actions like "open recent", add a
`"create_new_window": false` (default `false`) argument into the
`projects::OpenRemote` action.

Make all menus to use this default; allow users to change this in the
keybindings.
Same as with other actions, `cmd`/`ctrl` inverts the parameter value.

<img width="554" alt="default"
src="https://github.com/user-attachments/assets/156d50f0-6511-47b3-b650-7a5133ae9541"
/>

<img width="552" alt="override"
src="https://github.com/user-attachments/assets/cf7d963b-86a3-4925-afec-fdb5414418e1"
/>

Release Notes:

- Allowed to reuse windows in open remote projects dialogue
This commit is contained in:
Kirill Bulatov 2025-06-05 10:09:09 +03:00 committed by GitHub
parent 274a40b7e0
commit 9d533f9d30
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 82 additions and 25 deletions

View file

@ -512,14 +512,14 @@
{ {
"context": "Workspace", "context": "Workspace",
"bindings": { "bindings": {
"alt-open": ["projects::OpenRecent", { "create_new_window": false }],
// Change the default action on `menu::Confirm` by setting the parameter // Change the default action on `menu::Confirm` by setting the parameter
// "alt-ctrl-o": ["projects::OpenRecent", { "create_new_window": true }], // "alt-ctrl-o": ["projects::OpenRecent", { "create_new_window": true }],
"alt-open": ["projects::OpenRecent", { "create_new_window": false }],
"alt-ctrl-o": ["projects::OpenRecent", { "create_new_window": false }], "alt-ctrl-o": ["projects::OpenRecent", { "create_new_window": false }],
"alt-shift-open": "projects::OpenRemote", "alt-shift-open": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }],
"alt-ctrl-shift-o": "projects::OpenRemote",
// Change to open path modal for existing remote connection by setting the parameter // Change to open path modal for existing remote connection by setting the parameter
// "alt-ctrl-shift-o": "["projects::OpenRemote", { "from_existing_connection": true }]", // "alt-ctrl-shift-o": "["projects::OpenRemote", { "from_existing_connection": true }]",
"alt-ctrl-shift-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }],
"alt-ctrl-shift-b": "branches::OpenRecent", "alt-ctrl-shift-b": "branches::OpenRecent",
"alt-shift-enter": "toast::RunAction", "alt-shift-enter": "toast::RunAction",
"ctrl-~": "workspace::NewTerminal", "ctrl-~": "workspace::NewTerminal",

View file

@ -585,8 +585,8 @@
// Change the default action on `menu::Confirm` by setting the parameter // Change the default action on `menu::Confirm` by setting the parameter
// "alt-cmd-o": ["projects::OpenRecent", {"create_new_window": true }], // "alt-cmd-o": ["projects::OpenRecent", {"create_new_window": true }],
"alt-cmd-o": ["projects::OpenRecent", { "create_new_window": false }], "alt-cmd-o": ["projects::OpenRecent", { "create_new_window": false }],
"ctrl-cmd-o": "projects::OpenRemote", "ctrl-cmd-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }],
"ctrl-cmd-shift-o": ["projects::OpenRemote", { "from_existing_connection": true }], "ctrl-cmd-shift-o": ["projects::OpenRemote", { "from_existing_connection": true, "create_new_window": false }],
"alt-cmd-b": "branches::OpenRecent", "alt-cmd-b": "branches::OpenRecent",
"ctrl-~": "workspace::NewTerminal", "ctrl-~": "workspace::NewTerminal",
"cmd-s": "workspace::Save", "cmd-s": "workspace::Save",

View file

@ -50,6 +50,7 @@ pub fn init(cx: &mut App) {
}); });
cx.on_action(|open_remote: &OpenRemote, cx| { cx.on_action(|open_remote: &OpenRemote, cx| {
let from_existing_connection = open_remote.from_existing_connection; let from_existing_connection = open_remote.from_existing_connection;
let create_new_window = open_remote.create_new_window;
with_active_or_new_workspace(cx, move |workspace, window, cx| { with_active_or_new_workspace(cx, move |workspace, window, cx| {
if from_existing_connection { if from_existing_connection {
cx.propagate(); cx.propagate();
@ -58,7 +59,7 @@ pub fn init(cx: &mut App) {
let handle = cx.entity().downgrade(); let handle = cx.entity().downgrade();
let fs = workspace.project().read(cx).fs().clone(); let fs = workspace.project().read(cx).fs().clone();
workspace.toggle_modal(window, cx, |window, cx| { workspace.toggle_modal(window, cx, |window, cx| {
RemoteServerProjects::new(fs, window, cx, handle) RemoteServerProjects::new(create_new_window, fs, window, handle, cx)
}) })
}); });
}); });
@ -480,6 +481,7 @@ impl PickerDelegate for RecentProjectsDelegate {
.key_binding(KeyBinding::for_action( .key_binding(KeyBinding::for_action(
&OpenRemote { &OpenRemote {
from_existing_connection: false, from_existing_connection: false,
create_new_window: false,
}, },
window, window,
cx, cx,
@ -488,6 +490,7 @@ impl PickerDelegate for RecentProjectsDelegate {
window.dispatch_action( window.dispatch_action(
OpenRemote { OpenRemote {
from_existing_connection: false, from_existing_connection: false,
create_new_window: false,
} }
.boxed_clone(), .boxed_clone(),
cx, cx,

View file

@ -13,6 +13,7 @@ use futures::FutureExt;
use futures::channel::oneshot; use futures::channel::oneshot;
use futures::future::Shared; use futures::future::Shared;
use futures::select; use futures::select;
use gpui::ClickEvent;
use gpui::ClipboardItem; use gpui::ClipboardItem;
use gpui::Subscription; use gpui::Subscription;
use gpui::Task; use gpui::Task;
@ -69,6 +70,7 @@ pub struct RemoteServerProjects {
retained_connections: Vec<Entity<SshRemoteClient>>, retained_connections: Vec<Entity<SshRemoteClient>>,
ssh_config_updates: Task<()>, ssh_config_updates: Task<()>,
ssh_config_servers: BTreeSet<SharedString>, ssh_config_servers: BTreeSet<SharedString>,
create_new_window: bool,
_subscription: Subscription, _subscription: Subscription,
} }
@ -136,6 +138,7 @@ impl Focusable for ProjectPicker {
impl ProjectPicker { impl ProjectPicker {
fn new( fn new(
create_new_window: bool,
ix: usize, ix: usize,
connection: SshConnectionOptions, connection: SshConnectionOptions,
project: Entity<Project>, project: Entity<Project>,
@ -167,7 +170,13 @@ impl ProjectPicker {
let fs = workspace.project().read(cx).fs().clone(); let fs = workspace.project().read(cx).fs().clone();
let weak = cx.entity().downgrade(); let weak = cx.entity().downgrade();
workspace.toggle_modal(window, cx, |window, cx| { workspace.toggle_modal(window, cx, |window, cx| {
RemoteServerProjects::new(fs, window, cx, weak) RemoteServerProjects::new(
create_new_window,
fs,
window,
weak,
cx,
)
}); });
}) })
.log_err()?; .log_err()?;
@ -361,19 +370,12 @@ impl Mode {
} }
} }
impl RemoteServerProjects { impl RemoteServerProjects {
pub fn open(workspace: Entity<Workspace>, window: &mut Window, cx: &mut App) {
workspace.update(cx, |workspace, cx| {
let handle = cx.entity().downgrade();
let fs = workspace.project().read(cx).fs().clone();
workspace.toggle_modal(window, cx, |window, cx| Self::new(fs, window, cx, handle))
})
}
pub fn new( pub fn new(
create_new_window: bool,
fs: Arc<dyn Fs>, fs: Arc<dyn Fs>,
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>,
workspace: WeakEntity<Workspace>, workspace: WeakEntity<Workspace>,
cx: &mut Context<Self>,
) -> Self { ) -> Self {
let focus_handle = cx.focus_handle(); let focus_handle = cx.focus_handle();
let mut read_ssh_config = SshSettings::get_global(cx).read_ssh_config; let mut read_ssh_config = SshSettings::get_global(cx).read_ssh_config;
@ -410,11 +412,13 @@ impl RemoteServerProjects {
retained_connections: Vec::new(), retained_connections: Vec::new(),
ssh_config_updates, ssh_config_updates,
ssh_config_servers: BTreeSet::new(), ssh_config_servers: BTreeSet::new(),
create_new_window,
_subscription, _subscription,
} }
} }
pub fn project_picker( pub fn project_picker(
create_new_window: bool,
ix: usize, ix: usize,
connection_options: remote::SshConnectionOptions, connection_options: remote::SshConnectionOptions,
project: Entity<Project>, project: Entity<Project>,
@ -424,8 +428,9 @@ impl RemoteServerProjects {
workspace: WeakEntity<Workspace>, workspace: WeakEntity<Workspace>,
) -> Self { ) -> Self {
let fs = project.read(cx).fs().clone(); let fs = project.read(cx).fs().clone();
let mut this = Self::new(fs, window, cx, workspace.clone()); let mut this = Self::new(create_new_window, fs, window, workspace.clone(), cx);
this.mode = Mode::ProjectPicker(ProjectPicker::new( this.mode = Mode::ProjectPicker(ProjectPicker::new(
create_new_window,
ix, ix,
connection_options, connection_options,
project, project,
@ -541,6 +546,7 @@ impl RemoteServerProjects {
return; return;
}; };
let create_new_window = self.create_new_window;
let connection_options = ssh_connection.into(); let connection_options = ssh_connection.into();
workspace.update(cx, |_, cx| { workspace.update(cx, |_, cx| {
cx.defer_in(window, move |workspace, window, cx| { cx.defer_in(window, move |workspace, window, cx| {
@ -578,7 +584,7 @@ impl RemoteServerProjects {
let weak = cx.entity().downgrade(); let weak = cx.entity().downgrade();
let fs = workspace.project().read(cx).fs().clone(); let fs = workspace.project().read(cx).fs().clone();
workspace.toggle_modal(window, cx, |window, cx| { workspace.toggle_modal(window, cx, |window, cx| {
RemoteServerProjects::new(fs, window, cx, weak) RemoteServerProjects::new(create_new_window, fs, window, weak, cx)
}); });
}); });
}; };
@ -606,6 +612,7 @@ impl RemoteServerProjects {
let weak = cx.entity().downgrade(); let weak = cx.entity().downgrade();
workspace.toggle_modal(window, cx, |window, cx| { workspace.toggle_modal(window, cx, |window, cx| {
RemoteServerProjects::project_picker( RemoteServerProjects::project_picker(
create_new_window,
ix, ix,
connection_options, connection_options,
project, project,
@ -847,6 +854,7 @@ impl RemoteServerProjects {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> impl IntoElement { ) -> impl IntoElement {
let create_new_window = self.create_new_window;
let is_from_zed = server.is_from_zed(); let is_from_zed = server.is_from_zed();
let element_id_base = SharedString::from(format!("remote-project-{server_ix}")); let element_id_base = SharedString::from(format!("remote-project-{server_ix}"));
let container_element_id_base = let container_element_id_base =
@ -854,8 +862,11 @@ impl RemoteServerProjects {
let callback = Rc::new({ let callback = Rc::new({
let project = project.clone(); let project = project.clone();
move |this: &mut Self, window: &mut Window, cx: &mut Context<Self>| { move |remote_server_projects: &mut Self,
let Some(app_state) = this secondary_confirm: bool,
window: &mut Window,
cx: &mut Context<Self>| {
let Some(app_state) = remote_server_projects
.workspace .workspace
.read_with(cx, |workspace, _| workspace.app_state().clone()) .read_with(cx, |workspace, _| workspace.app_state().clone())
.log_err() .log_err()
@ -865,17 +876,26 @@ impl RemoteServerProjects {
let project = project.clone(); let project = project.clone();
let server = server.connection().into_owned(); let server = server.connection().into_owned();
cx.emit(DismissEvent); cx.emit(DismissEvent);
let replace_window = match (create_new_window, secondary_confirm) {
(true, false) | (false, true) => None,
(true, true) | (false, false) => window.window_handle().downcast::<Workspace>(),
};
cx.spawn_in(window, async move |_, cx| { cx.spawn_in(window, async move |_, cx| {
let result = open_ssh_project( let result = open_ssh_project(
server.into(), server.into(),
project.paths.into_iter().map(PathBuf::from).collect(), project.paths.into_iter().map(PathBuf::from).collect(),
app_state, app_state,
OpenOptions::default(), OpenOptions {
replace_window,
..OpenOptions::default()
},
cx, cx,
) )
.await; .await;
if let Err(e) = result { if let Err(e) = result {
log::error!("Failed to connect: {:?}", e); log::error!("Failed to connect: {e:#}");
cx.prompt( cx.prompt(
gpui::PromptLevel::Critical, gpui::PromptLevel::Critical,
"Failed to connect", "Failed to connect",
@ -897,7 +917,13 @@ impl RemoteServerProjects {
.on_action(cx.listener({ .on_action(cx.listener({
let callback = callback.clone(); let callback = callback.clone();
move |this, _: &menu::Confirm, window, cx| { move |this, _: &menu::Confirm, window, cx| {
callback(this, window, cx); callback(this, false, window, cx);
}
}))
.on_action(cx.listener({
let callback = callback.clone();
move |this, _: &menu::SecondaryConfirm, window, cx| {
callback(this, true, window, cx);
} }
})) }))
.child( .child(
@ -911,7 +937,10 @@ impl RemoteServerProjects {
.size(IconSize::Small), .size(IconSize::Small),
) )
.child(Label::new(project.paths.join(", "))) .child(Label::new(project.paths.join(", ")))
.on_click(cx.listener(move |this, _, window, cx| callback(this, window, cx))) .on_click(cx.listener(move |this, e: &ClickEvent, window, cx| {
let secondary_confirm = e.down.modifiers.platform;
callback(this, secondary_confirm, window, cx)
}))
.when(is_from_zed, |server_list_item| { .when(is_from_zed, |server_list_item| {
server_list_item.end_hover_slot::<AnyElement>(Some( server_list_item.end_hover_slot::<AnyElement>(Some(
div() div()
@ -1493,10 +1522,30 @@ impl RemoteServerProjects {
} }
let mut modal_section = modal_section.render(window, cx).into_any_element(); let mut modal_section = modal_section.render(window, cx).into_any_element();
let (create_window, reuse_window) = if self.create_new_window {
(
window.keystroke_text_for(&menu::Confirm),
window.keystroke_text_for(&menu::SecondaryConfirm),
)
} else {
(
window.keystroke_text_for(&menu::SecondaryConfirm),
window.keystroke_text_for(&menu::Confirm),
)
};
let placeholder_text = Arc::from(format!(
"{reuse_window} reuses this window, {create_window} opens a new one",
));
Modal::new("remote-projects", None) Modal::new("remote-projects", None)
.header( .header(
ModalHeader::new() ModalHeader::new()
.child(Headline::new("Remote Projects").size(HeadlineSize::XSmall)), .child(Headline::new("Remote Projects").size(HeadlineSize::XSmall))
.child(
Label::new(placeholder_text)
.color(Color::Muted)
.size(LabelSize::XSmall),
),
) )
.section( .section(
Section::new().padded(false).child( Section::new().padded(false).child(

View file

@ -439,6 +439,7 @@ impl TitleBar {
"Remote Project", "Remote Project",
Some(&OpenRemote { Some(&OpenRemote {
from_existing_connection: false, from_existing_connection: false,
create_new_window: false,
}), }),
meta.clone(), meta.clone(),
window, window,
@ -449,6 +450,7 @@ impl TitleBar {
window.dispatch_action( window.dispatch_action(
OpenRemote { OpenRemote {
from_existing_connection: false, from_existing_connection: false,
create_new_window: false,
} }
.boxed_clone(), .boxed_clone(),
cx, cx,

View file

@ -73,6 +73,7 @@ pub fn app_menus() -> Vec<Menu> {
MenuItem::action( MenuItem::action(
"Open Remote...", "Open Remote...",
zed_actions::OpenRemote { zed_actions::OpenRemote {
create_new_window: false,
from_existing_connection: false, from_existing_connection: false,
}, },
), ),

View file

@ -254,6 +254,8 @@ pub struct OpenRecent {
pub struct OpenRemote { pub struct OpenRemote {
#[serde(default)] #[serde(default)]
pub from_existing_connection: bool, pub from_existing_connection: bool,
#[serde(default)]
pub create_new_window: bool,
} }
impl_actions!(projects, [OpenRecent, OpenRemote]); impl_actions!(projects, [OpenRecent, OpenRemote]);