Restructure remote client crate, consolidate SSH logic (#36967)

Max Brunsfeld and Mikayla Maki created

This is a pure refactor that consolidates all SSH remoting logic such
that it should be straightforward to add another transport to the
remoting system.

Release Notes:

- N/A

---------

Co-authored-by: Mikayla Maki <mikayla.c.maki@gmail.com>

Change summary

crates/agent_ui/src/inline_prompt_editor.rs                   |    2 
crates/call/src/call_impl/room.rs                             |    2 
crates/collab/src/tests/remote_editing_collaboration_tests.rs |   22 
crates/collab/src/tests/test_server.rs                        |    6 
crates/debugger_ui/src/session/running.rs                     |    9 
crates/editor/src/editor.rs                                   |    6 
crates/extension_host/src/extension_host.rs                   |   18 
crates/file_finder/src/file_finder.rs                         |    2 
crates/language_tools/src/lsp_log.rs                          |    6 
crates/outline_panel/src/outline_panel.rs                     |    6 
crates/project/src/debugger/dap_store.rs                      |  108 
crates/project/src/git_store.rs                               |   68 
crates/project/src/project.rs                                 |  311 
crates/project/src/terminals.rs                               |  355 
crates/project/src/worktree_store.rs                          |    6 
crates/project_panel/src/project_panel.rs                     |    4 
crates/proto/src/proto.rs                                     |    4 
crates/recent_projects/src/disconnected_overlay.rs            |    4 
crates/recent_projects/src/remote_servers.rs                  |    8 
crates/recent_projects/src/ssh_connections.rs                 |   19 
crates/remote/src/protocol.rs                                 |   10 
crates/remote/src/remote.rs                                   |   10 
crates/remote/src/remote_client.rs                            | 1478 ++
crates/remote/src/ssh_session.rs                              | 2749 -----
crates/remote/src/transport.rs                                |    1 
crates/remote/src/transport/ssh.rs                            | 1358 ++
crates/remote_server/src/headless_project.rs                  |   52 
crates/remote_server/src/remote_editing_tests.rs              |   12 
crates/remote_server/src/unix.rs                              |   25 
crates/task/src/shell_builder.rs                              |   15 
crates/terminal_view/src/terminal_element.rs                  |    2 
crates/terminal_view/src/terminal_panel.rs                    |   29 
crates/title_bar/src/collab.rs                                |    2 
crates/title_bar/src/title_bar.rs                             |   12 
crates/vim/src/command.rs                                     |    4 
crates/workspace/src/tasks.rs                                 |    2 
crates/workspace/src/workspace.rs                             |   14 
crates/zed/src/reliability.rs                                 |    4 
crates/zed/src/zed.rs                                         |    4 
39 files changed, 3,334 insertions(+), 3,415 deletions(-)

Detailed changes

crates/agent_ui/src/inline_prompt_editor.rs 🔗

@@ -334,7 +334,7 @@ impl<T: 'static> PromptEditor<T> {
             EditorEvent::Edited { .. } => {
                 if let Some(workspace) = window.root::<Workspace>().flatten() {
                     workspace.update(cx, |workspace, cx| {
-                        let is_via_ssh = workspace.project().read(cx).is_via_ssh();
+                        let is_via_ssh = workspace.project().read(cx).is_via_remote_server();
 
                         workspace
                             .client()

crates/call/src/call_impl/room.rs 🔗

@@ -1161,7 +1161,7 @@ impl Room {
         let request = self.client.request(proto::ShareProject {
             room_id: self.id(),
             worktrees: project.read(cx).worktree_metadata_protos(cx),
-            is_ssh_project: project.read(cx).is_via_ssh(),
+            is_ssh_project: project.read(cx).is_via_remote_server(),
         });
 
         cx.spawn(async move |this, cx| {

crates/collab/src/tests/remote_editing_collaboration_tests.rs 🔗

@@ -26,7 +26,7 @@ use project::{
     debugger::session::ThreadId,
     lsp_store::{FormatTrigger, LspFormatTarget},
 };
-use remote::SshRemoteClient;
+use remote::RemoteClient;
 use remote_server::{HeadlessAppState, HeadlessProject};
 use rpc::proto;
 use serde_json::json;
@@ -59,7 +59,7 @@ async fn test_sharing_an_ssh_remote_project(
         .await;
 
     // Set up project on remote FS
-    let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx);
+    let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx);
     let remote_fs = FakeFs::new(server_cx.executor());
     remote_fs
         .insert_tree(
@@ -101,7 +101,7 @@ async fn test_sharing_an_ssh_remote_project(
         )
     });
 
-    let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await;
+    let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
     let (project_a, worktree_id) = client_a
         .build_ssh_project(path!("/code/project1"), client_ssh, cx_a)
         .await;
@@ -235,7 +235,7 @@ async fn test_ssh_collaboration_git_branches(
         .await;
 
     // Set up project on remote FS
-    let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx);
+    let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx);
     let remote_fs = FakeFs::new(server_cx.executor());
     remote_fs
         .insert_tree("/project", serde_json::json!({ ".git":{} }))
@@ -268,7 +268,7 @@ async fn test_ssh_collaboration_git_branches(
         )
     });
 
-    let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await;
+    let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
     let (project_a, _) = client_a
         .build_ssh_project("/project", client_ssh, cx_a)
         .await;
@@ -420,7 +420,7 @@ async fn test_ssh_collaboration_formatting_with_prettier(
         .create_room(&mut [(&client_a, cx_a), (&client_b, cx_b)])
         .await;
 
-    let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx);
+    let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx);
     let remote_fs = FakeFs::new(server_cx.executor());
     let buffer_text = "let one = \"two\"";
     let prettier_format_suffix = project::TEST_PRETTIER_FORMAT_SUFFIX;
@@ -473,7 +473,7 @@ async fn test_ssh_collaboration_formatting_with_prettier(
         )
     });
 
-    let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await;
+    let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
     let (project_a, worktree_id) = client_a
         .build_ssh_project(path!("/project"), client_ssh, cx_a)
         .await;
@@ -602,7 +602,7 @@ async fn test_remote_server_debugger(
         release_channel::init(SemanticVersion::default(), cx);
         dap_adapters::init(cx);
     });
-    let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx);
+    let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx);
     let remote_fs = FakeFs::new(server_cx.executor());
     remote_fs
         .insert_tree(
@@ -633,7 +633,7 @@ async fn test_remote_server_debugger(
         )
     });
 
-    let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await;
+    let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
     let mut server = TestServer::start(server_cx.executor()).await;
     let client_a = server.create_client(cx_a, "user_a").await;
     cx_a.update(|cx| {
@@ -711,7 +711,7 @@ async fn test_slow_adapter_startup_retries(
         release_channel::init(SemanticVersion::default(), cx);
         dap_adapters::init(cx);
     });
-    let (opts, server_ssh) = SshRemoteClient::fake_server(cx_a, server_cx);
+    let (opts, server_ssh) = RemoteClient::fake_server(cx_a, server_cx);
     let remote_fs = FakeFs::new(server_cx.executor());
     remote_fs
         .insert_tree(
@@ -742,7 +742,7 @@ async fn test_slow_adapter_startup_retries(
         )
     });
 
