Fix remoting things (#19587)

Mikayla Maki created

- Fixes modal closing when using the remote modal folder 
- Fixes a bug with local terminals where they could open in / instead of
~
- Fixes a bug where SSH connections would continue running after their
window is closed
- Hides SSH Terminal process details from Zed UI
- Implement `cmd-o` for remote projects
- Implement LanguageServerPromptRequest for remote LSPs

Release Notes:

- N/A

Change summary

crates/project/src/project.rs                 |  61 +++++++++++
crates/project/src/terminals.rs               |  49 ++++++---
crates/project_panel/src/project_panel.rs     |   2 
crates/proto/proto/zed.proto                  |  25 +++++
crates/proto/src/proto.rs                     |   4 
crates/recent_projects/src/remote_servers.rs  |   7 +
crates/recent_projects/src/ssh_connections.rs |  22 +++-
crates/remote_server/src/headless_project.rs  |  43 ++++++++
crates/task/src/lib.rs                        |   2 
crates/terminal/src/terminal.rs               | 104 ++++++++++++--------
crates/terminal_view/src/terminal_panel.rs    |   2 
crates/workspace/src/workspace.rs             |  32 ------
crates/zed/src/zed.rs                         |  76 ++++++++++++++
13 files changed, 319 insertions(+), 110 deletions(-)

Detailed changes

crates/project/src/project.rs 🔗

@@ -22,7 +22,7 @@ pub use environment::EnvironmentErrorMessage;
 pub mod search_history;
 mod yarn;
 
-use anyhow::{anyhow, Result};
+use anyhow::{anyhow, Context as _, Result};
 use buffer_store::{BufferStore, BufferStoreEvent};
 use client::{
     proto, Client, Collaborator, DevServerProjectId, PendingEntitySubscription, ProjectId,
@@ -40,8 +40,8 @@ use futures::{
 
 use git::{blame::Blame, repository::GitRepository};
 use gpui::{
-    AnyModel, AppContext, AsyncAppContext, BorrowAppContext, Context, EventEmitter, Hsla, Model,
-    ModelContext, SharedString, Task, WeakModel, WindowContext,
+    AnyModel, AppContext, AsyncAppContext, BorrowAppContext, Context as _, EventEmitter, Hsla,
+    Model, ModelContext, SharedString, Task, WeakModel, WindowContext,
 };
 use itertools::Itertools;
 use language::{
@@ -52,6 +52,7 @@ use language::{
 };
 use lsp::{
     CompletionContext, CompletionItemKind, DocumentHighlightKind, LanguageServer, LanguageServerId,
+    MessageActionItem,
 };
 use lsp_command::*;
 use node_runtime::NodeRuntime;
@@ -59,7 +60,10 @@ use parking_lot::{Mutex, RwLock};
 pub use prettier_store::PrettierStore;
 use project_settings::{ProjectSettings, SettingsObserver, SettingsObserverEvent};
 use remote::{SshConnectionOptions, SshRemoteClient};
-use rpc::{proto::SSH_PROJECT_ID, AnyProtoClient, ErrorCode};
+use rpc::{
+    proto::{LanguageServerPromptResponse, SSH_PROJECT_ID},
+    AnyProtoClient, ErrorCode,
+};
 use search::{SearchInputKind, SearchQuery, SearchResult};
 use search_history::SearchHistory;
 use settings::{InvalidSettingsError, Settings, SettingsLocation, SettingsStore};
@@ -810,6 +814,7 @@ impl Project {
             ssh_proto.add_model_message_handler(Self::handle_update_worktree);
             ssh_proto.add_model_message_handler(Self::handle_update_project);
             ssh_proto.add_model_message_handler(Self::handle_toast);
+            ssh_proto.add_model_request_handler(Self::handle_language_server_prompt_request);
             ssh_proto.add_model_message_handler(Self::handle_hide_toast);
             ssh_proto.add_model_request_handler(BufferStore::handle_update_buffer);
             BufferStore::init(&ssh_proto);
@@ -1180,6 +1185,7 @@ impl Project {
         cx: &mut gpui::TestAppContext,
     ) -> Model<Project> {
         use clock::FakeSystemClock;
+        use gpui::Context;
 
         let languages = LanguageRegistry::test(cx.executor());
         let clock = Arc::new(FakeSystemClock::default());
@@ -3622,6 +3628,45 @@ impl Project {
         })?
     }
 
+    async fn handle_language_server_prompt_request(
+        this: Model<Self>,
+        envelope: TypedEnvelope<proto::LanguageServerPromptRequest>,
+        mut cx: AsyncAppContext,
+    ) -> Result<proto::LanguageServerPromptResponse> {
+        let (tx, mut rx) = smol::channel::bounded(1);
+        let actions: Vec<_> = envelope
+            .payload
+            .actions
+            .into_iter()
+            .map(|action| MessageActionItem {
+                title: action,
+                properties: Default::default(),
+            })
+            .collect();
+        this.update(&mut cx, |_, cx| {
+            cx.emit(Event::LanguageServerPrompt(LanguageServerPromptRequest {
+                level: proto_to_prompt(envelope.payload.level.context("Invalid prompt level")?),
+                message: envelope.payload.message,
+                actions: actions.clone(),
+                lsp_name: envelope.payload.lsp_name,
+                response_channel: tx,
+            }));
+
+            anyhow::Ok(())
+        })??;
+
+        let answer = rx.next().await;
+
+        Ok(LanguageServerPromptResponse {
+            action_response: answer.and_then(|answer| {
+                actions
+                    .iter()
+                    .position(|action| *action == answer)
+                    .map(|index| index as u64)
+            }),
+        })
+    }
+
     async fn handle_hide_toast(
         this: Model<Self>,
         envelope: TypedEnvelope<proto::HideToast>,
@@ -4257,3 +4302,11 @@ pub fn sort_worktree_entries(entries: &mut [Entry]) {
         )
     });
 }
+
+fn proto_to_prompt(level: proto::language_server_prompt_request::Level) -> gpui::PromptLevel {
+    match level {
+        proto::language_server_prompt_request::Level::Info(_) => gpui::PromptLevel::Info,
+        proto::language_server_prompt_request::Level::Warning(_) => gpui::PromptLevel::Warning,
+        proto::language_server_prompt_request::Level::Critical(_) => gpui::PromptLevel::Critical,
+    }
+}

crates/project/src/terminals.rs 🔗

@@ -67,13 +67,15 @@ impl Project {
         }
     }
 
-    fn ssh_command(&self, cx: &AppContext) -> Option<SshCommand> {
-        if let Some(args) = self
-            .ssh_client
-            .as_ref()
-            .and_then(|session| session.read(cx).ssh_args())
-        {
-            return Some(SshCommand::Direct(args));
+    fn ssh_details(&self, cx: &AppContext) -> Option<(String, SshCommand)> {
+        if let Some(ssh_client) = &self.ssh_client {
+            let ssh_client = ssh_client.read(cx);
+            if let Some(args) = ssh_client.ssh_args() {
+                return Some((
+                    ssh_client.connection_options().host.clone(),
+                    SshCommand::Direct(args),
+                ));
+            }
         }
 
         let dev_server_project_id = self.dev_server_project_id()?;
@@ -83,7 +85,7 @@ impl Project {
             .ssh_connection_string
             .as_ref()?
             .to_string();
-        Some(SshCommand::DevServer(ssh_command))
+        Some(("".to_string(), SshCommand::DevServer(ssh_command)))
     }
 
     pub fn create_terminal(
@@ -102,7 +104,7 @@ impl Project {
                 }
             }
         };
-        let ssh_command = self.ssh_command(cx);
+        let ssh_details = self.ssh_details(cx);
 
         let mut settings_location = None;
         if let Some(path) = path.as_ref() {
@@ -127,7 +129,7 @@ impl Project {
         // precedence.
         env.extend(settings.env.clone());
 
-        let local_path = if ssh_command.is_none() {
+        let local_path = if ssh_details.is_none() {
             path.clone()
         } else {
             None
@@ -144,8 +146,8 @@ impl Project {
                         self.python_activate_command(&python_venv_directory, settings);
                 }
 
-                match &ssh_command {
-                    Some(ssh_command) => {
+                match &ssh_details {
+                    Some((host, ssh_command)) => {
                         log::debug!("Connecting to a remote server: {ssh_command:?}");
 
                         // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
@@ -158,7 +160,14 @@ impl Project {
                         let (program, args) =
                             wrap_for_ssh(ssh_command, None, path.as_deref(), env, None);
                         env = HashMap::default();
-                        (None, Shell::WithArguments { program, args })
+                        (
+                            None,
+                            Shell::WithArguments {
+                                program,
+                                args,
+                                title_override: Some(format!("{} — Terminal", host).into()),
+                            },
+                        )
                     }
                     None => (None, settings.shell.clone()),
                 }
@@ -183,8 +192,8 @@ impl Project {
                     );
                 }
 
-                match &ssh_command {
-                    Some(ssh_command) => {
+                match &ssh_details {
+                    Some((host, ssh_command)) => {
                         log::debug!("Connecting to a remote server: {ssh_command:?}");
                         env.entry("TERM".to_string())
                             .or_insert_with(|| "xterm-256color".to_string());
@@ -196,7 +205,14 @@ impl Project {
                             python_venv_directory,
                         );
                         env = HashMap::default();
-                        (task_state, Shell::WithArguments { program, args })
+                        (
+                            task_state,
+                            Shell::WithArguments {
+                                program,
+                                args,
+                                title_override: Some(format!("{} — Terminal", host).into()),
+                            },
+                        )
                     }
                     None => {
                         if let Some(venv_path) = &python_venv_directory {
@@ -208,6 +224,7 @@ impl Project {
                             Shell::WithArguments {
                                 program: spawn_task.command,
                                 args: spawn_task.args,
+                                title_override: None,
                             },
                         )
                     }

crates/project_panel/src/project_panel.rs 🔗

@@ -2939,7 +2939,7 @@ impl Render for ProjectPanel {
                         .key_binding(KeyBinding::for_action(&workspace::Open, cx))
                         .on_click(cx.listener(|this, _, cx| {
                             this.workspace
-                                .update(cx, |workspace, cx| workspace.open(&workspace::Open, cx))
+                                .update(cx, |_, cx| cx.dispatch_action(Box::new(workspace::Open)))
                                 .log_err();
                         })),
                 )

crates/proto/proto/zed.proto 🔗

@@ -299,6 +299,9 @@ message Envelope {
         GetPermalinkToLineResponse get_permalink_to_line_response = 265;
 
         FlushBufferedMessages flush_buffered_messages = 267;
+
+        LanguageServerPromptRequest language_server_prompt_request = 268;
+        LanguageServerPromptResponse language_server_prompt_response = 269; // current max
     }
 
     reserved 87 to 88;
@@ -2528,3 +2531,25 @@ message GetPermalinkToLineResponse {
 
 message FlushBufferedMessages {}
 message FlushBufferedMessagesResponse {}
+
+message LanguageServerPromptRequest {
+    uint64 project_id = 1;
+
+    oneof level {
+        Info info = 2;
+        Warning warning = 3;
+        Critical critical = 4;
+    }
+
+    message Info {}
+    message Warning {}
+    message Critical {}
+
+    string message = 5;
+    repeated string actions = 6;
+    string lsp_name = 7;
+}
+
+message LanguageServerPromptResponse {
+    optional uint64 action_response = 1;
+}

crates/proto/src/proto.rs 🔗

@@ -373,6 +373,8 @@ messages!(
     (GetPermalinkToLine, Foreground),
     (GetPermalinkToLineResponse, Foreground),
     (FlushBufferedMessages, Foreground),
+    (LanguageServerPromptRequest, Foreground),
+    (LanguageServerPromptResponse, Foreground),
 );
 
 request_messages!(
@@ -500,6 +502,7 @@ request_messages!(
     (OpenServerSettings, OpenBufferResponse),
     (GetPermalinkToLine, GetPermalinkToLineResponse),
     (FlushBufferedMessages, Ack),
+    (LanguageServerPromptRequest, LanguageServerPromptResponse),
 );
 
 entity_messages!(
@@ -577,6 +580,7 @@ entity_messages!(
     HideToast,
     OpenServerSettings,
     GetPermalinkToLine,
+    LanguageServerPromptRequest
 );
 
 entity_messages!(

crates/recent_projects/src/remote_servers.rs 🔗

@@ -386,6 +386,7 @@ impl RemoteServerProjects {
         if !matches!(self.mode, Mode::Default(_) | Mode::ViewServerOptions(_, _)) {
             return;
         }
+
         self.selectable_items.next(cx);
         cx.notify();
         self.scroll_to_selected(cx);
@@ -768,7 +769,7 @@ impl RemoteServerProjects {
                 };
                 let project = project.clone();
                 let server = server.clone();
-                cx.spawn(|_, mut cx| async move {
+                cx.spawn(|remote_server_projects, mut cx| async move {
                     let nickname = server.nickname.clone();
                     let result = open_ssh_project(
                         server.into(),
@@ -789,6 +790,10 @@ impl RemoteServerProjects {
                         )
                         .await
                         .ok();
+                    } else {
+                        remote_server_projects
+                            .update(&mut cx, |_, cx| cx.emit(DismissEvent))
+                            .ok();
                     }
                 })
                 .detach();

crates/recent_projects/src/ssh_connections.rs 🔗

@@ -1,13 +1,13 @@
 use std::{path::PathBuf, sync::Arc, time::Duration};
 
-use anyhow::Result;
+use anyhow::{anyhow, Result};
 use auto_update::AutoUpdater;
 use editor::Editor;
 use futures::channel::oneshot;
 use gpui::{
     percentage, Animation, AnimationExt, AnyWindowHandle, AsyncAppContext, DismissEvent,
     EventEmitter, FocusableView, ParentElement as _, PromptLevel, Render, SemanticVersion,
-    SharedString, Task, TextStyleRefinement, Transformation, View,
+    SharedString, Task, TextStyleRefinement, Transformation, View, WeakView,
 };
 use gpui::{AppContext, Model};
 
@@ -128,6 +128,14 @@ pub struct SshPrompt {
     editor: View<Editor>,
 }
 
+impl Drop for SshPrompt {
+    fn drop(&mut self) {
+        if let Some(cancel) = self.cancellation.take() {
+            cancel.send(()).ok();
+        }
+    }
+}
+
 pub struct SshConnectionModal {
     pub(crate) prompt: View<SshPrompt>,
     paths: Vec<PathBuf>,
@@ -393,7 +401,7 @@ impl ModalView for SshConnectionModal {
 #[derive(Clone)]
 pub struct SshClientDelegate {
     window: AnyWindowHandle,
-    ui: View<SshPrompt>,
+    ui: WeakView<SshPrompt>,
     known_password: Option<String>,
 }
 
@@ -493,7 +501,7 @@ impl SshClientDelegate {
         )
         .await
         .map_err(|e| {
-            anyhow::anyhow!(
+            anyhow!(
                 "failed to download remote server binary (os: {}, arch: {}): {}",
                 platform.os,
                 platform.arch,
@@ -520,7 +528,7 @@ impl SshClientDelegate {
                 .output()
                 .await?;
             if !output.status.success() {
-                Err(anyhow::anyhow!("failed to run command: {:?}", command))?;
+                Err(anyhow!("failed to run command: {:?}", command))?;
             }
             Ok(())
         }
@@ -629,7 +637,7 @@ pub fn connect_over_ssh(
         rx,
         Arc::new(SshClientDelegate {
             window,
-            ui,
+            ui: ui.downgrade(),
             known_password,
         }),
         cx,
@@ -686,7 +694,7 @@ pub async fn open_ssh_project(
 
                 Some(Arc::new(SshClientDelegate {
                     window: cx.window_handle(),
-                    ui,
+                    ui: ui.downgrade(),
                     known_password: connection_options.password.clone(),
                 }))
             }

crates/remote_server/src/headless_project.rs 🔗

@@ -1,6 +1,6 @@
 use anyhow::{anyhow, Result};
 use fs::Fs;
-use gpui::{AppContext, AsyncAppContext, Context, Model, ModelContext};
+use gpui::{AppContext, AsyncAppContext, Context, Model, ModelContext, PromptLevel};
 use http_client::HttpClient;
 use language::{proto::serialize_operation, Buffer, BufferEvent, LanguageRegistry};
 use node_runtime::NodeRuntime;
@@ -206,7 +206,7 @@ impl HeadlessProject {
         &mut self,
         _lsp_store: Model<LspStore>,
         event: &LspStoreEvent,
-        _cx: &mut ModelContext<Self>,
+        cx: &mut ModelContext<Self>,
     ) {
         match event {
             LspStoreEvent::LanguageServerUpdate {
@@ -240,6 +240,29 @@ impl HeadlessProject {
                     })
                     .log_err();
             }
+            LspStoreEvent::LanguageServerPrompt(prompt) => {
+                let prompt = prompt.clone();
+                let request = self.session.request(proto::LanguageServerPromptRequest {
+                    project_id: SSH_PROJECT_ID,
+                    actions: prompt
+                        .actions
+                        .iter()
+                        .map(|action| action.title.to_string())
+                        .collect(),
+                    level: Some(prompt_to_proto(&prompt)),
+                    lsp_name: Default::default(),
+                    message: Default::default(),
+                });
+                cx.background_executor()
+                    .spawn(async move {
+                        let response = request.await?;
+                        if let Some(action_response) = response.action_response {
+                            prompt.respond(action_response as usize).await;
+                        }
+                        anyhow::Ok(())
+                    })
+                    .detach();
+            }
             _ => {}
         }
     }
@@ -540,3 +563,19 @@ impl HeadlessProject {
         Ok(proto::Ack {})
     }
 }
+
+fn prompt_to_proto(
+    prompt: &project::LanguageServerPromptRequest,
+) -> proto::language_server_prompt_request::Level {
+    match prompt.level {
+        PromptLevel::Info => proto::language_server_prompt_request::Level::Info(
+            proto::language_server_prompt_request::Info {},
+        ),
+        PromptLevel::Warning => proto::language_server_prompt_request::Level::Warning(
+            proto::language_server_prompt_request::Warning {},
+        ),
+        PromptLevel::Critical => proto::language_server_prompt_request::Level::Critical(
+            proto::language_server_prompt_request::Critical {},
+        ),
+    }
+}

crates/task/src/lib.rs 🔗

@@ -269,5 +269,7 @@ pub enum Shell {
         program: String,
         /// The arguments to pass to the program.
         args: Vec<String>,
+        /// An optional string to override the title of the terminal tab
+        title_override: Option<SharedString>,
     },
 }

crates/terminal/src/terminal.rs 🔗

@@ -45,7 +45,7 @@ use smol::channel::{Receiver, Sender};
 use task::{HideStrategy, Shell, TaskId};
 use terminal_settings::{AlternateScroll, CursorShape, TerminalSettings};
 use theme::{ActiveTheme, Theme};
-use util::truncate_and_trailoff;
+use util::{paths::home_dir, truncate_and_trailoff};
 
 use std::{
     cmp::{self, min},
@@ -60,7 +60,7 @@ use thiserror::Error;
 use gpui::{
     actions, black, px, AnyWindowHandle, AppContext, Bounds, ClipboardItem, EventEmitter, Hsla,
     Keystroke, ModelContext, Modifiers, MouseButton, MouseDownEvent, MouseMoveEvent, MouseUpEvent,
-    Pixels, Point, Rgba, ScrollWheelEvent, Size, Task, TouchPhase,
+    Pixels, Point, Rgba, ScrollWheelEvent, SharedString, Size, Task, TouchPhase,
 };
 
 use crate::mappings::{colors::to_alac_rgb, keys::to_esc_str};
@@ -274,19 +274,21 @@ impl TerminalError {
             })
     }
 
-    pub fn shell_to_string(&self) -> String {
-        match &self.shell {
-            Shell::System => "<system shell>".to_string(),
-            Shell::Program(p) => p.to_string(),
-            Shell::WithArguments { program, args } => format!("{} {}", program, args.join(" ")),
-        }
-    }
-
     pub fn fmt_shell(&self) -> String {
         match &self.shell {
             Shell::System => "<system defined shell>".to_string(),
             Shell::Program(s) => s.to_string(),
-            Shell::WithArguments { program, args } => format!("{} {}", program, args.join(" ")),
+            Shell::WithArguments {
+                program,
+                args,
+                title_override,
+            } => {
+                if let Some(title_override) = title_override {
+                    format!("{} {} ({})", program, args.join(" "), title_override)
+                } else {
+                    format!("{} {}", program, args.join(" "))
+                }
+            }
         }
     }
 }
@@ -348,20 +350,29 @@ impl TerminalBuilder {
             release_channel::AppVersion::global(cx).to_string(),
         );
 
+        let mut terminal_title_override = None;
+
         let pty_options = {
             let alac_shell = match shell.clone() {
                 Shell::System => None,
                 Shell::Program(program) => {
                     Some(alacritty_terminal::tty::Shell::new(program, Vec::new()))
                 }
-                Shell::WithArguments { program, args } => {
+                Shell::WithArguments {
+                    program,
+                    args,
+                    title_override,
+                } => {
+                    terminal_title_override = title_override;
                     Some(alacritty_terminal::tty::Shell::new(program, args))
                 }
             };
 
             alacritty_terminal::tty::Options {
                 shell: alac_shell,
-                working_directory: working_directory.clone(),
+                working_directory: working_directory
+                    .clone()
+                    .or_else(|| Some(home_dir().to_path_buf())),
                 hold: false,
                 env: env.into_iter().collect(),
             }
@@ -441,6 +452,7 @@ impl TerminalBuilder {
             completion_tx,
             term,
             term_config: config,
+            title_override: terminal_title_override,
             events: VecDeque::with_capacity(10), //Should never get this high.
             last_content: Default::default(),
             last_mouse: None,
@@ -604,6 +616,7 @@ pub struct Terminal {
     pub selection_head: Option<AlacPoint>,
     pub breadcrumb_text: String,
     pub pty_info: PtyProcessInfo,
+    title_override: Option<SharedString>,
     scroll_px: Pixels,
     next_link_id: usize,
     selection_phase: SelectionPhase,
@@ -1640,37 +1653,42 @@ impl Terminal {
                 }
             }
             None => self
-                .pty_info
-                .current
+                .title_override
                 .as_ref()
-                .map(|fpi| {
-                    let process_file = fpi
-                        .cwd
-                        .file_name()
-                        .map(|name| name.to_string_lossy().to_string())
-                        .unwrap_or_default();
-
-                    let argv = fpi.argv.clone();
-                    let process_name = format!(
-                        "{}{}",
-                        fpi.name,
-                        if !argv.is_empty() {
-                            format!(" {}", (argv[1..]).join(" "))
-                        } else {
-                            "".to_string()
-                        }
-                    );
-                    let (process_file, process_name) = if truncate {
-                        (
-                            truncate_and_trailoff(&process_file, MAX_CHARS),
-                            truncate_and_trailoff(&process_name, MAX_CHARS),
-                        )
-                    } else {
-                        (process_file, process_name)
-                    };
-                    format!("{process_file} — {process_name}")
-                })
-                .unwrap_or_else(|| "Terminal".to_string()),
+                .map(|title_override| title_override.to_string())
+                .unwrap_or_else(|| {
+                    self.pty_info
+                        .current
+                        .as_ref()
+                        .map(|fpi| {
+                            let process_file = fpi
+                                .cwd
+                                .file_name()
+                                .map(|name| name.to_string_lossy().to_string())
+                                .unwrap_or_default();
+
+                            let argv = fpi.argv.clone();
+                            let process_name = format!(
+                                "{}{}",
+                                fpi.name,
+                                if !argv.is_empty() {
+                                    format!(" {}", (argv[1..]).join(" "))
+                                } else {
+                                    "".to_string()
+                                }
+                            );
+                            let (process_file, process_name) = if truncate {
+                                (
+                                    truncate_and_trailoff(&process_file, MAX_CHARS),
+                                    truncate_and_trailoff(&process_name, MAX_CHARS),
+                                )
+                            } else {
+                                (process_file, process_name)
+                            };
+                            format!("{process_file} — {process_name}")
+                        })
+                        .unwrap_or_else(|| "Terminal".to_string())
+                }),
         }
     }
 

crates/terminal_view/src/terminal_panel.rs 🔗

@@ -414,7 +414,7 @@ impl TerminalPanel {
                 }
             }
             Shell::Program(shell) => Some((shell, Vec::new())),
-            Shell::WithArguments { program, args } => Some((program, args)),
+            Shell::WithArguments { program, args, .. } => Some((program, args)),
         }) else {
             return;
         };

crates/workspace/src/workspace.rs 🔗

@@ -1877,37 +1877,6 @@ impl Workspace {
         })
     }
 
-    pub fn open(&mut self, _: &Open, cx: &mut ViewContext<Self>) {
-        self.client()
-            .telemetry()
-            .report_app_event("open project".to_string());
-        let paths = self.prompt_for_open_path(
-            PathPromptOptions {
-                files: true,
-                directories: true,
-                multiple: true,
-            },
-            DirectoryLister::Local(self.app_state.fs.clone()),
-            cx,
-        );
-
-        cx.spawn(|this, mut cx| async move {
-            let Some(paths) = paths.await.log_err().flatten() else {
-                return;
-            };
-
-            if let Some(task) = this
-                .update(&mut cx, |this, cx| {
-                    this.open_workspace_for_paths(false, paths, cx)
-                })
-                .log_err()
-            {
-                task.await.log_err();
-            }
-        })
-        .detach()
-    }
-
     pub fn open_workspace_for_paths(
         &mut self,
         replace_current_window: bool,
@@ -4345,7 +4314,6 @@ impl Workspace {
             .on_action(cx.listener(Self::send_keystrokes))
             .on_action(cx.listener(Self::add_folder_to_project))
             .on_action(cx.listener(Self::follow_next_collaborator))
-            .on_action(cx.listener(Self::open))
             .on_action(cx.listener(Self::close_window))
             .on_action(cx.listener(Self::activate_pane_at_index))
             .on_action(cx.listener(|workspace, _: &Unfollow, cx| {

crates/zed/src/zed.rs 🔗

@@ -18,8 +18,9 @@ use editor::ProposedChangesEditorToolbar;
 use editor::{scroll::Autoscroll, Editor, MultiBuffer};
 use feature_flags::FeatureFlagAppExt;
 use gpui::{
-    actions, point, px, AppContext, AsyncAppContext, Context, FocusableView, MenuItem, PromptLevel,
-    ReadGlobal, TitlebarOptions, View, ViewContext, VisualContext, WindowKind, WindowOptions,
+    actions, point, px, AppContext, AsyncAppContext, Context, FocusableView, MenuItem,
+    PathPromptOptions, PromptLevel, ReadGlobal, Task, TitlebarOptions, View, ViewContext,
+    VisualContext, WindowKind, WindowOptions,
 };
 pub use open_listener::*;
 
@@ -27,9 +28,10 @@ use anyhow::Context as _;
 use assets::Assets;
 use futures::{channel::mpsc, select_biased, StreamExt};
 use outline_panel::OutlinePanel;
-use project::Item;
+use project::{DirectoryLister, Item};
 use project_panel::ProjectPanel;
 use quick_action_bar::QuickActionBar;
+use recent_projects::open_ssh_project;
 use release_channel::{AppCommitSha, ReleaseChannel};
 use rope::Rope;
 use search::project_search::ProjectSearchBar;
@@ -38,6 +40,7 @@ use settings::{
     DEFAULT_KEYMAP_PATH,
 };
 use std::any::TypeId;
+use std::path::PathBuf;
 use std::{borrow::Cow, ops::Deref, path::Path, sync::Arc};
 use theme::ActiveTheme;
 use workspace::notifications::NotificationId;
@@ -296,6 +299,40 @@ pub fn initialize_workspace(
             .register_action(move |_, _: &zed_actions::IncreaseBufferFontSize, cx| {
                 theme::adjust_buffer_font_size(cx, |size| *size += px(1.0))
             })
+            .register_action(|workspace, _: &workspace::Open, cx| {
+                    workspace.client()
+                        .telemetry()
+                        .report_app_event("open project".to_string());
+                    let paths = workspace.prompt_for_open_path(
+                        PathPromptOptions {
+                            files: true,
+                            directories: true,
+                            multiple: true,
+                        },
+                        DirectoryLister::Project(workspace.project().clone()),
+                        cx,
+                    );
+
+                    cx.spawn(|this, mut cx| async move {
+                        let Some(paths) = paths.await.log_err().flatten() else {
+                            return;
+                        };
+
+                        if let Some(task) = this
+                            .update(&mut cx, |this, cx| {
+                                if this.project().read(cx).is_local() {
+                                    this.open_workspace_for_paths(false, paths, cx)
+                                } else {
+                                    open_new_ssh_project_from_project(this, paths, cx)
+                                }
+                            })
+                            .log_err()
+                        {
+                            task.await.log_err();
+                        }
+                    })
+                    .detach()
+            })
             .register_action(move |_, _: &zed_actions::DecreaseBufferFontSize, cx| {
                 theme::adjust_buffer_font_size(cx, |size| *size -= px(1.0))
             })
@@ -834,6 +871,39 @@ pub fn load_default_keymap(cx: &mut AppContext) {
     }
 }
 
+pub fn open_new_ssh_project_from_project(
+    workspace: &mut Workspace,
+    paths: Vec<PathBuf>,
+    cx: &mut ViewContext<Workspace>,
+) -> Task<anyhow::Result<()>> {
+    let app_state = workspace.app_state().clone();
+    let Some(ssh_client) = workspace.project().read(cx).ssh_client() else {
+        return Task::ready(Err(anyhow::anyhow!("Not an ssh project")));
+    };
+    let connection_options = ssh_client.read(cx).connection_options();
+    let nickname = recent_projects::SshSettings::get_global(cx).nickname_for(
+        &connection_options.host,
+        connection_options.port,
+        &connection_options.username,
+    );
+
+    cx.spawn(|_, mut cx| async move {
+        open_ssh_project(
+            connection_options,
+            paths,
+            app_state,
+            workspace::OpenOptions {
+                open_new_workspace: Some(true),
+                replace_window: None,
+                env: None,
+            },
+            nickname,
+            &mut cx,
+        )
+        .await
+    })
+}
+
 fn open_project_settings_file(
     workspace: &mut Workspace,
     _: &OpenProjectSettings,