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

Kirill Bulatov created

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

Change summary

assets/keymaps/default-linux.json             |  6 
assets/keymaps/default-macos.json             |  4 
crates/recent_projects/src/recent_projects.rs |  5 
crates/recent_projects/src/remote_servers.rs  | 87 ++++++++++++++++----
crates/title_bar/src/title_bar.rs             |  2 
crates/zed/src/zed/app_menus.rs               |  1 
crates/zed_actions/src/lib.rs                 |  2 
7 files changed, 82 insertions(+), 25 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -512,14 +512,14 @@
   {
     "context": "Workspace",
     "bindings": {
+      "alt-open": ["projects::OpenRecent", { "create_new_window": false }],
       // Change the default action on `menu::Confirm` by setting the parameter
       // "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-shift-open": "projects::OpenRemote",
-      "alt-ctrl-shift-o": "projects::OpenRemote",
+      "alt-shift-open": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }],
       // 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": false, "create_new_window": false }],
       "alt-ctrl-shift-b": "branches::OpenRecent",
       "alt-shift-enter": "toast::RunAction",
       "ctrl-~": "workspace::NewTerminal",

assets/keymaps/default-macos.json 🔗

@@ -585,8 +585,8 @@
       // 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": false }],
-      "ctrl-cmd-o": "projects::OpenRemote",
-      "ctrl-cmd-shift-o": ["projects::OpenRemote", { "from_existing_connection": true }],
+      "ctrl-cmd-o": ["projects::OpenRemote", { "from_existing_connection": false, "create_new_window": false }],
+      "ctrl-cmd-shift-o": ["projects::OpenRemote", { "from_existing_connection": true, "create_new_window": false }],
       "alt-cmd-b": "branches::OpenRecent",
       "ctrl-~": "workspace::NewTerminal",
       "cmd-s": "workspace::Save",

crates/recent_projects/src/recent_projects.rs 🔗