-    let client_ssh = SshRemoteClient::fake_client(opts, cx_a).await;
+    let client_ssh = RemoteClient::fake_client(opts, cx_a).await;
     let mut server = TestServer::start(server_cx.executor()).await;
     let client_a = server.create_client(cx_a, "user_a").await;
     cx_a.update(|cx| {

crates/collab/src/tests/test_server.rs 🔗

@@ -26,7 +26,7 @@ use node_runtime::NodeRuntime;
 use notifications::NotificationStore;
 use parking_lot::Mutex;
 use project::{Project, WorktreeId};
-use remote::SshRemoteClient;
+use remote::RemoteClient;
 use rpc::{
     RECEIVE_TIMEOUT,
     proto::{self, ChannelRole},
@@ -765,11 +765,11 @@ impl TestClient {
     pub async fn build_ssh_project(
         &self,
         root_path: impl AsRef<Path>,
-        ssh: Entity<SshRemoteClient>,
+        ssh: Entity<RemoteClient>,
         cx: &mut TestAppContext,
     ) -> (Entity<Project>, WorktreeId) {
         let project = cx.update(|cx| {
-            Project::ssh(
+            Project::remote(
                 ssh,
                 self.client().clone(),
                 self.app_state.node_runtime.clone(),

crates/debugger_ui/src/session/running.rs 🔗

@@ -916,10 +916,11 @@ impl RunningState {
         let task_store = project.read(cx).task_store().downgrade();
         let weak_project = project.downgrade();
         let weak_workspace = workspace.downgrade();
-        let ssh_info = project
+        let remote_shell = project
             .read(cx)
-            .ssh_client()
-            .and_then(|it| it.read(cx).ssh_info());
+            .remote_client()
+            .as_ref()
+            .and_then(|remote| remote.read(cx).shell());
 
         cx.spawn_in(window, async move |this, cx| {
             let DebugScenario {
@@ -1003,7 +1004,7 @@ impl RunningState {
                     None
                 };
 
-                let builder = ShellBuilder::new(ssh_info.as_ref().map(|info| &*info.shell), &task.resolved.shell);
+                let builder = ShellBuilder::new(remote_shell.as_deref(), &task.resolved.shell);
                 let command_label = builder.command_label(&task.resolved.command_label);
                 let (command, args) =
                     builder.build(task.resolved.command.clone(), &task.resolved.args);

crates/editor/src/editor.rs 🔗

@@ -20074,7 +20074,7 @@ impl Editor {
                 let (telemetry, is_via_ssh) = {
                     let project = project.read(cx);
                     let telemetry = project.client().telemetry().clone();
-                    let is_via_ssh = project.is_via_ssh();
+                    let is_via_ssh = project.is_via_remote_server();
                     (telemetry, is_via_ssh)
                 };
                 refresh_linked_ranges(self, window, cx);
@@ -20642,7 +20642,7 @@ impl Editor {
                 copilot_enabled,
                 copilot_enabled_for_language,
                 edit_predictions_provider,
-                is_via_ssh = project.is_via_ssh(),
+                is_via_ssh = project.is_via_remote_server(),
             );
         } else {
             telemetry::event!(
@@ -20652,7 +20652,7 @@ impl Editor {
                 copilot_enabled,
                 copilot_enabled_for_language,
                 edit_predictions_provider,
-                is_via_ssh = project.is_via_ssh(),
+                is_via_ssh = project.is_via_remote_server(),
             );
         };
     }

crates/extension_host/src/extension_host.rs 🔗

@@ -43,7 +43,7 @@ use language::{
 use node_runtime::NodeRuntime;
 use project::ContextProviderWithTasks;
 use release_channel::ReleaseChannel;
-use remote::SshRemoteClient;
+use remote::RemoteClient;
 use semantic_version::SemanticVersion;
 use serde::{Deserialize, Serialize};
 use settings::Settings;
@@ -117,7 +117,7 @@ pub struct ExtensionStore {
     pub wasm_host: Arc<WasmHost>,
     pub wasm_extensions: Vec<(Arc<ExtensionManifest>, WasmExtension)>,
     pub tasks: Vec<Task<()>>,
-    pub ssh_clients: HashMap<String, WeakEntity<SshRemoteClient>>,
+    pub remote_clients: HashMap<String, WeakEntity<RemoteClient>>,
     pub ssh_registered_tx: UnboundedSender<()>,
 }
 
@@ -270,7 +270,7 @@ impl ExtensionStore {
             reload_tx,
             tasks: Vec::new(),
 
-            ssh_clients: HashMap::default(),
+            remote_clients: HashMap::default(),
             ssh_registered_tx: connection_registered_tx,
         };
 
@@ -1693,7 +1693,7 @@ impl ExtensionStore {
 
     async fn sync_extensions_over_ssh(
         this: &WeakEntity<Self>,
-        client: WeakEntity<SshRemoteClient>,
+        client: WeakEntity<RemoteClient>,
         cx: &mut AsyncApp,
     ) -> Result<()> {
         let extensions = this.update(cx, |this, _cx| {
@@ -1765,8 +1765,8 @@ impl ExtensionStore {
 
     pub async fn update_ssh_clients(this: &WeakEntity<Self>, cx: &mut AsyncApp) -> Result<()> {
         let clients = this.update(cx, |this, _cx| {
-            this.ssh_clients.retain(|_k, v| v.upgrade().is_some());
-            this.ssh_clients.values().cloned().collect::<Vec<_>>()
+            this.remote_clients.retain(|_k, v| v.upgrade().is_some());
+            this.remote_clients.values().cloned().collect::<Vec<_>>()
         })?;
 
         for client in clients {
@@ -1778,17 +1778,17 @@ impl ExtensionStore {
         anyhow::Ok(())
     }
 
-    pub fn register_ssh_client(&mut self, client: Entity<SshRemoteClient>, cx: &mut Context<Self>) {
+    pub fn register_remote_client(&mut self, client: Entity<RemoteClient>, cx: &mut Context<Self>) {
         let connection_options = client.read(cx).connection_options();
         let ssh_url = connection_options.ssh_url();
 
-        if let Some(existing_client) = self.ssh_clients.get(&ssh_url)
+        if let Some(existing_client) = self.remote_clients.get(&ssh_url)
             && existing_client.upgrade().is_some()
         {
             return;
         }
 
-        self.ssh_clients.insert(ssh_url, client.downgrade());
+        self.remote_clients.insert(ssh_url, client.downgrade());
         self.ssh_registered_tx.unbounded_send(()).ok();
     }
 }

crates/file_finder/src/file_finder.rs 🔗

@@ -1381,7 +1381,7 @@ impl PickerDelegate for FileFinderDelegate {
                         project
                             .worktree_for_id(history_item.project.worktree_id, cx)
                             .is_some()
-                            || ((project.is_local() || project.is_via_ssh())
+                            || ((project.is_local() || project.is_via_remote_server())
                                 && history_item.absolute.is_some())
                     }),
                     self.currently_opened_path.as_ref(),

crates/language_tools/src/lsp_log.rs 🔗

@@ -222,7 +222,7 @@ pub fn init(cx: &mut App) {
 
     cx.observe_new(move |workspace: &mut Workspace, _, cx| {
         let project = workspace.project();
-        if project.read(cx).is_local() || project.read(cx).is_via_ssh() {
+        if project.read(cx).is_local() || project.read(cx).is_via_remote_server() {
             log_store.update(cx, |store, cx| {
                 store.add_project(project, cx);
             });
@@ -231,7 +231,7 @@ pub fn init(cx: &mut App) {
         let log_store = log_store.clone();
         workspace.register_action(move |workspace, _: &OpenLanguageServerLogs, window, cx| {
             let project = workspace.project().read(cx);
-            if project.is_local() || project.is_via_ssh() {
+            if project.is_local() || project.is_via_remote_server() {
                 let project = workspace.project().clone();
                 let log_store = log_store.clone();
                 get_or_create_tool(
@@ -321,7 +321,7 @@ impl LogStore {
                             .retain(|_, state| state.kind.project() != Some(&weak_project));
                     }),
                     cx.subscribe(project, |this, project, event, cx| {
-                        let server_kind = if project.read(cx).is_via_ssh() {
+                        let server_kind = if project.read(cx).is_via_remote_server() {
                             LanguageServerKind::Remote {
                                 project: project.downgrade(),
                             }

crates/outline_panel/src/outline_panel.rs 🔗

@@ -5102,9 +5102,9 @@ impl EventEmitter<PanelEvent> for OutlinePanel {}
 
 impl Render for OutlinePanel {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let (is_local, is_via_ssh) = self
-            .project
-            .read_with(cx, |project, _| (project.is_local(), project.is_via_ssh()));
+        let (is_local, is_via_ssh) = self.project.read_with(cx, |project, _| {
+            (project.is_local(), project.is_via_remote_server())
+        });
         let query = self.query(cx);
         let pinned = self.pinned;
         let settings = OutlinePanelSettings::get_global(cx);

crates/project/src/debugger/dap_store.rs 🔗

@@ -5,11 +5,8 @@ use super::{
     session::{self, Session, SessionStateEvent},
 };
 use crate::{
-    InlayHint, InlayHintLabel, ProjectEnvironment, ResolveState,
-    debugger::session::SessionQuirks,
-    project_settings::ProjectSettings,
-    terminals::{SshCommand, wrap_for_ssh},
-    worktree_store::WorktreeStore,
+    InlayHint, InlayHintLabel, ProjectEnvironment, ResolveState, debugger::session::SessionQuirks,
+    project_settings::ProjectSettings, worktree_store::WorktreeStore,
 };
 use anyhow::{Context as _, Result, anyhow};
 use async_trait::async_trait;
@@ -34,7 +31,7 @@ use http_client::HttpClient;
 use language::{Buffer, LanguageToolchainStore, language_settings::InlayHintKind};
 use node_runtime::NodeRuntime;
 
-use remote::{SshInfo, SshRemoteClient, ssh_session::SshArgs};
+use remote::RemoteClient;
 use rpc::{
     AnyProtoClient, TypedEnvelope,
     proto::{self},
@@ -68,7 +65,7 @@ pub enum DapStoreEvent {
 
 enum DapStoreMode {
     Local(LocalDapStore),
-    Ssh(SshDapStore),
+    Remote(RemoteDapStore),
     Collab,
 }
 
@@ -80,8 +77,8 @@ pub struct LocalDapStore {
     toolchain_store: Arc<dyn LanguageToolchainStore>,
 }
 
-pub struct SshDapStore {
-    ssh_client: Entity<SshRemoteClient>,
+pub struct RemoteDapStore {
+    remote_client: Entity<RemoteClient>,
     upstream_client: AnyProtoClient,
     upstream_project_id: u64,
 }
@@ -147,16 +144,16 @@ impl DapStore {
         Self::new(mode, breakpoint_store, worktree_store, cx)
     }
 
-    pub fn new_ssh(
+    pub fn new_remote(
         project_id: u64,
-        ssh_client: Entity<SshRemoteClient>,
+        remote_client: Entity<RemoteClient>,
         breakpoint_store: Entity<BreakpointStore>,
         worktree_store: Entity<WorktreeStore>,
         cx: &mut Context<Self>,
     ) -> Self {
-        let mode = DapStoreMode::Ssh(SshDapStore {
-            upstream_client: ssh_client.read(cx).proto_client(),
-            ssh_client,
+        let mode = DapStoreMode::Remote(RemoteDapStore {
+            upstream_client: remote_client.read(cx).proto_client(),
+            remote_client,
             upstream_project_id: project_id,
         });
 
@@ -242,64 +239,51 @@ impl DapStore {
                     Ok(binary)
                 })
             }
-            DapStoreMode::Ssh(ssh) => {
-                let request = ssh.upstream_client.request(proto::GetDebugAdapterBinary {
-                    session_id: session_id.to_proto(),
-                    project_id: ssh.upstream_project_id,
-                    worktree_id: worktree.read(cx).id().to_proto(),
-                    definition: Some(definition.to_proto()),
-                });
-                let ssh_client = ssh.ssh_client.clone();
+            DapStoreMode::Remote(remote) => {
+                let request = remote
+                    .upstream_client
+                    .request(proto::GetDebugAdapterBinary {
+                        session_id: session_id.to_proto(),
+                        project_id: remote.upstream_project_id,
+                        worktree_id: worktree.read(cx).id().to_proto(),
+                        definition: Some(definition.to_proto()),
+                    });
+                let remote = remote.remote_client.clone();
 
                 cx.spawn(async move |_, cx| {
                     let response = request.await?;
                     let binary = DebugAdapterBinary::from_proto(response)?;
-                    let (mut ssh_command, envs, path_style, ssh_shell) =
-                        ssh_client.read_with(cx, |ssh, _| {
-                            let SshInfo {
-                                args: SshArgs { arguments, envs },
-                                path_style,
-                                shell,
-                            } = ssh.ssh_info().context("SSH arguments not found")?;
-                            anyhow::Ok((
-                                SshCommand { arguments },
-                                envs.unwrap_or_default(),
-                                path_style,
-                                shell,
-                            ))
-                        })??;
-
-                    let mut connection = None;
-                    if let Some(c) = binary.connection {
-                        let local_bind_addr = Ipv4Addr::LOCALHOST;
-                        let port =
-                            dap::transport::TcpTransport::unused_port(local_bind_addr).await?;
 
-                        ssh_command.add_port_forwarding(port, c.host.to_string(), c.port);
+                    let port_forwarding;
+                    let connection;
+                    if let Some(c) = binary.connection {
+                        let host = Ipv4Addr::LOCALHOST;
+                        let port = dap::transport::TcpTransport::unused_port(host).await?;
+                        port_forwarding = Some((port, c.host.to_string(), c.port));
                         connection = Some(TcpArguments {
                             port,
-                            host: local_bind_addr,
+                            host,
                             timeout: c.timeout,
                         })
+                    } else {
+                        port_forwarding = None;
+                        connection = None;
                     }
 
-                    let (program, args) = wrap_for_ssh(
-                        &ssh_shell,
-                        &ssh_command,
-                        binary
-                            .command
-                            .as_ref()
-                            .map(|command| (command, &binary.arguments)),
-                        binary.cwd.as_deref(),
-                        binary.envs,
-                        None,
-                        path_style,
-                    );
+                    let command = remote.read_with(cx, |remote, _cx| {
+                        remote.build_command(
+                            binary.command,
+                            &binary.arguments,
+                            &binary.envs,
+                            binary.cwd.map(|path| path.display().to_string()),
+                            port_forwarding,
+                        )
+                    })??;
 
                     Ok(DebugAdapterBinary {
-                        command: Some(program),
-                        arguments: args,
-                        envs,
+                        command: Some(command.program),
+                        arguments: command.args,
+                        envs: command.env,
                         cwd: None,
                         connection,
                         request_args: binary.request_args,
@@ -365,9 +349,9 @@ impl DapStore {
                     )))
                 }
             }
-            DapStoreMode::Ssh(ssh) => {
-                let request = ssh.upstream_client.request(proto::RunDebugLocators {
-                    project_id: ssh.upstream_project_id,
+            DapStoreMode::Remote(remote) => {
+                let request = remote.upstream_client.request(proto::RunDebugLocators {
+                    project_id: remote.upstream_project_id,
                     build_command: Some(build_command.to_proto()),
                     locator: locator_name.to_owned(),
                 });

crates/project/src/git_store.rs 🔗

@@ -44,7 +44,7 @@ use parking_lot::Mutex;
 use postage::stream::Stream as _;
 use rpc::{
     AnyProtoClient, TypedEnvelope,
-    proto::{self, FromProto, SSH_PROJECT_ID, ToProto, git_reset, split_repository_update},
+    proto::{self, FromProto, ToProto, git_reset, split_repository_update},
 };
 use serde::Deserialize;
 use std::{
@@ -141,14 +141,10 @@ enum GitStoreState {
         project_environment: Entity<ProjectEnvironment>,
         fs: Arc<dyn Fs>,
     },
-    Ssh {
-        upstream_client: AnyProtoClient,
-        upstream_project_id: ProjectId,
-        downstream: Option<(AnyProtoClient, ProjectId)>,
-    },
     Remote {
         upstream_client: AnyProtoClient,
-        upstream_project_id: ProjectId,
+        upstream_project_id: u64,
+        downstream: Option<(AnyProtoClient, ProjectId)>,
     },
 }
 
@@ -355,7 +351,7 @@ impl GitStore {
         worktree_store: &Entity<WorktreeStore>,
         buffer_store: Entity<BufferStore>,
         upstream_client: AnyProtoClient,
-        project_id: ProjectId,
+        project_id: u64,
         cx: &mut Context<Self>,
     ) -> Self {
         Self::new(
@@ -364,23 +360,6 @@ impl GitStore {
             GitStoreState::Remote {
                 upstream_client,
                 upstream_project_id: project_id,
-            },
-            cx,
-        )
-    }
-
-    pub fn ssh(
-        worktree_store: &Entity<WorktreeStore>,
-        buffer_store: Entity<BufferStore>,
-        upstream_client: AnyProtoClient,
-        cx: &mut Context<Self>,
-    ) -> Self {
-        Self::new(
-            worktree_store.clone(),
-            buffer_store,
-            GitStoreState::Ssh {
-                upstream_client,
-                upstream_project_id: ProjectId(SSH_PROJECT_ID),
                 downstream: None,
             },
             cx,
@@ -451,7 +430,7 @@ impl GitStore {
 
     pub fn shared(&mut self, project_id: u64, client: AnyProtoClient, cx: &mut Context<Self>) {
         match &mut self.state {
-            GitStoreState::Ssh {
+            GitStoreState::Remote {
                 downstream: downstream_client,
                 ..
             } => {
@@ -527,9 +506,6 @@ impl GitStore {
                     }),
                 });
             }
-            GitStoreState::Remote { .. } => {
-                debug_panic!("shared called on remote store");
-            }
         }
     }
 
@@ -541,15 +517,12 @@ impl GitStore {
             } => {
                 downstream_client.take();
             }
-            GitStoreState::Ssh {
+            GitStoreState::Remote {
                 downstream: downstream_client,
                 ..
             } => {
                 downstream_client.take();
             }
-            GitStoreState::Remote { .. } => {
-                debug_panic!("unshared called on remote store");
-            }
         }
         self.shared_diffs.clear();
     }
@@ -1047,21 +1020,17 @@ impl GitStore {
             } => downstream_client
                 .as_ref()
                 .map(|state| (state.client.clone(), state.project_id)),
-            GitStoreState::Ssh {
+            GitStoreState::Remote {
                 downstream: downstream_client,
                 ..
             } => downstream_client.clone(),
-            GitStoreState::Remote { .. } => None,
         }
     }
 
     fn upstream_client(&self) -> Option<AnyProtoClient> {
         match &self.state {
             GitStoreState::Local { .. } => None,
-            GitStoreState::Ssh {
-                upstream_client, ..
-            }
-            | GitStoreState::Remote {
+            GitStoreState::Remote {
                 upstream_client, ..
             } => Some(upstream_client.clone()),
         }
@@ -1432,12 +1401,7 @@ impl GitStore {
                 cx.background_executor()
                     .spawn(async move { fs.git_init(&path, fallback_branch_name) })
             }
-            GitStoreState::Ssh {
-                upstream_client,
-                upstream_project_id: project_id,
-                ..
-            }
-            | GitStoreState::Remote {
+            GitStoreState::Remote {
                 upstream_client,
                 upstream_project_id: project_id,
                 ..
@@ -1447,7 +1411,7 @@ impl GitStore {
                 cx.background_executor().spawn(async move {
                     client
                         .request(proto::GitInit {
-                            project_id: project_id.0,
+                            project_id: project_id,
                             abs_path: path.to_string_lossy().to_string(),
                             fallback_branch_name,
                         })
@@ -1471,13 +1435,18 @@ impl GitStore {
                 cx.background_executor()
                     .spawn(async move { fs.git_clone(&repo, &path).await })
             }
-            GitStoreState::Ssh {
+            GitStoreState::Remote {
                 upstream_client,
                 upstream_project_id,
                 ..
             } => {
+                if upstream_client.is_via_collab() {
+                    return Task::ready(Err(anyhow!(
+                        "Git Clone isn't supported for project guests"
+                    )));
+                }
                 let request = upstream_client.request(proto::GitClone {
-                    project_id: upstream_project_id.0,
+                    project_id: *upstream_project_id,
                     abs_path: path.to_string_lossy().to_string(),
                     remote_repo: repo,
                 });
@@ -1491,9 +1460,6 @@ impl GitStore {
                     }
                 })
             }
-            GitStoreState::Remote { .. } => {
-                Task::ready(Err(anyhow!("Git Clone isn't supported for remote users")))
-            }
         }
     }
 

crates/project/src/project.rs 🔗

@@ -42,9 +42,7 @@ pub use manifest_tree::ManifestTree;
 
 use anyhow::{Context as _, Result, anyhow};
 use buffer_store::{BufferStore, BufferStoreEvent};
-use client::{
-    Client, Collaborator, PendingEntitySubscription, ProjectId, TypedEnvelope, UserStore, proto,
-};
+use client::{Client, Collaborator, PendingEntitySubscription, TypedEnvelope, UserStore, proto};
 use clock::ReplicaId;
 
 use dap::client::DebugAdapterClient;
@@ -89,10 +87,10 @@ use node_runtime::NodeRuntime;
 use parking_lot::Mutex;
 pub use prettier_store::PrettierStore;
 use project_settings::{ProjectSettings, SettingsObserver, SettingsObserverEvent};
-use remote::{SshConnectionOptions, SshRemoteClient};
+use remote::{RemoteClient, SshConnectionOptions};
 use rpc::{
     AnyProtoClient, ErrorCode,
-    proto::{FromProto, LanguageServerPromptResponse, SSH_PROJECT_ID, ToProto},
+    proto::{FromProto, LanguageServerPromptResponse, REMOTE_SERVER_PROJECT_ID, ToProto},
 };
 use search::{SearchInputKind, SearchQuery, SearchResult};
 use search_history::SearchHistory;
@@ -177,12 +175,12 @@ pub struct Project {
     dap_store: Entity<DapStore>,
 
     breakpoint_store: Entity<BreakpointStore>,
-    client: Arc<client::Client>,
+    collab_client: Arc<client::Client>,
     join_project_response_message_id: u32,
     task_store: Entity<TaskStore>,
     user_store: Entity<UserStore>,
     fs: Arc<dyn Fs>,
-    ssh_client: Option<Entity<SshRemoteClient>>,
+    remote_client: Option<Entity<RemoteClient>>,
     client_state: ProjectClientState,
     git_store: Entity<GitStore>,
     collaborators: HashMap<proto::PeerId, Collaborator>,
@@ -1154,12 +1152,12 @@ impl Project {
                 active_entry: None,
                 snippets,
                 languages,
-                client,
+                collab_client: client,
                 task_store,
                 user_store,
                 settings_observer,
                 fs,
-                ssh_client: None,
+                remote_client: None,
                 breakpoint_store,
                 dap_store,
 
@@ -1183,8 +1181,8 @@ impl Project {
         })
     }
 
-    pub fn ssh(
-        ssh: Entity<SshRemoteClient>,
+    pub fn remote(
+        remote: Entity<RemoteClient>,
         client: Arc<Client>,
         node: NodeRuntime,
         user_store: Entity<UserStore>,
@@ -1200,10 +1198,15 @@ impl Project {
             let snippets =
                 SnippetProvider::new(fs.clone(), BTreeSet::from_iter([global_snippets_dir]), cx);
 
-            let (ssh_proto, path_style) =
-                ssh.read_with(cx, |ssh, _| (ssh.proto_client(), ssh.path_style()));
+            let (remote_proto, path_style) =
+                remote.read_with(cx, |remote, _| (remote.proto_client(), remote.path_style()));
             let worktree_store = cx.new(|_| {
-                WorktreeStore::remote(false, ssh_proto.clone(), SSH_PROJECT_ID, path_style)
+                WorktreeStore::remote(
+                    false,
+                    remote_proto.clone(),
+                    REMOTE_SERVER_PROJECT_ID,
+                    path_style,
+                )
             });
             cx.subscribe(&worktree_store, Self::on_worktree_store_event)
                 .detach();
@@ -1215,31 +1218,32 @@ impl Project {
             let buffer_store = cx.new(|cx| {
                 BufferStore::remote(
                     worktree_store.clone(),
-                    ssh.read(cx).proto_client(),
-                    SSH_PROJECT_ID,
+                    remote.read(cx).proto_client(),
+                    REMOTE_SERVER_PROJECT_ID,
                     cx,
                 )
             });
             let image_store = cx.new(|cx| {
                 ImageStore::remote(
                     worktree_store.clone(),
-                    ssh.read(cx).proto_client(),
-                    SSH_PROJECT_ID,
+                    remote.read(cx).proto_client(),
+                    REMOTE_SERVER_PROJECT_ID,
                     cx,
                 )
             });
             cx.subscribe(&buffer_store, Self::on_buffer_store_event)
                 .detach();
-            let toolchain_store = cx
-                .new(|cx| ToolchainStore::remote(SSH_PROJECT_ID, ssh.read(cx).proto_client(), cx));
+            let toolchain_store = cx.new(|cx| {
+                ToolchainStore::remote(REMOTE_SERVER_PROJECT_ID, remote.read(cx).proto_client(), cx)
+            });
             let task_store = cx.new(|cx| {
                 TaskStore::remote(
                     fs.clone(),
                     buffer_store.downgrade(),
                     worktree_store.clone(),
                     toolchain_store.read(cx).as_language_toolchain_store(),
-                    ssh.read(cx).proto_client(),
-                    SSH_PROJECT_ID,
+                    remote.read(cx).proto_client(),
+                    REMOTE_SERVER_PROJECT_ID,
                     cx,
                 )
             });
@@ -1262,8 +1266,8 @@ impl Project {
                     buffer_store.clone(),
                     worktree_store.clone(),
                     languages.clone(),
-                    ssh_proto.clone(),
-                    SSH_PROJECT_ID,
+                    remote_proto.clone(),
+                    REMOTE_SERVER_PROJECT_ID,
                     fs.clone(),
                     cx,
                 )
@@ -1271,12 +1275,12 @@ impl Project {
             cx.subscribe(&lsp_store, Self::on_lsp_store_event).detach();
 
             let breakpoint_store =
-                cx.new(|_| BreakpointStore::remote(SSH_PROJECT_ID, ssh_proto.clone()));
+                cx.new(|_| BreakpointStore::remote(REMOTE_SERVER_PROJECT_ID, remote_proto.clone()));
 
             let dap_store = cx.new(|cx| {
-                DapStore::new_ssh(
-                    SSH_PROJECT_ID,
-                    ssh.clone(),
+                DapStore::new_remote(
+                    REMOTE_SERVER_PROJECT_ID,
+                    remote.clone(),
                     breakpoint_store.clone(),
                     worktree_store.clone(),
                     cx,
@@ -1284,10 +1288,16 @@ impl Project {
             });
 
             let git_store = cx.new(|cx| {
-                GitStore::ssh(&worktree_store, buffer_store.clone(), ssh_proto.clone(), cx)
+                GitStore::remote(
+                    &worktree_store,
+                    buffer_store.clone(),
+                    remote_proto.clone(),
+                    REMOTE_SERVER_PROJECT_ID,
+                    cx,
+                )
             });
 
-            cx.subscribe(&ssh, Self::on_ssh_event).detach();
+            cx.subscribe(&remote, Self::on_remote_client_event).detach();
 
             let this = Self {
                 buffer_ordered_messages_tx: tx,
@@ -1306,11 +1316,13 @@ impl Project {
                 _subscriptions: vec![
                     cx.on_release(Self::release),
                     cx.on_app_quit(|this, cx| {
-                        let shutdown = this.ssh_client.take().and_then(|client| {
-                            client.read(cx).shutdown_processes(
-                                Some(proto::ShutdownRemoteServer {}),
-                                cx.background_executor().clone(),
-                            )
+                        let shutdown = this.remote_client.take().and_then(|client| {
+                            client.update(cx, |client, cx| {
+                                client.shutdown_processes(
+                                    Some(proto::ShutdownRemoteServer {}),
+                                    cx.background_executor().clone(),
+                                )
+                            })
                         });
 
                         cx.background_executor().spawn(async move {
@@ -1323,12 +1335,12 @@ impl Project {
                 active_entry: None,
                 snippets,
                 languages,
-                client,
+                collab_client: client,
                 task_store,
                 user_store,
                 settings_observer,
                 fs,
-                ssh_client: Some(ssh.clone()),
+                remote_client: Some(remote.clone()),
                 buffers_needing_diff: Default::default(),
                 git_diff_debouncer: DebouncedDelay::new(),
                 terminals: Terminals {
@@ -1346,52 +1358,34 @@ impl Project {
                 agent_location: None,
             };
 
-            // ssh -> local machine handlers
-            ssh_proto.subscribe_to_entity(SSH_PROJECT_ID, &cx.entity());
-            ssh_proto.subscribe_to_entity(SSH_PROJECT_ID, &this.buffer_store);
-            ssh_proto.subscribe_to_entity(SSH_PROJECT_ID, &this.worktree_store);
-            ssh_proto.subscribe_to_entity(SSH_PROJECT_ID, &this.lsp_store);
-            ssh_proto.subscribe_to_entity(SSH_PROJECT_ID, &this.dap_store);
-            ssh_proto.subscribe_to_entity(SSH_PROJECT_ID, &this.settings_observer);
-            ssh_proto.subscribe_to_entity(SSH_PROJECT_ID, &this.git_store);
-
-            ssh_proto.add_entity_message_handler(Self::handle_create_buffer_for_peer);
-            ssh_proto.add_entity_message_handler(Self::handle_update_worktree);
-            ssh_proto.add_entity_message_handler(Self::handle_update_project);
-            ssh_proto.add_entity_message_handler(Self::handle_toast);
-            ssh_proto.add_entity_request_handler(Self::handle_language_server_prompt_request);
-            ssh_proto.add_entity_message_handler(Self::handle_hide_toast);
-            ssh_proto.add_entity_request_handler(Self::handle_update_buffer_from_ssh);
-            BufferStore::init(&ssh_proto);
-            LspStore::init(&ssh_proto);
-            SettingsObserver::init(&ssh_proto);
-            TaskStore::init(Some(&ssh_proto));
-            ToolchainStore::init(&ssh_proto);
-            DapStore::init(&ssh_proto, cx);
-            GitStore::init(&ssh_proto);
+            // remote server -> local machine handlers
+            remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &cx.entity());
+            remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.buffer_store);
+            remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.worktree_store);
+            remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.lsp_store);
+            remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.dap_store);
+            remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.settings_observer);
+            remote_proto.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &this.git_store);
+
+            remote_proto.add_entity_message_handler(Self::handle_create_buffer_for_peer);
+            remote_proto.add_entity_message_handler(Self::handle_update_worktree);
+            remote_proto.add_entity_message_handler(Self::handle_update_project);
+            remote_proto.add_entity_message_handler(Self::handle_toast);
+            remote_proto.add_entity_request_handler(Self::handle_language_server_prompt_request);
+            remote_proto.add_entity_message_handler(Self::handle_hide_toast);
+            remote_proto.add_entity_request_handler(Self::handle_update_buffer_from_remote_server);
+            BufferStore::init(&remote_proto);
+            LspStore::init(&remote_proto);
+            SettingsObserver::init(&remote_proto);
+            TaskStore::init(Some(&remote_proto));
+            ToolchainStore::init(&remote_proto);
+            DapStore::init(&remote_proto, cx);
+            GitStore::init(&remote_proto);
 
             this
         })
     }
 
-    pub async fn remote(
-        remote_id: u64,
-        client: Arc<Client>,
-        user_store: Entity<UserStore>,
-        languages: Arc<LanguageRegistry>,
-        fs: Arc<dyn Fs>,
-        cx: AsyncApp,
-    ) -> Result<Entity<Self>> {
-        let project =
-            Self::in_room(remote_id, client, user_store, languages, fs, cx.clone()).await?;
-        cx.update(|cx| {
-            connection_manager::Manager::global(cx).update(cx, |manager, cx| {
-                manager.maintain_project_connection(&project, cx)
-            })
-        })?;
-        Ok(project)
-    }
-
     pub async fn in_room(
         remote_id: u64,
         client: Arc<Client>,
@@ -1523,7 +1517,7 @@ impl Project {
                 &worktree_store,
                 buffer_store.clone(),
                 client.clone().into(),
-                ProjectId(remote_id),
+                remote_id,
                 cx,
             )
         })?;
@@ -1574,11 +1568,11 @@ impl Project {
                 task_store,
                 snippets,
                 fs,
-                ssh_client: None,
+                remote_client: None,
                 settings_observer: settings_observer.clone(),
                 client_subscriptions: Default::default(),
                 _subscriptions: vec![cx.on_release(Self::release)],
-                client: client.clone(),
+                collab_client: client.clone(),
                 client_state: ProjectClientState::Remote {
                     sharing_has_stopped: false,
                     capability: Capability::ReadWrite,
@@ -1661,11 +1655,13 @@ impl Project {
     }
 
     fn release(&mut self, cx: &mut App) {
-        if let Some(client) = self.ssh_client.take() {
-            let shutdown = client.read(cx).shutdown_processes(
-                Some(proto::ShutdownRemoteServer {}),
-                cx.background_executor().clone(),
-            );
+        if let Some(client) = self.remote_client.take() {
+            let shutdown = client.update(cx, |client, cx| {
+                client.shutdown_processes(
+                    Some(proto::ShutdownRemoteServer {}),
+                    cx.background_executor().clone(),
+                )
+            });
 
             cx.background_spawn(async move {
                 if let Some(shutdown) = shutdown {
@@ -1681,7 +1677,7 @@ impl Project {
                 let _ = self.unshare_internal(cx);
             }
             ProjectClientState::Remote { remote_id, .. } => {
-                let _ = self.client.send(proto::LeaveProject {
+                let _ = self.collab_client.send(proto::LeaveProject {
                     project_id: *remote_id,
                 });
                 self.disconnected_from_host_internal(cx);
@@ -1808,11 +1804,11 @@ impl Project {
     }
 
     pub fn client(&self) -> Arc<Client> {
-        self.client.clone()
+        self.collab_client.clone()
     }
 
-    pub fn ssh_client(&self) -> Option<Entity<SshRemoteClient>> {
-        self.ssh_client.clone()
+    pub fn remote_client(&self) -> Option<Entity<RemoteClient>> {
+        self.remote_client.clone()
     }
 
     pub fn user_store(&self) -> Entity<UserStore> {
@@ -1893,30 +1889,30 @@ impl Project {
         if self.is_local() {
             return true;
         }
-        if self.is_via_ssh() {
+        if self.is_via_remote_server() {
             return true;
         }
 
         false
     }
 
-    pub fn ssh_connection_state(&self, cx: &App) -> Option<remote::ConnectionState> {
-        self.ssh_client
+    pub fn remote_connection_state(&self, cx: &App) -> Option<remote::ConnectionState> {
+        self.remote_client
             .as_ref()
-            .map(|ssh| ssh.read(cx).connection_state())
+            .map(|remote| remote.read(cx).connection_state())
     }
 
-    pub fn ssh_connection_options(&self, cx: &App) -> Option<SshConnectionOptions> {
-        self.ssh_client
+    pub fn remote_connection_options(&self, cx: &App) -> Option<SshConnectionOptions> {
+        self.remote_client
             .as_ref()
-            .map(|ssh| ssh.read(cx).connection_options())
+            .map(|remote| remote.read(cx).connection_options())
     }
 
     pub fn replica_id(&self) -> ReplicaId {
         match self.client_state {
             ProjectClientState::Remote { replica_id, .. } => replica_id,
             _ => {
-                if self.ssh_client.is_some() {
+                if self.remote_client.is_some() {
                     1
                 } else {
                     0
@@ -2220,55 +2216,55 @@ impl Project {
         );
 
         self.client_subscriptions.extend([
-            self.client
+            self.collab_client
                 .subscribe_to_entity(project_id)?
                 .set_entity(&cx.entity(), &cx.to_async()),
-            self.client
+            self.collab_client
                 .subscribe_to_entity(project_id)?
                 .set_entity(&self.worktree_store, &cx.to_async()),
-            self.client
+            self.collab_client
                 .subscribe_to_entity(project_id)?
                 .set_entity(&self.buffer_store, &cx.to_async()),
-            self.client
+            self.collab_client
                 .subscribe_to_entity(project_id)?
                 .set_entity(&self.lsp_store, &cx.to_async()),
-            self.client
+            self.collab_client
                 .subscribe_to_entity(project_id)?
                 .set_entity(&self.settings_observer, &cx.to_async()),
-            self.client
+            self.collab_client
                 .subscribe_to_entity(project_id)?
                 .set_entity(&self.dap_store, &cx.to_async()),
-            self.client
+            self.collab_client
                 .subscribe_to_entity(project_id)?
                 .set_entity(&self.breakpoint_store, &cx.to_async()),
-            self.client
+            self.collab_client
                 .subscribe_to_entity(project_id)?
                 .set_entity(&self.git_store, &cx.to_async()),
         ]);
 
         self.buffer_store.update(cx, |buffer_store, cx| {
-            buffer_store.shared(project_id, self.client.clone().into(), cx)
+            buffer_store.shared(project_id, self.collab_client.clone().into(), cx)
         });
         self.worktree_store.update(cx, |worktree_store, cx| {
-            worktree_store.shared(project_id, self.client.clone().into(), cx);
+            worktree_store.shared(project_id, self.collab_client.clone().into(), cx);
         });
         self.lsp_store.update(cx, |lsp_store, cx| {
-            lsp_store.shared(project_id, self.client.clone().into(), cx)
+            lsp_store.shared(project_id, self.collab_client.clone().into(), cx)
         });
         self.breakpoint_store.update(cx, |breakpoint_store, _| {
-            breakpoint_store.shared(project_id, self.client.clone().into())
+            breakpoint_store.shared(project_id, self.collab_client.clone().into())
         });
         self.dap_store.update(cx, |dap_store, cx| {
-            dap_store.shared(project_id, self.client.clone().into(), cx);
+            dap_store.shared(project_id, self.collab_client.clone().into(), cx);
         });
         self.task_store.update(cx, |task_store, cx| {
-            task_store.shared(project_id, self.client.clone().into(), cx);
+            task_store.shared(project_id, self.collab_client.clone().into(), cx);
         });
         self.settings_observer.update(cx, |settings_observer, cx| {
-            settings_observer.shared(project_id, self.client.clone().into(), cx)
+            settings_observer.shared(project_id, self.collab_client.clone().into(), cx)
         });
         self.git_store.update(cx, |git_store, cx| {
-            git_store.shared(project_id, self.client.clone().into(), cx)
+            git_store.shared(project_id, self.collab_client.clone().into(), cx)
         });
 
         self.client_state = ProjectClientState::Shared {
@@ -2293,7 +2289,7 @@ impl Project {
         });
         if let Some(remote_id) = self.remote_id() {
             self.git_store.update(cx, |git_store, cx| {
-                git_store.shared(remote_id, self.client.clone().into(), cx)
+                git_store.shared(remote_id, self.collab_client.clone().into(), cx)
             });
         }
         cx.emit(Event::Reshared);
@@ -2370,7 +2366,7 @@ impl Project {
                 git_store.unshared(cx);
             });
 
-            self.client
+            self.collab_client
                 .send(proto::UnshareProject {
                     project_id: remote_id,
                 })
@@ -2437,15 +2433,17 @@ impl Project {
                 sharing_has_stopped,
                 ..
             } => *sharing_has_stopped,
-            ProjectClientState::Local if self.is_via_ssh() => self.ssh_is_disconnected(cx),
+            ProjectClientState::Local if self.is_via_remote_server() => {
+                self.remote_client_is_disconnected(cx)
+            }
             _ => false,
         }
     }
 
-    fn ssh_is_disconnected(&self, cx: &App) -> bool {
-        self.ssh_client
+    fn remote_client_is_disconnected(&self, cx: &App) -> bool {
+        self.remote_client
             .as_ref()
-            .map(|ssh| ssh.read(cx).is_disconnected())
+            .map(|remote| remote.read(cx).is_disconnected())
             .unwrap_or(false)
     }
 
@@ -2463,16 +2461,16 @@ impl Project {
     pub fn is_local(&self) -> bool {
         match &self.client_state {
             ProjectClientState::Local | ProjectClientState::Shared { .. } => {
-                self.ssh_client.is_none()
+                self.remote_client.is_none()
             }
             ProjectClientState::Remote { .. } => false,
         }
     }
 
-    pub fn is_via_ssh(&self) -> bool {
+    pub fn is_via_remote_server(&self) -> bool {
         match &self.client_state {
             ProjectClientState::Local | ProjectClientState::Shared { .. } => {
-                self.ssh_client.is_some()
+                self.remote_client.is_some()
             }
             ProjectClientState::Remote { .. } => false,
         }
@@ -2496,7 +2494,7 @@ impl Project {
         language: Option<Arc<Language>>,
         cx: &mut Context<Self>,
     ) -> Entity<Buffer> {
-        if self.is_via_collab() || self.is_via_ssh() {
+        if self.is_via_collab() || self.is_via_remote_server() {
             panic!("called create_local_buffer on a remote project")
         }
         self.buffer_store.update(cx, |buffer_store, cx| {
@@ -2620,10 +2618,10 @@ impl Project {
     ) -> Task<Result<Entity<Buffer>>> {
         if let Some(buffer) = self.buffer_for_id(id, cx) {
             Task::ready(Ok(buffer))
-        } else if self.is_local() || self.is_via_ssh() {
+        } else if self.is_local() || self.is_via_remote_server() {
             Task::ready(Err(anyhow!("buffer {id} does not exist")))
         } else if let Some(project_id) = self.remote_id() {
-            let request = self.client.request(proto::OpenBufferById {
+            let request = self.collab_client.request(proto::OpenBufferById {
                 project_id,
                 id: id.into(),
             });
@@ -2741,7 +2739,7 @@ impl Project {
             for (buffer_id, operations) in operations_by_buffer_id.drain() {
                 let request = this.read_with(cx, |this, _| {
                     let project_id = this.remote_id()?;
-                    Some(this.client.request(proto::UpdateBuffer {
+                    Some(this.collab_client.request(proto::UpdateBuffer {
                         buffer_id: buffer_id.into(),
                         project_id,
                         operations,
@@ -2808,7 +2806,7 @@ impl Project {
                         project.read_with(cx, |project, _| {
                             if let Some(project_id) = project.remote_id() {
                                 project
-                                    .client
+                                    .collab_client
                                     .send(proto::UpdateLanguageServer {
                                         project_id,
                                         server_name: name.map(|name| String::from(name.0)),
@@ -2846,8 +2844,8 @@ impl Project {
                 self.register_buffer(buffer, cx).log_err();
             }
             BufferStoreEvent::BufferDropped(buffer_id) => {
-                if let Some(ref ssh_client) = self.ssh_client {
-                    ssh_client
+                if let Some(ref remote_client) = self.remote_client {
+                    remote_client
                         .read(cx)
                         .proto_client()
                         .send(proto::CloseBuffer {
@@ -2995,16 +2993,14 @@ impl Project {
         }
     }
 
-    fn on_ssh_event(
+    fn on_remote_client_event(
         &mut self,
-        _: Entity<SshRemoteClient>,
-        event: &remote::SshRemoteEvent,
+        _: Entity<RemoteClient>,
+        event: &remote::RemoteClientEvent,
         cx: &mut Context<Self>,
     ) {
         match event {
-            remote::SshRemoteEvent::Disconnected => {
-                // if self.is_via_ssh() {
-                // self.collaborators.clear();
+            remote::RemoteClientEvent::Disconnected => {
                 self.worktree_store.update(cx, |store, cx| {
                     store.disconnected_from_host(cx);
                 });
@@ -3110,8 +3106,9 @@ impl Project {
     }
 
     fn on_worktree_released(&mut self, id_to_remove: WorktreeId, cx: &mut Context<Self>) {
-        if let Some(ssh) = &self.ssh_client {
-            ssh.read(cx)
+        if let Some(remote) = &self.remote_client {
+            remote
+                .read(cx)
                 .proto_client()
                 .send(proto::RemoveWorktree {
                     worktree_id: id_to_remove.to_proto(),
@@ -3144,8 +3141,9 @@ impl Project {
             } => {
                 let operation = language::proto::serialize_operation(operation);
 
-                if let Some(ssh) = &self.ssh_client {
-                    ssh.read(cx)
+                if let Some(remote) = &self.remote_client {
+                    remote
+                        .read(cx)
                         .proto_client()
                         .send(proto::UpdateBuffer {
                             project_id: 0,
@@ -3552,16 +3550,16 @@ impl Project {
 
     pub fn open_server_settings(&mut self, cx: &mut Context<Self>) -> Task<Result<Entity<Buffer>>> {
         let guard = self.retain_remotely_created_models(cx);
-        let Some(ssh_client) = self.ssh_client.as_ref() else {
+        let Some(remote) = self.remote_client.as_ref() else {
             return Task::ready(Err(anyhow!("not an ssh project")));
         };
 
-        let proto_client = ssh_client.read(cx).proto_client();
+        let proto_client = remote.read(cx).proto_client();
 
         cx.spawn(async move |project, cx| {
             let buffer = proto_client
                 .request(proto::OpenServerSettings {
-                    project_id: SSH_PROJECT_ID,
+                    project_id: REMOTE_SERVER_PROJECT_ID,
                 })
                 .await?;
 
@@ -3948,10 +3946,11 @@ impl Project {
     ) -> Receiver<Entity<Buffer>> {
         let (tx, rx) = smol::channel::unbounded();
 
-        let (client, remote_id): (AnyProtoClient, _) = if let Some(ssh_client) = &self.ssh_client {
+        let (client, remote_id): (AnyProtoClient, _) = if let Some(ssh_client) = &self.remote_client
+        {
             (ssh_client.read(cx).proto_client(), 0)
         } else if let Some(remote_id) = self.remote_id() {
-            (self.client.clone().into(), remote_id)
+            (self.collab_client.clone().into(), remote_id)
         } else {
             return rx;
         };
@@ -4095,14 +4094,14 @@ impl Project {
                     is_dir: metadata.is_dir,
                 })
             })
-        } else if let Some(ssh_client) = self.ssh_client.as_ref() {
+        } else if let Some(ssh_client) = self.remote_client.as_ref() {
             let path_style = ssh_client.read(cx).path_style();
             let request_path = RemotePathBuf::from_str(path, path_style);
             let request = ssh_client
                 .read(cx)
                 .proto_client()
                 .request(proto::GetPathMetadata {
-                    project_id: SSH_PROJECT_ID,
+                    project_id: REMOTE_SERVER_PROJECT_ID,
                     path: request_path.to_proto(),
                 });
             cx.background_spawn(async move {
@@ -4202,10 +4201,10 @@ impl Project {
     ) -> Task<Result<Vec<DirectoryItem>>> {
         if self.is_local() {
             DirectoryLister::Local(cx.entity(), self.fs.clone()).list_directory(query, cx)
-        } else if let Some(session) = self.ssh_client.as_ref() {
+        } else if let Some(session) = self.remote_client.as_ref() {
             let path_buf = PathBuf::from(query);
             let request = proto::ListRemoteDirectory {
-                dev_server_id: SSH_PROJECT_ID,
+                dev_server_id: REMOTE_SERVER_PROJECT_ID,
                 path: path_buf.to_proto(),
                 config: Some(proto::ListRemoteDirectoryConfig { is_dir: true }),
             };
@@ -4420,7 +4419,7 @@ impl Project {
         mut cx: AsyncApp,
     ) -> Result<()> {
         this.update(&mut cx, |this, cx| {
-            if this.is_local() || this.is_via_ssh() {
+            if this.is_local() || this.is_via_remote_server() {
                 this.unshare(cx)?;
             } else {
                 this.disconnected_from_host(cx);
@@ -4629,7 +4628,7 @@ impl Project {
         })?
     }
 
-    async fn handle_update_buffer_from_ssh(
+    async fn handle_update_buffer_from_remote_server(
         this: Entity<Self>,
         envelope: TypedEnvelope<proto::UpdateBuffer>,
         cx: AsyncApp,
@@ -4638,7 +4637,7 @@ impl Project {
             if let Some(remote_id) = this.remote_id() {
                 let mut payload = envelope.payload.clone();
                 payload.project_id = remote_id;
-                cx.background_spawn(this.client.request(payload))
+                cx.background_spawn(this.collab_client.request(payload))
                     .detach_and_log_err(cx);
             }
             this.buffer_store.clone()
@@ -4652,9 +4651,9 @@ impl Project {
         cx: AsyncApp,
     ) -> Result<proto::Ack> {
         let buffer_store = this.read_with(&cx, |this, cx| {
-            if let Some(ssh) = &this.ssh_client {
+            if let Some(ssh) = &this.remote_client {
                 let mut payload = envelope.payload.clone();
-                payload.project_id = SSH_PROJECT_ID;
+                payload.project_id = REMOTE_SERVER_PROJECT_ID;
                 cx.background_spawn(ssh.read(cx).proto_client().request(payload))
                     .detach_and_log_err(cx);
             }
@@ -4704,7 +4703,7 @@ impl Project {
         mut cx: AsyncApp,
     ) -> Result<proto::SynchronizeBuffersResponse> {
         let response = this.update(&mut cx, |this, cx| {
-            let client = this.client.clone();
+            let client = this.collab_client.clone();
             this.buffer_store.update(cx, |this, cx| {
                 this.handle_synchronize_buffers(envelope, cx, client)
             })
@@ -4841,7 +4840,7 @@ impl Project {
             }
         };
 
-        let client = self.client.clone();
+        let client = self.collab_client.clone();
         cx.spawn(async move |this, cx| {
             let (buffers, incomplete_buffer_ids) = this.update(cx, |this, cx| {
                 this.buffer_store.read(cx).buffer_version_info(cx)

crates/project/src/terminals.rs 🔗

@@ -2,13 +2,11 @@ use crate::{Project, ProjectPath};
 use anyhow::{Context as _, Result};
 use collections::HashMap;
 use gpui::{App, AppContext as _, Context, Entity, Task, WeakEntity};
-use itertools::Itertools;
 use language::LanguageName;
-use remote::{SshInfo, ssh_session::SshArgs};
+use remote::RemoteClient;
 use settings::{Settings, SettingsLocation};
 use smol::channel::bounded;
 use std::{
-    borrow::Cow,
     env::{self},
     path::{Path, PathBuf},
     sync::Arc,
@@ -18,10 +16,7 @@ use terminal::{
     TaskState, TaskStatus, Terminal, TerminalBuilder,
     terminal_settings::{self, ActivateScript, TerminalSettings, VenvSettings},
 };
-use util::{
-    ResultExt,
-    paths::{PathStyle, RemotePathBuf},
-};
+use util::{ResultExt, paths::RemotePathBuf};
 
 /// The directory inside a Python virtual environment that contains executables
 const PYTHON_VENV_BIN_DIR: &str = if cfg!(target_os = "windows") {
@@ -44,29 +39,6 @@ pub enum TerminalKind {
     Task(SpawnInTerminal),
 }
 
-/// SshCommand describes how to connect to a remote server
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct SshCommand {
-    pub arguments: Vec<String>,
-}
-
-impl SshCommand {
-    pub fn add_port_forwarding(&mut self, local_port: u16, host: String, remote_port: u16) {
-        self.arguments.push("-L".to_string());
-        self.arguments
-            .push(format!("{}:{}:{}", local_port, host, remote_port));
-    }
-}
-
-#[derive(Debug)]
-pub struct SshDetails {
-    pub host: String,
-    pub ssh_command: SshCommand,
-    pub envs: Option<HashMap<String, String>>,
-    pub path_style: PathStyle,
-    pub shell: String,
-}
-
 impl Project {
     pub fn active_project_directory(&self, cx: &App) -> Option<Arc<Path>> {
         self.active_entry()
@@ -86,28 +58,6 @@ impl Project {
         }
     }
 
-    pub fn ssh_details(&self, cx: &App) -> Option<SshDetails> {
-        if let Some(ssh_client) = &self.ssh_client {
-            let ssh_client = ssh_client.read(cx);
-            if let Some(SshInfo {
-                args: SshArgs { arguments, envs },
-                path_style,
-                shell,
-            }) = ssh_client.ssh_info()
-            {
-                return Some(SshDetails {
-                    host: ssh_client.connection_options().host,
-                    ssh_command: SshCommand { arguments },
-                    envs,
-                    path_style,
-                    shell,
-                });
-            }
-        }
-
-        None
-    }
-
     pub fn create_terminal(
         &mut self,
         kind: TerminalKind,
@@ -168,14 +118,14 @@ impl Project {
         TerminalSettings::get(settings_location, cx)
     }
 
-    pub fn exec_in_shell(&self, command: String, cx: &App) -> std::process::Command {
+    pub fn exec_in_shell(&self, command: String, cx: &App) -> Result<std::process::Command> {
         let path = self.first_project_directory(cx);
-        let ssh_details = self.ssh_details(cx);
+        let remote_client = self.remote_client.as_ref();
         let settings = self.terminal_settings(&path, cx).clone();
-
-        let builder =
-            ShellBuilder::new(ssh_details.as_ref().map(|ssh| &*ssh.shell), &settings.shell)
-                .non_interactive();
+        let remote_shell = remote_client
+            .as_ref()
+            .and_then(|remote_client| remote_client.read(cx).shell());
+        let builder = ShellBuilder::new(remote_shell.as_deref(), &settings.shell).non_interactive();
         let (command, args) = builder.build(Some(command), &Vec::new());
 
         let mut env = self
@@ -185,29 +135,16 @@ impl Project {
             .unwrap_or_default();
         env.extend(settings.env);
 
-        match self.ssh_details(cx) {
-            Some(SshDetails {
-                ssh_command,
-                envs,
-                path_style,
-                shell,
-                ..
-            }) => {
-                let (command, args) = wrap_for_ssh(
-                    &shell,
-                    &ssh_command,
-                    Some((&command, &args)),
-                    path.as_deref(),
-                    env,
-                    None,
-                    path_style,
-                );
-                let mut command = std::process::Command::new(command);
-                command.args(args);
-                if let Some(envs) = envs {
-                    command.envs(envs);
-                }
-                command
+        match remote_client {
+            Some(remote_client) => {
+                let command_template =
+                    remote_client
+                        .read(cx)
+                        .build_command(Some(command), &args, &env, None, None)?;
+                let mut command = std::process::Command::new(command_template.program);
+                command.args(command_template.args);
+                command.envs(command_template.env);
+                Ok(command)
             }
             None => {
                 let mut command = std::process::Command::new(command);
@@ -216,7 +153,7 @@ impl Project {
                 if let Some(path) = path {
                     command.current_dir(path);
                 }
-                command
+                Ok(command)
             }
         }
     }
@@ -227,13 +164,13 @@ impl Project {
         python_venv_directory: Option<PathBuf>,
         cx: &mut Context<Self>,
     ) -> Result<Entity<Terminal>> {
-        let this = &mut *self;
-        let ssh_details = this.ssh_details(cx);
+        let is_via_remote = self.remote_client.is_some();
+
         let path: Option<Arc<Path>> = match &kind {
             TerminalKind::Shell(path) => path.as_ref().map(|path| Arc::from(path.as_ref())),
             TerminalKind::Task(spawn_task) => {
                 if let Some(cwd) = &spawn_task.cwd {
-                    if ssh_details.is_some() {
+                    if is_via_remote {
                         Some(Arc::from(cwd.as_ref()))
                     } else {
                         let cwd = cwd.to_string_lossy();
@@ -241,16 +178,14 @@ impl Project {
                         Some(Arc::from(Path::new(tilde_substituted.as_ref())))
                     }
                 } else {
-                    this.active_project_directory(cx)
+                    self.active_project_directory(cx)
                 }
             }
         };
 
-        let is_ssh_terminal = ssh_details.is_some();
-
         let mut settings_location = None;
         if let Some(path) = path.as_ref()
-            && let Some((worktree, _)) = this.find_worktree(path, cx)
+            && let Some((worktree, _)) = self.find_worktree(path, cx)
         {
             settings_location = Some(SettingsLocation {
                 worktree_id: worktree.read(cx).id(),
@@ -262,7 +197,7 @@ impl Project {
         let (completion_tx, completion_rx) = bounded(1);
 
         // Start with the environment that we might have inherited from the Zed CLI.
-        let mut env = this
+        let mut env = self
             .environment
             .read(cx)
             .get_cli_environment()
@@ -271,14 +206,17 @@ impl Project {
         // precedence.
         env.extend(settings.env);
 
-        let local_path = if is_ssh_terminal { None } else { path.clone() };
+        let local_path = if is_via_remote { None } else { path.clone() };
 
         let mut python_venv_activate_command = Task::ready(None);
 
-        let (spawn_task, shell) = match kind {
+        let remote_client = self.remote_client.clone();
+        let spawn_task;
+        let shell;
+        match kind {
             TerminalKind::Shell(_) => {
                 if let Some(python_venv_directory) = &python_venv_directory {
-                    python_venv_activate_command = this.python_activate_command(
+                    python_venv_activate_command = self.python_activate_command(
                         python_venv_directory,
                         &settings.detect_venv,
                         &settings.shell,
@@ -286,63 +224,16 @@ impl Project {
                     );
                 }
 
-                match ssh_details {
-                    Some(SshDetails {
-                        host,
-                        ssh_command,
-                        envs,
-                        path_style,
-                        shell,
-                    }) => {
-                        log::debug!("Connecting to a remote server: {ssh_command:?}");
-
-                        // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
-                        // to properly display colors.
-                        // We do not have the luxury of assuming the host has it installed,
-                        // so we set it to a default that does not break the highlighting via ssh.
-                        env.entry("TERM".to_string())
-                            .or_insert_with(|| "xterm-256color".to_string());
-
-                        let (program, args) = wrap_for_ssh(
-                            &shell,
-                            &ssh_command,
-                            None,
-                            path.as_deref(),
-                            env,
-                            None,
-                            path_style,
-                        );
-                        env = HashMap::default();
-                        if let Some(envs) = envs {
-                            env.extend(envs);
-                        }
-                        (
-                            Option::<TaskState>::None,
-                            Shell::WithArguments {
-                                program,
-                                args,
-                                title_override: Some(format!("{} — Terminal", host).into()),
-                            },
-                        )
+                spawn_task = None;
+                shell = match remote_client {
+                    Some(remote_client) => {
+                        create_remote_shell(None, &mut env, path, remote_client, cx)?
                     }
-                    None => (None, settings.shell),
-                }
+                    None => settings.shell,
+                };
             }
-            TerminalKind::Task(spawn_task) => {
-                let task_state = Some(TaskState {
-                    id: spawn_task.id,
-                    full_label: spawn_task.full_label,
-                    label: spawn_task.label,
-                    command_label: spawn_task.command_label,
-                    hide: spawn_task.hide,
-                    status: TaskStatus::Running,
-                    show_summary: spawn_task.show_summary,
-                    show_command: spawn_task.show_command,
-                    show_rerun: spawn_task.show_rerun,
-                    completion_rx,
-                });
-
-                env.extend(spawn_task.env);
+            TerminalKind::Task(task) => {
+                env.extend(task.env);
 
                 if let Some(venv_path) = &python_venv_directory {
                     env.insert(
@@ -351,41 +242,38 @@ impl Project {
                     );
                 }
 
-                match ssh_details {
-                    Some(SshDetails {
-                        host,
-                        ssh_command,
-                        envs,
-                        path_style,
-                        shell,
-                    }) => {
-                        log::debug!("Connecting to a remote server: {ssh_command:?}");
-                        env.entry("TERM".to_string())
-                            .or_insert_with(|| "xterm-256color".to_string());
-                        let (program, args) = wrap_for_ssh(
-                            &shell,
-                            &ssh_command,
-                            spawn_task
-                                .command
-                                .as_ref()
-                                .map(|command| (command, &spawn_task.args)),
-                            path.as_deref(),
-                            env,
-                            python_venv_directory.as_deref(),
-                            path_style,
-                        );
-                        env = HashMap::default();
-                        if let Some(envs) = envs {
-                            env.extend(envs);
+                spawn_task = Some(TaskState {
+                    id: task.id,
+                    full_label: task.full_label,
+                    label: task.label,
+                    command_label: task.command_label,
+                    hide: task.hide,
+                    status: TaskStatus::Running,
+                    show_summary: task.show_summary,
+                    show_command: task.show_command,
+                    show_rerun: task.show_rerun,
+                    completion_rx,
+                });
+                shell = match remote_client {
+                    Some(remote_client) => {
+                        let path_style = remote_client.read(cx).path_style();
+                        if let Some(venv_directory) = &python_venv_directory
+                            && let Ok(str) =
+                                shlex::try_quote(venv_directory.to_string_lossy().as_ref())
+                        {
+                            let path =
+                                RemotePathBuf::new(PathBuf::from(str.to_string()), path_style)
+                                    .to_string();
+                            env.insert("PATH".into(), format!("{}:$PATH ", path));
                         }
-                        (
-                            task_state,
-                            Shell::WithArguments {
-                                program,
-                                args,
-                                title_override: Some(format!("{} — Terminal", host).into()),
-                            },
-                        )
+
+                        create_remote_shell(
+                            task.command.as_ref().map(|command| (command, &task.args)),
+                            &mut env,
+                            path,
+                            remote_client,
+                            cx,
+                        )?
                     }
                     None => {
                         if let Some(venv_path) = &python_venv_directory {
@@ -393,18 +281,17 @@ impl Project {
                                 .log_err();
                         }
 
-                        let shell = if let Some(program) = spawn_task.command {
+                        if let Some(program) = task.command {
                             Shell::WithArguments {
                                 program,
-                                args: spawn_task.args,
+                                args: task.args,
                                 title_override: None,
                             }
                         } else {
                             Shell::System
-                        };
-                        (task_state, shell)
+                        }
                     }
-                }
+                };
             }
         };
         TerminalBuilder::new(
@@ -416,7 +303,7 @@ impl Project {
             settings.cursor_shape.unwrap_or_default(),
             settings.alternate_scroll,
             settings.max_scroll_history_lines,
-            is_ssh_terminal,
+            is_via_remote,
             cx.entity_id().as_u64(),
             completion_tx,
             cx,
@@ -424,7 +311,7 @@ impl Project {
         .map(|builder| {
             let terminal_handle = cx.new(|cx| builder.subscribe(cx));
 
-            this.terminals
+            self.terminals
                 .local_handles
                 .push(terminal_handle.downgrade());
 
@@ -442,7 +329,7 @@ impl Project {
             })
             .detach();
 
-            this.activate_python_virtual_environment(
+            self.activate_python_virtual_environment(
                 python_venv_activate_command,
                 &terminal_handle,
                 cx,
@@ -652,62 +539,42 @@ impl Project {
     }
 }
 
-pub fn wrap_for_ssh(
-    shell: &str,
-    ssh_command: &SshCommand,
-    command: Option<(&String, &Vec<String>)>,
-    path: Option<&Path>,
-    env: HashMap<String, String>,
-    venv_directory: Option<&Path>,
-    path_style: PathStyle,
-) -> (String, Vec<String>) {
-    let to_run = if let Some((command, args)) = command {
-        let command: Option<Cow<str>> = shlex::try_quote(command).ok();
-        let args = args.iter().filter_map(|arg| shlex::try_quote(arg).ok());
-        command.into_iter().chain(args).join(" ")
-    } else {
-        format!("exec {shell} -l")
-    };
-
-    let mut env_changes = String::new();
-    for (k, v) in env.iter() {
-        if let Some((k, v)) = shlex::try_quote(k).ok().zip(shlex::try_quote(v).ok()) {
-            env_changes.push_str(&format!("{}={} ", k, v));
-        }
-    }
-    if let Some(venv_directory) = venv_directory
-        && let Ok(str) = shlex::try_quote(venv_directory.to_string_lossy().as_ref())
-    {
-        let path = RemotePathBuf::new(PathBuf::from(str.to_string()), path_style).to_string();
-        env_changes.push_str(&format!("PATH={}:$PATH ", path));
-    }
-
-    let commands = if let Some(path) = path {
-        let path = RemotePathBuf::new(path.to_path_buf(), path_style).to_string();
-        // shlex will wrap the command in single quotes (''), disabling ~ expansion,
-        // replace ith with something that works
-        let tilde_prefix = "~/";
-        if path.starts_with(tilde_prefix) {
-            let trimmed_path = path
-                .trim_start_matches("/")
-                .trim_start_matches("~")
-                .trim_start_matches("/");
-
-            format!("cd \"$HOME/{trimmed_path}\"; {env_changes} {to_run}")
-        } else {
-            format!("cd \"{path}\"; {env_changes} {to_run}")
-        }
-    } else {
-        format!("cd; {env_changes} {to_run}")
+fn create_remote_shell(
+    spawn_command: Option<(&String, &Vec<String>)>,
+    env: &mut HashMap<String, String>,
+    working_directory: Option<Arc<Path>>,
+    remote_client: Entity<RemoteClient>,
+    cx: &mut App,
+) -> Result<Shell> {
+    // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
+    // to properly display colors.
+    // We do not have the luxury of assuming the host has it installed,
+    // so we set it to a default that does not break the highlighting via ssh.
+    env.entry("TERM".to_string())
+        .or_insert_with(|| "xterm-256color".to_string());
+
+    let (program, args) = match spawn_command {
+        Some((program, args)) => (Some(program.clone()), args),
+        None => (None, &Vec::new()),
     };
-    let shell_invocation = format!("{shell} -c {}", shlex::try_quote(&commands).unwrap());
-
-    let program = "ssh".to_string();
-    let mut args = ssh_command.arguments.clone();
 
-    args.push("-t".to_string());
-    args.push(shell_invocation);
-    (program, args)
+    let command = remote_client.read(cx).build_command(
+        program,
+        args.as_slice(),
+        env,
+        working_directory.map(|path| path.display().to_string()),
+        None,
+    )?;
+    *env = command.env;
+
+    log::debug!("Connecting to a remote server: {:?}", command.program);
+    let host = remote_client.read(cx).connection_options().host;
+
+    Ok(Shell::WithArguments {
+        program: command.program,
+        args: command.args,
+        title_override: Some(format!("{} — Terminal", host).into()),
+    })
 }
 
 fn add_environment_path(env: &mut HashMap<String, String>, new_path: &Path) -> Result<()> {

crates/project/src/worktree_store.rs 🔗

@@ -18,7 +18,7 @@ use gpui::{
 use postage::oneshot;
 use rpc::{
     AnyProtoClient, ErrorExt, TypedEnvelope,
-    proto::{self, FromProto, SSH_PROJECT_ID, ToProto},
+    proto::{self, FromProto, REMOTE_SERVER_PROJECT_ID, ToProto},
 };
 use smol::{
     channel::{Receiver, Sender},
@@ -278,7 +278,7 @@ impl WorktreeStore {
             let path = RemotePathBuf::new(abs_path.into(), path_style);
             let response = client
                 .request(proto::AddWorktree {
-                    project_id: SSH_PROJECT_ID,
+                    project_id: REMOTE_SERVER_PROJECT_ID,
                     path: path.to_proto(),
                     visible,
                 })
@@ -298,7 +298,7 @@ impl WorktreeStore {
 
             let worktree = cx.update(|cx| {
                 Worktree::remote(
-                    SSH_PROJECT_ID,
+                    REMOTE_SERVER_PROJECT_ID,
                     0,
                     proto::WorktreeMetadata {
                         id: response.worktree_id,

crates/project_panel/src/project_panel.rs 🔗

@@ -653,7 +653,7 @@ impl ProjectPanel {
                             let file_path = entry.path.clone();
                             let worktree_id = worktree.read(cx).id();
                             let entry_id = entry.id;
-                            let is_via_ssh = project.read(cx).is_via_ssh();
+                            let is_via_ssh = project.read(cx).is_via_remote_server();
 
                             workspace
                                 .open_path_preview(
@@ -5301,7 +5301,7 @@ impl Render for ProjectPanel {
                         .on_action(cx.listener(Self::open_system))
                         .on_action(cx.listener(Self::open_in_terminal))
                 })
-                .when(project.is_via_ssh(), |el| {
+                .when(project.is_via_remote_server(), |el| {
                     el.on_action(cx.listener(Self::open_in_terminal))
                 })
                 .on_mouse_down(

crates/proto/src/proto.rs 🔗

@@ -16,8 +16,8 @@ pub use typed_envelope::*;
 
 include!(concat!(env!("OUT_DIR"), "/zed.messages.rs"));
 
-pub const SSH_PEER_ID: PeerId = PeerId { owner_id: 0, id: 0 };
-pub const SSH_PROJECT_ID: u64 = 0;
+pub const REMOTE_SERVER_PEER_ID: PeerId = PeerId { owner_id: 0, id: 0 };
+pub const REMOTE_SERVER_PROJECT_ID: u64 = 0;
 
 messages!(
     (Ack, Foreground),

crates/recent_projects/src/disconnected_overlay.rs 🔗

@@ -64,8 +64,8 @@ impl DisconnectedOverlay {
                 }
                 let handle = cx.entity().downgrade();
 
-                let ssh_connection_options = project.read(cx).ssh_connection_options(cx);
-                let host = if let Some(ssh_connection_options) = ssh_connection_options {
+                let remote_connection_options = project.read(cx).remote_connection_options(cx);
+                let host = if let Some(ssh_connection_options) = remote_connection_options {
                     Host::SshRemoteProject(ssh_connection_options)
                 } else {
                     Host::RemoteProject

crates/recent_projects/src/remote_servers.rs 🔗

@@ -28,8 +28,8 @@ use paths::user_ssh_config_file;
 use picker::Picker;
 use project::Fs;
 use project::Project;
-use remote::ssh_session::ConnectionIdentifier;
-use remote::{SshConnectionOptions, SshRemoteClient};
+use remote::remote_client::ConnectionIdentifier;
+use remote::{RemoteClient, SshConnectionOptions};
 use settings::Settings;
 use settings::SettingsStore;
 use settings::update_settings_file;
@@ -69,7 +69,7 @@ pub struct RemoteServerProjects {
     mode: Mode,
     focus_handle: FocusHandle,
     workspace: WeakEntity<Workspace>,
-    retained_connections: Vec<Entity<SshRemoteClient>>,
+    retained_connections: Vec<Entity<RemoteClient>>,
     ssh_config_updates: Task<()>,
     ssh_config_servers: BTreeSet<SharedString>,
     create_new_window: bool,
@@ -597,7 +597,7 @@ impl RemoteServerProjects {
                     let (path_style, project) = cx.update(|_, cx| {
                         (
                             session.read(cx).path_style(),
-                            project::Project::ssh(
+                            project::Project::remote(
                                 session,
                                 app_state.client.clone(),
                                 app_state.node_runtime.clone(),

crates/recent_projects/src/ssh_connections.rs 🔗

@@ -15,8 +15,9 @@ use gpui::{
 use language::CursorShape;
 use markdown::{Markdown, MarkdownElement, MarkdownStyle};
 use release_channel::ReleaseChannel;
-use remote::ssh_session::{ConnectionIdentifier, SshPortForwardOption};
-use remote::{SshConnectionOptions, SshPlatform, SshRemoteClient};
+use remote::{
+    ConnectionIdentifier, RemoteClient, RemotePlatform, SshConnectionOptions, SshPortForwardOption,
+};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::{Settings, SettingsSources};
@@ -451,7 +452,7 @@ pub struct SshClientDelegate {
     known_password: Option<String>,
 }
 
-impl remote::SshClientDelegate for SshClientDelegate {
+impl remote::RemoteClientDelegate for SshClientDelegate {
     fn ask_password(&self, prompt: String, tx: oneshot::Sender<String>, cx: &mut AsyncApp) {
         let mut known_password = self.known_password.clone();
         if let Some(password) = known_password.take() {
@@ -473,7 +474,7 @@ impl remote::SshClientDelegate for SshClientDelegate {
 
     fn download_server_binary_locally(
         &self,
-        platform: SshPlatform,
+        platform: RemotePlatform,
         release_channel: ReleaseChannel,
         version: Option<SemanticVersion>,
         cx: &mut AsyncApp,
@@ -503,7 +504,7 @@ impl remote::SshClientDelegate for SshClientDelegate {
 
     fn get_download_params(
         &self,
-        platform: SshPlatform,
+        platform: RemotePlatform,
         release_channel: ReleaseChannel,
         version: Option<SemanticVersion>,
         cx: &mut AsyncApp,
@@ -543,13 +544,13 @@ pub fn connect_over_ssh(
     ui: Entity<SshPrompt>,
     window: &mut Window,
     cx: &mut App,
-) -> Task<Result<Option<Entity<SshRemoteClient>>>> {
+) -> Task<Result<Option<Entity<RemoteClient>>>> {
     let window = window.window_handle();
     let known_password = connection_options.password.clone();
     let (tx, rx) = oneshot::channel();
     ui.update(cx, |ui, _cx| ui.set_cancellation_tx(tx));
 
-    remote::SshRemoteClient::new(
+    remote::RemoteClient::ssh(
         unique_identifier,
         connection_options,
         rx,
@@ -681,9 +682,9 @@ pub async fn open_ssh_project(
 
         window
             .update(cx, |workspace, _, cx| {
-                if let Some(client) = workspace.project().read(cx).ssh_client() {
+                if let Some(client) = workspace.project().read(cx).remote_client() {
                     ExtensionStore::global(cx)
-                        .update(cx, |store, cx| store.register_ssh_client(client, cx));
+                        .update(cx, |store, cx| store.register_remote_client(client, cx));
                 }
             })
             .ok();

crates/remote/src/protocol.rs 🔗

@@ -51,6 +51,16 @@ pub async fn write_message<S: AsyncWrite + Unpin>(
     Ok(())
 }
 
+pub async fn write_size_prefixed_buffer<S: AsyncWrite + Unpin>(
+    stream: &mut S,
+    buffer: &mut Vec<u8>,
+) -> Result<()> {
+    let len = buffer.len() as u32;
+    stream.write_all(len.to_le_bytes().as_slice()).await?;
+    stream.write_all(buffer).await?;
+    Ok(())
+}
+
 pub async fn read_message_raw<S: AsyncRead + Unpin>(
     stream: &mut S,
     buffer: &mut Vec<u8>,

crates/remote/src/remote.rs 🔗

@@ -1,9 +1,11 @@
 pub mod json_log;
 pub mod protocol;
 pub mod proxy;
-pub mod ssh_session;
+pub mod remote_client;
+mod transport;
 
-pub use ssh_session::{
-    ConnectionState, SshClientDelegate, SshConnectionOptions, SshInfo, SshPlatform,
-    SshRemoteClient, SshRemoteEvent,
+pub use remote_client::{
+    ConnectionIdentifier, ConnectionState, RemoteClient, RemoteClientDelegate, RemoteClientEvent,
+    RemotePlatform,
 };
+pub use transport::ssh::{SshConnectionOptions, SshPortForwardOption};

crates/remote/src/remote_client.rs 🔗

@@ -0,0 +1,1478 @@
+use crate::{
+    SshConnectionOptions, protocol::MessageId, proxy::ProxyLaunchError,
+    transport::ssh::SshRemoteConnection,
+};
+use anyhow::{Context as _, Result, anyhow};
+use async_trait::async_trait;
+use collections::HashMap;
+use futures::{
+    Future, FutureExt as _, StreamExt as _,
+    channel::{
+        mpsc::{self, Sender, UnboundedReceiver, UnboundedSender},
+        oneshot,
+    },
+    future::{BoxFuture, Shared},
+    select, select_biased,
+};
+use gpui::{
+    App, AppContext as _, AsyncApp, BackgroundExecutor, BorrowAppContext, Context, Entity,
+    EventEmitter, Global, SemanticVersion, Task, WeakEntity,
+};
+use parking_lot::Mutex;
+
+use release_channel::ReleaseChannel;
+use rpc::{
+    AnyProtoClient, ErrorExt, ProtoClient, ProtoMessageHandlerSet, RpcError,
+    proto::{self, Envelope, EnvelopedMessage, PeerId, RequestMessage, build_typed_envelope},
+};
+use std::{
+    collections::VecDeque,
+    fmt,
+    ops::ControlFlow,
+    path::PathBuf,
+    sync::{
+        Arc, Weak,
+        atomic::{AtomicU32, AtomicU64, Ordering::SeqCst},
+    },
+    time::{Duration, Instant},
+};
+use util::{
+    ResultExt,
+    paths::{PathStyle, RemotePathBuf},
+};
+
+#[derive(Copy, Clone, Debug)]
+pub struct RemotePlatform {
+    pub os: &'static str,
+    pub arch: &'static str,
+}
+
+#[derive(Clone, Debug)]
+pub struct CommandTemplate {
+    pub program: String,
+    pub args: Vec<String>,
+    pub env: HashMap<String, String>,
+}
+
+pub trait RemoteClientDelegate: Send + Sync {
+    fn ask_password(&self, prompt: String, tx: oneshot::Sender<String>, cx: &mut AsyncApp);
+    fn get_download_params(
+        &self,
+        platform: RemotePlatform,
+        release_channel: ReleaseChannel,
+        version: Option<SemanticVersion>,
+        cx: &mut AsyncApp,
+    ) -> Task<Result<Option<(String, String)>>>;
+    fn download_server_binary_locally(
+        &self,
+        platform: RemotePlatform,
+        release_channel: ReleaseChannel,
+        version: Option<SemanticVersion>,
+        cx: &mut AsyncApp,
+    ) -> Task<Result<PathBuf>>;
+    fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp);
+}
+
+const MAX_MISSED_HEARTBEATS: usize = 5;
+const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
+const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(5);
+
+const MAX_RECONNECT_ATTEMPTS: usize = 3;
+
+enum State {
+    Connecting,
+    Connected {
+        ssh_connection: Arc<dyn RemoteConnection>,
+        delegate: Arc<dyn RemoteClientDelegate>,
+
+        multiplex_task: Task<Result<()>>,
+        heartbeat_task: Task<Result<()>>,
+    },
+    HeartbeatMissed {
+        missed_heartbeats: usize,
+
+        ssh_connection: Arc<dyn RemoteConnection>,
+        delegate: Arc<dyn RemoteClientDelegate>,
+
+        multiplex_task: Task<Result<()>>,
+        heartbeat_task: Task<Result<()>>,
+    },
+    Reconnecting,
+    ReconnectFailed {
+        ssh_connection: Arc<dyn RemoteConnection>,
+        delegate: Arc<dyn RemoteClientDelegate>,
+
+        error: anyhow::Error,
+        attempts: usize,
+    },
+    ReconnectExhausted,
+    ServerNotRunning,
+}
+
+impl fmt::Display for State {
+    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
+        match self {
+            Self::Connecting => write!(f, "connecting"),
+            Self::Connected { .. } => write!(f, "connected"),
+            Self::Reconnecting => write!(f, "reconnecting"),
+            Self::ReconnectFailed { .. } => write!(f, "reconnect failed"),
+            Self::ReconnectExhausted => write!(f, "reconnect exhausted"),
+            Self::HeartbeatMissed { .. } => write!(f, "heartbeat missed"),
+            Self::ServerNotRunning { .. } => write!(f, "server not running"),
+        }
+    }
+}
+
+impl State {
+    fn remote_connection(&self) -> Option<Arc<dyn RemoteConnection>> {
+        match self {
+            Self::Connected { ssh_connection, .. } => Some(ssh_connection.clone()),
+            Self::HeartbeatMissed { ssh_connection, .. } => Some(ssh_connection.clone()),
+            Self::ReconnectFailed { ssh_connection, .. } => Some(ssh_connection.clone()),
+            _ => None,
+        }
+    }
+
+    fn can_reconnect(&self) -> bool {
+        match self {
+            Self::Connected { .. }
+            | Self::HeartbeatMissed { .. }
+            | Self::ReconnectFailed { .. } => true,
+            State::Connecting
+            | State::Reconnecting
+            | State::ReconnectExhausted
+            | State::ServerNotRunning => false,
+        }
+    }
+
+    fn is_reconnect_failed(&self) -> bool {
+        matches!(self, Self::ReconnectFailed { .. })
+    }
+
+    fn is_reconnect_exhausted(&self) -> bool {
+        matches!(self, Self::ReconnectExhausted { .. })
+    }
+
+    fn is_server_not_running(&self) -> bool {
+        matches!(self, Self::ServerNotRunning)
+    }
+
+    fn is_reconnecting(&self) -> bool {
+        matches!(self, Self::Reconnecting { .. })
+    }
+
+    fn heartbeat_recovered(self) -> Self {
+        match self {
+            Self::HeartbeatMissed {
+                ssh_connection,
+                delegate,
+                multiplex_task,
+                heartbeat_task,
+                ..
+            } => Self::Connected {
+                ssh_connection,
+                delegate,
+                multiplex_task,
+                heartbeat_task,
+            },
+            _ => self,
+        }
+    }
+
+    fn heartbeat_missed(self) -> Self {
+        match self {
+            Self::Connected {
+                ssh_connection,
+                delegate,
+                multiplex_task,
+                heartbeat_task,
+            } => Self::HeartbeatMissed {
+                missed_heartbeats: 1,
+                ssh_connection,
+                delegate,
+                multiplex_task,
+                heartbeat_task,
+            },
+            Self::HeartbeatMissed {
+                missed_heartbeats,
+                ssh_connection,
+                delegate,
+                multiplex_task,
+                heartbeat_task,
+            } => Self::HeartbeatMissed {
+                missed_heartbeats: missed_heartbeats + 1,
+                ssh_connection,
+                delegate,
+                multiplex_task,
+                heartbeat_task,
+            },
+            _ => self,
+        }
+    }
+}
+
+/// The state of the ssh connection.
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum ConnectionState {
+    Connecting,
+    Connected,
+    HeartbeatMissed,
+    Reconnecting,
+    Disconnected,
+}
+
+impl From<&State> for ConnectionState {
+    fn from(value: &State) -> Self {
+        match value {
+            State::Connecting => Self::Connecting,
+            State::Connected { .. } => Self::Connected,
+            State::Reconnecting | State::ReconnectFailed { .. } => Self::Reconnecting,
+            State::HeartbeatMissed { .. } => Self::HeartbeatMissed,
+            State::ReconnectExhausted => Self::Disconnected,
+            State::ServerNotRunning => Self::Disconnected,
+        }
+    }
+}
+
+pub struct RemoteClient {
+    client: Arc<ChannelClient>,
+    unique_identifier: String,
+    connection_options: SshConnectionOptions,
+    path_style: PathStyle,
+    state: Option<State>,
+}
+
+#[derive(Debug)]
+pub enum RemoteClientEvent {
+    Disconnected,
+}
+
+impl EventEmitter<RemoteClientEvent> for RemoteClient {}
+
+// Identifies the socket on the remote server so that reconnects
+// can re-join the same project.
+pub enum ConnectionIdentifier {
+    Setup(u64),
+    Workspace(i64),
+}
+
+static NEXT_ID: AtomicU64 = AtomicU64::new(1);
+
+impl ConnectionIdentifier {
+    pub fn setup() -> Self {
+        Self::Setup(NEXT_ID.fetch_add(1, SeqCst))
+    }
+
+    // This string gets used in a socket name, and so must be relatively short.
+    // The total length of:
+    //   /home/{username}/.local/share/zed/server_state/{name}/stdout.sock
+    // Must be less than about 100 characters
+    //   https://unix.stackexchange.com/questions/367008/why-is-socket-path-length-limited-to-a-hundred-chars
+    // So our strings should be at most 20 characters or so.
+    fn to_string(&self, cx: &App) -> String {
+        let identifier_prefix = match ReleaseChannel::global(cx) {
+            ReleaseChannel::Stable => "".to_string(),
+            release_channel => format!("{}-", release_channel.dev_name()),
+        };
+        match self {
+            Self::Setup(setup_id) => format!("{identifier_prefix}setup-{setup_id}"),
+            Self::Workspace(workspace_id) => {
+                format!("{identifier_prefix}workspace-{workspace_id}",)
+            }
+        }
+    }
+}
+
+impl RemoteClient {
+    pub fn ssh(
+        unique_identifier: ConnectionIdentifier,
+        connection_options: SshConnectionOptions,
+        cancellation: oneshot::Receiver<()>,
+        delegate: Arc<dyn RemoteClientDelegate>,
+        cx: &mut App,
+    ) -> Task<Result<Option<Entity<Self>>>> {
+        let unique_identifier = unique_identifier.to_string(cx);
+        cx.spawn(async move |cx| {
+            let success = Box::pin(async move {
+                let (outgoing_tx, outgoing_rx) = mpsc::unbounded::<Envelope>();
+                let (incoming_tx, incoming_rx) = mpsc::unbounded::<Envelope>();
+                let (connection_activity_tx, connection_activity_rx) = mpsc::channel::<()>(1);
+
+                let client =
+                    cx.update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "client"))?;
+
+                let ssh_connection = cx
+                    .update(|cx| {
+                        cx.update_default_global(|pool: &mut ConnectionPool, cx| {
+                            pool.connect(connection_options.clone(), &delegate, cx)
+                        })
+                    })?
+                    .await
+                    .map_err(|e| e.cloned())?;
+
+                let path_style = ssh_connection.path_style();
+                let this = cx.new(|_| Self {
+                    client: client.clone(),
+                    unique_identifier: unique_identifier.clone(),
+                    connection_options,
+                    path_style,
+                    state: Some(State::Connecting),
+                })?;
+
+                let io_task = ssh_connection.start_proxy(
+                    unique_identifier,
+                    false,
+                    incoming_tx,
+                    outgoing_rx,
+                    connection_activity_tx,
+                    delegate.clone(),
+                    cx,
+                );
+
+                let multiplex_task = Self::monitor(this.downgrade(), io_task, cx);
+
+                if let Err(error) = client.ping(HEARTBEAT_TIMEOUT).await {
+                    log::error!("failed to establish connection: {}", error);
+                    return Err(error);
+                }
+
+                let heartbeat_task = Self::heartbeat(this.downgrade(), connection_activity_rx, cx);
+
+                this.update(cx, |this, _| {
+                    this.state = Some(State::Connected {
+                        ssh_connection,
+                        delegate,
+                        multiplex_task,
+                        heartbeat_task,
+                    });
+                })?;
+
+                Ok(Some(this))
+            });
+
+            select! {
+                _ = cancellation.fuse() => {
+                    Ok(None)
+                }
+                result = success.fuse() =>  result
+            }
+        })
+    }
+
+    pub fn proto_client_from_channels(
+        incoming_rx: mpsc::UnboundedReceiver<Envelope>,
+        outgoing_tx: mpsc::UnboundedSender<Envelope>,
+        cx: &App,
+        name: &'static str,
+    ) -> AnyProtoClient {
+        ChannelClient::new(incoming_rx, outgoing_tx, cx, name).into()
+    }
+
+    pub fn shutdown_processes<T: RequestMessage>(
+        &mut self,
+        shutdown_request: Option<T>,
+        executor: BackgroundExecutor,
+    ) -> Option<impl Future<Output = ()> + use<T>> {
+        let state = self.state.take()?;
+        log::info!("shutting down ssh processes");
+
+        let State::Connected {
+            multiplex_task,
+            heartbeat_task,
+            ssh_connection,
+            delegate,
+        } = state
+        else {
+            return None;
+        };
+
+        let client = self.client.clone();
+
+        Some(async move {
+            if let Some(shutdown_request) = shutdown_request {
+                client.send(shutdown_request).log_err();
+                // We wait 50ms instead of waiting for a response, because
+                // waiting for a response would require us to wait on the main thread
+                // which we want to avoid in an `on_app_quit` callback.
+                executor.timer(Duration::from_millis(50)).await;
+            }
+
+            // Drop `multiplex_task` because it owns our ssh_proxy_process, which is a
+            // child of master_process.
+            drop(multiplex_task);
+            // Now drop the rest of state, which kills master process.
+            drop(heartbeat_task);
+            drop(ssh_connection);
+            drop(delegate);
+        })
+    }
+
+    fn reconnect(&mut self, cx: &mut Context<Self>) -> Result<()> {
+        let can_reconnect = self
+            .state
+            .as_ref()
+            .map(|state| state.can_reconnect())
+            .unwrap_or(false);
+        if !can_reconnect {
+            log::info!("aborting reconnect, because not in state that allows reconnecting");
+            let error = if let Some(state) = self.state.as_ref() {
+                format!("invalid state, cannot reconnect while in state {state}")
+            } else {
+                "no state set".to_string()
+            };
+            anyhow::bail!(error);
+        }
+
+        let state = self.state.take().unwrap();
+        let (attempts, ssh_connection, delegate) = match state {
+            State::Connected {
+                ssh_connection,
+                delegate,
+                multiplex_task,
+                heartbeat_task,
+            }
+            | State::HeartbeatMissed {
+                ssh_connection,
+                delegate,
+                multiplex_task,
+                heartbeat_task,
+                ..
+            } => {
+                drop(multiplex_task);
+                drop(heartbeat_task);
+                (0, ssh_connection, delegate)
+            }
+            State::ReconnectFailed {
+                attempts,
+                ssh_connection,
+                delegate,
+                ..
+            } => (attempts, ssh_connection, delegate),
+            State::Connecting
+            | State::Reconnecting
+            | State::ReconnectExhausted
+            | State::ServerNotRunning => unreachable!(),
+        };
+
+        let attempts = attempts + 1;
+        if attempts > MAX_RECONNECT_ATTEMPTS {
+            log::error!(
+                "Failed to reconnect to after {} attempts, giving up",
+                MAX_RECONNECT_ATTEMPTS
+            );
+            self.set_state(State::ReconnectExhausted, cx);
+            return Ok(());
+        }
+
+        self.set_state(State::Reconnecting, cx);
+
+        log::info!("Trying to reconnect to ssh server... Attempt {}", attempts);
+
+        let unique_identifier = self.unique_identifier.clone();
+        let client = self.client.clone();
+        let reconnect_task = cx.spawn(async move |this, cx| {
+            macro_rules! failed {
+                ($error:expr, $attempts:expr, $ssh_connection:expr, $delegate:expr) => {
+                    return State::ReconnectFailed {
+                        error: anyhow!($error),
+                        attempts: $attempts,
+                        ssh_connection: $ssh_connection,
+                        delegate: $delegate,
+                    };
+                };
+            }
+
+            if let Err(error) = ssh_connection
+                .kill()
+                .await
+                .context("Failed to kill ssh process")
+            {
+                failed!(error, attempts, ssh_connection, delegate);
+            };
+
+            let connection_options = ssh_connection.connection_options();
+
+            let (outgoing_tx, outgoing_rx) = mpsc::unbounded::<Envelope>();
+            let (incoming_tx, incoming_rx) = mpsc::unbounded::<Envelope>();
+            let (connection_activity_tx, connection_activity_rx) = mpsc::channel::<()>(1);
+
+            let (ssh_connection, io_task) = match async {
+                let ssh_connection = cx
+                    .update_global(|pool: &mut ConnectionPool, cx| {
+                        pool.connect(connection_options, &delegate, cx)
+                    })?
+                    .await
+                    .map_err(|error| error.cloned())?;
+
+                let io_task = ssh_connection.start_proxy(
+                    unique_identifier,
+                    true,
+                    incoming_tx,
+                    outgoing_rx,
+                    connection_activity_tx,
+                    delegate.clone(),
+                    cx,
+                );
+                anyhow::Ok((ssh_connection, io_task))
+            }
+            .await
+            {
+                Ok((ssh_connection, io_task)) => (ssh_connection, io_task),
+                Err(error) => {
+                    failed!(error, attempts, ssh_connection, delegate);
+                }
+            };
+
+            let multiplex_task = Self::monitor(this.clone(), io_task, cx);
+            client.reconnect(incoming_rx, outgoing_tx, cx);
+
+            if let Err(error) = client.resync(HEARTBEAT_TIMEOUT).await {
+                failed!(error, attempts, ssh_connection, delegate);
+            };
+
+            State::Connected {
+                ssh_connection,
+                delegate,
+                multiplex_task,
+                heartbeat_task: Self::heartbeat(this.clone(), connection_activity_rx, cx),
+            }
+        });
+
+        cx.spawn(async move |this, cx| {
+            let new_state = reconnect_task.await;
+            this.update(cx, |this, cx| {
+                this.try_set_state(cx, |old_state| {
+                    if old_state.is_reconnecting() {
+                        match &new_state {
+                            State::Connecting
+                            | State::Reconnecting
+                            | State::HeartbeatMissed { .. }
+                            | State::ServerNotRunning => {}
+                            State::Connected { .. } => {
+                                log::info!("Successfully reconnected");
+                            }
+                            State::ReconnectFailed {
+                                error, attempts, ..
+                            } => {
+                                log::error!(
+                                    "Reconnect attempt {} failed: {:?}. Starting new attempt...",
+                                    attempts,
+                                    error
+                                );
+                            }
+                            State::ReconnectExhausted => {
+                                log::error!("Reconnect attempt failed and all attempts exhausted");
+                            }
+                        }
+                        Some(new_state)
+                    } else {
+                        None
+                    }
+                });
+
+                if this.state_is(State::is_reconnect_failed) {
+                    this.reconnect(cx)
+                } else if this.state_is(State::is_reconnect_exhausted) {
+                    Ok(())
+                } else {
+                    log::debug!("State has transition from Reconnecting into new state while attempting reconnect.");
+                    Ok(())
+                }
+            })
+        })
+        .detach_and_log_err(cx);
+
+        Ok(())
+    }
+
+    fn heartbeat(
+        this: WeakEntity<Self>,
+        mut connection_activity_rx: mpsc::Receiver<()>,
+        cx: &mut AsyncApp,
+    ) -> Task<Result<()>> {
+        let Ok(client) = this.read_with(cx, |this, _| this.client.clone()) else {
+            return Task::ready(Err(anyhow!("SshRemoteClient lost")));
+        };
+
+        cx.spawn(async move |cx| {
+            let mut missed_heartbeats = 0;
+
+            let keepalive_timer = cx.background_executor().timer(HEARTBEAT_INTERVAL).fuse();
+            futures::pin_mut!(keepalive_timer);
+
+            loop {
+                select_biased! {
+                    result = connection_activity_rx.next().fuse() => {
+                        if result.is_none() {
+                            log::warn!("ssh heartbeat: connection activity channel has been dropped. stopping.");
+                            return Ok(());
+                        }
+
+                        if missed_heartbeats != 0 {
+                            missed_heartbeats = 0;
+                            let _ =this.update(cx, |this, cx| {
+                                this.handle_heartbeat_result(missed_heartbeats, cx)
+                            })?;
+                        }
+                    }
+                    _ = keepalive_timer => {
+                        log::debug!("Sending heartbeat to server...");
+
+                        let result = select_biased! {
+                            _ = connection_activity_rx.next().fuse() => {
+                                Ok(())
+                            }
+                            ping_result = client.ping(HEARTBEAT_TIMEOUT).fuse() => {
+                                ping_result
+                            }
+                        };
+
+                        if result.is_err() {
+                            missed_heartbeats += 1;
+                            log::warn!(
+                                "No heartbeat from server after {:?}. Missed heartbeat {} out of {}.",
+                                HEARTBEAT_TIMEOUT,
+                                missed_heartbeats,
+                                MAX_MISSED_HEARTBEATS
+                            );
+                        } else if missed_heartbeats != 0 {
+                            missed_heartbeats = 0;
+                        } else {
+                            continue;
+                        }
+
+                        let result = this.update(cx, |this, cx| {
+                            this.handle_heartbeat_result(missed_heartbeats, cx)
+                        })?;
+                        if result.is_break() {
+                            return Ok(());
+                        }
+                    }
+                }
+
+                keepalive_timer.set(cx.background_executor().timer(HEARTBEAT_INTERVAL).fuse());
+            }
+        })
+    }
+
+    fn handle_heartbeat_result(
+        &mut self,
+        missed_heartbeats: usize,
+        cx: &mut Context<Self>,
+    ) -> ControlFlow<()> {
+        let state = self.state.take().unwrap();
+        let next_state = if missed_heartbeats > 0 {
+            state.heartbeat_missed()
+        } else {
+            state.heartbeat_recovered()
+        };
+
+        self.set_state(next_state, cx);
+
+        if missed_heartbeats >= MAX_MISSED_HEARTBEATS {
+            log::error!(
+                "Missed last {} heartbeats. Reconnecting...",
+                missed_heartbeats
+            );
+
+            self.reconnect(cx)
+                .context("failed to start reconnect process after missing heartbeats")
+                .log_err();
+            ControlFlow::Break(())
+        } else {
+            ControlFlow::Continue(())
+        }
+    }
+
+    fn monitor(
+        this: WeakEntity<Self>,
+        io_task: Task<Result<i32>>,
+        cx: &AsyncApp,
+    ) -> Task<Result<()>> {
+        cx.spawn(async move |cx| {
+            let result = io_task.await;
+
+            match result {
+                Ok(exit_code) => {
+                    if let Some(error) = ProxyLaunchError::from_exit_code(exit_code) {
+                        match error {
+                            ProxyLaunchError::ServerNotRunning => {
+                                log::error!("failed to reconnect because server is not running");
+                                this.update(cx, |this, cx| {
+                                    this.set_state(State::ServerNotRunning, cx);
+                                })?;
+                            }
+                        }
+                    } else if exit_code > 0 {
+                        log::error!("proxy process terminated unexpectedly");
+                        this.update(cx, |this, cx| {
+                            this.reconnect(cx).ok();
+                        })?;
+                    }
+                }
+                Err(error) => {
+                    log::warn!("ssh io task died with error: {:?}. reconnecting...", error);
+                    this.update(cx, |this, cx| {
+                        this.reconnect(cx).ok();
+                    })?;
+                }
+            }
+
+            Ok(())
+        })
+    }
+
+    fn state_is(&self, check: impl FnOnce(&State) -> bool) -> bool {
+        self.state.as_ref().is_some_and(check)
+    }
+
+    fn try_set_state(&mut self, cx: &mut Context<Self>, map: impl FnOnce(&State) -> Option<State>) {
+        let new_state = self.state.as_ref().and_then(map);
+        if let Some(new_state) = new_state {
+            self.state.replace(new_state);
+            cx.notify();
+        }
+    }
+
+    fn set_state(&mut self, state: State, cx: &mut Context<Self>) {
+        log::info!("setting state to '{}'", &state);
+
+        let is_reconnect_exhausted = state.is_reconnect_exhausted();
+        let is_server_not_running = state.is_server_not_running();
+        self.state.replace(state);
+
+        if is_reconnect_exhausted || is_server_not_running {
+            cx.emit(RemoteClientEvent::Disconnected);
+        }
+        cx.notify();
+    }
+
+    pub fn shell(&self) -> Option<String> {
+        Some(self.state.as_ref()?.remote_connection()?.shell())
+    }
+
+    pub fn build_command(
+        &self,
+        program: Option<String>,
+        args: &[String],
+        env: &HashMap<String, String>,
+        working_dir: Option<String>,
+        port_forward: Option<(u16, String, u16)>,
+    ) -> Result<CommandTemplate> {
+        let Some(connection) = self
+            .state
+            .as_ref()
+            .and_then(|state| state.remote_connection())
+        else {
+            return Err(anyhow!("no connection"));
+        };
+        connection.build_command(program, args, env, working_dir, port_forward)
+    }
+
+    pub fn upload_directory(
+        &self,
+        src_path: PathBuf,
+        dest_path: RemotePathBuf,
+        cx: &App,
+    ) -> Task<Result<()>> {
+        let Some(connection) = self
+            .state
+            .as_ref()
+            .and_then(|state| state.remote_connection())
+        else {
+            return Task::ready(Err(anyhow!("no ssh connection")));
+        };
+        connection.upload_directory(src_path, dest_path, cx)
+    }
+
+    pub fn proto_client(&self) -> AnyProtoClient {
+        self.client.clone().into()
+    }
+
+    pub fn host(&self) -> String {
+        self.connection_options.host.clone()
+    }
+
+    pub fn connection_options(&self) -> SshConnectionOptions {
+        self.connection_options.clone()
+    }
+
+    pub fn connection_state(&self) -> ConnectionState {
+        self.state
+            .as_ref()
+            .map(ConnectionState::from)
+            .unwrap_or(ConnectionState::Disconnected)
+    }
+
+    pub fn is_disconnected(&self) -> bool {
+        self.connection_state() == ConnectionState::Disconnected
+    }
+
+    pub fn path_style(&self) -> PathStyle {
+        self.path_style
+    }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn simulate_disconnect(&self, client_cx: &mut App) -> Task<()> {
+        let opts = self.connection_options();
+        client_cx.spawn(async move |cx| {
+            let connection = cx
+                .update_global(|c: &mut ConnectionPool, _| {
+                    if let Some(ConnectionPoolEntry::Connecting(c)) = c.connections.get(&opts) {
+                        c.clone()
+                    } else {
+                        panic!("missing test connection")
+                    }
+                })
+                .unwrap()
+                .await
+                .unwrap();
+
+            connection.simulate_disconnect(cx);
+        })
+    }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub fn fake_server(
+        client_cx: &mut gpui::TestAppContext,
+        server_cx: &mut gpui::TestAppContext,
+    ) -> (SshConnectionOptions, AnyProtoClient) {
+        let port = client_cx
+            .update(|cx| cx.default_global::<ConnectionPool>().connections.len() as u16 + 1);
+        let opts = SshConnectionOptions {
+            host: "<fake>".to_string(),
+            port: Some(port),
+            ..Default::default()
+        };
+        let (outgoing_tx, _) = mpsc::unbounded::<Envelope>();
+        let (_, incoming_rx) = mpsc::unbounded::<Envelope>();
+        let server_client =
+            server_cx.update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "fake-server"));
+        let connection: Arc<dyn RemoteConnection> = Arc::new(fake::FakeRemoteConnection {
+            connection_options: opts.clone(),
+            server_cx: fake::SendableCx::new(server_cx),
+            server_channel: server_client.clone(),
+        });
+
+        client_cx.update(|cx| {
+            cx.update_default_global(|c: &mut ConnectionPool, cx| {
+                c.connections.insert(
+                    opts.clone(),
+                    ConnectionPoolEntry::Connecting(
+                        cx.background_spawn({
+                            let connection = connection.clone();
+                            async move { Ok(connection.clone()) }
+                        })
+                        .shared(),
+                    ),
+                );
+            })
+        });
+
+        (opts, server_client.into())
+    }
+
+    #[cfg(any(test, feature = "test-support"))]
+    pub async fn fake_client(
+        opts: SshConnectionOptions,
+        client_cx: &mut gpui::TestAppContext,
+    ) -> Entity<Self> {
+        let (_tx, rx) = oneshot::channel();
+        client_cx
+            .update(|cx| {
+                Self::ssh(
+                    ConnectionIdentifier::setup(),
+                    opts,
+                    rx,
+                    Arc::new(fake::Delegate),
+                    cx,
+                )
+            })
+            .await
+            .unwrap()
+            .unwrap()
+    }
+}
+
+enum ConnectionPoolEntry {
+    Connecting(Shared<Task<Result<Arc<dyn RemoteConnection>, Arc<anyhow::Error>>>>),
+    Connected(Weak<dyn RemoteConnection>),
+}
+
+#[derive(Default)]
+struct ConnectionPool {
+    connections: HashMap<SshConnectionOptions, ConnectionPoolEntry>,
+}
+
+impl Global for ConnectionPool {}
+
+impl ConnectionPool {
+    pub fn connect(
+        &mut self,
+        opts: SshConnectionOptions,
+        delegate: &Arc<dyn RemoteClientDelegate>,
+        cx: &mut App,
+    ) -> Shared<Task<Result<Arc<dyn RemoteConnection>, Arc<anyhow::Error>>>> {
+        let connection = self.connections.get(&opts);
+        match connection {
+            Some(ConnectionPoolEntry::Connecting(task)) => {
+                let delegate = delegate.clone();
+                cx.spawn(async move |cx| {
+                    delegate.set_status(Some("Waiting for existing connection attempt"), cx);
+                })
+                .detach();
+                return task.clone();
+            }
+            Some(ConnectionPoolEntry::Connected(ssh)) => {
+                if let Some(ssh) = ssh.upgrade()
+                    && !ssh.has_been_killed()
+                {
+                    return Task::ready(Ok(ssh)).shared();
+                }
+                self.connections.remove(&opts);
+            }
+            None => {}
+        }
+
+        let task = cx
+            .spawn({
+                let opts = opts.clone();
+                let delegate = delegate.clone();
+                async move |cx| {
+                    let connection = SshRemoteConnection::new(opts.clone(), delegate, cx)
+                        .await
+                        .map(|connection| Arc::new(connection) as Arc<dyn RemoteConnection>);
+
+                    cx.update_global(|pool: &mut Self, _| {
+                        debug_assert!(matches!(
+                            pool.connections.get(&opts),
+                            Some(ConnectionPoolEntry::Connecting(_))
+                        ));
+                        match connection {
+                            Ok(connection) => {
+                                pool.connections.insert(
+                                    opts.clone(),
+                                    ConnectionPoolEntry::Connected(Arc::downgrade(&connection)),
+                                );
+                                Ok(connection)
+                            }
+                            Err(error) => {
+                                pool.connections.remove(&opts);
+                                Err(Arc::new(error))
+                            }
+                        }
+                    })?
+                }
+            })
+            .shared();
+
+        self.connections
+            .insert(opts.clone(), ConnectionPoolEntry::Connecting(task.clone()));
+        task
+    }
+}
+
+#[async_trait(?Send)]
+pub(crate) trait RemoteConnection: Send + Sync {
+    fn start_proxy(
+        &self,
+        unique_identifier: String,
+        reconnect: bool,
+        incoming_tx: UnboundedSender<Envelope>,
+        outgoing_rx: UnboundedReceiver<Envelope>,
+        connection_activity_tx: Sender<()>,
+        delegate: Arc<dyn RemoteClientDelegate>,
+        cx: &mut AsyncApp,
+    ) -> Task<Result<i32>>;
+    fn upload_directory(
+        &self,
+        src_path: PathBuf,
+        dest_path: RemotePathBuf,
+        cx: &App,
+    ) -> Task<Result<()>>;
+    async fn kill(&self) -> Result<()>;
+    fn has_been_killed(&self) -> bool;
+    fn build_command(
+        &self,
+        program: Option<String>,
+        args: &[String],
+        env: &HashMap<String, String>,
+        working_dir: Option<String>,
+        port_forward: Option<(u16, String, u16)>,
+    ) -> Result<CommandTemplate>;
+    fn connection_options(&self) -> SshConnectionOptions;
+    fn path_style(&self) -> PathStyle;
+    fn shell(&self) -> String;
+
+    #[cfg(any(test, feature = "test-support"))]
+    fn simulate_disconnect(&self, _: &AsyncApp) {}
+}
+
+type ResponseChannels = Mutex<HashMap<MessageId, oneshot::Sender<(Envelope, oneshot::Sender<()>)>>>;
+
+struct ChannelClient {
+    next_message_id: AtomicU32,
+    outgoing_tx: Mutex<mpsc::UnboundedSender<Envelope>>,
+    buffer: Mutex<VecDeque<Envelope>>,
+    response_channels: ResponseChannels,
+    message_handlers: Mutex<ProtoMessageHandlerSet>,
+    max_received: AtomicU32,
+    name: &'static str,
+    task: Mutex<Task<Result<()>>>,
+}
+
+impl ChannelClient {
+    fn new(
+        incoming_rx: mpsc::UnboundedReceiver<Envelope>,
+        outgoing_tx: mpsc::UnboundedSender<Envelope>,
+        cx: &App,
+        name: &'static str,
+    ) -> Arc<Self> {
+        Arc::new_cyclic(|this| Self {
+            outgoing_tx: Mutex::new(outgoing_tx),
+            next_message_id: AtomicU32::new(0),
+            max_received: AtomicU32::new(0),
+            response_channels: ResponseChannels::default(),
+            message_handlers: Default::default(),
+            buffer: Mutex::new(VecDeque::new()),
+            name,
+            task: Mutex::new(Self::start_handling_messages(
+                this.clone(),
+                incoming_rx,
+                &cx.to_async(),
+            )),
+        })
+    }
+
+    fn start_handling_messages(
+        this: Weak<Self>,
+        mut incoming_rx: mpsc::UnboundedReceiver<Envelope>,
+        cx: &AsyncApp,
+    ) -> Task<Result<()>> {
+        cx.spawn(async move |cx| {
+            let peer_id = PeerId { owner_id: 0, id: 0 };
+            while let Some(incoming) = incoming_rx.next().await {
+                let Some(this) = this.upgrade() else {
+                    return anyhow::Ok(());
+                };
+                if let Some(ack_id) = incoming.ack_id {
+                    let mut buffer = this.buffer.lock();
+                    while buffer.front().is_some_and(|msg| msg.id <= ack_id) {
+                        buffer.pop_front();
+                    }
+                }
+                if let Some(proto::envelope::Payload::FlushBufferedMessages(_)) = &incoming.payload
+                {
+                    log::debug!(
+                        "{}:ssh message received. name:FlushBufferedMessages",
+                        this.name
+                    );
+                    {
+                        let buffer = this.buffer.lock();
+                        for envelope in buffer.iter() {
+                            this.outgoing_tx
+                                .lock()
+                                .unbounded_send(envelope.clone())
+                                .ok();
+                        }
+                    }
+                    let mut envelope = proto::Ack {}.into_envelope(0, Some(incoming.id), None);
+                    envelope.id = this.next_message_id.fetch_add(1, SeqCst);
+                    this.outgoing_tx.lock().unbounded_send(envelope).ok();
+                    continue;
+                }
+
+                this.max_received.store(incoming.id, SeqCst);
+
+                if let Some(request_id) = incoming.responding_to {
+                    let request_id = MessageId(request_id);
+                    let sender = this.response_channels.lock().remove(&request_id);
+                    if let Some(sender) = sender {
+                        let (tx, rx) = oneshot::channel();
+                        if incoming.payload.is_some() {
+                            sender.send((incoming, tx)).ok();
+                        }
+                        rx.await.ok();
+                    }
+                } else if let Some(envelope) =
+                    build_typed_envelope(peer_id, Instant::now(), incoming)
+                {
+                    let type_name = envelope.payload_type_name();
+                    let message_id = envelope.message_id();
+                    if let Some(future) = ProtoMessageHandlerSet::handle_message(
+                        &this.message_handlers,
+                        envelope,
+                        this.clone().into(),
+                        cx.clone(),
+                    ) {
+                        log::debug!("{}:ssh message received. name:{type_name}", this.name);
+                        cx.foreground_executor()
+                            .spawn(async move {
+                                match future.await {
+                                    Ok(_) => {
+                                        log::debug!(
+                                            "{}:ssh message handled. name:{type_name}",
+                                            this.name
+                                        );
+                                    }
+                                    Err(error) => {
+                                        log::error!(
+                                            "{}:error handling message. type:{}, error:{}",
+                                            this.name,
+                                            type_name,
+                                            format!("{error:#}").lines().fold(
+                                                String::new(),
+                                                |mut message, line| {
+                                                    if !message.is_empty() {
+                                                        message.push(' ');
+                                                    }
+                                                    message.push_str(line);
+                                                    message
+                                                }
+                                            )
+                                        );
+                                    }
+                                }
+                            })
+                            .detach()
+                    } else {
+                        log::error!("{}:unhandled ssh message name:{type_name}", this.name);
+                        if let Err(e) = AnyProtoClient::from(this.clone()).send_response(
+                            message_id,
+                            anyhow::anyhow!("no handler registered for {type_name}").to_proto(),
+                        ) {
+                            log::error!(
+                                "{}:error sending error response for {type_name}:{e:#}",
+                                this.name
+                            );
+                        }
+                    }
+                }
+            }
+            anyhow::Ok(())
+        })
+    }
+
+    fn reconnect(
+        self: &Arc<Self>,
+        incoming_rx: UnboundedReceiver<Envelope>,
+        outgoing_tx: UnboundedSender<Envelope>,
+        cx: &AsyncApp,
+    ) {
+        *self.outgoing_tx.lock() = outgoing_tx;
+        *self.task.lock() = Self::start_handling_messages(Arc::downgrade(self), incoming_rx, cx);
+    }
+
+    fn request<T: RequestMessage>(
+        &self,
+        payload: T,
+    ) -> impl 'static + Future<Output = Result<T::Response>> {
+        self.request_internal(payload, true)
+    }
+
+    fn request_internal<T: RequestMessage>(
+        &self,
+        payload: T,
+        use_buffer: bool,
+    ) -> impl 'static + Future<Output = Result<T::Response>> {
+        log::debug!("ssh request start. name:{}", T::NAME);
+        let response =
+            self.request_dynamic(payload.into_envelope(0, None, None), T::NAME, use_buffer);
+        async move {
+            let response = response.await?;
+            log::debug!("ssh request finish. name:{}", T::NAME);
+            T::Response::from_envelope(response).context("received a response of the wrong type")
+        }
+    }
+
+    async fn resync(&self, timeout: Duration) -> Result<()> {
+        smol::future::or(
+            async {
+                self.request_internal(proto::FlushBufferedMessages {}, false)
+                    .await?;
+
+                for envelope in self.buffer.lock().iter() {
+                    self.outgoing_tx
+                        .lock()
+                        .unbounded_send(envelope.clone())
+                        .ok();
+                }
+                Ok(())
+            },
+            async {
+                smol::Timer::after(timeout).await;
+                anyhow::bail!("Timed out resyncing remote client")
+            },
+        )
+        .await
+    }
+
+    async fn ping(&self, timeout: Duration) -> Result<()> {
+        smol::future::or(
+            async {
+                self.request(proto::Ping {}).await?;
+                Ok(())
+            },
+            async {
+                smol::Timer::after(timeout).await;
+                anyhow::bail!("Timed out pinging remote client")
+            },
+        )
+        .await
+    }
+
+    fn send<T: EnvelopedMessage>(&self, payload: T) -> Result<()> {
+        log::debug!("ssh send name:{}", T::NAME);
+        self.send_dynamic(payload.into_envelope(0, None, None))
+    }
+
+    fn request_dynamic(
+        &self,
+        mut envelope: proto::Envelope,
+        type_name: &'static str,
+        use_buffer: bool,
+    ) -> impl 'static + Future<Output = Result<proto::Envelope>> {
+        envelope.id = self.next_message_id.fetch_add(1, SeqCst);
+        let (tx, rx) = oneshot::channel();
+        let mut response_channels_lock = self.response_channels.lock();
+        response_channels_lock.insert(MessageId(envelope.id), tx);
+        drop(response_channels_lock);
+
+        let result = if use_buffer {
+            self.send_buffered(envelope)
+        } else {
+            self.send_unbuffered(envelope)
+        };
+        async move {
+            if let Err(error) = &result {
+                log::error!("failed to send message: {error}");
+                anyhow::bail!("failed to send message: {error}");
+            }
+
+            let response = rx.await.context("connection lost")?.0;
+            if let Some(proto::envelope::Payload::Error(error)) = &response.payload {
+                return Err(RpcError::from_proto(error, type_name));
+            }
+            Ok(response)
+        }
+    }
+
+    pub fn send_dynamic(&self, mut envelope: proto::Envelope) -> Result<()> {
+        envelope.id = self.next_message_id.fetch_add(1, SeqCst);
+        self.send_buffered(envelope)
+    }
+
+    fn send_buffered(&self, mut envelope: proto::Envelope) -> Result<()> {
+        envelope.ack_id = Some(self.max_received.load(SeqCst));
+        self.buffer.lock().push_back(envelope.clone());
+        // ignore errors on send (happen while we're reconnecting)
+        // assume that the global "disconnected" overlay is sufficient.
+        self.outgoing_tx.lock().unbounded_send(envelope).ok();
+        Ok(())
+    }
+
+    fn send_unbuffered(&self, mut envelope: proto::Envelope) -> Result<()> {
+        envelope.ack_id = Some(self.max_received.load(SeqCst));
+        self.outgoing_tx.lock().unbounded_send(envelope).ok();
+        Ok(())
+    }
+}
+
+impl ProtoClient for ChannelClient {
+    fn request(
+        &self,
+        envelope: proto::Envelope,
+        request_type: &'static str,
+    ) -> BoxFuture<'static, Result<proto::Envelope>> {
+        self.request_dynamic(envelope, request_type, true).boxed()
+    }
+
+    fn send(&self, envelope: proto::Envelope, _message_type: &'static str) -> Result<()> {
+        self.send_dynamic(envelope)
+    }
+
+    fn send_response(&self, envelope: Envelope, _message_type: &'static str) -> anyhow::Result<()> {
+        self.send_dynamic(envelope)
+    }
+
+    fn message_handler_set(&self) -> &Mutex<ProtoMessageHandlerSet> {
+        &self.message_handlers
+    }
+
+    fn is_via_collab(&self) -> bool {
+        false
+    }
+}
+
+#[cfg(any(test, feature = "test-support"))]
+mod fake {
+    use super::{ChannelClient, RemoteClientDelegate, RemoteConnection, RemotePlatform};
+    use crate::{SshConnectionOptions, remote_client::CommandTemplate};
+    use anyhow::Result;
+    use async_trait::async_trait;
+    use collections::HashMap;
+    use futures::{
+        FutureExt, SinkExt, StreamExt,
+        channel::{
+            mpsc::{self, Sender},
+            oneshot,
+        },
+        select_biased,
+    };
+    use gpui::{App, AppContext as _, AsyncApp, SemanticVersion, Task, TestAppContext};
+    use release_channel::ReleaseChannel;
+    use rpc::proto::Envelope;
+    use std::{path::PathBuf, sync::Arc};
+    use util::paths::{PathStyle, RemotePathBuf};
+
+    pub(super) struct FakeRemoteConnection {
+        pub(super) connection_options: SshConnectionOptions,
+        pub(super) server_channel: Arc<ChannelClient>,
+        pub(super) server_cx: SendableCx,
+    }
+
+    pub(super) struct SendableCx(AsyncApp);
+    impl SendableCx {
+        // SAFETY: When run in test mode, GPUI is always single threaded.
+        pub(super) fn new(cx: &TestAppContext) -> Self {
+            Self(cx.to_async())
+        }
+
+        // SAFETY: Enforce that we're on the main thread by requiring a valid AsyncApp
+        fn get(&self, _: &AsyncApp) -> AsyncApp {
+            self.0.clone()
+        }
+    }
+
+    // SAFETY: There is no way to access a SendableCx from a different thread, see [`SendableCx::new`] and [`SendableCx::get`]
+    unsafe impl Send for SendableCx {}
+    unsafe impl Sync for SendableCx {}
+
+    #[async_trait(?Send)]
+    impl RemoteConnection for FakeRemoteConnection {
+        async fn kill(&self) -> Result<()> {
+            Ok(())
+        }
+
+        fn has_been_killed(&self) -> bool {
+            false
+        }
+
+        fn build_command(
+            &self,
+            program: Option<String>,
+            args: &[String],
+            env: &HashMap<String, String>,
+            _: Option<String>,
+            _: Option<(u16, String, u16)>,
+        ) -> Result<CommandTemplate> {
+            let ssh_program = program.unwrap_or_else(|| "sh".to_string());
+            let mut ssh_args = Vec::new();
+            ssh_args.push(ssh_program);
+            ssh_args.extend(args.iter().cloned());
+            Ok(CommandTemplate {
+                program: "ssh".into(),
+                args: ssh_args,
+                env: env.clone(),
+            })
+        }
+
+        fn upload_directory(
+            &self,
+            _src_path: PathBuf,
+            _dest_path: RemotePathBuf,
+            _cx: &App,
+        ) -> Task<Result<()>> {
+            unreachable!()
+        }
+
+        fn connection_options(&self) -> SshConnectionOptions {
+            self.connection_options.clone()
+        }
+
+        fn simulate_disconnect(&self, cx: &AsyncApp) {
+            let (outgoing_tx, _) = mpsc::unbounded::<Envelope>();
+            let (_, incoming_rx) = mpsc::unbounded::<Envelope>();
+            self.server_channel
+                .reconnect(incoming_rx, outgoing_tx, &self.server_cx.get(cx));
+        }
+
+        fn start_proxy(
+            &self,
+            _unique_identifier: String,
+            _reconnect: bool,
+            mut client_incoming_tx: mpsc::UnboundedSender<Envelope>,
+            mut client_outgoing_rx: mpsc::UnboundedReceiver<Envelope>,
+            mut connection_activity_tx: Sender<()>,
+            _delegate: Arc<dyn RemoteClientDelegate>,
+            cx: &mut AsyncApp,
+        ) -> Task<Result<i32>> {
+            let (mut server_incoming_tx, server_incoming_rx) = mpsc::unbounded::<Envelope>();
+            let (server_outgoing_tx, mut server_outgoing_rx) = mpsc::unbounded::<Envelope>();
+
+            self.server_channel.reconnect(
+                server_incoming_rx,
+                server_outgoing_tx,
+                &self.server_cx.get(cx),
+            );
+
+            cx.background_spawn(async move {
+                loop {
+                    select_biased! {
+                        server_to_client = server_outgoing_rx.next().fuse() => {
+                            let Some(server_to_client) = server_to_client else {
+                                return Ok(1)
+                            };
+                            connection_activity_tx.try_send(()).ok();
+                            client_incoming_tx.send(server_to_client).await.ok();
+                        }
+                        client_to_server = client_outgoing_rx.next().fuse() => {
+                            let Some(client_to_server) = client_to_server else {
+                                return Ok(1)
+                            };
+                            server_incoming_tx.send(client_to_server).await.ok();
+                        }
+                    }
+                }
+            })
+        }
+
+        fn path_style(&self) -> PathStyle {
+            PathStyle::current()
+        }
+
+        fn shell(&self) -> String {
+            "sh".to_owned()
+        }
+    }
+
+    pub(super) struct Delegate;
+
+    impl RemoteClientDelegate for Delegate {
+        fn ask_password(&self, _: String, _: oneshot::Sender<String>, _: &mut AsyncApp) {
+            unreachable!()
+        }
+
+        fn download_server_binary_locally(
+            &self,
+            _: RemotePlatform,
+            _: ReleaseChannel,
+            _: Option<SemanticVersion>,
+            _: &mut AsyncApp,
+        ) -> Task<Result<PathBuf>> {
+            unreachable!()
+        }
+
+        fn get_download_params(
+            &self,
+            _platform: RemotePlatform,
+            _release_channel: ReleaseChannel,
+            _version: Option<SemanticVersion>,
+            _cx: &mut AsyncApp,
+        ) -> Task<Result<Option<(String, String)>>> {
+            unreachable!()
+        }
+
+        fn set_status(&self, _: Option<&str>, _: &mut AsyncApp) {}
+    }
+}

crates/remote/src/ssh_session.rs 🔗

@@ -1,2749 +0,0 @@
-use crate::{
-    json_log::LogRecord,
-    protocol::{
-        MESSAGE_LEN_SIZE, MessageId, message_len_from_buffer, read_message_with_len, write_message,
-    },
-    proxy::ProxyLaunchError,
-};
-use anyhow::{Context as _, Result, anyhow};
-use async_trait::async_trait;
-use collections::HashMap;
-use futures::{
-    AsyncReadExt as _, Future, FutureExt as _, StreamExt as _,
-    channel::{
-        mpsc::{self, Sender, UnboundedReceiver, UnboundedSender},
-        oneshot,
-    },
-    future::{BoxFuture, Shared},
-    select, select_biased,
-};
-use gpui::{
-    App, AppContext as _, AsyncApp, BackgroundExecutor, BorrowAppContext, Context, Entity,
-    EventEmitter, Global, SemanticVersion, Task, WeakEntity,
-};
-use itertools::Itertools;
-use parking_lot::Mutex;
-
-use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
-use rpc::{
-    AnyProtoClient, ErrorExt, ProtoClient, ProtoMessageHandlerSet, RpcError,
-    proto::{self, Envelope, EnvelopedMessage, PeerId, RequestMessage, build_typed_envelope},
-};
-use schemars::JsonSchema;
-use serde::{Deserialize, Serialize};
-use smol::{
-    fs,
-    process::{self, Child, Stdio},
-};
-use std::{
-    collections::VecDeque,
-    fmt, iter,
-    ops::ControlFlow,
-    path::{Path, PathBuf},
-    sync::{
-        Arc, Weak,
-        atomic::{AtomicU32, AtomicU64, Ordering::SeqCst},
-    },
-    time::{Duration, Instant},
-};
-use tempfile::TempDir;
-use util::{
-    ResultExt,
-    paths::{PathStyle, RemotePathBuf},
-};
-
-#[derive(Clone)]
-pub struct SshSocket {
-    connection_options: SshConnectionOptions,
-    #[cfg(not(target_os = "windows"))]
-    socket_path: PathBuf,
-    #[cfg(target_os = "windows")]
-    envs: HashMap<String, String>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize, JsonSchema)]
-pub struct SshPortForwardOption {
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub local_host: Option<String>,
-    pub local_port: u16,
-    #[serde(skip_serializing_if = "Option::is_none")]
-    pub remote_host: Option<String>,
-    pub remote_port: u16,
-}
-
-#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
-pub struct SshConnectionOptions {
-    pub host: String,
-    pub username: Option<String>,
-    pub port: Option<u16>,
-    pub password: Option<String>,
-    pub args: Option<Vec<String>>,
-    pub port_forwards: Option<Vec<SshPortForwardOption>>,
-
-    pub nickname: Option<String>,
-    pub upload_binary_over_ssh: bool,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct SshArgs {
-    pub arguments: Vec<String>,
-    pub envs: Option<HashMap<String, String>>,
-}
-
-#[derive(Debug, Clone, PartialEq, Eq)]
-pub struct SshInfo {
-    pub args: SshArgs,
-    pub path_style: PathStyle,
-    pub shell: String,
-}
-
-#[macro_export]
-macro_rules! shell_script {
-    ($fmt:expr, $($name:ident = $arg:expr),+ $(,)?) => {{
-        format!(
-            $fmt,
-            $(
-                $name = shlex::try_quote($arg).unwrap()
-            ),+
-        )
-    }};
-}
-
-fn parse_port_number(port_str: &str) -> Result<u16> {
-    port_str
-        .parse()
-        .with_context(|| format!("parsing port number: {port_str}"))
-}
-
-fn parse_port_forward_spec(spec: &str) -> Result<SshPortForwardOption> {
-    let parts: Vec<&str> = spec.split(':').collect();
-
-    match parts.len() {
-        4 => {
-            let local_port = parse_port_number(parts[1])?;
-            let remote_port = parse_port_number(parts[3])?;
-
-            Ok(SshPortForwardOption {
-                local_host: Some(parts[0].to_string()),
-                local_port,
-                remote_host: Some(parts[2].to_string()),
-                remote_port,
-            })
-        }
-        3 => {
-            let local_port = parse_port_number(parts[0])?;
-            let remote_port = parse_port_number(parts[2])?;
-
-            Ok(SshPortForwardOption {
-                local_host: None,
-                local_port,
-                remote_host: Some(parts[1].to_string()),
-                remote_port,
-            })
-        }
-        _ => anyhow::bail!("Invalid port forward format"),
-    }
-}
-
-impl SshConnectionOptions {
-    pub fn parse_command_line(input: &str) -> Result<Self> {
-        let input = input.trim_start_matches("ssh ");
-        let mut hostname: Option<String> = None;
-        let mut username: Option<String> = None;
-        let mut port: Option<u16> = None;
-        let mut args = Vec::new();
-        let mut port_forwards: Vec<SshPortForwardOption> = Vec::new();
-
-        // disallowed: -E, -e, -F, -f, -G, -g, -M, -N, -n, -O, -q, -S, -s, -T, -t, -V, -v, -W
-        const ALLOWED_OPTS: &[&str] = &[
-            "-4", "-6", "-A", "-a", "-C", "-K", "-k", "-X", "-x", "-Y", "-y",
-        ];
-        const ALLOWED_ARGS: &[&str] = &[
-            "-B", "-b", "-c", "-D", "-F", "-I", "-i", "-J", "-l", "-m", "-o", "-P", "-p", "-R",
-            "-w",
-        ];
-
-        let mut tokens = shlex::split(input).context("invalid input")?.into_iter();
-
-        'outer: while let Some(arg) = tokens.next() {
-            if ALLOWED_OPTS.contains(&(&arg as &str)) {
-                args.push(arg.to_string());
-                continue;
-            }
-            if arg == "-p" {
-                port = tokens.next().and_then(|arg| arg.parse().ok());
-                continue;
-            } else if let Some(p) = arg.strip_prefix("-p") {
-                port = p.parse().ok();
-                continue;
-            }
-            if arg == "-l" {
-                username = tokens.next();
-                continue;
-            } else if let Some(l) = arg.strip_prefix("-l") {
-                username = Some(l.to_string());
-                continue;
-            }
-            if arg == "-L" || arg.starts_with("-L") {
-                let forward_spec = if arg == "-L" {
-                    tokens.next()
-                } else {
-                    Some(arg.strip_prefix("-L").unwrap().to_string())
-                };
-
-                if let Some(spec) = forward_spec {
-                    port_forwards.push(parse_port_forward_spec(&spec)?);
-                } else {
-                    anyhow::bail!("Missing port forward format");
-                }
-            }
-
-            for a in ALLOWED_ARGS {
-                if arg == *a {
-                    args.push(arg);
-                    if let Some(next) = tokens.next() {
-                        args.push(next);
-                    }
-                    continue 'outer;
-                } else if arg.starts_with(a) {
-                    args.push(arg);
-                    continue 'outer;
-                }
-            }
-            if arg.starts_with("-") || hostname.is_some() {
-                anyhow::bail!("unsupported argument: {:?}", arg);
-            }
-            let mut input = &arg as &str;
-            // Destination might be: username1@username2@ip2@ip1
-            if let Some((u, rest)) = input.rsplit_once('@') {
-                input = rest;
-                username = Some(u.to_string());
-            }
-            if let Some((rest, p)) = input.split_once(':') {
-                input = rest;
-                port = p.parse().ok()
-            }
-            hostname = Some(input.to_string())
-        }
-
-        let Some(hostname) = hostname else {
-            anyhow::bail!("missing hostname");
-        };
-
-        let port_forwards = match port_forwards.len() {
-            0 => None,
-            _ => Some(port_forwards),
-        };
-
-        Ok(Self {
-            host: hostname,
-            username,
-            port,
-            port_forwards,
-            args: Some(args),
-            password: None,
-            nickname: None,
-            upload_binary_over_ssh: false,
-        })
-    }
-
-    pub fn ssh_url(&self) -> String {
-        let mut result = String::from("ssh://");
-        if let Some(username) = &self.username {
-            // Username might be: username1@username2@ip2
-            let username = urlencoding::encode(username);
-            result.push_str(&username);
-            result.push('@');
-        }
-        result.push_str(&self.host);
-        if let Some(port) = self.port {
-            result.push(':');
-            result.push_str(&port.to_string());
-        }
-        result
-    }
-
-    pub fn additional_args(&self) -> Vec<String> {
-        let mut args = self.args.iter().flatten().cloned().collect::<Vec<String>>();
-
-        if let Some(forwards) = &self.port_forwards {
-            args.extend(forwards.iter().map(|pf| {
-                let local_host = match &pf.local_host {
-                    Some(host) => host,
-                    None => "localhost",
-                };
-                let remote_host = match &pf.remote_host {
-                    Some(host) => host,
-                    None => "localhost",
-                };
-
-                format!(
-                    "-L{}:{}:{}:{}",
-                    local_host, pf.local_port, remote_host, pf.remote_port
-                )
-            }));
-        }
-
-        args
-    }
-
-    fn scp_url(&self) -> String {
-        if let Some(username) = &self.username {
-            format!("{}@{}", username, self.host)
-        } else {
-            self.host.clone()
-        }
-    }
-
-    pub fn connection_string(&self) -> String {
-        let host = if let Some(username) = &self.username {
-            format!("{}@{}", username, self.host)
-        } else {
-            self.host.clone()
-        };
-        if let Some(port) = &self.port {
-            format!("{}:{}", host, port)
-        } else {
-            host
-        }
-    }
-}
-
-#[derive(Copy, Clone, Debug)]
-pub struct SshPlatform {
-    pub os: &'static str,
-    pub arch: &'static str,
-}
-
-pub trait SshClientDelegate: Send + Sync {
-    fn ask_password(&self, prompt: String, tx: oneshot::Sender<String>, cx: &mut AsyncApp);
-    fn get_download_params(
-        &self,
-        platform: SshPlatform,
-        release_channel: ReleaseChannel,
-        version: Option<SemanticVersion>,
-        cx: &mut AsyncApp,
-    ) -> Task<Result<Option<(String, String)>>>;
-
-    fn download_server_binary_locally(
-        &self,
-        platform: SshPlatform,
-        release_channel: ReleaseChannel,
-        version: Option<SemanticVersion>,
-        cx: &mut AsyncApp,
-    ) -> Task<Result<PathBuf>>;
-    fn set_status(&self, status: Option<&str>, cx: &mut AsyncApp);
-}
-
-impl SshSocket {
-    #[cfg(not(target_os = "windows"))]
-    fn new(options: SshConnectionOptions, socket_path: PathBuf) -> Result<Self> {
-        Ok(Self {
-            connection_options: options,
-            socket_path,
-        })
-    }
-
-    #[cfg(target_os = "windows")]
-    fn new(options: SshConnectionOptions, temp_dir: &TempDir, secret: String) -> Result<Self> {
-        let askpass_script = temp_dir.path().join("askpass.bat");
-        std::fs::write(&askpass_script, "@ECHO OFF\necho %ZED_SSH_ASKPASS%")?;
-        let mut envs = HashMap::default();
-        envs.insert("SSH_ASKPASS_REQUIRE".into(), "force".into());
-        envs.insert("SSH_ASKPASS".into(), askpass_script.display().to_string());
-        envs.insert("ZED_SSH_ASKPASS".into(), secret);
-        Ok(Self {
-            connection_options: options,
-            envs,
-        })
-    }
-
-    // :WARNING: ssh unquotes arguments when executing on the remote :WARNING:
-    // e.g. $ ssh host sh -c 'ls -l' is equivalent to $ ssh host sh -c ls -l
-    // and passes -l as an argument to sh, not to ls.
-    // Furthermore, some setups (e.g. Coder) will change directory when SSH'ing
-    // into a machine. You must use `cd` to get back to $HOME.
-    // You need to do it like this: $ ssh host "cd; sh -c 'ls -l /tmp'"
-    fn ssh_command(&self, program: &str, args: &[&str]) -> process::Command {
-        let mut command = util::command::new_smol_command("ssh");
-        let to_run = iter::once(&program)
-            .chain(args.iter())
-            .map(|token| {
-                // We're trying to work with: sh, bash, zsh, fish, tcsh, ...?
-                debug_assert!(
-                    !token.contains('\n'),
-                    "multiline arguments do not work in all shells"
-                );
-                shlex::try_quote(token).unwrap()
-            })
-            .join(" ");
-        let to_run = format!("cd; {to_run}");
-        log::debug!("ssh {} {:?}", self.connection_options.ssh_url(), to_run);
-        self.ssh_options(&mut command)
-            .arg(self.connection_options.ssh_url())
-            .arg(to_run);
-        command
-    }
-
-    async fn run_command(&self, program: &str, args: &[&str]) -> Result<String> {
-        let output = self.ssh_command(program, args).output().await?;
-        anyhow::ensure!(
-            output.status.success(),
-            "failed to run command: {}",
-            String::from_utf8_lossy(&output.stderr)
-        );
-        Ok(String::from_utf8_lossy(&output.stdout).to_string())
-    }
-
-    #[cfg(not(target_os = "windows"))]
-    fn ssh_options<'a>(&self, command: &'a mut process::Command) -> &'a mut process::Command {
-        command
-            .stdin(Stdio::piped())
-            .stdout(Stdio::piped())
-            .stderr(Stdio::piped())
-            .args(self.connection_options.additional_args())
-            .args(["-o", "ControlMaster=no", "-o"])
-            .arg(format!("ControlPath={}", self.socket_path.display()))
-    }
-
-    #[cfg(target_os = "windows")]
-    fn ssh_options<'a>(&self, command: &'a mut process::Command) -> &'a mut process::Command {
-        command
-            .stdin(Stdio::piped())
-            .stdout(Stdio::piped())
-            .stderr(Stdio::piped())
-            .args(self.connection_options.additional_args())
-            .envs(self.envs.clone())
-    }
-
-    // On Windows, we need to use `SSH_ASKPASS` to provide the password to ssh.
-    // On Linux, we use the `ControlPath` option to create a socket file that ssh can use to
-    #[cfg(not(target_os = "windows"))]
-    fn ssh_args(&self) -> SshArgs {
-        let mut arguments = self.connection_options.additional_args();
-        arguments.extend(vec![
-            "-o".to_string(),
-            "ControlMaster=no".to_string(),
-            "-o".to_string(),
-            format!("ControlPath={}", self.socket_path.display()),
-            self.connection_options.ssh_url(),
-        ]);
-        SshArgs {
-            arguments,
-            envs: None,
-        }
-    }
-
-    #[cfg(target_os = "windows")]
-    fn ssh_args(&self) -> SshArgs {
-        let mut arguments = self.connection_options.additional_args();
-        arguments.push(self.connection_options.ssh_url());
-        SshArgs {
-            arguments,
-            envs: Some(self.envs.clone()),
-        }
-    }
-
-    async fn platform(&self) -> Result<SshPlatform> {
-        let uname = self.run_command("sh", &["-lc", "uname -sm"]).await?;
-        let Some((os, arch)) = uname.split_once(" ") else {
-            anyhow::bail!("unknown uname: {uname:?}")
-        };
-
-        let os = match os.trim() {
-            "Darwin" => "macos",
-            "Linux" => "linux",
-            _ => anyhow::bail!(
-                "Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development"
-            ),
-        };
-        // exclude armv5,6,7 as they are 32-bit.
-        let arch = if arch.starts_with("armv8")
-            || arch.starts_with("armv9")
-            || arch.starts_with("arm64")
-            || arch.starts_with("aarch64")
-        {
-            "aarch64"
-        } else if arch.starts_with("x86") {
-            "x86_64"
-        } else {
-            anyhow::bail!(
-                "Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development"
-            )
-        };
-
-        Ok(SshPlatform { os, arch })
-    }
-
-    async fn shell(&self) -> String {
-        match self.run_command("sh", &["-lc", "echo $SHELL"]).await {
-            Ok(shell) => shell.trim().to_owned(),
-            Err(e) => {
-                log::error!("Failed to get shell: {e}");
-                "sh".to_owned()
-            }
-        }
-    }
-}
-
-const MAX_MISSED_HEARTBEATS: usize = 5;
-const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
-const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(5);
-
-const MAX_RECONNECT_ATTEMPTS: usize = 3;
-
-enum State {
-    Connecting,
-    Connected {
-        ssh_connection: Arc<dyn RemoteConnection>,
-        delegate: Arc<dyn SshClientDelegate>,
-
-        multiplex_task: Task<Result<()>>,
-        heartbeat_task: Task<Result<()>>,
-    },
-    HeartbeatMissed {
-        missed_heartbeats: usize,
-
-        ssh_connection: Arc<dyn RemoteConnection>,
-        delegate: Arc<dyn SshClientDelegate>,
-
-        multiplex_task: Task<Result<()>>,
-        heartbeat_task: Task<Result<()>>,
-    },
-    Reconnecting,
-    ReconnectFailed {
-        ssh_connection: Arc<dyn RemoteConnection>,
-        delegate: Arc<dyn SshClientDelegate>,
-
-        error: anyhow::Error,
-        attempts: usize,
-    },
-    ReconnectExhausted,
-    ServerNotRunning,
-}
-
-impl fmt::Display for State {
-    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
-        match self {
-            Self::Connecting => write!(f, "connecting"),
-            Self::Connected { .. } => write!(f, "connected"),
-            Self::Reconnecting => write!(f, "reconnecting"),
-            Self::ReconnectFailed { .. } => write!(f, "reconnect failed"),
-            Self::ReconnectExhausted => write!(f, "reconnect exhausted"),
-            Self::HeartbeatMissed { .. } => write!(f, "heartbeat missed"),
-            Self::ServerNotRunning { .. } => write!(f, "server not running"),
-        }
-    }
-}
-
-impl State {
-    fn ssh_connection(&self) -> Option<&dyn RemoteConnection> {
-        match self {
-            Self::Connected { ssh_connection, .. } => Some(ssh_connection.as_ref()),
-            Self::HeartbeatMissed { ssh_connection, .. } => Some(ssh_connection.as_ref()),
-            Self::ReconnectFailed { ssh_connection, .. } => Some(ssh_connection.as_ref()),
-            _ => None,
-        }
-    }
-
-    fn can_reconnect(&self) -> bool {
-        match self {
-            Self::Connected { .. }
-            | Self::HeartbeatMissed { .. }
-            | Self::ReconnectFailed { .. } => true,
-            State::Connecting
-            | State::Reconnecting
-            | State::ReconnectExhausted
-            | State::ServerNotRunning => false,
-        }
-    }
-
-    fn is_reconnect_failed(&self) -> bool {
-        matches!(self, Self::ReconnectFailed { .. })
-    }
-
-    fn is_reconnect_exhausted(&self) -> bool {
-        matches!(self, Self::ReconnectExhausted { .. })
-    }
-
-    fn is_server_not_running(&self) -> bool {
-        matches!(self, Self::ServerNotRunning)
-    }
-
-    fn is_reconnecting(&self) -> bool {
-        matches!(self, Self::Reconnecting { .. })
-    }
-
-    fn heartbeat_recovered(self) -> Self {
-        match self {
-            Self::HeartbeatMissed {
-                ssh_connection,
-                delegate,
-                multiplex_task,
-                heartbeat_task,
-                ..
-            } => Self::Connected {
-                ssh_connection,
-                delegate,
-                multiplex_task,
-                heartbeat_task,
-            },
-            _ => self,
-        }
-    }
-
-    fn heartbeat_missed(self) -> Self {
-        match self {
-            Self::Connected {
-                ssh_connection,
-                delegate,
-                multiplex_task,
-                heartbeat_task,
-            } => Self::HeartbeatMissed {
-                missed_heartbeats: 1,
-                ssh_connection,
-                delegate,
-                multiplex_task,
-                heartbeat_task,
-            },
-            Self::HeartbeatMissed {
-                missed_heartbeats,
-                ssh_connection,
-                delegate,
-                multiplex_task,
-                heartbeat_task,
-            } => Self::HeartbeatMissed {
-                missed_heartbeats: missed_heartbeats + 1,
-                ssh_connection,
-                delegate,
-                multiplex_task,
-                heartbeat_task,
-            },
-            _ => self,
-        }
-    }
-}
-
-/// The state of the ssh connection.
-#[derive(Clone, Copy, Debug, PartialEq, Eq)]
-pub enum ConnectionState {
-    Connecting,
-    Connected,
-    HeartbeatMissed,
-    Reconnecting,
-    Disconnected,
-}
-
-impl From<&State> for ConnectionState {
-    fn from(value: &State) -> Self {
-        match value {
-            State::Connecting => Self::Connecting,
-            State::Connected { .. } => Self::Connected,
-            State::Reconnecting | State::ReconnectFailed { .. } => Self::Reconnecting,
-            State::HeartbeatMissed { .. } => Self::HeartbeatMissed,
-            State::ReconnectExhausted => Self::Disconnected,
-            State::ServerNotRunning => Self::Disconnected,
-        }
-    }
-}
-
-pub struct SshRemoteClient {
-    client: Arc<ChannelClient>,
-    unique_identifier: String,
-    connection_options: SshConnectionOptions,
-    path_style: PathStyle,
-    state: Arc<Mutex<Option<State>>>,
-}
-
-#[derive(Debug)]
-pub enum SshRemoteEvent {
-    Disconnected,
-}
-
-impl EventEmitter<SshRemoteEvent> for SshRemoteClient {}
-
-// Identifies the socket on the remote server so that reconnects
-// can re-join the same project.
-pub enum ConnectionIdentifier {
-    Setup(u64),
-    Workspace(i64),
-}
-
-static NEXT_ID: AtomicU64 = AtomicU64::new(1);
-
-impl ConnectionIdentifier {
-    pub fn setup() -> Self {
-        Self::Setup(NEXT_ID.fetch_add(1, SeqCst))
-    }
-
-    // This string gets used in a socket name, and so must be relatively short.
-    // The total length of:
-    //   /home/{username}/.local/share/zed/server_state/{name}/stdout.sock
-    // Must be less than about 100 characters
-    //   https://unix.stackexchange.com/questions/367008/why-is-socket-path-length-limited-to-a-hundred-chars
-    // So our strings should be at most 20 characters or so.
-    fn to_string(&self, cx: &App) -> String {
-        let identifier_prefix = match ReleaseChannel::global(cx) {
-            ReleaseChannel::Stable => "".to_string(),
-            release_channel => format!("{}-", release_channel.dev_name()),
-        };
-        match self {
-            Self::Setup(setup_id) => format!("{identifier_prefix}setup-{setup_id}"),
-            Self::Workspace(workspace_id) => {
-                format!("{identifier_prefix}workspace-{workspace_id}",)
-            }
-        }
-    }
-}
-
-impl SshRemoteClient {
-    pub fn new(
-        unique_identifier: ConnectionIdentifier,
-        connection_options: SshConnectionOptions,
-        cancellation: oneshot::Receiver<()>,
-        delegate: Arc<dyn SshClientDelegate>,
-        cx: &mut App,
-    ) -> Task<Result<Option<Entity<Self>>>> {
-        let unique_identifier = unique_identifier.to_string(cx);
-        cx.spawn(async move |cx| {
-            let success = Box::pin(async move {
-                let (outgoing_tx, outgoing_rx) = mpsc::unbounded::<Envelope>();
-                let (incoming_tx, incoming_rx) = mpsc::unbounded::<Envelope>();
-                let (connection_activity_tx, connection_activity_rx) = mpsc::channel::<()>(1);
-
-                let client =
-                    cx.update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "client"))?;
-
-                let ssh_connection = cx
-                    .update(|cx| {
-                        cx.update_default_global(|pool: &mut ConnectionPool, cx| {
-                            pool.connect(connection_options.clone(), &delegate, cx)
-                        })
-                    })?
-                    .await
-                    .map_err(|e| e.cloned())?;
-
-                let path_style = ssh_connection.path_style();
-                let this = cx.new(|_| Self {
-                    client: client.clone(),
-                    unique_identifier: unique_identifier.clone(),
-                    connection_options,
-                    path_style,
-                    state: Arc::new(Mutex::new(Some(State::Connecting))),
-                })?;
-
-                let io_task = ssh_connection.start_proxy(
-                    unique_identifier,
-                    false,
-                    incoming_tx,
-                    outgoing_rx,
-                    connection_activity_tx,
-                    delegate.clone(),
-                    cx,
-                );
-
-                let multiplex_task = Self::monitor(this.downgrade(), io_task, cx);
-
-                if let Err(error) = client.ping(HEARTBEAT_TIMEOUT).await {
-                    log::error!("failed to establish connection: {}", error);
-                    return Err(error);
-                }
-
-                let heartbeat_task = Self::heartbeat(this.downgrade(), connection_activity_rx, cx);
-
-                this.update(cx, |this, _| {
-                    *this.state.lock() = Some(State::Connected {
-                        ssh_connection,
-                        delegate,
-                        multiplex_task,
-                        heartbeat_task,
-                    });
-                })?;
-
-                Ok(Some(this))
-            });
-
-            select! {
-                _ = cancellation.fuse() => {
-                    Ok(None)
-                }
-                result = success.fuse() =>  result
-            }
-        })
-    }
-
-    pub fn proto_client_from_channels(
-        incoming_rx: mpsc::UnboundedReceiver<Envelope>,
-        outgoing_tx: mpsc::UnboundedSender<Envelope>,
-        cx: &App,
-        name: &'static str,
-    ) -> AnyProtoClient {
-        ChannelClient::new(incoming_rx, outgoing_tx, cx, name).into()
-    }
-
-    pub fn shutdown_processes<T: RequestMessage>(
-        &self,
-        shutdown_request: Option<T>,
-        executor: BackgroundExecutor,
-    ) -> Option<impl Future<Output = ()> + use<T>> {
-        let state = self.state.lock().take()?;
-        log::info!("shutting down ssh processes");
-
-        let State::Connected {
-            multiplex_task,
-            heartbeat_task,
-            ssh_connection,
-            delegate,
-        } = state
-        else {
-            return None;
-        };
-
-        let client = self.client.clone();
-
-        Some(async move {
-            if let Some(shutdown_request) = shutdown_request {
-                client.send(shutdown_request).log_err();
-                // We wait 50ms instead of waiting for a response, because
-                // waiting for a response would require us to wait on the main thread
-                // which we want to avoid in an `on_app_quit` callback.
-                executor.timer(Duration::from_millis(50)).await;
-            }
-
-            // Drop `multiplex_task` because it owns our ssh_proxy_process, which is a
-            // child of master_process.
-            drop(multiplex_task);
-            // Now drop the rest of state, which kills master process.
-            drop(heartbeat_task);
-            drop(ssh_connection);
-            drop(delegate);
-        })
-    }
-
-    fn reconnect(&mut self, cx: &mut Context<Self>) -> Result<()> {
-        let mut lock = self.state.lock();
-
-        let can_reconnect = lock
-            .as_ref()
-            .map(|state| state.can_reconnect())
-            .unwrap_or(false);
-        if !can_reconnect {
-            log::info!("aborting reconnect, because not in state that allows reconnecting");
-            let error = if let Some(state) = lock.as_ref() {
-                format!("invalid state, cannot reconnect while in state {state}")
-            } else {
-                "no state set".to_string()
-            };
-            anyhow::bail!(error);
-        }
-
-        let state = lock.take().unwrap();
-        let (attempts, ssh_connection, delegate) = match state {
-            State::Connected {
-                ssh_connection,
-                delegate,
-                multiplex_task,
-                heartbeat_task,
-            }
-            | State::HeartbeatMissed {
-                ssh_connection,
-                delegate,
-                multiplex_task,
-                heartbeat_task,
-                ..
-            } => {
-                drop(multiplex_task);
-                drop(heartbeat_task);
-                (0, ssh_connection, delegate)
-            }
-            State::ReconnectFailed {
-                attempts,
-                ssh_connection,
-                delegate,
-                ..
-            } => (attempts, ssh_connection, delegate),
-            State::Connecting
-            | State::Reconnecting
-            | State::ReconnectExhausted
-            | State::ServerNotRunning => unreachable!(),
-        };
-
-        let attempts = attempts + 1;
-        if attempts > MAX_RECONNECT_ATTEMPTS {
-            log::error!(
-                "Failed to reconnect to after {} attempts, giving up",
-                MAX_RECONNECT_ATTEMPTS
-            );
-            drop(lock);
-            self.set_state(State::ReconnectExhausted, cx);
-            return Ok(());
-        }
-        drop(lock);
-
-        self.set_state(State::Reconnecting, cx);
-
-        log::info!("Trying to reconnect to ssh server... Attempt {}", attempts);
-
-        let unique_identifier = self.unique_identifier.clone();
-        let client = self.client.clone();
-        let reconnect_task = cx.spawn(async move |this, cx| {
-            macro_rules! failed {
-                ($error:expr, $attempts:expr, $ssh_connection:expr, $delegate:expr) => {
-                    return State::ReconnectFailed {
-                        error: anyhow!($error),
-                        attempts: $attempts,
-                        ssh_connection: $ssh_connection,
-                        delegate: $delegate,
-                    };
-                };
-            }
-
-            if let Err(error) = ssh_connection
-                .kill()
-                .await
-                .context("Failed to kill ssh process")
-            {
-                failed!(error, attempts, ssh_connection, delegate);
-            };
-
-            let connection_options = ssh_connection.connection_options();
-
-            let (outgoing_tx, outgoing_rx) = mpsc::unbounded::<Envelope>();
-            let (incoming_tx, incoming_rx) = mpsc::unbounded::<Envelope>();
-            let (connection_activity_tx, connection_activity_rx) = mpsc::channel::<()>(1);
-
-            let (ssh_connection, io_task) = match async {
-                let ssh_connection = cx
-                    .update_global(|pool: &mut ConnectionPool, cx| {
-                        pool.connect(connection_options, &delegate, cx)
-                    })?
-                    .await
-                    .map_err(|error| error.cloned())?;
-
-                let io_task = ssh_connection.start_proxy(
-                    unique_identifier,
-                    true,
-                    incoming_tx,
-                    outgoing_rx,
-                    connection_activity_tx,
-                    delegate.clone(),
-                    cx,
-                );
-                anyhow::Ok((ssh_connection, io_task))
-            }
-            .await
-            {
-                Ok((ssh_connection, io_task)) => (ssh_connection, io_task),
-                Err(error) => {
-                    failed!(error, attempts, ssh_connection, delegate);
-                }
-            };
-
-            let multiplex_task = Self::monitor(this.clone(), io_task, cx);
-            client.reconnect(incoming_rx, outgoing_tx, cx);
-
-            if let Err(error) = client.resync(HEARTBEAT_TIMEOUT).await {
-                failed!(error, attempts, ssh_connection, delegate);
-            };
-
-            State::Connected {
-                ssh_connection,
-                delegate,
-                multiplex_task,
-                heartbeat_task: Self::heartbeat(this.clone(), connection_activity_rx, cx),
-            }
-        });
-
-        cx.spawn(async move |this, cx| {
-            let new_state = reconnect_task.await;
-            this.update(cx, |this, cx| {
-                this.try_set_state(cx, |old_state| {
-                    if old_state.is_reconnecting() {
-                        match &new_state {
-                            State::Connecting
-                            | State::Reconnecting
-                            | State::HeartbeatMissed { .. }
-                            | State::ServerNotRunning => {}
-                            State::Connected { .. } => {
-                                log::info!("Successfully reconnected");
-                            }
-                            State::ReconnectFailed {
-                                error, attempts, ..
-                            } => {
-                                log::error!(
-                                    "Reconnect attempt {} failed: {:?}. Starting new attempt...",
-                                    attempts,
-                                    error
-                                );
-                            }
-                            State::ReconnectExhausted => {
-                                log::error!("Reconnect attempt failed and all attempts exhausted");
-                            }
-                        }
-                        Some(new_state)
-                    } else {
-                        None
-                    }
-                });
-
-                if this.state_is(State::is_reconnect_failed) {
-                    this.reconnect(cx)
-                } else if this.state_is(State::is_reconnect_exhausted) {
-                    Ok(())
-                } else {
-                    log::debug!("State has transition from Reconnecting into new state while attempting reconnect.");
-                    Ok(())
-                }
-            })
-        })
-        .detach_and_log_err(cx);
-
-        Ok(())
-    }
-
-    fn heartbeat(
-        this: WeakEntity<Self>,
-        mut connection_activity_rx: mpsc::Receiver<()>,
-        cx: &mut AsyncApp,
-    ) -> Task<Result<()>> {
-        let Ok(client) = this.read_with(cx, |this, _| this.client.clone()) else {
-            return Task::ready(Err(anyhow!("SshRemoteClient lost")));
-        };
-
-        cx.spawn(async move |cx| {
-            let mut missed_heartbeats = 0;
-
-            let keepalive_timer = cx.background_executor().timer(HEARTBEAT_INTERVAL).fuse();
-            futures::pin_mut!(keepalive_timer);
-
-            loop {
-                select_biased! {
-                    result = connection_activity_rx.next().fuse() => {
-                        if result.is_none() {
-                            log::warn!("ssh heartbeat: connection activity channel has been dropped. stopping.");
-                            return Ok(());
-                        }
-
-                        if missed_heartbeats != 0 {
-                            missed_heartbeats = 0;
-                            let _ =this.update(cx, |this, cx| {
-                                this.handle_heartbeat_result(missed_heartbeats, cx)
-                            })?;
-                        }
-                    }
-                    _ = keepalive_timer => {
-                        log::debug!("Sending heartbeat to server...");
-
-                        let result = select_biased! {
-                            _ = connection_activity_rx.next().fuse() => {
-                                Ok(())
-                            }
-                            ping_result = client.ping(HEARTBEAT_TIMEOUT).fuse() => {
-                                ping_result
-                            }
-                        };
-
-                        if result.is_err() {
-                            missed_heartbeats += 1;
-                            log::warn!(
-                                "No heartbeat from server after {:?}. Missed heartbeat {} out of {}.",
-                                HEARTBEAT_TIMEOUT,
-                                missed_heartbeats,
-                                MAX_MISSED_HEARTBEATS
-                            );
-                        } else if missed_heartbeats != 0 {
-                            missed_heartbeats = 0;
-                        } else {
-                            continue;
-                        }
-
-                        let result = this.update(cx, |this, cx| {
-                            this.handle_heartbeat_result(missed_heartbeats, cx)
-                        })?;
-                        if result.is_break() {
-                            return Ok(());
-                        }
-                    }
-                }
-
-                keepalive_timer.set(cx.background_executor().timer(HEARTBEAT_INTERVAL).fuse());
-            }
-        })
-    }
-
-    fn handle_heartbeat_result(
-        &mut self,
-        missed_heartbeats: usize,
-        cx: &mut Context<Self>,
-    ) -> ControlFlow<()> {
-        let state = self.state.lock().take().unwrap();
-        let next_state = if missed_heartbeats > 0 {
-            state.heartbeat_missed()
-        } else {
-            state.heartbeat_recovered()
-        };
-
-        self.set_state(next_state, cx);
-
-        if missed_heartbeats >= MAX_MISSED_HEARTBEATS {
-            log::error!(
-                "Missed last {} heartbeats. Reconnecting...",
-                missed_heartbeats
-            );
-
-            self.reconnect(cx)
-                .context("failed to start reconnect process after missing heartbeats")
-                .log_err();
-            ControlFlow::Break(())
-        } else {
-            ControlFlow::Continue(())
-        }
-    }
-
-    fn monitor(
-        this: WeakEntity<Self>,
-        io_task: Task<Result<i32>>,
-        cx: &AsyncApp,
-    ) -> Task<Result<()>> {
-        cx.spawn(async move |cx| {
-            let result = io_task.await;
-
-            match result {
-                Ok(exit_code) => {
-                    if let Some(error) = ProxyLaunchError::from_exit_code(exit_code) {
-                        match error {
-                            ProxyLaunchError::ServerNotRunning => {
-                                log::error!("failed to reconnect because server is not running");
-                                this.update(cx, |this, cx| {
-                                    this.set_state(State::ServerNotRunning, cx);
-                                })?;
-                            }
-                        }
-                    } else if exit_code > 0 {
-                        log::error!("proxy process terminated unexpectedly");
-                        this.update(cx, |this, cx| {
-                            this.reconnect(cx).ok();
-                        })?;
-                    }
-                }
-                Err(error) => {
-                    log::warn!("ssh io task died with error: {:?}. reconnecting...", error);
-                    this.update(cx, |this, cx| {
-                        this.reconnect(cx).ok();
-                    })?;
-                }
-            }
-
-            Ok(())
-        })
-    }
-
-    fn state_is(&self, check: impl FnOnce(&State) -> bool) -> bool {
-        self.state.lock().as_ref().is_some_and(check)
-    }
-
-    fn try_set_state(&self, cx: &mut Context<Self>, map: impl FnOnce(&State) -> Option<State>) {
-        let mut lock = self.state.lock();
-        let new_state = lock.as_ref().and_then(map);
-
-        if let Some(new_state) = new_state {
-            lock.replace(new_state);
-            cx.notify();
-        }
-    }
-
-    fn set_state(&self, state: State, cx: &mut Context<Self>) {
-        log::info!("setting state to '{}'", &state);
-
-        let is_reconnect_exhausted = state.is_reconnect_exhausted();
-        let is_server_not_running = state.is_server_not_running();
-        self.state.lock().replace(state);
-
-        if is_reconnect_exhausted || is_server_not_running {
-            cx.emit(SshRemoteEvent::Disconnected);
-        }
-        cx.notify();
-    }
-
-    pub fn ssh_info(&self) -> Option<SshInfo> {
-        self.state
-            .lock()
-            .as_ref()
-            .and_then(|state| state.ssh_connection())
-            .map(|ssh_connection| SshInfo {
-                args: ssh_connection.ssh_args(),
-                path_style: ssh_connection.path_style(),
-                shell: ssh_connection.shell(),
-            })
-    }
-
-    pub fn upload_directory(
-        &self,
-        src_path: PathBuf,
-        dest_path: RemotePathBuf,
-        cx: &App,
-    ) -> Task<Result<()>> {
-        let state = self.state.lock();
-        let Some(connection) = state.as_ref().and_then(|state| state.ssh_connection()) else {
-            return Task::ready(Err(anyhow!("no ssh connection")));
-        };
-        connection.upload_directory(src_path, dest_path, cx)
-    }
-
-    pub fn proto_client(&self) -> AnyProtoClient {
-        self.client.clone().into()
-    }
-
-    pub fn connection_string(&self) -> String {
-        self.connection_options.connection_string()
-    }
-
-    pub fn connection_options(&self) -> SshConnectionOptions {
-        self.connection_options.clone()
-    }
-
-    pub fn connection_state(&self) -> ConnectionState {
-        self.state
-            .lock()
-            .as_ref()
-            .map(ConnectionState::from)
-            .unwrap_or(ConnectionState::Disconnected)
-    }
-
-    pub fn is_disconnected(&self) -> bool {
-        self.connection_state() == ConnectionState::Disconnected
-    }
-
-    pub fn path_style(&self) -> PathStyle {
-        self.path_style
-    }
-
-    #[cfg(any(test, feature = "test-support"))]
-    pub fn simulate_disconnect(&self, client_cx: &mut App) -> Task<()> {
-        let opts = self.connection_options();
-        client_cx.spawn(async move |cx| {
-            let connection = cx
-                .update_global(|c: &mut ConnectionPool, _| {
-                    if let Some(ConnectionPoolEntry::Connecting(c)) = c.connections.get(&opts) {
-                        c.clone()
-                    } else {
-                        panic!("missing test connection")
-                    }
-                })
-                .unwrap()
-                .await
-                .unwrap();
-
-            connection.simulate_disconnect(cx);
-        })
-    }
-
-    #[cfg(any(test, feature = "test-support"))]
-    pub fn fake_server(
-        client_cx: &mut gpui::TestAppContext,
-        server_cx: &mut gpui::TestAppContext,
-    ) -> (SshConnectionOptions, AnyProtoClient) {
-        let port = client_cx
-            .update(|cx| cx.default_global::<ConnectionPool>().connections.len() as u16 + 1);
-        let opts = SshConnectionOptions {
-            host: "<fake>".to_string(),
-            port: Some(port),
-            ..Default::default()
-        };
-        let (outgoing_tx, _) = mpsc::unbounded::<Envelope>();
-        let (_, incoming_rx) = mpsc::unbounded::<Envelope>();
-        let server_client =
-            server_cx.update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "fake-server"));
-        let connection: Arc<dyn RemoteConnection> = Arc::new(fake::FakeRemoteConnection {
-            connection_options: opts.clone(),
-            server_cx: fake::SendableCx::new(server_cx),
-            server_channel: server_client.clone(),
-        });
-
-        client_cx.update(|cx| {
-            cx.update_default_global(|c: &mut ConnectionPool, cx| {
-                c.connections.insert(
-                    opts.clone(),
-                    ConnectionPoolEntry::Connecting(
-                        cx.background_spawn({
-                            let connection = connection.clone();
-                            async move { Ok(connection.clone()) }
-                        })
-                        .shared(),
-                    ),
-                );
-            })
-        });
-
-        (opts, server_client.into())
-    }
-
-    #[cfg(any(test, feature = "test-support"))]
-    pub async fn fake_client(
-        opts: SshConnectionOptions,
-        client_cx: &mut gpui::TestAppContext,
-    ) -> Entity<Self> {
-        let (_tx, rx) = oneshot::channel();
-        client_cx
-            .update(|cx| {
-                Self::new(
-                    ConnectionIdentifier::setup(),
-                    opts,
-                    rx,
-                    Arc::new(fake::Delegate),
-                    cx,
-                )
-            })
-            .await
-            .unwrap()
-            .unwrap()
-    }
-}
-
-enum ConnectionPoolEntry {
-    Connecting(Shared<Task<Result<Arc<dyn RemoteConnection>, Arc<anyhow::Error>>>>),
-    Connected(Weak<dyn RemoteConnection>),
-}
-
-#[derive(Default)]
-struct ConnectionPool {
-    connections: HashMap<SshConnectionOptions, ConnectionPoolEntry>,
-}
-
-impl Global for ConnectionPool {}
-
-impl ConnectionPool {
-    pub fn connect(
-        &mut self,
-        opts: SshConnectionOptions,
-        delegate: &Arc<dyn SshClientDelegate>,
-        cx: &mut App,
-    ) -> Shared<Task<Result<Arc<dyn RemoteConnection>, Arc<anyhow::Error>>>> {
-        let connection = self.connections.get(&opts);
-        match connection {
-            Some(ConnectionPoolEntry::Connecting(task)) => {
-                let delegate = delegate.clone();
-                cx.spawn(async move |cx| {
-                    delegate.set_status(Some("Waiting for existing connection attempt"), cx);
-                })
-                .detach();
-                return task.clone();
-            }
-            Some(ConnectionPoolEntry::Connected(ssh)) => {
-                if let Some(ssh) = ssh.upgrade()
-                    && !ssh.has_been_killed()
-                {
-                    return Task::ready(Ok(ssh)).shared();
-                }
-                self.connections.remove(&opts);
-            }
-            None => {}
-        }
-
-        let task = cx
-            .spawn({
-                let opts = opts.clone();
-                let delegate = delegate.clone();
-                async move |cx| {
-                    let connection = SshRemoteConnection::new(opts.clone(), delegate, cx)
-                        .await
-                        .map(|connection| Arc::new(connection) as Arc<dyn RemoteConnection>);
-
-                    cx.update_global(|pool: &mut Self, _| {
-                        debug_assert!(matches!(
-                            pool.connections.get(&opts),
-                            Some(ConnectionPoolEntry::Connecting(_))
-                        ));
-                        match connection {
-                            Ok(connection) => {
-                                pool.connections.insert(
-                                    opts.clone(),
-                                    ConnectionPoolEntry::Connected(Arc::downgrade(&connection)),
-                                );
-                                Ok(connection)
-                            }
-                            Err(error) => {
-                                pool.connections.remove(&opts);
-                                Err(Arc::new(error))
-                            }
-                        }
-                    })?
-                }
-            })
-            .shared();
-
-        self.connections
-            .insert(opts.clone(), ConnectionPoolEntry::Connecting(task.clone()));
-        task
-    }
-}
-
-impl From<SshRemoteClient> for AnyProtoClient {
-    fn from(client: SshRemoteClient) -> Self {
-        AnyProtoClient::new(client.client)
-    }
-}
-
-#[async_trait(?Send)]
-trait RemoteConnection: Send + Sync {
-    fn start_proxy(
-        &self,
-        unique_identifier: String,
-        reconnect: bool,
-        incoming_tx: UnboundedSender<Envelope>,
-        outgoing_rx: UnboundedReceiver<Envelope>,
-        connection_activity_tx: Sender<()>,
-        delegate: Arc<dyn SshClientDelegate>,
-        cx: &mut AsyncApp,
-    ) -> Task<Result<i32>>;
-    fn upload_directory(
-        &self,
-        src_path: PathBuf,
-        dest_path: RemotePathBuf,
-        cx: &App,
-    ) -> Task<Result<()>>;
-    async fn kill(&self) -> Result<()>;
-    fn has_been_killed(&self) -> bool;
-    /// On Windows, we need to use `SSH_ASKPASS` to provide the password to ssh.
-    /// On Linux, we use the `ControlPath` option to create a socket file that ssh can use to
-    fn ssh_args(&self) -> SshArgs;
-    fn connection_options(&self) -> SshConnectionOptions;
-    fn path_style(&self) -> PathStyle;
-    fn shell(&self) -> String;
-
-    #[cfg(any(test, feature = "test-support"))]
-    fn simulate_disconnect(&self, _: &AsyncApp) {}
-}
-
-struct SshRemoteConnection {
-    socket: SshSocket,
-    master_process: Mutex<Option<Child>>,
-    remote_binary_path: Option<RemotePathBuf>,
-    ssh_platform: SshPlatform,
-    ssh_path_style: PathStyle,
-    ssh_shell: String,
-    _temp_dir: TempDir,
-}
-
-#[async_trait(?Send)]
-impl RemoteConnection for SshRemoteConnection {
-    async fn kill(&self) -> Result<()> {
-        let Some(mut process) = self.master_process.lock().take() else {
-            return Ok(());
-        };
-        process.kill().ok();
-        process.status().await?;
-        Ok(())
-    }
-
-    fn has_been_killed(&self) -> bool {
-        self.master_process.lock().is_none()
-    }
-
-    fn ssh_args(&self) -> SshArgs {
-        self.socket.ssh_args()
-    }
-
-    fn connection_options(&self) -> SshConnectionOptions {
-        self.socket.connection_options.clone()
-    }
-
-    fn shell(&self) -> String {
-        self.ssh_shell.clone()
-    }
-
-    fn upload_directory(
-        &self,
-        src_path: PathBuf,
-        dest_path: RemotePathBuf,
-        cx: &App,
-    ) -> Task<Result<()>> {
-        let mut command = util::command::new_smol_command("scp");
-        let output = self
-            .socket
-            .ssh_options(&mut command)
-            .args(
-                self.socket
-                    .connection_options
-                    .port
-                    .map(|port| vec!["-P".to_string(), port.to_string()])
-                    .unwrap_or_default(),
-            )
-            .arg("-C")
-            .arg("-r")
-            .arg(&src_path)
-            .arg(format!(
-                "{}:{}",
-                self.socket.connection_options.scp_url(),
-                dest_path
-            ))
-            .output();
-
-        cx.background_spawn(async move {
-            let output = output.await?;
-
-            anyhow::ensure!(
-                output.status.success(),
-                "failed to upload directory {} -> {}: {}",
-                src_path.display(),
-                dest_path.to_string(),
-                String::from_utf8_lossy(&output.stderr)
-            );
-
-            Ok(())
-        })
-    }
-
-    fn start_proxy(
-        &self,
-        unique_identifier: String,
-        reconnect: bool,
-        incoming_tx: UnboundedSender<Envelope>,
-        outgoing_rx: UnboundedReceiver<Envelope>,
-        connection_activity_tx: Sender<()>,
-        delegate: Arc<dyn SshClientDelegate>,
-        cx: &mut AsyncApp,
-    ) -> Task<Result<i32>> {
-        delegate.set_status(Some("Starting proxy"), cx);
-
-        let Some(remote_binary_path) = self.remote_binary_path.clone() else {
-            return Task::ready(Err(anyhow!("Remote binary path not set")));
-        };
-
-        let mut start_proxy_command = shell_script!(
-            "exec {binary_path} proxy --identifier {identifier}",
-            binary_path = &remote_binary_path.to_string(),
-            identifier = &unique_identifier,
-        );
-
-        for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] {
-            if let Some(value) = std::env::var(env_var).ok() {
-                start_proxy_command = format!(
-                    "{}={} {} ",
-                    env_var,
-                    shlex::try_quote(&value).unwrap(),
-                    start_proxy_command,
-                );
-            }
-        }
-
-        if reconnect {
-            start_proxy_command.push_str(" --reconnect");
-        }
-
-        let ssh_proxy_process = match self
-            .socket
-            .ssh_command("sh", &["-lc", &start_proxy_command])
-            // IMPORTANT: we kill this process when we drop the task that uses it.
-            .kill_on_drop(true)
-            .spawn()
-        {
-            Ok(process) => process,
-            Err(error) => {
-                return Task::ready(Err(anyhow!("failed to spawn remote server: {}", error)));
-            }
-        };
-
-        Self::multiplex(
-            ssh_proxy_process,
-            incoming_tx,
-            outgoing_rx,
-            connection_activity_tx,
-            cx,
-        )
-    }
-
-    fn path_style(&self) -> PathStyle {
-        self.ssh_path_style
-    }
-}
-
-impl SshRemoteConnection {
-    async fn new(
-        connection_options: SshConnectionOptions,
-        delegate: Arc<dyn SshClientDelegate>,
-        cx: &mut AsyncApp,
-    ) -> Result<Self> {
-        use askpass::AskPassResult;
-
-        delegate.set_status(Some("Connecting"), cx);
-
-        let url = connection_options.ssh_url();
-
-        let temp_dir = tempfile::Builder::new()
-            .prefix("zed-ssh-session")
-            .tempdir()?;
-        let askpass_delegate = askpass::AskPassDelegate::new(cx, {
-            let delegate = delegate.clone();
-            move |prompt, tx, cx| delegate.ask_password(prompt, tx, cx)
-        });
-
-        let mut askpass =
-            askpass::AskPassSession::new(cx.background_executor(), askpass_delegate).await?;
-
-        // Start the master SSH process, which does not do anything except for establish
-        // the connection and keep it open, allowing other ssh commands to reuse it
-        // via a control socket.
-        #[cfg(not(target_os = "windows"))]
-        let socket_path = temp_dir.path().join("ssh.sock");
-
-        let mut master_process = {
-            #[cfg(not(target_os = "windows"))]
-            let args = [
-                "-N",
-                "-o",
-                "ControlPersist=no",
-                "-o",
-                "ControlMaster=yes",
-                "-o",
-            ];
-            // On Windows, `ControlMaster` and `ControlPath` are not supported:
-            // https://github.com/PowerShell/Win32-OpenSSH/issues/405
-            // https://github.com/PowerShell/Win32-OpenSSH/wiki/Project-Scope
-            #[cfg(target_os = "windows")]
-            let args = ["-N"];
-            let mut master_process = util::command::new_smol_command("ssh");
-            master_process
-                .kill_on_drop(true)
-                .stdin(Stdio::null())
-                .stdout(Stdio::piped())
-                .stderr(Stdio::piped())
-                .env("SSH_ASKPASS_REQUIRE", "force")
-                .env("SSH_ASKPASS", askpass.script_path())
-                .args(connection_options.additional_args())
-                .args(args);
-            #[cfg(not(target_os = "windows"))]
-            master_process.arg(format!("ControlPath={}", socket_path.display()));
-            master_process.arg(&url).spawn()?
-        };
-        // Wait for this ssh process to close its stdout, indicating that authentication
-        // has completed.
-        let mut stdout = master_process.stdout.take().unwrap();
-        let mut output = Vec::new();
-
-        let result = select_biased! {
-            result = askpass.run().fuse() => {
-                match result {
-                    AskPassResult::CancelledByUser => {
-                        master_process.kill().ok();
-                        anyhow::bail!("SSH connection canceled")
-                    }
-                    AskPassResult::Timedout => {
-                        anyhow::bail!("connecting to host timed out")
-                    }
-                }
-            }
-            _ = stdout.read_to_end(&mut output).fuse() => {
-                anyhow::Ok(())
-            }
-        };
-
-        if let Err(e) = result {
-            return Err(e.context("Failed to connect to host"));
-        }
-
-        if master_process.try_status()?.is_some() {
-            output.clear();
-            let mut stderr = master_process.stderr.take().unwrap();
-            stderr.read_to_end(&mut output).await?;
-
-            let error_message = format!(
-                "failed to connect: {}",
-                String::from_utf8_lossy(&output).trim()
-            );
-            anyhow::bail!(error_message);
-        }
-
-        #[cfg(not(target_os = "windows"))]
-        let socket = SshSocket::new(connection_options, socket_path)?;
-        #[cfg(target_os = "windows")]
-        let socket = SshSocket::new(connection_options, &temp_dir, askpass.get_password())?;
-        drop(askpass);
-
-        let ssh_platform = socket.platform().await?;
-        let ssh_path_style = match ssh_platform.os {
-            "windows" => PathStyle::Windows,
-            _ => PathStyle::Posix,
-        };
-        let ssh_shell = socket.shell().await;
-
-        let mut this = Self {
-            socket,
-            master_process: Mutex::new(Some(master_process)),
-            _temp_dir: temp_dir,
-            remote_binary_path: None,
-            ssh_path_style,
-            ssh_platform,
-            ssh_shell,
-        };
-
-        let (release_channel, version, commit) = cx.update(|cx| {
-            (
-                ReleaseChannel::global(cx),
-                AppVersion::global(cx),
-                AppCommitSha::try_global(cx),
-            )
-        })?;
-        this.remote_binary_path = Some(
-            this.ensure_server_binary(&delegate, release_channel, version, commit, cx)
-                .await?,
-        );
-
-        Ok(this)
-    }
-
-    fn multiplex(
-        mut ssh_proxy_process: Child,
-        incoming_tx: UnboundedSender<Envelope>,
-        mut outgoing_rx: UnboundedReceiver<Envelope>,
-        mut connection_activity_tx: Sender<()>,
-        cx: &AsyncApp,
-    ) -> Task<Result<i32>> {
-        let mut child_stderr = ssh_proxy_process.stderr.take().unwrap();
-        let mut child_stdout = ssh_proxy_process.stdout.take().unwrap();
-        let mut child_stdin = ssh_proxy_process.stdin.take().unwrap();
-
-        let mut stdin_buffer = Vec::new();
-        let mut stdout_buffer = Vec::new();
-        let mut stderr_buffer = Vec::new();
-        let mut stderr_offset = 0;
-
-        let stdin_task = cx.background_spawn(async move {
-            while let Some(outgoing) = outgoing_rx.next().await {
-                write_message(&mut child_stdin, &mut stdin_buffer, outgoing).await?;
-            }
-            anyhow::Ok(())
-        });
-
-        let stdout_task = cx.background_spawn({
-            let mut connection_activity_tx = connection_activity_tx.clone();
-            async move {
-                loop {
-                    stdout_buffer.resize(MESSAGE_LEN_SIZE, 0);
-                    let len = child_stdout.read(&mut stdout_buffer).await?;
-
-                    if len == 0 {
-                        return anyhow::Ok(());
-                    }
-
-                    if len < MESSAGE_LEN_SIZE {
-                        child_stdout.read_exact(&mut stdout_buffer[len..]).await?;
-                    }
-
-                    let message_len = message_len_from_buffer(&stdout_buffer);
-                    let envelope =
-                        read_message_with_len(&mut child_stdout, &mut stdout_buffer, message_len)
-                            .await?;
-                    connection_activity_tx.try_send(()).ok();
-                    incoming_tx.unbounded_send(envelope).ok();
-                }
-            }
-        });
-
-        let stderr_task: Task<anyhow::Result<()>> = cx.background_spawn(async move {
-            loop {
-                stderr_buffer.resize(stderr_offset + 1024, 0);
-
-                let len = child_stderr
-                    .read(&mut stderr_buffer[stderr_offset..])
-                    .await?;
-                if len == 0 {
-                    return anyhow::Ok(());
-                }
-
-                stderr_offset += len;
-                let mut start_ix = 0;
-                while let Some(ix) = stderr_buffer[start_ix..stderr_offset]
-                    .iter()
-                    .position(|b| b == &b'\n')
-                {
-                    let line_ix = start_ix + ix;
-                    let content = &stderr_buffer[start_ix..line_ix];
-                    start_ix = line_ix + 1;
-                    if let Ok(record) = serde_json::from_slice::<LogRecord>(content) {
-                        record.log(log::logger())
-                    } else {
-                        eprintln!("(remote) {}", String::from_utf8_lossy(content));
-                    }
-                }
-                stderr_buffer.drain(0..start_ix);
-                stderr_offset -= start_ix;
-
-                connection_activity_tx.try_send(()).ok();
-            }
-        });
-
-        cx.background_spawn(async move {
-            let result = futures::select! {
-                result = stdin_task.fuse() => {
-                    result.context("stdin")
-                }
-                result = stdout_task.fuse() => {
-                    result.context("stdout")
-                }
-                result = stderr_task.fuse() => {
-                    result.context("stderr")
-                }
-            };
-
-            let status = ssh_proxy_process.status().await?.code().unwrap_or(1);
-            match result {
-                Ok(_) => Ok(status),
-                Err(error) => Err(error),
-            }
-        })
-    }
-
-    #[allow(unused)]
-    async fn ensure_server_binary(
-        &self,
-        delegate: &Arc<dyn SshClientDelegate>,
-        release_channel: ReleaseChannel,
-        version: SemanticVersion,
-        commit: Option<AppCommitSha>,
-        cx: &mut AsyncApp,
-    ) -> Result<RemotePathBuf> {
-        let version_str = match release_channel {
-            ReleaseChannel::Nightly => {
-                let commit = commit.map(|s| s.full()).unwrap_or_default();
-                format!("{}-{}", version, commit)
-            }
-            ReleaseChannel::Dev => "build".to_string(),
-            _ => version.to_string(),
-        };
-        let binary_name = format!(
-            "zed-remote-server-{}-{}",
-            release_channel.dev_name(),
-            version_str
-        );
-        let dst_path = RemotePathBuf::new(
-            paths::remote_server_dir_relative().join(binary_name),
-            self.ssh_path_style,
-        );
-
-        let build_remote_server = std::env::var("ZED_BUILD_REMOTE_SERVER").ok();
-        #[cfg(debug_assertions)]
-        if let Some(build_remote_server) = build_remote_server {
-            let src_path = self.build_local(build_remote_server, delegate, cx).await?;
-            let tmp_path = RemotePathBuf::new(
-                paths::remote_server_dir_relative().join(format!(
-                    "download-{}-{}",
-                    std::process::id(),
-                    src_path.file_name().unwrap().to_string_lossy()
-                )),
-                self.ssh_path_style,
-            );
-            self.upload_local_server_binary(&src_path, &tmp_path, delegate, cx)
-                .await?;
-            self.extract_server_binary(&dst_path, &tmp_path, delegate, cx)
-                .await?;
-            return Ok(dst_path);
-        }
-
-        if self
-            .socket
-            .run_command(&dst_path.to_string(), &["version"])
-            .await
-            .is_ok()
-        {
-            return Ok(dst_path);
-        }
-
-        let wanted_version = cx.update(|cx| match release_channel {
-            ReleaseChannel::Nightly => Ok(None),
-            ReleaseChannel::Dev => {
-                anyhow::bail!(
-                    "ZED_BUILD_REMOTE_SERVER is not set and no remote server exists at ({:?})",
-                    dst_path
-                )
-            }
-            _ => Ok(Some(AppVersion::global(cx))),
-        })??;
-
-        let tmp_path_gz = RemotePathBuf::new(
-            PathBuf::from(format!("{}-download-{}.gz", dst_path, std::process::id())),
-            self.ssh_path_style,
-        );
-        if !self.socket.connection_options.upload_binary_over_ssh
-            && let Some((url, body)) = delegate
-                .get_download_params(self.ssh_platform, release_channel, wanted_version, cx)
-                .await?
-        {
-            match self
-                .download_binary_on_server(&url, &body, &tmp_path_gz, delegate, cx)
-                .await
-            {
-                Ok(_) => {
-                    self.extract_server_binary(&dst_path, &tmp_path_gz, delegate, cx)
-                        .await?;
-                    return Ok(dst_path);
-                }
-                Err(e) => {
-                    log::error!(
-                        "Failed to download binary on server, attempting to upload server: {}",
-                        e
-                    )
-                }
-            }
-        }
-
-        let src_path = delegate
-            .download_server_binary_locally(self.ssh_platform, release_channel, wanted_version, cx)
-            .await?;
-        self.upload_local_server_binary(&src_path, &tmp_path_gz, delegate, cx)
-            .await?;
-        self.extract_server_binary(&dst_path, &tmp_path_gz, delegate, cx)
-            .await?;
-        Ok(dst_path)
-    }
-
-    async fn download_binary_on_server(
-        &self,
-        url: &str,
-        body: &str,
-        tmp_path_gz: &RemotePathBuf,
-        delegate: &Arc<dyn SshClientDelegate>,
-        cx: &mut AsyncApp,
-    ) -> Result<()> {
-        if let Some(parent) = tmp_path_gz.parent() {
-            self.socket
-                .run_command(
-                    "sh",
-                    &[
-                        "-lc",
-                        &shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()),
-                    ],
-                )
-                .await?;
-        }
-
-        delegate.set_status(Some("Downloading remote development server on host"), cx);
-
-        match self
-            .socket
-            .run_command(
-                "curl",
-                &[
-                    "-f",
-                    "-L",
-                    "-X",
-                    "GET",
-                    "-H",
-                    "Content-Type: application/json",
-                    "-d",
-                    body,
-                    url,
-                    "-o",
-                    &tmp_path_gz.to_string(),
-                ],
-            )
-            .await
-        {
-            Ok(_) => {}
-            Err(e) => {
-                if self.socket.run_command("which", &["curl"]).await.is_ok() {
-                    return Err(e);
-                }
-
-                match self
-                    .socket
-                    .run_command(
-                        "wget",
-                        &[
-                            "--method=GET",
-                            "--header=Content-Type: application/json",
-                            "--body-data",
-                            body,
-                            url,
-                            "-O",
-                            &tmp_path_gz.to_string(),
-                        ],
-                    )
-                    .await
-                {
-                    Ok(_) => {}
-                    Err(e) => {
-                        if self.socket.run_command("which", &["wget"]).await.is_ok() {
-                            return Err(e);
-                        } else {
-                            anyhow::bail!("Neither curl nor wget is available");
-                        }
-                    }
-                }
-            }
-        }
-
-        Ok(())
-    }
-
-    async fn upload_local_server_binary(
-        &self,
-        src_path: &Path,
-        tmp_path_gz: &RemotePathBuf,
-        delegate: &Arc<dyn SshClientDelegate>,
-        cx: &mut AsyncApp,
-    ) -> Result<()> {
-        if let Some(parent) = tmp_path_gz.parent() {
-            self.socket
-                .run_command(
-                    "sh",
-                    &[
-                        "-lc",
-                        &shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()),
-                    ],
-                )
-                .await?;
-        }
-
-        let src_stat = fs::metadata(&src_path).await?;
-        let size = src_stat.len();
-
-        let t0 = Instant::now();
-        delegate.set_status(Some("Uploading remote development server"), cx);
-        log::info!(
-            "uploading remote development server to {:?} ({}kb)",
-            tmp_path_gz,
-            size / 1024
-        );
-        self.upload_file(src_path, tmp_path_gz)
-            .await
-            .context("failed to upload server binary")?;
-        log::info!("uploaded remote development server in {:?}", t0.elapsed());
-        Ok(())
-    }
-
-    async fn extract_server_binary(
-        &self,
-        dst_path: &RemotePathBuf,
-        tmp_path: &RemotePathBuf,
-        delegate: &Arc<dyn SshClientDelegate>,
-        cx: &mut AsyncApp,
-    ) -> Result<()> {
-        delegate.set_status(Some("Extracting remote development server"), cx);
-        let server_mode = 0o755;
-
-        let orig_tmp_path = tmp_path.to_string();
-        let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".gz") {
-            shell_script!(
-                "gunzip -f {orig_tmp_path} && chmod {server_mode} {tmp_path} && mv {tmp_path} {dst_path}",
-                server_mode = &format!("{:o}", server_mode),
-                dst_path = &dst_path.to_string(),
-            )
-        } else {
-            shell_script!(
-                "chmod {server_mode} {orig_tmp_path} && mv {orig_tmp_path} {dst_path}",
-                server_mode = &format!("{:o}", server_mode),
-                dst_path = &dst_path.to_string()
-            )
-        };
-        self.socket.run_command("sh", &["-lc", &script]).await?;
-        Ok(())
-    }
-
-    async fn upload_file(&self, src_path: &Path, dest_path: &RemotePathBuf) -> Result<()> {
-        log::debug!("uploading file {:?} to {:?}", src_path, dest_path);
-        let mut command = util::command::new_smol_command("scp");
-        let output = self
-            .socket
-            .ssh_options(&mut command)
-            .args(
-                self.socket
-                    .connection_options
-                    .port
-                    .map(|port| vec!["-P".to_string(), port.to_string()])
-                    .unwrap_or_default(),
-            )
-            .arg(src_path)
-            .arg(format!(
-                "{}:{}",
-                self.socket.connection_options.scp_url(),
-                dest_path
-            ))
-            .output()
-            .await?;
-
-        anyhow::ensure!(
-            output.status.success(),
-            "failed to upload file {} -> {}: {}",
-            src_path.display(),
-            dest_path.to_string(),
-            String::from_utf8_lossy(&output.stderr)
-        );
-        Ok(())
-    }
-
-    #[cfg(debug_assertions)]
-    async fn build_local(
-        &self,
-        build_remote_server: String,
-        delegate: &Arc<dyn SshClientDelegate>,
-        cx: &mut AsyncApp,
-    ) -> Result<PathBuf> {
-        use smol::process::{Command, Stdio};
-        use std::env::VarError;
-
-        async fn run_cmd(command: &mut Command) -> Result<()> {
-            let output = command
-                .kill_on_drop(true)
-                .stderr(Stdio::inherit())
-                .output()
-                .await?;
-            anyhow::ensure!(
-                output.status.success(),
-                "Failed to run command: {command:?}"
-            );
-            Ok(())
-        }
-
-        let use_musl = !build_remote_server.contains("nomusl");
-        let triple = format!(
-            "{}-{}",
-            self.ssh_platform.arch,
-            match self.ssh_platform.os {
-                "linux" =>
-                    if use_musl {
-                        "unknown-linux-musl"
-                    } else {
-                        "unknown-linux-gnu"
-                    },
-                "macos" => "apple-darwin",
-                _ => anyhow::bail!("can't cross compile for: {:?}", self.ssh_platform),
-            }
-        );
-        let mut rust_flags = match std::env::var("RUSTFLAGS") {
-            Ok(val) => val,
-            Err(VarError::NotPresent) => String::new(),
-            Err(e) => {
-                log::error!("Failed to get env var `RUSTFLAGS` value: {e}");
-                String::new()
-            }
-        };
-        if self.ssh_platform.os == "linux" && use_musl {
-            rust_flags.push_str(" -C target-feature=+crt-static");
-        }
-        if build_remote_server.contains("mold") {
-            rust_flags.push_str(" -C link-arg=-fuse-ld=mold");
-        }
-
-        if self.ssh_platform.arch == std::env::consts::ARCH
-            && self.ssh_platform.os == std::env::consts::OS
-        {
-            delegate.set_status(Some("Building remote server binary from source"), cx);
-            log::info!("building remote server binary from source");
-            run_cmd(
-                Command::new("cargo")
-                    .args([
-                        "build",
-                        "--package",
-                        "remote_server",
-                        "--features",
-                        "debug-embed",
-                        "--target-dir",
-                        "target/remote_server",
-                        "--target",
-                        &triple,
-                    ])
-                    .env("RUSTFLAGS", &rust_flags),
-            )
-            .await?;
-        } else if build_remote_server.contains("cross") {
-            #[cfg(target_os = "windows")]
-            use util::paths::SanitizedPath;
-
-            delegate.set_status(Some("Installing cross.rs for cross-compilation"), cx);
-            log::info!("installing cross");
-            run_cmd(Command::new("cargo").args([
-                "install",
-                "cross",
-                "--git",
-                "https://github.com/cross-rs/cross",
-            ]))
-            .await?;
-
-            delegate.set_status(
-                Some(&format!(
-                    "Building remote server binary from source for {} with Docker",
-                    &triple
-                )),
-                cx,
-            );
-            log::info!("building remote server binary from source for {}", &triple);
-
-            // On Windows, the binding needs to be set to the canonical path
-            #[cfg(target_os = "windows")]
-            let src =
-                SanitizedPath::from(smol::fs::canonicalize("./target").await?).to_glob_string();
-            #[cfg(not(target_os = "windows"))]
-            let src = "./target";
-            run_cmd(
-                Command::new("cross")
-                    .args([
-                        "build",
-                        "--package",
-                        "remote_server",
-                        "--features",
-                        "debug-embed",
-                        "--target-dir",
-                        "target/remote_server",
-                        "--target",
-                        &triple,
-                    ])
-                    .env(
-                        "CROSS_CONTAINER_OPTS",
-                        format!("--mount type=bind,src={src},dst=/app/target"),
-                    )
-                    .env("RUSTFLAGS", &rust_flags),
-            )
-            .await?;
-        } else {
-            let which = cx
-                .background_spawn(async move { which::which("zig") })
-                .await;
-
-            if which.is_err() {
-                #[cfg(not(target_os = "windows"))]
-                {
-                    anyhow::bail!(
-                        "zig not found on $PATH, install zig (see https://ziglang.org/learn/getting-started or use zigup) or pass ZED_BUILD_REMOTE_SERVER=cross to use cross"
-                    )
-                }
-                #[cfg(target_os = "windows")]
-                {
-                    anyhow::bail!(
-                        "zig not found on $PATH, install zig (use `winget install -e --id zig.zig` or see https://ziglang.org/learn/getting-started or use zigup) or pass ZED_BUILD_REMOTE_SERVER=cross to use cross"
-                    )
-                }
-            }
-
-            delegate.set_status(Some("Adding rustup target for cross-compilation"), cx);
-            log::info!("adding rustup target");
-            run_cmd(Command::new("rustup").args(["target", "add"]).arg(&triple)).await?;
-
-            delegate.set_status(Some("Installing cargo-zigbuild for cross-compilation"), cx);
-            log::info!("installing cargo-zigbuild");
-            run_cmd(Command::new("cargo").args(["install", "--locked", "cargo-zigbuild"])).await?;
-
-            delegate.set_status(
-                Some(&format!(
-                    "Building remote binary from source for {triple} with Zig"
-                )),
-                cx,
-            );
-            log::info!("building remote binary from source for {triple} with Zig");
-            run_cmd(
-                Command::new("cargo")
-                    .args([
-                        "zigbuild",
-                        "--package",
-                        "remote_server",
-                        "--features",
-                        "debug-embed",
-                        "--target-dir",
-                        "target/remote_server",
-                        "--target",
-                        &triple,
-                    ])
-                    .env("RUSTFLAGS", &rust_flags),
-            )
-            .await?;
-        };
-        let bin_path = Path::new("target")
-            .join("remote_server")
-            .join(&triple)
-            .join("debug")
-            .join("remote_server");
-
-        let path = if !build_remote_server.contains("nocompress") {
-            delegate.set_status(Some("Compressing binary"), cx);
-
-            #[cfg(not(target_os = "windows"))]
-            {
-                run_cmd(Command::new("gzip").args(["-f", &bin_path.to_string_lossy()])).await?;
-            }
-            #[cfg(target_os = "windows")]
-            {
-                // On Windows, we use 7z to compress the binary
-                let seven_zip = which::which("7z.exe").context("7z.exe not found on $PATH, install it (e.g. with `winget install -e --id 7zip.7zip`) or, if you don't want this behaviour, set $env:ZED_BUILD_REMOTE_SERVER=\"nocompress\"")?;
-                let gz_path = format!("target/remote_server/{}/debug/remote_server.gz", triple);
-                if smol::fs::metadata(&gz_path).await.is_ok() {
-                    smol::fs::remove_file(&gz_path).await?;
-                }
-                run_cmd(Command::new(seven_zip).args([
-                    "a",
-                    "-tgzip",
-                    &gz_path,
-                    &bin_path.to_string_lossy(),
-                ]))
-                .await?;
-            }
-
-            let mut archive_path = bin_path;
-            archive_path.set_extension("gz");
-            std::env::current_dir()?.join(archive_path)
-        } else {
-            bin_path
-        };
-
-        Ok(path)
-    }
-}
-
-type ResponseChannels = Mutex<HashMap<MessageId, oneshot::Sender<(Envelope, oneshot::Sender<()>)>>>;
-
-struct ChannelClient {
-    next_message_id: AtomicU32,
-    outgoing_tx: Mutex<mpsc::UnboundedSender<Envelope>>,
-    buffer: Mutex<VecDeque<Envelope>>,
-    response_channels: ResponseChannels,
-    message_handlers: Mutex<ProtoMessageHandlerSet>,
-    max_received: AtomicU32,
-    name: &'static str,
-    task: Mutex<Task<Result<()>>>,
-}
-
-impl ChannelClient {
-    fn new(
-        incoming_rx: mpsc::UnboundedReceiver<Envelope>,
-        outgoing_tx: mpsc::UnboundedSender<Envelope>,
-        cx: &App,
-        name: &'static str,
-    ) -> Arc<Self> {
-        Arc::new_cyclic(|this| Self {
-            outgoing_tx: Mutex::new(outgoing_tx),
-            next_message_id: AtomicU32::new(0),
-            max_received: AtomicU32::new(0),
-            response_channels: ResponseChannels::default(),
-            message_handlers: Default::default(),
-            buffer: Mutex::new(VecDeque::new()),
-            name,
-            task: Mutex::new(Self::start_handling_messages(
-                this.clone(),
-                incoming_rx,
-                &cx.to_async(),
-            )),
-        })
-    }
-
-    fn start_handling_messages(
-        this: Weak<Self>,
-        mut incoming_rx: mpsc::UnboundedReceiver<Envelope>,
-        cx: &AsyncApp,
-    ) -> Task<Result<()>> {
-        cx.spawn(async move |cx| {
-            let peer_id = PeerId { owner_id: 0, id: 0 };
-            while let Some(incoming) = incoming_rx.next().await {
-                let Some(this) = this.upgrade() else {
-                    return anyhow::Ok(());
-                };
-                if let Some(ack_id) = incoming.ack_id {
-                    let mut buffer = this.buffer.lock();
-                    while buffer.front().is_some_and(|msg| msg.id <= ack_id) {
-                        buffer.pop_front();
-                    }
-                }
-                if let Some(proto::envelope::Payload::FlushBufferedMessages(_)) = &incoming.payload
-                {
-                    log::debug!(
-                        "{}:ssh message received. name:FlushBufferedMessages",
-                        this.name
-                    );
-                    {
-                        let buffer = this.buffer.lock();
-                        for envelope in buffer.iter() {
-                            this.outgoing_tx
-                                .lock()
-                                .unbounded_send(envelope.clone())
-                                .ok();
-                        }
-                    }
-                    let mut envelope = proto::Ack {}.into_envelope(0, Some(incoming.id), None);
-                    envelope.id = this.next_message_id.fetch_add(1, SeqCst);
-                    this.outgoing_tx.lock().unbounded_send(envelope).ok();
-                    continue;
-                }
-
-                this.max_received.store(incoming.id, SeqCst);
-
-                if let Some(request_id) = incoming.responding_to {
-                    let request_id = MessageId(request_id);
-                    let sender = this.response_channels.lock().remove(&request_id);
-                    if let Some(sender) = sender {
-                        let (tx, rx) = oneshot::channel();
-                        if incoming.payload.is_some() {
-                            sender.send((incoming, tx)).ok();
-                        }
-                        rx.await.ok();
-                    }
-                } else if let Some(envelope) =
-                    build_typed_envelope(peer_id, Instant::now(), incoming)
-                {
-                    let type_name = envelope.payload_type_name();
-                    let message_id = envelope.message_id();
-                    if let Some(future) = ProtoMessageHandlerSet::handle_message(
-                        &this.message_handlers,
-                        envelope,
-                        this.clone().into(),
-                        cx.clone(),
-                    ) {
-                        log::debug!("{}:ssh message received. name:{type_name}", this.name);
-                        cx.foreground_executor()
-                            .spawn(async move {
-                                match future.await {
-                                    Ok(_) => {
-                                        log::debug!(
-                                            "{}:ssh message handled. name:{type_name}",
-                                            this.name
-                                        );
-                                    }
-                                    Err(error) => {
-                                        log::error!(
-                                            "{}:error handling message. type:{}, error:{}",
-                                            this.name,
-                                            type_name,
-                                            format!("{error:#}").lines().fold(
-                                                String::new(),
-                                                |mut message, line| {
-                                                    if !message.is_empty() {
-                                                        message.push(' ');
-                                                    }
-                                                    message.push_str(line);
-                                                    message
-                                                }
-                                            )
-                                        );
-                                    }
-                                }
-                            })
-                            .detach()
-                    } else {
-                        log::error!("{}:unhandled ssh message name:{type_name}", this.name);
-                        if let Err(e) = AnyProtoClient::from(this.clone()).send_response(
-                            message_id,
-                            anyhow::anyhow!("no handler registered for {type_name}").to_proto(),
-                        ) {
-                            log::error!(
-                                "{}:error sending error response for {type_name}:{e:#}",
-                                this.name
-                            );
-                        }
-                    }
-                }
-            }
-            anyhow::Ok(())
-        })
-    }
-
-    fn reconnect(
-        self: &Arc<Self>,
-        incoming_rx: UnboundedReceiver<Envelope>,
-        outgoing_tx: UnboundedSender<Envelope>,
-        cx: &AsyncApp,
-    ) {
-        *self.outgoing_tx.lock() = outgoing_tx;
-        *self.task.lock() = Self::start_handling_messages(Arc::downgrade(self), incoming_rx, cx);
-    }
-
-    fn request<T: RequestMessage>(
-        &self,
-        payload: T,
-    ) -> impl 'static + Future<Output = Result<T::Response>> {
-        self.request_internal(payload, true)
-    }
-
-    fn request_internal<T: RequestMessage>(
-        &self,
-        payload: T,
-        use_buffer: bool,
-    ) -> impl 'static + Future<Output = Result<T::Response>> {
-        log::debug!("ssh request start. name:{}", T::NAME);
-        let response =
-            self.request_dynamic(payload.into_envelope(0, None, None), T::NAME, use_buffer);
-        async move {
-            let response = response.await?;
-            log::debug!("ssh request finish. name:{}", T::NAME);
-            T::Response::from_envelope(response).context("received a response of the wrong type")
-        }
-    }
-
-    async fn resync(&self, timeout: Duration) -> Result<()> {
-        smol::future::or(
-            async {
-                self.request_internal(proto::FlushBufferedMessages {}, false)
-                    .await?;
-
-                for envelope in self.buffer.lock().iter() {
-                    self.outgoing_tx
-                        .lock()
-                        .unbounded_send(envelope.clone())
-                        .ok();
-                }
-                Ok(())
-            },
-            async {
-                smol::Timer::after(timeout).await;
-                anyhow::bail!("Timed out resyncing remote client")
-            },
-        )
-        .await
-    }
-
-    async fn ping(&self, timeout: Duration) -> Result<()> {
-        smol::future::or(
-            async {
-                self.request(proto::Ping {}).await?;
-                Ok(())
-            },
-            async {
-                smol::Timer::after(timeout).await;
-                anyhow::bail!("Timed out pinging remote client")
-            },
-        )
-        .await
-    }
-
-    pub fn send<T: EnvelopedMessage>(&self, payload: T) -> Result<()> {
-        log::debug!("ssh send name:{}", T::NAME);
-        self.send_dynamic(payload.into_envelope(0, None, None))
-    }
-
-    fn request_dynamic(
-        &self,
-        mut envelope: proto::Envelope,
-        type_name: &'static str,
-        use_buffer: bool,
-    ) -> impl 'static + Future<Output = Result<proto::Envelope>> {
-        envelope.id = self.next_message_id.fetch_add(1, SeqCst);
-        let (tx, rx) = oneshot::channel();
-        let mut response_channels_lock = self.response_channels.lock();
-        response_channels_lock.insert(MessageId(envelope.id), tx);
-        drop(response_channels_lock);
-
-        let result = if use_buffer {
-            self.send_buffered(envelope)
-        } else {
-            self.send_unbuffered(envelope)
-        };
-        async move {
-            if let Err(error) = &result {
-                log::error!("failed to send message: {error}");
-                anyhow::bail!("failed to send message: {error}");
-            }
-
-            let response = rx.await.context("connection lost")?.0;
-            if let Some(proto::envelope::Payload::Error(error)) = &response.payload {
-                return Err(RpcError::from_proto(error, type_name));
-            }
-            Ok(response)
-        }
-    }
-
-    pub fn send_dynamic(&self, mut envelope: proto::Envelope) -> Result<()> {
-        envelope.id = self.next_message_id.fetch_add(1, SeqCst);
-        self.send_buffered(envelope)
-    }
-
-    fn send_buffered(&self, mut envelope: proto::Envelope) -> Result<()> {
-        envelope.ack_id = Some(self.max_received.load(SeqCst));
-        self.buffer.lock().push_back(envelope.clone());
-        // ignore errors on send (happen while we're reconnecting)
-        // assume that the global "disconnected" overlay is sufficient.
-        self.outgoing_tx.lock().unbounded_send(envelope).ok();
-        Ok(())
-    }
-
-    fn send_unbuffered(&self, mut envelope: proto::Envelope) -> Result<()> {
-        envelope.ack_id = Some(self.max_received.load(SeqCst));
-        self.outgoing_tx.lock().unbounded_send(envelope).ok();
-        Ok(())
-    }
-}
-
-impl ProtoClient for ChannelClient {
-    fn request(
-        &self,
-        envelope: proto::Envelope,
-        request_type: &'static str,
-    ) -> BoxFuture<'static, Result<proto::Envelope>> {
-        self.request_dynamic(envelope, request_type, true).boxed()
-    }
-
-    fn send(&self, envelope: proto::Envelope, _message_type: &'static str) -> Result<()> {
-        self.send_dynamic(envelope)
-    }
-
-    fn send_response(&self, envelope: Envelope, _message_type: &'static str) -> anyhow::Result<()> {
-        self.send_dynamic(envelope)
-    }
-
-    fn message_handler_set(&self) -> &Mutex<ProtoMessageHandlerSet> {
-        &self.message_handlers
-    }
-
-    fn is_via_collab(&self) -> bool {
-        false
-    }
-}
-
-#[cfg(any(test, feature = "test-support"))]
-mod fake {
-    use std::{path::PathBuf, sync::Arc};
-
-    use anyhow::Result;
-    use async_trait::async_trait;
-    use futures::{
-        FutureExt, SinkExt, StreamExt,
-        channel::{
-            mpsc::{self, Sender},
-            oneshot,
-        },
-        select_biased,
-    };
-    use gpui::{App, AppContext as _, AsyncApp, SemanticVersion, Task, TestAppContext};
-    use release_channel::ReleaseChannel;
-    use rpc::proto::Envelope;
-    use util::paths::{PathStyle, RemotePathBuf};
-
-    use super::{
-        ChannelClient, RemoteConnection, SshArgs, SshClientDelegate, SshConnectionOptions,
-        SshPlatform,
-    };
-
-    pub(super) struct FakeRemoteConnection {
-        pub(super) connection_options: SshConnectionOptions,
-        pub(super) server_channel: Arc<ChannelClient>,
-        pub(super) server_cx: SendableCx,
-    }
-
-    pub(super) struct SendableCx(AsyncApp);
-    impl SendableCx {
-        // SAFETY: When run in test mode, GPUI is always single threaded.
-        pub(super) fn new(cx: &TestAppContext) -> Self {
-            Self(cx.to_async())
-        }
-
-        // SAFETY: Enforce that we're on the main thread by requiring a valid AsyncApp
-        fn get(&self, _: &AsyncApp) -> AsyncApp {
-            self.0.clone()
-        }
-    }
-
-    // SAFETY: There is no way to access a SendableCx from a different thread, see [`SendableCx::new`] and [`SendableCx::get`]
-    unsafe impl Send for SendableCx {}
-    unsafe impl Sync for SendableCx {}
-
-    #[async_trait(?Send)]
-    impl RemoteConnection for FakeRemoteConnection {
-        async fn kill(&self) -> Result<()> {
-            Ok(())
-        }
-
-        fn has_been_killed(&self) -> bool {
-            false
-        }
-
-        fn ssh_args(&self) -> SshArgs {
-            SshArgs {
-                arguments: Vec::new(),
-                envs: None,
-            }
-        }
-
-        fn upload_directory(
-            &self,
-            _src_path: PathBuf,
-            _dest_path: RemotePathBuf,
-            _cx: &App,
-        ) -> Task<Result<()>> {
-            unreachable!()
-        }
-
-        fn connection_options(&self) -> SshConnectionOptions {
-            self.connection_options.clone()
-        }
-
-        fn simulate_disconnect(&self, cx: &AsyncApp) {
-            let (outgoing_tx, _) = mpsc::unbounded::<Envelope>();
-            let (_, incoming_rx) = mpsc::unbounded::<Envelope>();
-            self.server_channel
-                .reconnect(incoming_rx, outgoing_tx, &self.server_cx.get(cx));
-        }
-
-        fn start_proxy(
-            &self,
-            _unique_identifier: String,
-            _reconnect: bool,
-            mut client_incoming_tx: mpsc::UnboundedSender<Envelope>,
-            mut client_outgoing_rx: mpsc::UnboundedReceiver<Envelope>,
-            mut connection_activity_tx: Sender<()>,
-            _delegate: Arc<dyn SshClientDelegate>,
-            cx: &mut AsyncApp,
-        ) -> Task<Result<i32>> {
-            let (mut server_incoming_tx, server_incoming_rx) = mpsc::unbounded::<Envelope>();
-            let (server_outgoing_tx, mut server_outgoing_rx) = mpsc::unbounded::<Envelope>();
-
-            self.server_channel.reconnect(
-                server_incoming_rx,
-                server_outgoing_tx,
-                &self.server_cx.get(cx),
-            );
-
-            cx.background_spawn(async move {
-                loop {
-                    select_biased! {
-                        server_to_client = server_outgoing_rx.next().fuse() => {
-                            let Some(server_to_client) = server_to_client else {
-                                return Ok(1)
-                            };
-                            connection_activity_tx.try_send(()).ok();
-                            client_incoming_tx.send(server_to_client).await.ok();
-                        }
-                        client_to_server = client_outgoing_rx.next().fuse() => {
-                            let Some(client_to_server) = client_to_server else {
-                                return Ok(1)
-                            };
-                            server_incoming_tx.send(client_to_server).await.ok();
-                        }
-                    }
-                }
-            })
-        }
-
-        fn path_style(&self) -> PathStyle {
-            PathStyle::current()
-        }
-
-        fn shell(&self) -> String {
-            "sh".to_owned()
-        }
-    }
-
-    pub(super) struct Delegate;
-
-    impl SshClientDelegate for Delegate {
-        fn ask_password(&self, _: String, _: oneshot::Sender<String>, _: &mut AsyncApp) {
-            unreachable!()
-        }
-
-        fn download_server_binary_locally(
-            &self,
-            _: SshPlatform,
-            _: ReleaseChannel,
-            _: Option<SemanticVersion>,
-            _: &mut AsyncApp,
-        ) -> Task<Result<PathBuf>> {
-            unreachable!()
-        }
-
-        fn get_download_params(
-            &self,
-            _platform: SshPlatform,
-            _release_channel: ReleaseChannel,
-            _version: Option<SemanticVersion>,
-            _cx: &mut AsyncApp,
-        ) -> Task<Result<Option<(String, String)>>> {
-            unreachable!()
-        }
-
-        fn set_status(&self, _: Option<&str>, _: &mut AsyncApp) {}
-    }
-}

crates/remote/src/transport/ssh.rs 🔗

@@ -0,0 +1,1358 @@
+use crate::{
+    RemoteClientDelegate, RemotePlatform,
+    json_log::LogRecord,
+    protocol::{MESSAGE_LEN_SIZE, message_len_from_buffer, read_message_with_len, write_message},
+    remote_client::{CommandTemplate, RemoteConnection},
+};
+use anyhow::{Context as _, Result, anyhow};
+use async_trait::async_trait;
+use collections::HashMap;
+use futures::{
+    AsyncReadExt as _, FutureExt as _, StreamExt as _,
+    channel::mpsc::{Sender, UnboundedReceiver, UnboundedSender},
+    select_biased,
+};
+use gpui::{App, AppContext as _, AsyncApp, SemanticVersion, Task};
+use itertools::Itertools;
+use parking_lot::Mutex;
+use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
+use rpc::proto::Envelope;
+use schemars::JsonSchema;
+use serde::{Deserialize, Serialize};
+use smol::{
+    fs,
+    process::{self, Child, Stdio},
+};
+use std::{
+    iter,
+    path::{Path, PathBuf},
+    sync::Arc,
+    time::Instant,
+};
+use tempfile::TempDir;
+use util::paths::{PathStyle, RemotePathBuf};
+
+pub(crate) struct SshRemoteConnection {
+    socket: SshSocket,
+    master_process: Mutex<Option<Child>>,
+    remote_binary_path: Option<RemotePathBuf>,
+    ssh_platform: RemotePlatform,
+    ssh_path_style: PathStyle,
+    ssh_shell: String,
+    _temp_dir: TempDir,
+}
+
+#[derive(Debug, Default, Clone, PartialEq, Eq, Hash)]
+pub struct SshConnectionOptions {
+    pub host: String,
+    pub username: Option<String>,
+    pub port: Option<u16>,
+    pub password: Option<String>,
+    pub args: Option<Vec<String>>,
+    pub port_forwards: Option<Vec<SshPortForwardOption>>,
+
+    pub nickname: Option<String>,
+    pub upload_binary_over_ssh: bool,
+}
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize, JsonSchema)]
+pub struct SshPortForwardOption {
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub local_host: Option<String>,
+    pub local_port: u16,
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub remote_host: Option<String>,
+    pub remote_port: u16,
+}
+
+#[derive(Clone)]
+struct SshSocket {
+    connection_options: SshConnectionOptions,
+    #[cfg(not(target_os = "windows"))]
+    socket_path: PathBuf,
+    envs: HashMap<String, String>,
+}
+
+macro_rules! shell_script {
+    ($fmt:expr, $($name:ident = $arg:expr),+ $(,)?) => {{
+        format!(
+            $fmt,
+            $(
+                $name = shlex::try_quote($arg).unwrap()
+            ),+
+        )
+    }};
+}
+
+#[async_trait(?Send)]
+impl RemoteConnection for SshRemoteConnection {
+    async fn kill(&self) -> Result<()> {
+        let Some(mut process) = self.master_process.lock().take() else {
+            return Ok(());
+        };
+        process.kill().ok();
+        process.status().await?;
+        Ok(())
+    }
+
+    fn has_been_killed(&self) -> bool {
+        self.master_process.lock().is_none()
+    }
+
+    fn connection_options(&self) -> SshConnectionOptions {
+        self.socket.connection_options.clone()
+    }
+
+    fn shell(&self) -> String {
+        self.ssh_shell.clone()
+    }
+
+    fn build_command(
+        &self,
+        input_program: Option<String>,
+        input_args: &[String],
+        input_env: &HashMap<String, String>,
+        working_dir: Option<String>,
+        port_forward: Option<(u16, String, u16)>,
+    ) -> Result<CommandTemplate> {
+        use std::fmt::Write as _;
+
+        let mut script = String::new();
+        if let Some(working_dir) = working_dir {
+            let working_dir =
+                RemotePathBuf::new(working_dir.into(), self.ssh_path_style).to_string();
+
+            // shlex will wrap the command in single quotes (''), disabling ~ expansion,
+            // replace ith with something that works
+            const TILDE_PREFIX: &'static str = "~/";
+            if working_dir.starts_with(TILDE_PREFIX) {
+                let working_dir = working_dir.trim_start_matches("~").trim_start_matches("/");
+                write!(&mut script, "cd \"$HOME/{working_dir}\"; ").unwrap();
+            } else {
+                write!(&mut script, "cd \"{working_dir}\"; ").unwrap();
+            }
+        } else {
+            write!(&mut script, "cd; ").unwrap();
+        };
+
+        for (k, v) in input_env.iter() {
+            if let Some((k, v)) = shlex::try_quote(k).ok().zip(shlex::try_quote(v).ok()) {
+                write!(&mut script, "{}={} ", k, v).unwrap();
+            }
+        }
+
+        let shell = &self.ssh_shell;
+
+        if let Some(input_program) = input_program {
+            let command = shlex::try_quote(&input_program)?;
+            script.push_str(&command);
+            for arg in input_args {
+                let arg = shlex::try_quote(&arg)?;
+                script.push_str(" ");
+                script.push_str(&arg);
+            }
+        } else {
+            write!(&mut script, "exec {shell} -l").unwrap();
+        };
+
+        let shell_invocation = format!("{shell} -c {}", shlex::try_quote(&script).unwrap());
+
+        let mut args = Vec::new();
+        args.extend(self.socket.ssh_args());
+
+        if let Some((local_port, host, remote_port)) = port_forward {
+            args.push("-L".into());
+            args.push(format!("{local_port}:{host}:{remote_port}"));
+        }
+
+        args.push("-t".into());
+        args.push(shell_invocation);
+
+        Ok(CommandTemplate {
+            program: "ssh".into(),
+            args,
+            env: self.socket.envs.clone(),
+        })
+    }
+
+    fn upload_directory(
+        &self,
+        src_path: PathBuf,
+        dest_path: RemotePathBuf,
+        cx: &App,
+    ) -> Task<Result<()>> {
+        let mut command = util::command::new_smol_command("scp");
+        let output = self
+            .socket
+            .ssh_options(&mut command)
+            .args(
+                self.socket
+                    .connection_options
+                    .port
+                    .map(|port| vec!["-P".to_string(), port.to_string()])
+                    .unwrap_or_default(),
+            )
+            .arg("-C")
+            .arg("-r")
+            .arg(&src_path)
+            .arg(format!(
+                "{}:{}",
+                self.socket.connection_options.scp_url(),
+                dest_path
+            ))
+            .output();
+
+        cx.background_spawn(async move {
+            let output = output.await?;
+
+            anyhow::ensure!(
+                output.status.success(),
+                "failed to upload directory {} -> {}: {}",
+                src_path.display(),
+                dest_path.to_string(),
+                String::from_utf8_lossy(&output.stderr)
+            );
+
+            Ok(())
+        })
+    }
+
+    fn start_proxy(
+        &self,
+        unique_identifier: String,
+        reconnect: bool,
+        incoming_tx: UnboundedSender<Envelope>,
+        outgoing_rx: UnboundedReceiver<Envelope>,
+        connection_activity_tx: Sender<()>,
+        delegate: Arc<dyn RemoteClientDelegate>,
+        cx: &mut AsyncApp,
+    ) -> Task<Result<i32>> {
+        delegate.set_status(Some("Starting proxy"), cx);
+
+        let Some(remote_binary_path) = self.remote_binary_path.clone() else {
+            return Task::ready(Err(anyhow!("Remote binary path not set")));
+        };
+
+        let mut start_proxy_command = shell_script!(
+            "exec {binary_path} proxy --identifier {identifier}",
+            binary_path = &remote_binary_path.to_string(),
+            identifier = &unique_identifier,
+        );
+
+        for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] {
+            if let Some(value) = std::env::var(env_var).ok() {
+                start_proxy_command = format!(
+                    "{}={} {} ",
+                    env_var,
+                    shlex::try_quote(&value).unwrap(),
+                    start_proxy_command,
+                );
+            }
+        }
+
+        if reconnect {
+            start_proxy_command.push_str(" --reconnect");
+        }
+
+        let ssh_proxy_process = match self
+            .socket
+            .ssh_command("sh", &["-lc", &start_proxy_command])
+            // IMPORTANT: we kill this process when we drop the task that uses it.
+            .kill_on_drop(true)
+            .spawn()
+        {
+            Ok(process) => process,
+            Err(error) => {
+                return Task::ready(Err(anyhow!("failed to spawn remote server: {}", error)));
+            }
+        };
+
+        Self::multiplex(
+            ssh_proxy_process,
+            incoming_tx,
+            outgoing_rx,
+            connection_activity_tx,
+            cx,
+        )
+    }
+
+    fn path_style(&self) -> PathStyle {
+        self.ssh_path_style
+    }
+}
+
+impl SshRemoteConnection {
+    pub(crate) async fn new(
+        connection_options: SshConnectionOptions,
+        delegate: Arc<dyn RemoteClientDelegate>,
+        cx: &mut AsyncApp,
+    ) -> Result<Self> {
+        use askpass::AskPassResult;
+
+        delegate.set_status(Some("Connecting"), cx);
+
+        let url = connection_options.ssh_url();
+
+        let temp_dir = tempfile::Builder::new()
+            .prefix("zed-ssh-session")
+            .tempdir()?;
+        let askpass_delegate = askpass::AskPassDelegate::new(cx, {
+            let delegate = delegate.clone();
+            move |prompt, tx, cx| delegate.ask_password(prompt, tx, cx)
+        });
+
+        let mut askpass =
+            askpass::AskPassSession::new(cx.background_executor(), askpass_delegate).await?;
+
+        // Start the master SSH process, which does not do anything except for establish
+        // the connection and keep it open, allowing other ssh commands to reuse it
+        // via a control socket.
+        #[cfg(not(target_os = "windows"))]
+        let socket_path = temp_dir.path().join("ssh.sock");
+
+        let mut master_process = {
+            #[cfg(not(target_os = "windows"))]
+            let args = [
+                "-N",
+                "-o",
+                "ControlPersist=no",
+                "-o",
+                "ControlMaster=yes",
+                "-o",
+            ];
+            // On Windows, `ControlMaster` and `ControlPath` are not supported:
+            // https://github.com/PowerShell/Win32-OpenSSH/issues/405
+            // https://github.com/PowerShell/Win32-OpenSSH/wiki/Project-Scope
+            #[cfg(target_os = "windows")]
+            let args = ["-N"];
+            let mut master_process = util::command::new_smol_command("ssh");
+            master_process
+                .kill_on_drop(true)
+                .stdin(Stdio::null())
+                .stdout(Stdio::piped())
+                .stderr(Stdio::piped())
+                .env("SSH_ASKPASS_REQUIRE", "force")
+                .env("SSH_ASKPASS", askpass.script_path())
+                .args(connection_options.additional_args())
+                .args(args);
+            #[cfg(not(target_os = "windows"))]
+            master_process.arg(format!("ControlPath={}", socket_path.display()));
+            master_process.arg(&url).spawn()?
+        };
+        // Wait for this ssh process to close its stdout, indicating that authentication
+        // has completed.
+        let mut stdout = master_process.stdout.take().unwrap();
+        let mut output = Vec::new();
+
+        let result = select_biased! {
+            result = askpass.run().fuse() => {
+                match result {
+                    AskPassResult::CancelledByUser => {
+                        master_process.kill().ok();
+                        anyhow::bail!("SSH connection canceled")
+                    }
+                    AskPassResult::Timedout => {
+                        anyhow::bail!("connecting to host timed out")
+                    }
+                }
+            }
+            _ = stdout.read_to_end(&mut output).fuse() => {
+                anyhow::Ok(())
+            }
+        };
+
+        if let Err(e) = result {
+            return Err(e.context("Failed to connect to host"));
+        }
+
+        if master_process.try_status()?.is_some() {
+            output.clear();
+            let mut stderr = master_process.stderr.take().unwrap();
+            stderr.read_to_end(&mut output).await?;
+
+            let error_message = format!(
+                "failed to connect: {}",
+                String::from_utf8_lossy(&output).trim()
+            );
+            anyhow::bail!(error_message);
+        }
+
+        #[cfg(not(target_os = "windows"))]
+        let socket = SshSocket::new(connection_options, socket_path)?;
+        #[cfg(target_os = "windows")]
+        let socket = SshSocket::new(connection_options, &temp_dir, askpass.get_password())?;
+        drop(askpass);
+
+        let ssh_platform = socket.platform().await?;
+        let ssh_path_style = match ssh_platform.os {
+            "windows" => PathStyle::Windows,
+            _ => PathStyle::Posix,
+        };
+        let ssh_shell = socket.shell().await;
+
+        let mut this = Self {
+            socket,
+            master_process: Mutex::new(Some(master_process)),
+            _temp_dir: temp_dir,
+            remote_binary_path: None,
+            ssh_path_style,
+            ssh_platform,
+            ssh_shell,
+        };
+
+        let (release_channel, version, commit) = cx.update(|cx| {
+            (
+                ReleaseChannel::global(cx),
+                AppVersion::global(cx),
+                AppCommitSha::try_global(cx),
+            )
+        })?;
+        this.remote_binary_path = Some(
+            this.ensure_server_binary(&delegate, release_channel, version, commit, cx)
+                .await?,
+        );
+
+        Ok(this)
+    }
+
+    fn multiplex(
+        mut ssh_proxy_process: Child,
+        incoming_tx: UnboundedSender<Envelope>,
+        mut outgoing_rx: UnboundedReceiver<Envelope>,
+        mut connection_activity_tx: Sender<()>,
+        cx: &AsyncApp,
+    ) -> Task<Result<i32>> {
+        let mut child_stderr = ssh_proxy_process.stderr.take().unwrap();
+        let mut child_stdout = ssh_proxy_process.stdout.take().unwrap();
+        let mut child_stdin = ssh_proxy_process.stdin.take().unwrap();
+
+        let mut stdin_buffer = Vec::new();
+        let mut stdout_buffer = Vec::new();
+        let mut stderr_buffer = Vec::new();
+        let mut stderr_offset = 0;
+
+        let stdin_task = cx.background_spawn(async move {
+            while let Some(outgoing) = outgoing_rx.next().await {
+                write_message(&mut child_stdin, &mut stdin_buffer, outgoing).await?;
+            }
+            anyhow::Ok(())
+        });
+
+        let stdout_task = cx.background_spawn({
+            let mut connection_activity_tx = connection_activity_tx.clone();
+            async move {
+                loop {
+                    stdout_buffer.resize(MESSAGE_LEN_SIZE, 0);
+                    let len = child_stdout.read(&mut stdout_buffer).await?;
+
+                    if len == 0 {
+                        return anyhow::Ok(());
+                    }
+
+                    if len < MESSAGE_LEN_SIZE {
+                        child_stdout.read_exact(&mut stdout_buffer[len..]).await?;
+                    }
+
+                    let message_len = message_len_from_buffer(&stdout_buffer);
+                    let envelope =
+                        read_message_with_len(&mut child_stdout, &mut stdout_buffer, message_len)
+                            .await?;
+                    connection_activity_tx.try_send(()).ok();
+                    incoming_tx.unbounded_send(envelope).ok();
+                }
+            }
+        });
+
+        let stderr_task: Task<anyhow::Result<()>> = cx.background_spawn(async move {
+            loop {
+                stderr_buffer.resize(stderr_offset + 1024, 0);
+
+                let len = child_stderr
+                    .read(&mut stderr_buffer[stderr_offset..])
+                    .await?;
+                if len == 0 {
+                    return anyhow::Ok(());
+                }
+
+                stderr_offset += len;
+                let mut start_ix = 0;
+                while let Some(ix) = stderr_buffer[start_ix..stderr_offset]
+                    .iter()
+                    .position(|b| b == &b'\n')
+                {
+                    let line_ix = start_ix + ix;
+                    let content = &stderr_buffer[start_ix..line_ix];
+                    start_ix = line_ix + 1;
+                    if let Ok(record) = serde_json::from_slice::<LogRecord>(content) {
+                        record.log(log::logger())
+                    } else {
+                        eprintln!("(remote) {}", String::from_utf8_lossy(content));
+                    }
+                }
+                stderr_buffer.drain(0..start_ix);
+                stderr_offset -= start_ix;
+
+                connection_activity_tx.try_send(()).ok();
+            }
+        });
+
+        cx.background_spawn(async move {
+            let result = futures::select! {
+                result = stdin_task.fuse() => {
+                    result.context("stdin")
+                }
+                result = stdout_task.fuse() => {
+                    result.context("stdout")
+                }
+                result = stderr_task.fuse() => {
+                    result.context("stderr")
+                }
+            };
+
+            let status = ssh_proxy_process.status().await?.code().unwrap_or(1);
+            match result {
+                Ok(_) => Ok(status),
+                Err(error) => Err(error),
+            }
+        })
+    }
+
+    #[allow(unused)]
+    async fn ensure_server_binary(
+        &self,
+        delegate: &Arc<dyn RemoteClientDelegate>,
+        release_channel: ReleaseChannel,
+        version: SemanticVersion,
+        commit: Option<AppCommitSha>,
+        cx: &mut AsyncApp,
+    ) -> Result<RemotePathBuf> {
+        let version_str = match release_channel {
+            ReleaseChannel::Nightly => {
+                let commit = commit.map(|s| s.full()).unwrap_or_default();
+                format!("{}-{}", version, commit)
+            }
+            ReleaseChannel::Dev => "build".to_string(),
+            _ => version.to_string(),
+        };
+        let binary_name = format!(
+            "zed-remote-server-{}-{}",
+            release_channel.dev_name(),
+            version_str
+        );
+        let dst_path = RemotePathBuf::new(
+            paths::remote_server_dir_relative().join(binary_name),
+            self.ssh_path_style,
+        );
+
+        let build_remote_server = std::env::var("ZED_BUILD_REMOTE_SERVER").ok();
+        #[cfg(debug_assertions)]
+        if let Some(build_remote_server) = build_remote_server {
+            let src_path = self.build_local(build_remote_server, delegate, cx).await?;
+            let tmp_path = RemotePathBuf::new(
+                paths::remote_server_dir_relative().join(format!(
+                    "download-{}-{}",
+                    std::process::id(),
+                    src_path.file_name().unwrap().to_string_lossy()
+                )),
+                self.ssh_path_style,
+            );
+            self.upload_local_server_binary(&src_path, &tmp_path, delegate, cx)
+                .await?;
+            self.extract_server_binary(&dst_path, &tmp_path, delegate, cx)
+                .await?;
+            return Ok(dst_path);
+        }
+
+        if self
+            .socket
+            .run_command(&dst_path.to_string(), &["version"])
+            .await
+            .is_ok()
+        {
+            return Ok(dst_path);
+        }
+
+        let wanted_version = cx.update(|cx| match release_channel {
+            ReleaseChannel::Nightly => Ok(None),
+            ReleaseChannel::Dev => {
+                anyhow::bail!(
+                    "ZED_BUILD_REMOTE_SERVER is not set and no remote server exists at ({:?})",
+                    dst_path
+                )
+            }
+            _ => Ok(Some(AppVersion::global(cx))),
+        })??;
+
+        let tmp_path_gz = RemotePathBuf::new(
+            PathBuf::from(format!("{}-download-{}.gz", dst_path, std::process::id())),
+            self.ssh_path_style,
+        );
+        if !self.socket.connection_options.upload_binary_over_ssh
+            && let Some((url, body)) = delegate
+                .get_download_params(self.ssh_platform, release_channel, wanted_version, cx)
+                .await?
+        {
+            match self
+                .download_binary_on_server(&url, &body, &tmp_path_gz, delegate, cx)
+                .await
+            {
+                Ok(_) => {
+                    self.extract_server_binary(&dst_path, &tmp_path_gz, delegate, cx)
+                        .await?;
+                    return Ok(dst_path);
+                }
+                Err(e) => {
+                    log::error!(
+                        "Failed to download binary on server, attempting to upload server: {}",
+                        e
+                    )
+                }
+            }
+        }
+
+        let src_path = delegate
+            .download_server_binary_locally(self.ssh_platform, release_channel, wanted_version, cx)
+            .await?;
+        self.upload_local_server_binary(&src_path, &tmp_path_gz, delegate, cx)
+            .await?;
+        self.extract_server_binary(&dst_path, &tmp_path_gz, delegate, cx)
+            .await?;
+        Ok(dst_path)
+    }
+
+    async fn download_binary_on_server(
+        &self,
+        url: &str,
+        body: &str,
+        tmp_path_gz: &RemotePathBuf,
+        delegate: &Arc<dyn RemoteClientDelegate>,
+        cx: &mut AsyncApp,
+    ) -> Result<()> {
+        if let Some(parent) = tmp_path_gz.parent() {
+            self.socket
+                .run_command(
+                    "sh",
+                    &[
+                        "-lc",
+                        &shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()),
+                    ],
+                )
+                .await?;
+        }
+
+        delegate.set_status(Some("Downloading remote development server on host"), cx);
+
+        match self
+            .socket
+            .run_command(
+                "curl",
+                &[
+                    "-f",
+                    "-L",
+                    "-X",
+                    "GET",
+                    "-H",
+                    "Content-Type: application/json",
+                    "-d",
+                    body,
+                    url,
+                    "-o",
+                    &tmp_path_gz.to_string(),
+                ],
+            )
+            .await
+        {
+            Ok(_) => {}
+            Err(e) => {
+                if self.socket.run_command("which", &["curl"]).await.is_ok() {
+                    return Err(e);
+                }
+
+                match self
+                    .socket
+                    .run_command(
+                        "wget",
+                        &[
+                            "--method=GET",
+                            "--header=Content-Type: application/json",
+                            "--body-data",
+                            body,
+                            url,
+                            "-O",
+                            &tmp_path_gz.to_string(),
+                        ],
+                    )
+                    .await
+                {
+                    Ok(_) => {}
+                    Err(e) => {
+                        if self.socket.run_command("which", &["wget"]).await.is_ok() {
+                            return Err(e);
+                        } else {
+                            anyhow::bail!("Neither curl nor wget is available");
+                        }
+                    }
+                }
+            }
+        }
+
+        Ok(())
+    }
+
+    async fn upload_local_server_binary(
+        &self,
+        src_path: &Path,
+        tmp_path_gz: &RemotePathBuf,
+        delegate: &Arc<dyn RemoteClientDelegate>,
+        cx: &mut AsyncApp,
+    ) -> Result<()> {
+        if let Some(parent) = tmp_path_gz.parent() {
+            self.socket
+                .run_command(
+                    "sh",
+                    &[
+                        "-lc",
+                        &shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()),
+                    ],
+                )
+                .await?;
+        }
+
+        let src_stat = fs::metadata(&src_path).await?;
+        let size = src_stat.len();
+
+        let t0 = Instant::now();
+        delegate.set_status(Some("Uploading remote development server"), cx);
+        log::info!(
+            "uploading remote development server to {:?} ({}kb)",
+            tmp_path_gz,
+            size / 1024
+        );
+        self.upload_file(src_path, tmp_path_gz)
+            .await
+            .context("failed to upload server binary")?;
+        log::info!("uploaded remote development server in {:?}", t0.elapsed());
+        Ok(())
+    }
+
+    async fn extract_server_binary(
+        &self,
+        dst_path: &RemotePathBuf,
+        tmp_path: &RemotePathBuf,
+        delegate: &Arc<dyn RemoteClientDelegate>,
+        cx: &mut AsyncApp,
+    ) -> Result<()> {
+        delegate.set_status(Some("Extracting remote development server"), cx);
+        let server_mode = 0o755;
+
+        let orig_tmp_path = tmp_path.to_string();
+        let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".gz") {
+            shell_script!(
+                "gunzip -f {orig_tmp_path} && chmod {server_mode} {tmp_path} && mv {tmp_path} {dst_path}",
+                server_mode = &format!("{:o}", server_mode),
+                dst_path = &dst_path.to_string(),
+            )
+        } else {
+            shell_script!(
+                "chmod {server_mode} {orig_tmp_path} && mv {orig_tmp_path} {dst_path}",
+                server_mode = &format!("{:o}", server_mode),
+                dst_path = &dst_path.to_string()
+            )
+        };
+        self.socket.run_command("sh", &["-lc", &script]).await?;
+        Ok(())
+    }
+
+    async fn upload_file(&self, src_path: &Path, dest_path: &RemotePathBuf) -> Result<()> {
+        log::debug!("uploading file {:?} to {:?}", src_path, dest_path);
+        let mut command = util::command::new_smol_command("scp");
+        let output = self
+            .socket
+            .ssh_options(&mut command)
+            .args(
+                self.socket
+                    .connection_options
+                    .port
+                    .map(|port| vec!["-P".to_string(), port.to_string()])
+                    .unwrap_or_default(),
+            )
+            .arg(src_path)
+            .arg(format!(
+                "{}:{}",
+                self.socket.connection_options.scp_url(),
+                dest_path
+            ))
+            .output()
+            .await?;
+
+        anyhow::ensure!(
+            output.status.success(),
+            "failed to upload file {} -> {}: {}",
+            src_path.display(),
+            dest_path.to_string(),
+            String::from_utf8_lossy(&output.stderr)
+        );
+        Ok(())
+    }
+
+    #[cfg(debug_assertions)]
+    async fn build_local(
+        &self,
+        build_remote_server: String,
+        delegate: &Arc<dyn RemoteClientDelegate>,
+        cx: &mut AsyncApp,
+    ) -> Result<PathBuf> {
+        use smol::process::{Command, Stdio};
+        use std::env::VarError;
+
+        async fn run_cmd(command: &mut Command) -> Result<()> {
+            let output = command
+                .kill_on_drop(true)
+                .stderr(Stdio::inherit())
+                .output()
+                .await?;
+            anyhow::ensure!(
+                output.status.success(),
+                "Failed to run command: {command:?}"
+            );
+            Ok(())
+        }
+
+        let use_musl = !build_remote_server.contains("nomusl");
+        let triple = format!(
+            "{}-{}",
+            self.ssh_platform.arch,
+            match self.ssh_platform.os {
+                "linux" =>
+                    if use_musl {
+                        "unknown-linux-musl"
+                    } else {
+                        "unknown-linux-gnu"
+                    },
+                "macos" => "apple-darwin",
+                _ => anyhow::bail!("can't cross compile for: {:?}", self.ssh_platform),
+            }
+        );
+        let mut rust_flags = match std::env::var("RUSTFLAGS") {
+            Ok(val) => val,
+            Err(VarError::NotPresent) => String::new(),
+            Err(e) => {
+                log::error!("Failed to get env var `RUSTFLAGS` value: {e}");
+                String::new()
+            }
+        };
+        if self.ssh_platform.os == "linux" && use_musl {
+            rust_flags.push_str(" -C target-feature=+crt-static");
+        }
+        if build_remote_server.contains("mold") {
+            rust_flags.push_str(" -C link-arg=-fuse-ld=mold");
+        }
+
+        if self.ssh_platform.arch == std::env::consts::ARCH
+            && self.ssh_platform.os == std::env::consts::OS
+        {
+            delegate.set_status(Some("Building remote server binary from source"), cx);
+            log::info!("building remote server binary from source");
+            run_cmd(
+                Command::new("cargo")
+                    .args([
+                        "build",
+                        "--package",
+                        "remote_server",
+                        "--features",
+                        "debug-embed",
+                        "--target-dir",
+                        "target/remote_server",
+                        "--target",
+                        &triple,
+                    ])
+                    .env("RUSTFLAGS", &rust_flags),
+            )
+            .await?;
+        } else if build_remote_server.contains("cross") {
+            #[cfg(target_os = "windows")]
+            use util::paths::SanitizedPath;
+
+            delegate.set_status(Some("Installing cross.rs for cross-compilation"), cx);
+            log::info!("installing cross");
+            run_cmd(Command::new("cargo").args([
+                "install",
+                "cross",
+                "--git",
+                "https://github.com/cross-rs/cross",
+            ]))
+            .await?;
+
+            delegate.set_status(
+                Some(&format!(
+                    "Building remote server binary from source for {} with Docker",
+                    &triple
+                )),
+                cx,
+            );
+            log::info!("building remote server binary from source for {}", &triple);
+
+            // On Windows, the binding needs to be set to the canonical path
+            #[cfg(target_os = "windows")]
+            let src =
+                SanitizedPath::from(smol::fs::canonicalize("./target").await?).to_glob_string();
+            #[cfg(not(target_os = "windows"))]
+            let src = "./target";
+            run_cmd(
+                Command::new("cross")
+                    .args([
+                        "build",
+                        "--package",
+                        "remote_server",
+                        "--features",
+                        "debug-embed",
+                        "--target-dir",
+                        "target/remote_server",
+                        "--target",
+                        &triple,
+                    ])
+                    .env(
+                        "CROSS_CONTAINER_OPTS",
+                        format!("--mount type=bind,src={src},dst=/app/target"),
+                    )
+                    .env("RUSTFLAGS", &rust_flags),
+            )
+            .await?;
+        } else {
+            let which = cx
+                .background_spawn(async move { which::which("zig") })
+                .await;
+
+            if which.is_err() {
+                #[cfg(not(target_os = "windows"))]
+                {
+                    anyhow::bail!(
+                        "zig not found on $PATH, install zig (see https://ziglang.org/learn/getting-started or use zigup) or pass ZED_BUILD_REMOTE_SERVER=cross to use cross"
+                    )
+                }
+                #[cfg(target_os = "windows")]
+                {
+                    anyhow::bail!(
+                        "zig not found on $PATH, install zig (use `winget install -e --id zig.zig` or see https://ziglang.org/learn/getting-started or use zigup) or pass ZED_BUILD_REMOTE_SERVER=cross to use cross"
+                    )
+                }
+            }
+
+            delegate.set_status(Some("Adding rustup target for cross-compilation"), cx);
+            log::info!("adding rustup target");
+            run_cmd(Command::new("rustup").args(["target", "add"]).arg(&triple)).await?;
+
+            delegate.set_status(Some("Installing cargo-zigbuild for cross-compilation"), cx);
+            log::info!("installing cargo-zigbuild");
+            run_cmd(Command::new("cargo").args(["install", "--locked", "cargo-zigbuild"])).await?;
+
+            delegate.set_status(
+                Some(&format!(
+                    "Building remote binary from source for {triple} with Zig"
+                )),
+                cx,
+            );
+            log::info!("building remote binary from source for {triple} with Zig");
+            run_cmd(
+                Command::new("cargo")
+                    .args([
+                        "zigbuild",
+                        "--package",
+                        "remote_server",
+                        "--features",
+                        "debug-embed",
+                        "--target-dir",
+                        "target/remote_server",
+                        "--target",
+                        &triple,
+                    ])
+                    .env("RUSTFLAGS", &rust_flags),
+            )
+            .await?;
+        };
+        let bin_path = Path::new("target")
+            .join("remote_server")
+            .join(&triple)
+            .join("debug")
+            .join("remote_server");
+
+        let path = if !build_remote_server.contains("nocompress") {
+            delegate.set_status(Some("Compressing binary"), cx);
+
+            #[cfg(not(target_os = "windows"))]
+            {
+                run_cmd(Command::new("gzip").args(["-f", &bin_path.to_string_lossy()])).await?;
+            }
+            #[cfg(target_os = "windows")]
+            {
+                // On Windows, we use 7z to compress the binary
+                let seven_zip = which::which("7z.exe").context("7z.exe not found on $PATH, install it (e.g. with `winget install -e --id 7zip.7zip`) or, if you don't want this behaviour, set $env:ZED_BUILD_REMOTE_SERVER=\"nocompress\"")?;
+                let gz_path = format!("target/remote_server/{}/debug/remote_server.gz", triple);
+                if smol::fs::metadata(&gz_path).await.is_ok() {
+                    smol::fs::remove_file(&gz_path).await?;
+                }
+                run_cmd(Command::new(seven_zip).args([
+                    "a",
+                    "-tgzip",
+                    &gz_path,
+                    &bin_path.to_string_lossy(),
+                ]))
+                .await?;
+            }
+
+            let mut archive_path = bin_path;
+            archive_path.set_extension("gz");
+            std::env::current_dir()?.join(archive_path)
+        } else {
+            bin_path
+        };
+
+        Ok(path)
+    }
+}
+
+impl SshSocket {
+    #[cfg(not(target_os = "windows"))]
+    fn new(options: SshConnectionOptions, socket_path: PathBuf) -> Result<Self> {
+        Ok(Self {
+            connection_options: options,
+            envs: HashMap::default(),
+            socket_path,
+        })
+    }
+
+    #[cfg(target_os = "windows")]
+    fn new(options: SshConnectionOptions, temp_dir: &TempDir, secret: String) -> Result<Self> {
+        let askpass_script = temp_dir.path().join("askpass.bat");
+        std::fs::write(&askpass_script, "@ECHO OFF\necho %ZED_SSH_ASKPASS%")?;
+        let mut envs = HashMap::default();
+        envs.insert("SSH_ASKPASS_REQUIRE".into(), "force".into());
+        envs.insert("SSH_ASKPASS".into(), askpass_script.display().to_string());
+        envs.insert("ZED_SSH_ASKPASS".into(), secret);
+        Ok(Self {
+            connection_options: options,
+            envs,
+        })
+    }
+
+    // :WARNING: ssh unquotes arguments when executing on the remote :WARNING:
+    // e.g. $ ssh host sh -c 'ls -l' is equivalent to $ ssh host sh -c ls -l
+    // and passes -l as an argument to sh, not to ls.
+    // Furthermore, some setups (e.g. Coder) will change directory when SSH'ing
+    // into a machine. You must use `cd` to get back to $HOME.
+    // You need to do it like this: $ ssh host "cd; sh -c 'ls -l /tmp'"
+    fn ssh_command(&self, program: &str, args: &[&str]) -> process::Command {
+        let mut command = util::command::new_smol_command("ssh");
+        let to_run = iter::once(&program)
+            .chain(args.iter())
+            .map(|token| {
+                // We're trying to work with: sh, bash, zsh, fish, tcsh, ...?
+                debug_assert!(
+                    !token.contains('\n'),
+                    "multiline arguments do not work in all shells"
+                );
+                shlex::try_quote(token).unwrap()
+            })
+            .join(" ");
+        let to_run = format!("cd; {to_run}");
+        log::debug!("ssh {} {:?}", self.connection_options.ssh_url(), to_run);
+        self.ssh_options(&mut command)
+            .arg(self.connection_options.ssh_url())
+            .arg(to_run);
+        command
+    }
+
+    async fn run_command(&self, program: &str, args: &[&str]) -> Result<String> {
+        let output = self.ssh_command(program, args).output().await?;
+        anyhow::ensure!(
+            output.status.success(),
+            "failed to run command: {}",
+            String::from_utf8_lossy(&output.stderr)
+        );
+        Ok(String::from_utf8_lossy(&output.stdout).to_string())
+    }
+
+    #[cfg(not(target_os = "windows"))]
+    fn ssh_options<'a>(&self, command: &'a mut process::Command) -> &'a mut process::Command {
+        command
+            .stdin(Stdio::piped())
+            .stdout(Stdio::piped())
+            .stderr(Stdio::piped())
+            .args(self.connection_options.additional_args())
+            .args(["-o", "ControlMaster=no", "-o"])
+            .arg(format!("ControlPath={}", self.socket_path.display()))
+    }
+
+    #[cfg(target_os = "windows")]
+    fn ssh_options<'a>(&self, command: &'a mut process::Command) -> &'a mut process::Command {
+        command
+            .stdin(Stdio::piped())
+            .stdout(Stdio::piped())
+            .stderr(Stdio::piped())
+            .args(self.connection_options.additional_args())
+            .envs(self.envs.clone())
+    }
+
+    // On Windows, we need to use `SSH_ASKPASS` to provide the password to ssh.
+    // On Linux, we use the `ControlPath` option to create a socket file that ssh can use to
+    #[cfg(not(target_os = "windows"))]
+    fn ssh_args(&self) -> Vec<String> {
+        let mut arguments = self.connection_options.additional_args();
+        arguments.extend(vec![
+            "-o".to_string(),
+            "ControlMaster=no".to_string(),
+            "-o".to_string(),
+            format!("ControlPath={}", self.socket_path.display()),
+            self.connection_options.ssh_url(),
+        ]);
+        arguments
+    }
+
+    #[cfg(target_os = "windows")]
+    fn ssh_args(&self) -> Vec<String> {
+        let mut arguments = self.connection_options.additional_args();
+        arguments.push(self.connection_options.ssh_url());
+        arguments
+    }
+
+    async fn platform(&self) -> Result<RemotePlatform> {
+        let uname = self.run_command("sh", &["-lc", "uname -sm"]).await?;
+        let Some((os, arch)) = uname.split_once(" ") else {
+            anyhow::bail!("unknown uname: {uname:?}")
+        };
+
+        let os = match os.trim() {
+            "Darwin" => "macos",
+            "Linux" => "linux",
+            _ => anyhow::bail!(
+                "Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development"
+            ),
+        };
+        // exclude armv5,6,7 as they are 32-bit.
+        let arch = if arch.starts_with("armv8")
+            || arch.starts_with("armv9")
+            || arch.starts_with("arm64")
+            || arch.starts_with("aarch64")
+        {
+            "aarch64"
+        } else if arch.starts_with("x86") {
+            "x86_64"
+        } else {
+            anyhow::bail!(
+                "Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development"
+            )
+        };
+
+        Ok(RemotePlatform { os, arch })
+    }
+
+    async fn shell(&self) -> String {
+        match self.run_command("sh", &["-lc", "echo $SHELL"]).await {
+            Ok(shell) => shell.trim().to_owned(),
+            Err(e) => {
+                log::error!("Failed to get shell: {e}");
+                "sh".to_owned()
+            }
+        }
+    }
+}
+
+fn parse_port_number(port_str: &str) -> Result<u16> {
+    port_str
+        .parse()
+        .with_context(|| format!("parsing port number: {port_str}"))
+}
+
+fn parse_port_forward_spec(spec: &str) -> Result<SshPortForwardOption> {
+    let parts: Vec<&str> = spec.split(':').collect();
+
+    match parts.len() {
+        4 => {
+            let local_port = parse_port_number(parts[1])?;
+            let remote_port = parse_port_number(parts[3])?;
+
+            Ok(SshPortForwardOption {
+                local_host: Some(parts[0].to_string()),
+                local_port,
+                remote_host: Some(parts[2].to_string()),
+                remote_port,
+            })
+        }
+        3 => {
+            let local_port = parse_port_number(parts[0])?;
+            let remote_port = parse_port_number(parts[2])?;
+
+            Ok(SshPortForwardOption {
+                local_host: None,
+                local_port,
+                remote_host: Some(parts[1].to_string()),
+                remote_port,
+            })
+        }
+        _ => anyhow::bail!("Invalid port forward format"),
+    }
+}
+
+impl SshConnectionOptions {
+    pub fn parse_command_line(input: &str) -> Result<Self> {
+        let input = input.trim_start_matches("ssh ");
+        let mut hostname: Option<String> = None;
+        let mut username: Option<String> = None;
+        let mut port: Option<u16> = None;
+        let mut args = Vec::new();
+        let mut port_forwards: Vec<SshPortForwardOption> = Vec::new();
+
+        // disallowed: -E, -e, -F, -f, -G, -g, -M, -N, -n, -O, -q, -S, -s, -T, -t, -V, -v, -W
+        const ALLOWED_OPTS: &[&str] = &[
+            "-4", "-6", "-A", "-a", "-C", "-K", "-k", "-X", "-x", "-Y", "-y",
+        ];
+        const ALLOWED_ARGS: &[&str] = &[
+            "-B", "-b", "-c", "-D", "-F", "-I", "-i", "-J", "-l", "-m", "-o", "-P", "-p", "-R",
+            "-w",
+        ];
+
+        let mut tokens = shlex::split(input).context("invalid input")?.into_iter();
+
+        'outer: while let Some(arg) = tokens.next() {
+            if ALLOWED_OPTS.contains(&(&arg as &str)) {
+                args.push(arg.to_string());
+                continue;
+            }
+            if arg == "-p" {
+                port = tokens.next().and_then(|arg| arg.parse().ok());
+                continue;
+            } else if let Some(p) = arg.strip_prefix("-p") {
+                port = p.parse().ok();
+                continue;
+            }
+            if arg == "-l" {
+                username = tokens.next();
+                continue;
+            } else if let Some(l) = arg.strip_prefix("-l") {
+                username = Some(l.to_string());
+                continue;
+            }
+            if arg == "-L" || arg.starts_with("-L") {
+                let forward_spec = if arg == "-L" {
+                    tokens.next()
+                } else {
+                    Some(arg.strip_prefix("-L").unwrap().to_string())
+                };
+
+                if let Some(spec) = forward_spec {
+                    port_forwards.push(parse_port_forward_spec(&spec)?);
+                } else {
+                    anyhow::bail!("Missing port forward format");
+                }
+            }
+
+            for a in ALLOWED_ARGS {
+                if arg == *a {
+                    args.push(arg);
+                    if let Some(next) = tokens.next() {
+                        args.push(next);
+                    }
+                    continue 'outer;
+                } else if arg.starts_with(a) {
+                    args.push(arg);
+                    continue 'outer;
+                }
+            }
+            if arg.starts_with("-") || hostname.is_some() {
+                anyhow::bail!("unsupported argument: {:?}", arg);
+            }
+            let mut input = &arg as &str;
+            // Destination might be: username1@username2@ip2@ip1
+            if let Some((u, rest)) = input.rsplit_once('@') {
+                input = rest;
+                username = Some(u.to_string());
+            }
+            if let Some((rest, p)) = input.split_once(':') {
+                input = rest;
+                port = p.parse().ok()
+            }
+            hostname = Some(input.to_string())
+        }
+
+        let Some(hostname) = hostname else {
+            anyhow::bail!("missing hostname");
+        };
+
+        let port_forwards = match port_forwards.len() {
+            0 => None,
+            _ => Some(port_forwards),
+        };
+
+        Ok(Self {
+            host: hostname,
+            username,
+            port,
+            port_forwards,
+            args: Some(args),
+            password: None,
+            nickname: None,
+            upload_binary_over_ssh: false,
+        })
+    }
+
+    pub fn ssh_url(&self) -> String {
+        let mut result = String::from("ssh://");
+        if let Some(username) = &self.username {
+            // Username might be: username1@username2@ip2
+            let username = urlencoding::encode(username);
+            result.push_str(&username);
+            result.push('@');
+        }
+        result.push_str(&self.host);
+        if let Some(port) = self.port {
+            result.push(':');
+            result.push_str(&port.to_string());
+        }
+        result
+    }
+
+    pub fn additional_args(&self) -> Vec<String> {
+        let mut args = self.args.iter().flatten().cloned().collect::<Vec<String>>();
+
+        if let Some(forwards) = &self.port_forwards {
+            args.extend(forwards.iter().map(|pf| {
+                let local_host = match &pf.local_host {
+                    Some(host) => host,
+                    None => "localhost",
+                };
+                let remote_host = match &pf.remote_host {
+                    Some(host) => host,
+                    None => "localhost",
+                };
+
+                format!(
+                    "-L{}:{}:{}:{}",
+                    local_host, pf.local_port, remote_host, pf.remote_port
+                )
+            }));
+        }
+
+        args
+    }
+
+    fn scp_url(&self) -> String {
+        if let Some(username) = &self.username {
+            format!("{}@{}", username, self.host)
+        } else {
+            self.host.clone()
+        }
+    }
+
+    pub fn connection_string(&self) -> String {
+        let host = if let Some(username) = &self.username {
+            format!("{}@{}", username, self.host)
+        } else {
+            self.host.clone()
+        };
+        if let Some(port) = &self.port {
+            format!("{}:{}", host, port)
+        } else {
+            host
+        }
+    }
+}

crates/remote_server/src/headless_project.rs 🔗

@@ -21,7 +21,7 @@ use project::{
 };
 use rpc::{
     AnyProtoClient, TypedEnvelope,
-    proto::{self, SSH_PEER_ID, SSH_PROJECT_ID},
+    proto::{self, REMOTE_SERVER_PEER_ID, REMOTE_SERVER_PROJECT_ID},
 };
 
 use settings::initial_server_settings_content;
@@ -83,7 +83,7 @@ impl HeadlessProject {
 
         let worktree_store = cx.new(|cx| {
             let mut store = WorktreeStore::local(true, fs.clone());
-            store.shared(SSH_PROJECT_ID, session.clone(), cx);
+            store.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx);
             store
         });
 
@@ -101,7 +101,7 @@ impl HeadlessProject {
 
         let buffer_store = cx.new(|cx| {
             let mut buffer_store = BufferStore::local(worktree_store.clone(), cx);
-            buffer_store.shared(SSH_PROJECT_ID, session.clone(), cx);
+            buffer_store.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx);
             buffer_store
         });
 
@@ -119,7 +119,7 @@ impl HeadlessProject {
                 breakpoint_store.clone(),
                 cx,
             );
-            dap_store.shared(SSH_PROJECT_ID, session.clone(), cx);
+            dap_store.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx);
             dap_store
         });
 
@@ -131,7 +131,7 @@ impl HeadlessProject {
                 fs.clone(),
                 cx,
             );
-            store.shared(SSH_PROJECT_ID, session.clone(), cx);
+            store.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx);
             store
         });
 
@@ -154,7 +154,7 @@ impl HeadlessProject {
                 environment.clone(),
                 cx,
             );
-            task_store.shared(SSH_PROJECT_ID, session.clone(), cx);
+            task_store.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx);
             task_store
         });
         let settings_observer = cx.new(|cx| {
@@ -164,7 +164,7 @@ impl HeadlessProject {
                 task_store.clone(),
                 cx,
             );
-            observer.shared(SSH_PROJECT_ID, session.clone(), cx);
+            observer.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx);
             observer
         });
 
@@ -185,7 +185,7 @@ impl HeadlessProject {
                 fs.clone(),
                 cx,
             );
-            lsp_store.shared(SSH_PROJECT_ID, session.clone(), cx);
+            lsp_store.shared(REMOTE_SERVER_PROJECT_ID, session.clone(), cx);
             lsp_store
         });
 
@@ -213,15 +213,15 @@ impl HeadlessProject {
         );
 
         // local_machine -> ssh handlers
-        session.subscribe_to_entity(SSH_PROJECT_ID, &worktree_store);
-        session.subscribe_to_entity(SSH_PROJECT_ID, &buffer_store);
-        session.subscribe_to_entity(SSH_PROJECT_ID, &cx.entity());
-        session.subscribe_to_entity(SSH_PROJECT_ID, &lsp_store);
-        session.subscribe_to_entity(SSH_PROJECT_ID, &task_store);
-        session.subscribe_to_entity(SSH_PROJECT_ID, &toolchain_store);
-        session.subscribe_to_entity(SSH_PROJECT_ID, &dap_store);
-        session.subscribe_to_entity(SSH_PROJECT_ID, &settings_observer);
-        session.subscribe_to_entity(SSH_PROJECT_ID, &git_store);
+        session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &worktree_store);
+        session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &buffer_store);
+        session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &cx.entity());
+        session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &lsp_store);
+        session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &task_store);
+        session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &toolchain_store);
+        session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &dap_store);
+        session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &settings_observer);
+        session.subscribe_to_entity(REMOTE_SERVER_PROJECT_ID, &git_store);
 
         session.add_request_handler(cx.weak_entity(), Self::handle_list_remote_directory);
         session.add_request_handler(cx.weak_entity(), Self::handle_get_path_metadata);
@@ -288,7 +288,7 @@ impl HeadlessProject {
         } = event
         {
             cx.background_spawn(self.session.request(proto::UpdateBuffer {
-                project_id: SSH_PROJECT_ID,
+                project_id: REMOTE_SERVER_PROJECT_ID,
                 buffer_id: buffer.read(cx).remote_id().to_proto(),
                 operations: vec![serialize_operation(operation)],
             }))
@@ -310,7 +310,7 @@ impl HeadlessProject {
             } => {
                 self.session
                     .send(proto::UpdateLanguageServer {
-                        project_id: SSH_PROJECT_ID,
+                        project_id: REMOTE_SERVER_PROJECT_ID,
                         server_name: name.as_ref().map(|name| name.to_string()),
                         language_server_id: language_server_id.to_proto(),
                         variant: Some(message.clone()),
@@ -320,7 +320,7 @@ impl HeadlessProject {
             LspStoreEvent::Notification(message) => {
                 self.session
                     .send(proto::Toast {
-                        project_id: SSH_PROJECT_ID,
+                        project_id: REMOTE_SERVER_PROJECT_ID,
                         notification_id: "lsp".to_string(),
                         message: message.clone(),
                     })
@@ -329,7 +329,7 @@ impl HeadlessProject {
             LspStoreEvent::LanguageServerLog(language_server_id, log_type, message) => {
                 self.session
                     .send(proto::LanguageServerLog {
-                        project_id: SSH_PROJECT_ID,
+                        project_id: REMOTE_SERVER_PROJECT_ID,
                         language_server_id: language_server_id.to_proto(),
                         message: message.clone(),
                         log_type: Some(log_type.to_proto()),
@@ -338,7 +338,7 @@ impl HeadlessProject {
             }
             LspStoreEvent::LanguageServerPrompt(prompt) => {
                 let request = self.session.request(proto::LanguageServerPromptRequest {
-                    project_id: SSH_PROJECT_ID,
+                    project_id: REMOTE_SERVER_PROJECT_ID,
                     actions: prompt
                         .actions
                         .iter()
@@ -474,7 +474,7 @@ impl HeadlessProject {
         let buffer_id = buffer.read_with(&cx, |b, _| b.remote_id())?;
         buffer_store.update(&mut cx, |buffer_store, cx| {
             buffer_store
-                .create_buffer_for_peer(&buffer, SSH_PEER_ID, cx)
+                .create_buffer_for_peer(&buffer, REMOTE_SERVER_PEER_ID, cx)
                 .detach_and_log_err(cx);
         })?;
 
@@ -500,7 +500,7 @@ impl HeadlessProject {
         let buffer_id = buffer.read_with(&cx, |b, _| b.remote_id())?;
         buffer_store.update(&mut cx, |buffer_store, cx| {
             buffer_store
-                .create_buffer_for_peer(&buffer, SSH_PEER_ID, cx)
+                .create_buffer_for_peer(&buffer, REMOTE_SERVER_PEER_ID, cx)
                 .detach_and_log_err(cx);
         })?;
 
@@ -550,7 +550,7 @@ impl HeadlessProject {
 
             buffer_store.update(cx, |buffer_store, cx| {
                 buffer_store
-                    .create_buffer_for_peer(&buffer, SSH_PEER_ID, cx)
+                    .create_buffer_for_peer(&buffer, REMOTE_SERVER_PEER_ID, cx)
                     .detach_and_log_err(cx);
             });
 
@@ -586,7 +586,7 @@ impl HeadlessProject {
             response.buffer_ids.push(buffer_id.to_proto());
             buffer_store
                 .update(&mut cx, |buffer_store, cx| {
-                    buffer_store.create_buffer_for_peer(&buffer, SSH_PEER_ID, cx)
+                    buffer_store.create_buffer_for_peer(&buffer, REMOTE_SERVER_PEER_ID, cx)
                 })?
                 .await?;
         }

crates/remote_server/src/remote_editing_tests.rs 🔗

@@ -22,7 +22,7 @@ use project::{
     Project, ProjectPath,
     search::{SearchQuery, SearchResult},
 };
-use remote::SshRemoteClient;
+use remote::RemoteClient;
 use serde_json::json;
 use settings::{Settings, SettingsLocation, SettingsStore, initial_server_settings_content};
 use smol::stream::StreamExt;
@@ -1119,7 +1119,7 @@ async fn test_reconnect(cx: &mut TestAppContext, server_cx: &mut TestAppContext)
         buffer.edit([(ix..ix + 1, "100")], None, cx);
     });
 
-    let client = cx.read(|cx| project.read(cx).ssh_client().unwrap());
+    let client = cx.read(|cx| project.read(cx).remote_client().unwrap());
     client
         .update(cx, |client, cx| client.simulate_disconnect(cx))
         .detach();
@@ -1782,7 +1782,7 @@ pub async fn init_test(
     });
     init_logger();
 
-    let (opts, ssh_server_client) = SshRemoteClient::fake_server(cx, server_cx);
+    let (opts, ssh_server_client) = RemoteClient::fake_server(cx, server_cx);
     let http_client = Arc::new(BlockedHttpClient);
     let node_runtime = NodeRuntime::unavailable();
     let languages = Arc::new(LanguageRegistry::new(cx.executor()));
@@ -1804,7 +1804,7 @@ pub async fn init_test(
         )
     });
 
-    let ssh = SshRemoteClient::fake_client(opts, cx).await;
+    let ssh = RemoteClient::fake_client(opts, cx).await;
     let project = build_project(ssh, cx);
     project
         .update(cx, {
@@ -1819,7 +1819,7 @@ fn init_logger() {
     zlog::init_test();
 }
 
-fn build_project(ssh: Entity<SshRemoteClient>, cx: &mut TestAppContext) -> Entity<Project> {
+fn build_project(ssh: Entity<RemoteClient>, cx: &mut TestAppContext) -> Entity<Project> {
     cx.update(|cx| {
         if !cx.has_global::<SettingsStore>() {
             let settings_store = SettingsStore::test(cx);
@@ -1845,5 +1845,5 @@ fn build_project(ssh: Entity<SshRemoteClient>, cx: &mut TestAppContext) -> Entit
         language::init(cx);
     });
 
-    cx.update(|cx| Project::ssh(ssh, client, node, user_store, languages, fs, cx))
+    cx.update(|cx| Project::remote(ssh, client, node, user_store, languages, fs, cx))
 }

crates/remote_server/src/unix.rs 🔗

@@ -19,14 +19,14 @@ use project::project_settings::ProjectSettings;
 
 use proto::CrashReport;
 use release_channel::{AppVersion, RELEASE_CHANNEL, ReleaseChannel};
-use remote::SshRemoteClient;
+use remote::RemoteClient;
 use remote::{
     json_log::LogRecord,
     protocol::{read_message, write_message},
     proxy::ProxyLaunchError,
 };
 use reqwest_client::ReqwestClient;
-use rpc::proto::{self, Envelope, SSH_PROJECT_ID};
+use rpc::proto::{self, Envelope, REMOTE_SERVER_PROJECT_ID};
 use rpc::{AnyProtoClient, TypedEnvelope};
 use settings::{Settings, SettingsStore, watch_config_file};
 use smol::channel::{Receiver, Sender};
@@ -396,7 +396,7 @@ fn start_server(
     })
     .detach();
 
-    SshRemoteClient::proto_client_from_channels(incoming_rx, outgoing_tx, cx, "server")
+    RemoteClient::proto_client_from_channels(incoming_rx, outgoing_tx, cx, "server")
 }
 
 fn init_paths() -> anyhow::Result<()> {
@@ -867,34 +867,21 @@ where
     R: AsyncRead + Unpin,
     W: AsyncWrite + Unpin,
 {
-    use remote::protocol::read_message_raw;
+    use remote::protocol::{read_message_raw, write_size_prefixed_buffer};
 
     let mut buffer = Vec::new();
     loop {
         read_message_raw(&mut reader, &mut buffer)
             .await
             .with_context(|| format!("failed to read message from {}", socket_name))?;
-
         write_size_prefixed_buffer(&mut writer, &mut buffer)
             .await
             .with_context(|| format!("failed to write message to {}", socket_name))?;
-
         writer.flush().await?;
-
         buffer.clear();
     }
 }
 
-async fn write_size_prefixed_buffer<S: AsyncWrite + Unpin>(
-    stream: &mut S,
-    buffer: &mut Vec<u8>,
-) -> Result<()> {
-    let len = buffer.len() as u32;
-    stream.write_all(len.to_le_bytes().as_slice()).await?;
-    stream.write_all(buffer).await?;
-    Ok(())
-}
-
 fn initialize_settings(
     session: AnyProtoClient,
     fs: Arc<dyn Fs>,
@@ -910,7 +897,7 @@ fn initialize_settings(
 
                 session
                     .send(proto::Toast {
-                        project_id: SSH_PROJECT_ID,
+                        project_id: REMOTE_SERVER_PROJECT_ID,
                         notification_id: "server-settings-failed".to_string(),
                         message: format!(
                             "Error in settings on remote host {:?}: {}",
@@ -922,7 +909,7 @@ fn initialize_settings(
             } else {
                 session
                     .send(proto::HideToast {
-                        project_id: SSH_PROJECT_ID,
+                        project_id: REMOTE_SERVER_PROJECT_ID,
                         notification_id: "server-settings-failed".to_string(),
                     })
                     .log_err();

crates/task/src/shell_builder.rs 🔗

@@ -36,7 +36,7 @@ impl ShellKind {
         } else if program == "csh" {
             ShellKind::Csh
         } else {
-            // Someother shell detected, the user might install and use a
+            // Some other shell detected, the user might install and use a
             // unix-like shell.
             ShellKind::Posix
         }
@@ -203,14 +203,15 @@ pub struct ShellBuilder {
 impl ShellBuilder {
     /// Create a new ShellBuilder as configured.
     pub fn new(remote_system_shell: Option<&str>, shell: &Shell) -> Self {
-        let (program, args) = match shell {
-            Shell::System => match remote_system_shell {
-                Some(remote_shell) => (remote_shell.to_string(), Vec::new()),
-                None => (system_shell(), Vec::new()),
+        let (program, args) = match remote_system_shell {
+            Some(program) => (program.to_string(), Vec::new()),
+            None => match shell {
+                Shell::System => (system_shell(), Vec::new()),
+                Shell::Program(shell) => (shell.clone(), Vec::new()),
+                Shell::WithArguments { program, args, .. } => (program.clone(), args.clone()),
             },
-            Shell::Program(shell) => (shell.clone(), Vec::new()),
-            Shell::WithArguments { program, args, .. } => (program.clone(), args.clone()),
         };
+
         let kind = ShellKind::new(&program);
         Self {
             program,

crates/terminal_view/src/terminal_element.rs 🔗

@@ -1403,7 +1403,7 @@ impl InputHandler for TerminalInputHandler {
                 window.invalidate_character_coordinates();
                 let project = this.project().read(cx);
                 let telemetry = project.client().telemetry().clone();
-                telemetry.log_edit_event("terminal", project.is_via_ssh());
+                telemetry.log_edit_event("terminal", project.is_via_remote_server());
             })
             .ok();
     }

crates/terminal_view/src/terminal_panel.rs 🔗

@@ -481,17 +481,28 @@ impl TerminalPanel {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Task<Result<WeakEntity<Terminal>>> {
-        let Ok((ssh_client, false)) = self.workspace.update(cx, |workspace, cx| {
-            let project = workspace.project().read(cx);
-            (
-                project.ssh_client().and_then(|it| it.read(cx).ssh_info()),
-                project.is_via_collab(),
-            )
-        }) else {
-            return Task::ready(Err(anyhow!("Project is not local")));
+        let remote_client = self
+            .workspace
+            .update(cx, |workspace, cx| {
+                let project = workspace.project().read(cx);
+                if project.is_via_collab() {
+                    Err(anyhow!("cannot spawn tasks as a guest"))
+                } else {
+                    Ok(project.remote_client())
+                }
+            })
+            .flatten();
+
+        let remote_client = match remote_client {
+            Ok(remote_client) => remote_client,
+            Err(e) => return Task::ready(Err(e)),
         };
 
-        let builder = ShellBuilder::new(ssh_client.as_ref().map(|info| &*info.shell), &task.shell);
+        let remote_shell = remote_client
+            .as_ref()
+            .and_then(|remote_client| remote_client.read(cx).shell());
+
+        let builder = ShellBuilder::new(remote_shell.as_deref(), &task.shell);
         let command_label = builder.command_label(&task.command_label);
         let (command, args) = builder.build(task.command.clone(), &task.args);
 

crates/title_bar/src/collab.rs 🔗

@@ -337,7 +337,7 @@ impl TitleBar {
 
         let room = room.read(cx);
         let project = self.project.read(cx);
-        let is_local = project.is_local() || project.is_via_ssh();
+        let is_local = project.is_local() || project.is_via_remote_server();
         let is_shared = is_local && project.is_shared();
         let is_muted = room.is_muted();
         let muted_by_user = room.muted_by_user();

crates/title_bar/src/title_bar.rs 🔗

@@ -299,8 +299,8 @@ impl TitleBar {
         }
     }
 
-    fn render_ssh_project_host(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
-        let options = self.project.read(cx).ssh_connection_options(cx)?;
+    fn render_remote_project_connection(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
+        let options = self.project.read(cx).remote_connection_options(cx)?;
         let host: SharedString = options.connection_string().into();
 
         let nickname = options
@@ -308,7 +308,7 @@ impl TitleBar {
             .map(|nick| nick.into())
             .unwrap_or_else(|| host.clone());
 
-        let (indicator_color, meta) = match self.project.read(cx).ssh_connection_state(cx)? {
+        let (indicator_color, meta) = match self.project.read(cx).remote_connection_state(cx)? {
             remote::ConnectionState::Connecting => (Color::Info, format!("Connecting to: {host}")),
             remote::ConnectionState::Connected => (Color::Success, format!("Connected to: {host}")),
             remote::ConnectionState::HeartbeatMissed => (
@@ -324,7 +324,7 @@ impl TitleBar {
             }
         };
 
-        let icon_color = match self.project.read(cx).ssh_connection_state(cx)? {
+        let icon_color = match self.project.read(cx).remote_connection_state(cx)? {
             remote::ConnectionState::Connecting => Color::Info,
             remote::ConnectionState::Connected => Color::Default,
             remote::ConnectionState::HeartbeatMissed => Color::Warning,
@@ -379,8 +379,8 @@ impl TitleBar {
     }
 
     pub fn render_project_host(&self, cx: &mut Context<Self>) -> Option<AnyElement> {
-        if self.project.read(cx).is_via_ssh() {
-            return self.render_ssh_project_host(cx);
+        if self.project.read(cx).is_via_remote_server() {
+            return self.render_remote_project_connection(cx);
         }
 
         if self.project.read(cx).is_disconnected(cx) {

crates/vim/src/command.rs 🔗

@@ -1924,7 +1924,9 @@ impl ShellExec {
 
         let Some(range) = input_range else { return };
 
-        let mut process = project.read(cx).exec_in_shell(command, cx);
+        let Some(mut process) = project.read(cx).exec_in_shell(command, cx).log_err() else {
+            return;
+        };
         process.stdout(Stdio::piped());
         process.stderr(Stdio::piped());
 

crates/workspace/src/tasks.rs 🔗

@@ -20,7 +20,7 @@ impl Workspace {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
-        match self.project.read(cx).ssh_connection_state(cx) {
+        match self.project.read(cx).remote_connection_state(cx) {
             None | Some(ConnectionState::Connected) => {}
             Some(
                 ConnectionState::Connecting

crates/workspace/src/workspace.rs 🔗

@@ -74,7 +74,7 @@ use project::{
     DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree, WorktreeId,
     debugger::{breakpoint_store::BreakpointStoreEvent, session::ThreadStatus},
 };
-use remote::{SshClientDelegate, SshConnectionOptions, ssh_session::ConnectionIdentifier};
+use remote::{RemoteClientDelegate, SshConnectionOptions, remote_client::ConnectionIdentifier};
 use schemars::JsonSchema;
 use serde::Deserialize;
 use session::AppSession;
@@ -2084,7 +2084,7 @@ impl Workspace {
         cx: &mut Context<Self>,
     ) -> oneshot::Receiver<Option<Vec<PathBuf>>> {
         if self.project.read(cx).is_via_collab()
-            || self.project.read(cx).is_via_ssh()
+            || self.project.read(cx).is_via_remote_server()
             || !WorkspaceSettings::get_global(cx).use_system_path_prompts
         {
             let prompt = self.on_prompt_for_new_path.take().unwrap();
@@ -5249,7 +5249,7 @@ impl Workspace {
 
     fn serialize_workspace_location(&self, cx: &App) -> WorkspaceLocation {
         let paths = PathList::new(&self.root_paths(cx));
-        if let Some(connection) = self.project.read(cx).ssh_connection_options(cx) {
+        if let Some(connection) = self.project.read(cx).remote_connection_options(cx) {
             WorkspaceLocation::Location(
                 SerializedWorkspaceLocation::Ssh(SerializedSshConnection {
                     host: connection.host,
@@ -6917,7 +6917,7 @@ async fn join_channel_internal(
                     return None;
                 }
 
-                if (project.is_local() || project.is_via_ssh())
+                if (project.is_local() || project.is_via_remote_server())
                     && project.visible_worktrees(cx).any(|tree| {
                         tree.read(cx)
                             .root_entry()
@@ -7263,7 +7263,7 @@ pub fn open_ssh_project_with_new_connection(
     window: WindowHandle<Workspace>,
     connection_options: SshConnectionOptions,
     cancel_rx: oneshot::Receiver<()>,
-    delegate: Arc<dyn SshClientDelegate>,
+    delegate: Arc<dyn RemoteClientDelegate>,
     app_state: Arc<AppState>,
     paths: Vec<PathBuf>,
     cx: &mut App,
@@ -7274,7 +7274,7 @@ pub fn open_ssh_project_with_new_connection(
 
         let session = match cx
             .update(|cx| {
-                remote::SshRemoteClient::new(
+                remote::RemoteClient::ssh(
                     ConnectionIdentifier::Workspace(workspace_id.0),
                     connection_options,
                     cancel_rx,
@@ -7289,7 +7289,7 @@ pub fn open_ssh_project_with_new_connection(
         };
 
         let project = cx.update(|cx| {
-            project::Project::ssh(
+            project::Project::remote(
                 session,
                 app_state.client.clone(),
                 app_state.node_runtime.clone(),

crates/zed/src/reliability.rs 🔗

@@ -220,10 +220,10 @@ pub fn init(
         let installation_id = installation_id.clone();
         let system_id = system_id.clone();
 
-        let Some(ssh_client) = project.ssh_client() else {
+        let Some(remote_client) = project.remote_client() else {
             return;
         };
-        ssh_client.update(cx, |client, cx| {
+        remote_client.update(cx, |client, cx| {
             if !TelemetrySettings::get_global(cx).diagnostics {
                 return;
             }

crates/zed/src/zed.rs 🔗

@@ -918,7 +918,7 @@ fn register_actions(
             capture_audio(workspace, window, cx);
         });
 
-    if workspace.project().read(cx).is_via_ssh() {
+    if workspace.project().read(cx).is_via_remote_server() {
         workspace.register_action({
             move |workspace, _: &OpenServerSettings, window, cx| {
                 let open_server_settings = workspace
@@ -1543,7 +1543,7 @@ pub fn open_new_ssh_project_from_project(
     cx: &mut Context<Workspace>,
 ) -> Task<anyhow::Result<()>> {
     let app_state = workspace.app_state().clone();
-    let Some(ssh_client) = workspace.project().read(cx).ssh_client() else {
+    let Some(ssh_client) = workspace.project().read(cx).remote_client() else {
         return Task::ready(Err(anyhow::anyhow!("Not an ssh project")));
     };
     let connection_options = ssh_client.read(cx).connection_options();