@@ -50,6 +50,7 @@ pub fn init(cx: &mut App) {
     });
     cx.on_action(|open_remote: &OpenRemote, cx| {
         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| {
             if from_existing_connection {
                 cx.propagate();
@@ -58,7 +59,7 @@ pub fn init(cx: &mut App) {
             let handle = cx.entity().downgrade();
             let fs = workspace.project().read(cx).fs().clone();
             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(
                             &OpenRemote {
                                 from_existing_connection: false,
+                                create_new_window: false,
                             },
                             window,
                             cx,
@@ -488,6 +490,7 @@ impl PickerDelegate for RecentProjectsDelegate {
                             window.dispatch_action(
                                 OpenRemote {
                                     from_existing_connection: false,
+                                    create_new_window: false,
                                 }
                                 .boxed_clone(),
                                 cx,

crates/recent_projects/src/remote_servers.rs 🔗

@@ -13,6 +13,7 @@ use futures::FutureExt;
 use futures::channel::oneshot;
 use futures::future::Shared;
 use futures::select;
+use gpui::ClickEvent;
 use gpui::ClipboardItem;
 use gpui::Subscription;
 use gpui::Task;
@@ -69,6 +70,7 @@ pub struct RemoteServerProjects {
     retained_connections: Vec<Entity<SshRemoteClient>>,
     ssh_config_updates: Task<()>,
     ssh_config_servers: BTreeSet<SharedString>,
+    create_new_window: bool,
     _subscription: Subscription,
 }
 
@@ -136,6 +138,7 @@ impl Focusable for ProjectPicker {
 
 impl ProjectPicker {
     fn new(
+        create_new_window: bool,
         ix: usize,
         connection: SshConnectionOptions,
         project: Entity<Project>,
@@ -167,7 +170,13 @@ impl ProjectPicker {
                                 let fs = workspace.project().read(cx).fs().clone();
                                 let weak = cx.entity().downgrade();
                                 workspace.toggle_modal(window, cx, |window, cx| {
-                                    RemoteServerProjects::new(fs, window, cx, weak)
+                                    RemoteServerProjects::new(
+                                        create_new_window,
+                                        fs,
+                                        window,
+                                        weak,
+                                        cx,
+                                    )
                                 });
                             })
                             .log_err()?;
@@ -361,19 +370,12 @@ impl Mode {
     }
 }
 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(
+        create_new_window: bool,
         fs: Arc<dyn Fs>,
         window: &mut Window,
-        cx: &mut Context<Self>,
         workspace: WeakEntity<Workspace>,
+        cx: &mut Context<Self>,
     ) -> Self {
         let focus_handle = cx.focus_handle();
         let mut read_ssh_config = SshSettings::get_global(cx).read_ssh_config;
@@ -410,11 +412,13 @@ impl RemoteServerProjects {
             retained_connections: Vec::new(),
             ssh_config_updates,
             ssh_config_servers: BTreeSet::new(),
+            create_new_window,
             _subscription,
         }
     }
 
     pub fn project_picker(
+        create_new_window: bool,
         ix: usize,
         connection_options: remote::SshConnectionOptions,
         project: Entity<Project>,
@@ -424,8 +428,9 @@ impl RemoteServerProjects {
         workspace: WeakEntity<Workspace>,
     ) -> Self {
         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(
+            create_new_window,
             ix,
             connection_options,
             project,
@@ -541,6 +546,7 @@ impl RemoteServerProjects {
             return;
         };
 
+        let create_new_window = self.create_new_window;
         let connection_options = ssh_connection.into();
         workspace.update(cx, |_, cx| {
             cx.defer_in(window, move |workspace, window, cx| {
@@ -578,7 +584,7 @@ impl RemoteServerProjects {
                             let weak = cx.entity().downgrade();
                             let fs = workspace.project().read(cx).fs().clone();
                             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();
                             workspace.toggle_modal(window, cx, |window, cx| {
                                 RemoteServerProjects::project_picker(
+                                    create_new_window,
                                     ix,
                                     connection_options,
                                     project,
@@ -847,6 +854,7 @@ impl RemoteServerProjects {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> impl IntoElement {
+        let create_new_window = self.create_new_window;
         let is_from_zed = server.is_from_zed();
         let element_id_base = SharedString::from(format!("remote-project-{server_ix}"));
         let container_element_id_base =
@@ -854,8 +862,11 @@ impl RemoteServerProjects {
 
         let callback = Rc::new({
             let project = project.clone();
-            move |this: &mut Self, window: &mut Window, cx: &mut Context<Self>| {
-                let Some(app_state) = this
+            move |remote_server_projects: &mut Self,
+                  secondary_confirm: bool,
+                  window: &mut Window,
+                  cx: &mut Context<Self>| {
+                let Some(app_state) = remote_server_projects
                     .workspace
                     .read_with(cx, |workspace, _| workspace.app_state().clone())
                     .log_err()
@@ -865,17 +876,26 @@ impl RemoteServerProjects {
                 let project = project.clone();
                 let server = server.connection().into_owned();
                 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| {
                     let result = open_ssh_project(
                         server.into(),
                         project.paths.into_iter().map(PathBuf::from).collect(),
                         app_state,
-                        OpenOptions::default(),
+                        OpenOptions {
+                            replace_window,
+                            ..OpenOptions::default()
+                        },
                         cx,
                     )
                     .await;
                     if let Err(e) = result {
-                        log::error!("Failed to connect: {:?}", e);
+                        log::error!("Failed to connect: {e:#}");
                         cx.prompt(
                             gpui::PromptLevel::Critical,
                             "Failed to connect",
@@ -897,7 +917,13 @@ impl RemoteServerProjects {
             .on_action(cx.listener({
                 let callback = callback.clone();
                 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(
@@ -911,7 +937,10 @@ impl RemoteServerProjects {
                             .size(IconSize::Small),
                     )
                     .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| {
                         server_list_item.end_hover_slot::<AnyElement>(Some(
                             div()
@@ -1493,10 +1522,30 @@ impl RemoteServerProjects {
         }
         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)
             .header(
                 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::new().padded(false).child(

crates/title_bar/src/title_bar.rs 🔗

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

crates/zed/src/zed/app_menus.rs 🔗

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

crates/zed_actions/src/lib.rs 🔗

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