Add initial support for WSL (#37035)

Max Brunsfeld and Junkui Zhang created

Closes #36188

## Todo

* [x] CLI
* [x] terminals
* [x] tasks

## For future PRs
* debugging
* UI for opening WSL projects
* fixing workspace state restoration

Release Notes:

- Windows alpha: Zed now supports editing folders in WSL.

---------

Co-authored-by: Junkui Zhang <364772080@qq.com>

Change summary

crates/auto_update_helper/src/updater.rs           |  26 
crates/cli/src/cli.rs                              |   1 
crates/cli/src/main.rs                             |  74 ++
crates/extension_host/src/extension_host.rs        |  11 
crates/paths/src/paths.rs                          |   5 
crates/project/src/debugger/dap_store.rs           |  10 
crates/project/src/project.rs                      |   4 
crates/project/src/terminals.rs                    |   2 
crates/recent_projects/src/disconnected_overlay.rs |  31 
crates/recent_projects/src/recent_projects.rs      |  33 
crates/recent_projects/src/remote_connections.rs   | 138 ++--
crates/recent_projects/src/remote_servers.rs       | 123 +--
crates/remote/src/remote.rs                        |   3 
crates/remote/src/remote_client.rs                 | 117 ++
crates/remote/src/transport.rs                     | 335 ++++++++++
crates/remote/src/transport/ssh.rs                 | 341 ----------
crates/remote/src/transport/wsl.rs                 | 494 ++++++++++++++++
crates/title_bar/src/title_bar.rs                  |  13 
crates/workspace/src/persistence.rs                | 438 ++++++++++----
crates/workspace/src/persistence/model.rs          |  32 
crates/workspace/src/workspace.rs                  |  59 -
crates/zed/resources/windows/zed-wsl               |  25 
crates/zed/src/main.rs                             |  75 +-
crates/zed/src/zed.rs                              |   4 
crates/zed/src/zed/open_listener.rs                |  91 +-
crates/zed/src/zed/windows_only_instance.rs        |   1 
script/bundle-windows.ps1                          |   1 
27 files changed, 1,701 insertions(+), 786 deletions(-)

Detailed changes

crates/auto_update_helper/src/updater.rs 🔗

@@ -16,7 +16,7 @@ use crate::windows_impl::WM_JOB_UPDATED;
 type Job = fn(&Path) -> Result<()>;
 
 #[cfg(not(test))]
-pub(crate) const JOBS: [Job; 6] = [
+pub(crate) const JOBS: &[Job] = &[
     // Delete old files
     |app_dir| {
         let zed_executable = app_dir.join("Zed.exe");
@@ -32,6 +32,12 @@ pub(crate) const JOBS: [Job; 6] = [
         std::fs::remove_file(&zed_cli)
             .context(format!("Failed to remove old file {}", zed_cli.display()))
     },
+    |app_dir| {
+        let zed_wsl = app_dir.join("bin\\zed");
+        log::info!("Removing old file: {}", zed_wsl.display());
+        std::fs::remove_file(&zed_wsl)
+            .context(format!("Failed to remove old file {}", zed_wsl.display()))
+    },
     // Copy new files
     |app_dir| {
         let zed_executable_source = app_dir.join("install\\Zed.exe");
@@ -65,6 +71,22 @@ pub(crate) const JOBS: [Job; 6] = [
                 zed_cli_dest.display()
             ))
     },
+    |app_dir| {
+        let zed_wsl_source = app_dir.join("install\\bin\\zed");
+        let zed_wsl_dest = app_dir.join("bin\\zed");
+        log::info!(
+            "Copying new file {} to {}",
+            zed_wsl_source.display(),
+            zed_wsl_dest.display()
+        );
+        std::fs::copy(&zed_wsl_source, &zed_wsl_dest)
+            .map(|_| ())
+            .context(format!(
+                "Failed to copy new file {} to {}",
+                zed_wsl_source.display(),
+                zed_wsl_dest.display()
+            ))
+    },
     // Clean up installer folder and updates folder
     |app_dir| {
         let updates_folder = app_dir.join("updates");
@@ -85,7 +107,7 @@ pub(crate) const JOBS: [Job; 6] = [
 ];
 
 #[cfg(test)]
-pub(crate) const JOBS: [Job; 2] = [
+pub(crate) const JOBS: &[Job] = &[
     |_| {
         std::thread::sleep(Duration::from_millis(1000));
         if let Ok(config) = std::env::var("ZED_AUTO_UPDATE") {

crates/cli/src/cli.rs 🔗

@@ -14,6 +14,7 @@ pub enum CliRequest {
         paths: Vec<String>,
         urls: Vec<String>,
         diff_paths: Vec<[String; 2]>,
+        wsl: Option<String>,
         wait: bool,
         open_new_workspace: Option<bool>,
         env: Option<HashMap<String, String>>,

crates/cli/src/main.rs 🔗

@@ -6,7 +6,6 @@
 use anyhow::{Context as _, Result};
 use clap::Parser;
 use cli::{CliRequest, CliResponse, IpcHandshake, ipc::IpcOneShotServer};
-use collections::HashMap;
 use parking_lot::Mutex;
 use std::{
     env, fs, io,
@@ -85,6 +84,15 @@ struct Args {
     /// Run zed in dev-server mode
     #[arg(long)]
     dev_server_token: Option<String>,
+    /// The username and WSL distribution to use when opening paths. ,If not specified,
+    /// Zed will attempt to open the paths directly.
+    ///
+    /// The username is optional, and if not specified, the default user for the distribution
+    /// will be used.
+    ///
+    /// Example: `me@Ubuntu` or `Ubuntu` for default distribution.
+    #[arg(long, value_name = "USER@DISTRO")]
+    wsl: Option<String>,
     /// Not supported in Zed CLI, only supported on Zed binary
     /// Will attempt to give the correct command to run
     #[arg(long)]
@@ -129,14 +137,41 @@ fn parse_path_with_position(argument_str: &str) -> anyhow::Result<String> {
     Ok(canonicalized.to_string(|path| path.to_string_lossy().to_string()))
 }
 
-fn main() -> Result<()> {
-    #[cfg(all(not(debug_assertions), target_os = "windows"))]
-    unsafe {
-        use ::windows::Win32::System::Console::{ATTACH_PARENT_PROCESS, AttachConsole};
+fn parse_path_in_wsl(source: &str, wsl: &str) -> Result<String> {
+    let mut command = util::command::new_std_command("wsl.exe");
 
-        let _ = AttachConsole(ATTACH_PARENT_PROCESS);
+    let (user, distro_name) = if let Some((user, distro)) = wsl.split_once('@') {
+        if user.is_empty() {
+            anyhow::bail!("user is empty in wsl argument");
+        }
+        (Some(user), distro)
+    } else {
+        (None, wsl)
+    };
+
+    if let Some(user) = user {
+        command.arg("--user").arg(user);
     }
 
+    let output = command
+        .arg("--distribution")
+        .arg(distro_name)
+        .arg("wslpath")
+        .arg("-m")
+        .arg(source)
+        .output()?;
+
+    let result = String::from_utf8_lossy(&output.stdout);
+    let prefix = format!("//wsl.localhost/{}", distro_name);
+
+    Ok(result
+        .trim()
+        .strip_prefix(&prefix)
+        .unwrap_or(&result)
+        .to_string())
+}
+
+fn main() -> Result<()> {
     #[cfg(unix)]
     util::prevent_root_execution();
 
@@ -223,6 +258,8 @@ fn main() -> Result<()> {
     let env = {
         #[cfg(any(target_os = "linux", target_os = "freebsd"))]
         {
+            use collections::HashMap;
+
             // On Linux, the desktop entry uses `cli` to spawn `zed`.
             // We need to handle env vars correctly since std::env::vars() may not contain
             // project-specific vars (e.g. those set by direnv).
@@ -235,8 +272,19 @@ fn main() -> Result<()> {
             }
         }
 
-        #[cfg(not(any(target_os = "linux", target_os = "freebsd")))]
-        Some(std::env::vars().collect::<HashMap<_, _>>())
+        #[cfg(target_os = "windows")]
+        {
+            // On Windows, by default, a child process inherits a copy of the environment block of the parent process.
+            // So we don't need to pass env vars explicitly.
+            None
+        }
+
+        #[cfg(not(any(target_os = "linux", target_os = "freebsd", target_os = "windows")))]
+        {
+            use collections::HashMap;
+
+            Some(std::env::vars().collect::<HashMap<_, _>>())
+        }
     };
 
     let exit_status = Arc::new(Mutex::new(None));
@@ -271,8 +319,10 @@ fn main() -> Result<()> {
             paths.push(tmp_file.path().to_string_lossy().to_string());
             let (tmp_file, _) = tmp_file.keep()?;
             anonymous_fd_tmp_files.push((file, tmp_file));
+        } else if let Some(wsl) = &args.wsl {
+            urls.push(format!("file://{}", parse_path_in_wsl(path, wsl)?));
         } else {
-            paths.push(parse_path_with_position(path)?)
+            paths.push(parse_path_with_position(path)?);
         }
     }
 
@@ -292,6 +342,7 @@ fn main() -> Result<()> {
                 paths,
                 urls,
                 diff_paths,
+                wsl: args.wsl,
                 wait: args.wait,
                 open_new_workspace,
                 env,
@@ -644,15 +695,15 @@ mod windows {
             Storage::FileSystem::{
                 CreateFileW, FILE_FLAGS_AND_ATTRIBUTES, FILE_SHARE_MODE, OPEN_EXISTING, WriteFile,
             },
-            System::Threading::CreateMutexW,
+            System::Threading::{CREATE_NEW_PROCESS_GROUP, CreateMutexW},
         },
         core::HSTRING,
     };
 
     use crate::{Detect, InstalledApp};
-    use std::io;
     use std::path::{Path, PathBuf};
     use std::process::ExitStatus;
+    use std::{io, os::windows::process::CommandExt};
 
     fn check_single_instance() -> bool {
         let mutex = unsafe {
@@ -691,6 +742,7 @@ mod windows {
         fn launch(&self, ipc_url: String) -> anyhow::Result<()> {
             if check_single_instance() {
                 std::process::Command::new(self.0.clone())
+                    .creation_flags(CREATE_NEW_PROCESS_GROUP.0)
                     .arg(ipc_url)
                     .spawn()?;
             } else {

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::RemoteClient;
+use remote::{RemoteClient, RemoteConnectionOptions};
 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 remote_clients: HashMap<String, WeakEntity<RemoteClient>>,
+    pub remote_clients: HashMap<RemoteConnectionOptions, WeakEntity<RemoteClient>>,
     pub ssh_registered_tx: UnboundedSender<()>,
 }
 
@@ -1779,16 +1779,15 @@ impl ExtensionStore {
     }
 
     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();
+        let options = client.read(cx).connection_options();
 
-        if let Some(existing_client) = self.remote_clients.get(&ssh_url)
+        if let Some(existing_client) = self.remote_clients.get(&options)
             && existing_client.upgrade().is_some()
         {
             return;
         }
 
-        self.remote_clients.insert(ssh_url, client.downgrade());
+        self.remote_clients.insert(options, client.downgrade());
         self.ssh_registered_tx.unbounded_send(()).ok();
     }
 }

crates/paths/src/paths.rs 🔗

@@ -33,6 +33,11 @@ pub fn remote_server_dir_relative() -> &'static Path {
     Path::new(".zed_server")
 }
 
+/// Returns the relative path to the zed_wsl_server directory on the wsl host.
+pub fn remote_wsl_server_dir_relative() -> &'static Path {
+    Path::new(".zed_wsl_server")
+}
+
 /// Sets a custom directory for all user data, overriding the default data directory.
 /// This function must be called before any other path operations that depend on the data directory.
 /// The directory's path will be canonicalized to an absolute path by a blocking FS operation.

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

@@ -258,8 +258,14 @@ impl DapStore {
                     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));
+                        let port;
+                        if remote.read_with(cx, |remote, _cx| remote.shares_network_interface())? {
+                            port = c.port;
+                            port_forwarding = None;
+                        } else {
+                            port = dap::transport::TcpTransport::unused_port(host).await?;
+                            port_forwarding = Some((port, c.host.to_string(), c.port));
+                        }
                         connection = Some(TcpArguments {
                             port,
                             host,

crates/project/src/project.rs 🔗

@@ -87,7 +87,7 @@ use node_runtime::NodeRuntime;
 use parking_lot::Mutex;
 pub use prettier_store::PrettierStore;
 use project_settings::{ProjectSettings, SettingsObserver, SettingsObserverEvent};
-use remote::{RemoteClient, SshConnectionOptions};
+use remote::{RemoteClient, RemoteConnectionOptions};
 use rpc::{
     AnyProtoClient, ErrorCode,
     proto::{FromProto, LanguageServerPromptResponse, REMOTE_SERVER_PROJECT_ID, ToProto},
@@ -1916,7 +1916,7 @@ impl Project {
             .map(|remote| remote.read(cx).connection_state())
     }
 
-    pub fn remote_connection_options(&self, cx: &App) -> Option<SshConnectionOptions> {
+    pub fn remote_connection_options(&self, cx: &App) -> Option<RemoteConnectionOptions> {
         self.remote_client
             .as_ref()
             .map(|remote| remote.read(cx).connection_options())

crates/project/src/terminals.rs 🔗

@@ -512,7 +512,7 @@ fn create_remote_shell(
     *env = command.env;
 
     log::debug!("Connecting to a remote server: {:?}", command.program);
-    let host = remote_client.read(cx).connection_options().host;
+    let host = remote_client.read(cx).connection_options().display_name();
 
     Ok(Shell::WithArguments {
         program: command.program,

crates/recent_projects/src/disconnected_overlay.rs 🔗

@@ -1,6 +1,6 @@
 use gpui::{ClickEvent, DismissEvent, EventEmitter, FocusHandle, Focusable, Render, WeakEntity};
 use project::project_settings::ProjectSettings;
-use remote::SshConnectionOptions;
+use remote::RemoteConnectionOptions;
 use settings::Settings;
 use ui::{
     Button, ButtonCommon, ButtonStyle, Clickable, Context, ElevationIndex, FluentBuilder, Headline,
@@ -9,11 +9,11 @@ use ui::{
 };
 use workspace::{ModalView, OpenOptions, Workspace, notifications::DetachAndPromptErr};
 
-use crate::open_ssh_project;
+use crate::open_remote_project;
 
 enum Host {
-    RemoteProject,
-    SshRemoteProject(SshConnectionOptions),
+    CollabGuestProject,
+    RemoteServerProject(RemoteConnectionOptions),
 }
 
 pub struct DisconnectedOverlay {
@@ -66,9 +66,9 @@ impl DisconnectedOverlay {
 
                 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)
+                    Host::RemoteServerProject(ssh_connection_options)
                 } else {
-                    Host::RemoteProject
+                    Host::CollabGuestProject
                 };
 
                 workspace.toggle_modal(window, cx, |_, cx| DisconnectedOverlay {
@@ -86,14 +86,14 @@ impl DisconnectedOverlay {
         self.finished = true;
         cx.emit(DismissEvent);
 
-        if let Host::SshRemoteProject(ssh_connection_options) = &self.host {
-            self.reconnect_to_ssh_remote(ssh_connection_options.clone(), window, cx);
+        if let Host::RemoteServerProject(ssh_connection_options) = &self.host {
+            self.reconnect_to_remote_project(ssh_connection_options.clone(), window, cx);
         }
     }
 
-    fn reconnect_to_ssh_remote(
+    fn reconnect_to_remote_project(
         &self,
-        connection_options: SshConnectionOptions,
+        connection_options: RemoteConnectionOptions,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
@@ -114,7 +114,7 @@ impl DisconnectedOverlay {
             .collect();
 
         cx.spawn_in(window, async move |_, cx| {
-            open_ssh_project(
+            open_remote_project(
                 connection_options,
                 paths,
                 app_state,
@@ -138,13 +138,13 @@ impl DisconnectedOverlay {
 
 impl Render for DisconnectedOverlay {
     fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        let can_reconnect = matches!(self.host, Host::SshRemoteProject(_));
+        let can_reconnect = matches!(self.host, Host::RemoteServerProject(_));
 
         let message = match &self.host {
-            Host::RemoteProject => {
+            Host::CollabGuestProject => {
                 "Your connection to the remote project has been lost.".to_string()
             }
-            Host::SshRemoteProject(options) => {
+            Host::RemoteServerProject(options) => {
                 let autosave = if ProjectSettings::get_global(cx)
                     .session
                     .restore_unsaved_buffers
@@ -155,7 +155,8 @@ impl Render for DisconnectedOverlay {
                 };
                 format!(
                     "Your connection to {} has been lost.{}",
-                    options.host, autosave
+                    options.display_name(),
+                    autosave
                 )
             }
         };

crates/recent_projects/src/recent_projects.rs 🔗

@@ -1,9 +1,10 @@
 pub mod disconnected_overlay;
+mod remote_connections;
 mod remote_servers;
 mod ssh_config;
-mod ssh_connections;
 
-pub use ssh_connections::{is_connecting_over_ssh, open_ssh_project};
+use remote::RemoteConnectionOptions;
+pub use remote_connections::open_remote_project;
 
 use disconnected_overlay::DisconnectedOverlay;
 use fuzzy::{StringMatch, StringMatchCandidate};
@@ -16,9 +17,9 @@ use picker::{
     Picker, PickerDelegate,
     highlighted_match_with_paths::{HighlightedMatch, HighlightedMatchWithPaths},
 };
+pub use remote_connections::SshSettings;
 pub use remote_servers::RemoteServerProjects;
 use settings::Settings;
-pub use ssh_connections::SshSettings;
 use std::{path::Path, sync::Arc};
 use ui::{KeyBinding, ListItem, ListItemSpacing, Tooltip, prelude::*, tooltip_container};
 use util::{ResultExt, paths::PathExt};
@@ -290,7 +291,7 @@ impl PickerDelegate for RecentProjectsDelegate {
                     if workspace.database_id() == Some(*candidate_workspace_id) {
                         Task::ready(Ok(()))
                     } else {
-                        match candidate_workspace_location {
+                        match candidate_workspace_location.clone() {
                             SerializedWorkspaceLocation::Local => {
                                 let paths = candidate_workspace_paths.paths().to_vec();
                                 if replace_current_window {
@@ -320,7 +321,7 @@ impl PickerDelegate for RecentProjectsDelegate {
                                     workspace.open_workspace_for_paths(false, paths, window, cx)
                                 }
                             }
-                            SerializedWorkspaceLocation::Ssh(connection) => {
+                            SerializedWorkspaceLocation::Remote(mut connection) => {
                                 let app_state = workspace.app_state().clone();
 
                                 let replace_window = if replace_current_window {
@@ -334,18 +335,16 @@ impl PickerDelegate for RecentProjectsDelegate {
                                     ..Default::default()
                                 };
 
-                                let connection_options = SshSettings::get_global(cx)
-                                    .connection_options_for(
-                                        connection.host.clone(),
-                                        connection.port,
-                                        connection.user.clone(),
-                                    );
+                                if let RemoteConnectionOptions::Ssh(connection) = &mut connection {
+                                    SshSettings::get_global(cx)
+                                        .fill_connection_options_from_settings(connection);
+                                };
 
                                 let paths = candidate_workspace_paths.paths().to_vec();
 
                                 cx.spawn_in(window, async move |_, cx| {
-                                    open_ssh_project(
-                                        connection_options,
+                                    open_remote_project(
+                                        connection.clone(),
                                         paths,
                                         app_state,
                                         open_options,
@@ -418,9 +417,11 @@ impl PickerDelegate for RecentProjectsDelegate {
                                 SerializedWorkspaceLocation::Local => Icon::new(IconName::Screen)
                                     .color(Color::Muted)
                                     .into_any_element(),
-                                SerializedWorkspaceLocation::Ssh(_) => Icon::new(IconName::Server)
-                                    .color(Color::Muted)
-                                    .into_any_element(),
+                                SerializedWorkspaceLocation::Remote(_) => {
+                                    Icon::new(IconName::Server)
+                                        .color(Color::Muted)
+                                        .into_any_element()
+                                }
                             })
                         })
                         .child({

crates/recent_projects/src/ssh_connections.rs → crates/recent_projects/src/remote_connections.rs 🔗

@@ -16,7 +16,8 @@ use language::CursorShape;
 use markdown::{Markdown, MarkdownElement, MarkdownStyle};
 use release_channel::ReleaseChannel;
 use remote::{
-    ConnectionIdentifier, RemoteClient, RemotePlatform, SshConnectionOptions, SshPortForwardOption,
+    ConnectionIdentifier, RemoteClient, RemoteConnectionOptions, RemotePlatform,
+    SshConnectionOptions, SshPortForwardOption,
 };
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
@@ -42,32 +43,35 @@ impl SshSettings {
         self.ssh_connections.clone().into_iter().flatten()
     }
 
+    pub fn fill_connection_options_from_settings(&self, options: &mut SshConnectionOptions) {
+        for conn in self.ssh_connections() {
+            if conn.host == options.host
+                && conn.username == options.username
+                && conn.port == options.port
+            {
+                options.nickname = conn.nickname;
+                options.upload_binary_over_ssh = conn.upload_binary_over_ssh.unwrap_or_default();
+                options.args = Some(conn.args);
+                options.port_forwards = conn.port_forwards;
+                break;
+            }
+        }
+    }
+
     pub fn connection_options_for(
         &self,
         host: String,
         port: Option<u16>,
         username: Option<String>,
     ) -> SshConnectionOptions {
-        for conn in self.ssh_connections() {
-            if conn.host == host && conn.username == username && conn.port == port {
-                return SshConnectionOptions {
-                    nickname: conn.nickname,
-                    upload_binary_over_ssh: conn.upload_binary_over_ssh.unwrap_or_default(),
-                    args: Some(conn.args),
-                    host,
-                    port,
-                    username,
-                    port_forwards: conn.port_forwards,
-                    password: None,
-                };
-            }
-        }
-        SshConnectionOptions {
+        let mut options = SshConnectionOptions {
             host,
             port,
             username,
             ..Default::default()
-        }
+        };
+        self.fill_connection_options_from_settings(&mut options);
+        options
     }
 }
 
@@ -135,7 +139,7 @@ impl Settings for SshSettings {
     fn import_from_vscode(_vscode: &settings::VsCodeSettings, _current: &mut Self::FileContent) {}
 }
 
-pub struct SshPrompt {
+pub struct RemoteConnectionPrompt {
     connection_string: SharedString,
     nickname: Option<SharedString>,
     status_message: Option<SharedString>,
@@ -144,7 +148,7 @@ pub struct SshPrompt {
     editor: Entity<Editor>,
 }
 
-impl Drop for SshPrompt {
+impl Drop for RemoteConnectionPrompt {
     fn drop(&mut self) {
         if let Some(cancel) = self.cancellation.take() {
             cancel.send(()).ok();
@@ -152,24 +156,22 @@ impl Drop for SshPrompt {
     }
 }
 
-pub struct SshConnectionModal {
-    pub(crate) prompt: Entity<SshPrompt>,
+pub struct RemoteConnectionModal {
+    pub(crate) prompt: Entity<RemoteConnectionPrompt>,
     paths: Vec<PathBuf>,
     finished: bool,
 }
 
-impl SshPrompt {
+impl RemoteConnectionPrompt {
     pub(crate) fn new(
-        connection_options: &SshConnectionOptions,
+        connection_string: String,
+        nickname: Option<String>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
-        let connection_string = connection_options.connection_string().into();
-        let nickname = connection_options.nickname.clone().map(|s| s.into());
-
         Self {
-            connection_string,
-            nickname,
+            connection_string: connection_string.into(),
+            nickname: nickname.map(|nickname| nickname.into()),
             editor: cx.new(|cx| Editor::single_line(window, cx)),
             status_message: None,
             cancellation: None,
@@ -232,7 +234,7 @@ impl SshPrompt {
     }
 }
 
-impl Render for SshPrompt {
+impl Render for RemoteConnectionPrompt {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
         let theme = ThemeSettings::get_global(cx);
 
@@ -297,15 +299,22 @@ impl Render for SshPrompt {
     }
 }
 
-impl SshConnectionModal {
+impl RemoteConnectionModal {
     pub(crate) fn new(
-        connection_options: &SshConnectionOptions,
+        connection_options: &RemoteConnectionOptions,
         paths: Vec<PathBuf>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Self {
+        let (connection_string, nickname) = match connection_options {
+            RemoteConnectionOptions::Ssh(options) => {
+                (options.connection_string(), options.nickname.clone())
+            }
+            RemoteConnectionOptions::Wsl(options) => (options.distro_name.clone(), None),
+        };
         Self {
-            prompt: cx.new(|cx| SshPrompt::new(connection_options, window, cx)),
+            prompt: cx
+                .new(|cx| RemoteConnectionPrompt::new(connection_string, nickname, window, cx)),
             finished: false,
             paths,
         }
@@ -386,7 +395,7 @@ impl RenderOnce for SshConnectionHeader {
     }
 }
 
-impl Render for SshConnectionModal {
+impl Render for RemoteConnectionModal {
     fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
         let nickname = self.prompt.read(cx).nickname.clone();
         let connection_string = self.prompt.read(cx).connection_string.clone();
@@ -423,15 +432,15 @@ impl Render for SshConnectionModal {
     }
 }
 
-impl Focusable for SshConnectionModal {
+impl Focusable for RemoteConnectionModal {
     fn focus_handle(&self, cx: &gpui::App) -> gpui::FocusHandle {
         self.prompt.read(cx).editor.focus_handle(cx)
     }
 }
 
-impl EventEmitter<DismissEvent> for SshConnectionModal {}
+impl EventEmitter<DismissEvent> for RemoteConnectionModal {}
 
-impl ModalView for SshConnectionModal {
+impl ModalView for RemoteConnectionModal {
     fn on_before_dismiss(
         &mut self,
         _window: &mut Window,
@@ -446,13 +455,13 @@ impl ModalView for SshConnectionModal {
 }
 
 #[derive(Clone)]
-pub struct SshClientDelegate {
+pub struct RemoteClientDelegate {
     window: AnyWindowHandle,
-    ui: WeakEntity<SshPrompt>,
+    ui: WeakEntity<RemoteConnectionPrompt>,
     known_password: Option<String>,
 }
 
-impl remote::RemoteClientDelegate for SshClientDelegate {
+impl remote::RemoteClientDelegate for RemoteClientDelegate {
     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() {
@@ -522,7 +531,7 @@ impl remote::RemoteClientDelegate for SshClientDelegate {
     }
 }
 
-impl SshClientDelegate {
+impl RemoteClientDelegate {
     fn update_status(&self, status: Option<&str>, cx: &mut AsyncApp) {
         self.window
             .update(cx, |_, _, cx| {
@@ -534,14 +543,10 @@ impl SshClientDelegate {
     }
 }
 
-pub fn is_connecting_over_ssh(workspace: &Workspace, cx: &App) -> bool {
-    workspace.active_modal::<SshConnectionModal>(cx).is_some()
-}
-
 pub fn connect_over_ssh(
     unique_identifier: ConnectionIdentifier,
     connection_options: SshConnectionOptions,
-    ui: Entity<SshPrompt>,
+    ui: Entity<RemoteConnectionPrompt>,
     window: &mut Window,
     cx: &mut App,
 ) -> Task<Result<Option<Entity<RemoteClient>>>> {
@@ -554,7 +559,7 @@ pub fn connect_over_ssh(
         unique_identifier,
         connection_options,
         rx,
-        Arc::new(SshClientDelegate {
+        Arc::new(RemoteClientDelegate {
             window,
             ui: ui.downgrade(),
             known_password,
@@ -563,8 +568,8 @@ pub fn connect_over_ssh(
     )
 }
 
-pub async fn open_ssh_project(
-    connection_options: SshConnectionOptions,
+pub async fn open_remote_project(
+    connection_options: RemoteConnectionOptions,
     paths: Vec<PathBuf>,
     app_state: Arc<AppState>,
     open_options: workspace::OpenOptions,
@@ -575,13 +580,7 @@ pub async fn open_ssh_project(
     } else {
         let workspace_position = cx
             .update(|cx| {
-                workspace::ssh_workspace_position_from_db(
-                    connection_options.host.clone(),
-                    connection_options.port,
-                    connection_options.username.clone(),
-                    &paths,
-                    cx,
-                )
+                workspace::remote_workspace_position_from_db(connection_options.clone(), &paths, cx)
             })?
             .await
             .context("fetching ssh workspace position from db")?;
@@ -611,16 +610,16 @@ pub async fn open_ssh_project(
     loop {
         let (cancel_tx, cancel_rx) = oneshot::channel();
         let delegate = window.update(cx, {
-            let connection_options = connection_options.clone();
             let paths = paths.clone();
+            let connection_options = connection_options.clone();
             move |workspace, window, cx| {
                 window.activate_window();
                 workspace.toggle_modal(window, cx, |window, cx| {
-                    SshConnectionModal::new(&connection_options, paths, window, cx)
+                    RemoteConnectionModal::new(&connection_options, paths, window, cx)
                 });
 
                 let ui = workspace
-                    .active_modal::<SshConnectionModal>(cx)?
+                    .active_modal::<RemoteConnectionModal>(cx)?
                     .read(cx)
                     .prompt
                     .clone();
@@ -629,19 +628,25 @@ pub async fn open_ssh_project(
                     ui.set_cancellation_tx(cancel_tx);
                 });
 
-                Some(Arc::new(SshClientDelegate {
+                Some(Arc::new(RemoteClientDelegate {
                     window: window.window_handle(),
                     ui: ui.downgrade(),
-                    known_password: connection_options.password.clone(),
+                    known_password: if let RemoteConnectionOptions::Ssh(options) =
+                        &connection_options
+                    {
+                        options.password.clone()
+                    } else {
+                        None
+                    },
                 }))
             }
         })?;
 
         let Some(delegate) = delegate else { break };
 
-        let did_open_ssh_project = cx
+        let did_open_project = cx
             .update(|cx| {
-                workspace::open_ssh_project_with_new_connection(
+                workspace::open_remote_project_with_new_connection(
                     window,
                     connection_options.clone(),
                     cancel_rx,
@@ -655,19 +660,22 @@ pub async fn open_ssh_project(
 
         window
             .update(cx, |workspace, _, cx| {
-                if let Some(ui) = workspace.active_modal::<SshConnectionModal>(cx) {
+                if let Some(ui) = workspace.active_modal::<RemoteConnectionModal>(cx) {
                     ui.update(cx, |modal, cx| modal.finished(cx))
                 }
             })
             .ok();
 
-        if let Err(e) = did_open_ssh_project {
+        if let Err(e) = did_open_project {
             log::error!("Failed to open project: {e:?}");
             let response = window
                 .update(cx, |_, window, cx| {
                     window.prompt(
                         PromptLevel::Critical,
-                        "Failed to connect over SSH",
+                        match connection_options {
+                            RemoteConnectionOptions::Ssh(_) => "Failed to connect over SSH",
+                            RemoteConnectionOptions::Wsl(_) => "Failed to connect to WSL",
+                        },
                         Some(&e.to_string()),
                         &["Retry", "Ok"],
                         cx,

crates/recent_projects/src/remote_servers.rs 🔗

@@ -1,70 +1,52 @@
-use std::any::Any;
-use std::borrow::Cow;
-use std::collections::BTreeSet;
-use std::path::PathBuf;
-use std::rc::Rc;
-use std::sync::Arc;
-use std::sync::atomic;
-use std::sync::atomic::AtomicUsize;
-
+use crate::{
+    remote_connections::{
+        RemoteConnectionModal, RemoteConnectionPrompt, RemoteSettingsContent, SshConnection,
+        SshConnectionHeader, SshProject, SshSettings, connect_over_ssh, open_remote_project,
+    },
+    ssh_config::parse_ssh_config_hosts,
+};
 use editor::Editor;
 use file_finder::OpenPathDelegate;
-use futures::FutureExt;
-use futures::channel::oneshot;
-use futures::future::Shared;
-use futures::select;
-use gpui::ClickEvent;
-use gpui::ClipboardItem;
-use gpui::Subscription;
-use gpui::Task;
-use gpui::WeakEntity;
-use gpui::canvas;
+use futures::{FutureExt, channel::oneshot, future::Shared, select};
 use gpui::{
-    AnyElement, App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable,
-    PromptLevel, ScrollHandle, Window,
+    AnyElement, App, ClickEvent, ClipboardItem, Context, DismissEvent, Entity, EventEmitter,
+    FocusHandle, Focusable, PromptLevel, ScrollHandle, Subscription, Task, WeakEntity, Window,
+    canvas,
 };
-use paths::global_ssh_config_file;
-use paths::user_ssh_config_file;
+use paths::{global_ssh_config_file, user_ssh_config_file};
 use picker::Picker;
-use project::Fs;
-use project::Project;
-use remote::remote_client::ConnectionIdentifier;
-use remote::{RemoteClient, SshConnectionOptions};
-use settings::Settings;
-use settings::SettingsStore;
-use settings::update_settings_file;
-use settings::watch_config_file;
+use project::{Fs, Project};
+use remote::{
+    RemoteClient, RemoteConnectionOptions, SshConnectionOptions,
+    remote_client::ConnectionIdentifier,
+};
+use settings::{Settings, SettingsStore, update_settings_file, watch_config_file};
 use smol::stream::StreamExt as _;
-use ui::Navigable;
-use ui::NavigableEntry;
+use std::{
+    any::Any,
+    borrow::Cow,
+    collections::BTreeSet,
+    path::PathBuf,
+    rc::Rc,
+    sync::{
+        Arc,
+        atomic::{self, AtomicUsize},
+    },
+};
 use ui::{
-    IconButtonShape, List, ListItem, ListSeparator, Modal, ModalHeader, Scrollbar, ScrollbarState,
-    Section, Tooltip, prelude::*,
+    IconButtonShape, List, ListItem, ListSeparator, Modal, ModalHeader, Navigable, NavigableEntry,
+    Scrollbar, ScrollbarState, Section, Tooltip, prelude::*,
 };
 use util::{
     ResultExt,
     paths::{PathStyle, RemotePathBuf},
 };
-use workspace::OpenOptions;
-use workspace::Toast;
-use workspace::notifications::NotificationId;
 use workspace::{
-    ModalView, Workspace, notifications::DetachAndPromptErr,
-    open_ssh_project_with_existing_connection,
+    ModalView, OpenOptions, Toast, Workspace,
+    notifications::{DetachAndPromptErr, NotificationId},
+    open_remote_project_with_existing_connection,
 };
 
-use crate::ssh_config::parse_ssh_config_hosts;
-use crate::ssh_connections::RemoteSettingsContent;
-use crate::ssh_connections::SshConnection;
-use crate::ssh_connections::SshConnectionHeader;
-use crate::ssh_connections::SshConnectionModal;
-use crate::ssh_connections::SshProject;
-use crate::ssh_connections::SshPrompt;
-use crate::ssh_connections::SshSettings;
-use crate::ssh_connections::connect_over_ssh;
-use crate::ssh_connections::open_ssh_project;
-
-mod navigation_base {}
 pub struct RemoteServerProjects {
     mode: Mode,
     focus_handle: FocusHandle,
@@ -79,7 +61,7 @@ pub struct RemoteServerProjects {
 struct CreateRemoteServer {
     address_editor: Entity<Editor>,
     address_error: Option<SharedString>,
-    ssh_prompt: Option<Entity<SshPrompt>>,
+    ssh_prompt: Option<Entity<RemoteConnectionPrompt>>,
     _creating: Option<Task<Option<()>>>,
 }
 
@@ -222,8 +204,13 @@ impl ProjectPicker {
                         })
                         .log_err()?;
 
-                    open_ssh_project_with_existing_connection(
-                        connection, project, paths, app_state, window, cx,
+                    open_remote_project_with_existing_connection(
+                        RemoteConnectionOptions::Ssh(connection),
+                        project,
+                        paths,
+                        app_state,
+                        window,
+                        cx,
                     )
                     .await
                     .log_err();
@@ -472,7 +459,14 @@ impl RemoteServerProjects {
                 return;
             }
         };
-        let ssh_prompt = cx.new(|cx| SshPrompt::new(&connection_options, window, cx));
+        let ssh_prompt = cx.new(|cx| {
+            RemoteConnectionPrompt::new(
+                connection_options.connection_string(),
+                connection_options.nickname.clone(),
+                window,
+                cx,
+            )
+        });
 
         let connection = connect_over_ssh(
             ConnectionIdentifier::setup(),
@@ -552,15 +546,20 @@ impl RemoteServerProjects {
         };
 
         let create_new_window = self.create_new_window;
-        let connection_options = ssh_connection.into();
+        let connection_options: SshConnectionOptions = ssh_connection.into();
         workspace.update(cx, |_, cx| {
             cx.defer_in(window, move |workspace, window, cx| {
                 let app_state = workspace.app_state().clone();
                 workspace.toggle_modal(window, cx, |window, cx| {
-                    SshConnectionModal::new(&connection_options, Vec::new(), window, cx)
+                    RemoteConnectionModal::new(
+                        &RemoteConnectionOptions::Ssh(connection_options.clone()),
+                        Vec::new(),
+                        window,
+                        cx,
+                    )
                 });
                 let prompt = workspace
-                    .active_modal::<SshConnectionModal>(cx)
+                    .active_modal::<RemoteConnectionModal>(cx)
                     .unwrap()
                     .read(cx)
                     .prompt
@@ -579,7 +578,7 @@ impl RemoteServerProjects {
                     let session = connect.await;
 
                     workspace.update(cx, |workspace, cx| {
-                        if let Some(prompt) = workspace.active_modal::<SshConnectionModal>(cx) {
+                        if let Some(prompt) = workspace.active_modal::<RemoteConnectionModal>(cx) {
                             prompt.update(cx, |prompt, cx| prompt.finished(cx))
                         }
                     })?;
@@ -898,8 +897,8 @@ impl RemoteServerProjects {
                 };
 
                 cx.spawn_in(window, async move |_, cx| {
-                    let result = open_ssh_project(
-                        server.into(),
+                    let result = open_remote_project(
+                        RemoteConnectionOptions::Ssh(server.into()),
                         project.paths.into_iter().map(PathBuf::from).collect(),
                         app_state,
                         OpenOptions {

crates/remote/src/remote.rs 🔗

@@ -6,6 +6,7 @@ mod transport;
 
 pub use remote_client::{
     ConnectionIdentifier, ConnectionState, RemoteClient, RemoteClientDelegate, RemoteClientEvent,
-    RemotePlatform,
+    RemoteConnectionOptions, RemotePlatform,
 };
 pub use transport::ssh::{SshConnectionOptions, SshPortForwardOption};
+pub use transport::wsl::WslConnectionOptions;

crates/remote/src/remote_client.rs 🔗

@@ -1,6 +1,11 @@
 use crate::{
-    SshConnectionOptions, protocol::MessageId, proxy::ProxyLaunchError,
-    transport::ssh::SshRemoteConnection,
+    SshConnectionOptions,
+    protocol::MessageId,
+    proxy::ProxyLaunchError,
+    transport::{
+        ssh::SshRemoteConnection,
+        wsl::{WslConnectionOptions, WslRemoteConnection},
+    },
 };
 use anyhow::{Context as _, Result, anyhow};
 use async_trait::async_trait;
@@ -237,7 +242,7 @@ impl From<&State> for ConnectionState {
 pub struct RemoteClient {
     client: Arc<ChannelClient>,
     unique_identifier: String,
-    connection_options: SshConnectionOptions,
+    connection_options: RemoteConnectionOptions,
     path_style: PathStyle,
     state: Option<State>,
 }
@@ -290,6 +295,22 @@ impl RemoteClient {
         cancellation: oneshot::Receiver<()>,
         delegate: Arc<dyn RemoteClientDelegate>,
         cx: &mut App,
+    ) -> Task<Result<Option<Entity<Self>>>> {
+        Self::new(
+            unique_identifier,
+            RemoteConnectionOptions::Ssh(connection_options),
+            cancellation,
+            delegate,
+            cx,
+        )
+    }
+
+    pub fn new(
+        unique_identifier: ConnectionIdentifier,
+        connection_options: RemoteConnectionOptions,
+        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| {
@@ -424,7 +445,7 @@ impl RemoteClient {
         }
 
         let state = self.state.take().unwrap();
-        let (attempts, ssh_connection, delegate) = match state {
+        let (attempts, remote_connection, delegate) = match state {
             State::Connected {
                 ssh_connection,
                 delegate,
@@ -482,15 +503,15 @@ impl RemoteClient {
                 };
             }
 
-            if let Err(error) = ssh_connection
+            if let Err(error) = remote_connection
                 .kill()
                 .await
                 .context("Failed to kill ssh process")
             {
-                failed!(error, attempts, ssh_connection, delegate);
+                failed!(error, attempts, remote_connection, delegate);
             };
 
-            let connection_options = ssh_connection.connection_options();
+            let connection_options = remote_connection.connection_options();
 
             let (outgoing_tx, outgoing_rx) = mpsc::unbounded::<Envelope>();
             let (incoming_tx, incoming_rx) = mpsc::unbounded::<Envelope>();
@@ -519,7 +540,7 @@ impl RemoteClient {
             {
                 Ok((ssh_connection, io_task)) => (ssh_connection, io_task),
                 Err(error) => {
-                    failed!(error, attempts, ssh_connection, delegate);
+                    failed!(error, attempts, remote_connection, delegate);
                 }
             };
 
@@ -751,6 +772,13 @@ impl RemoteClient {
         Some(self.state.as_ref()?.remote_connection()?.shell())
     }
 
+    pub fn shares_network_interface(&self) -> bool {
+        self.state
+            .as_ref()
+            .and_then(|state| state.remote_connection())
+            .map_or(false, |connection| connection.shares_network_interface())
+    }
+
     pub fn build_command(
         &self,
         program: Option<String>,
@@ -789,11 +817,7 @@ impl RemoteClient {
         self.client.clone().into()
     }
 
-    pub fn host(&self) -> String {
-        self.connection_options.host.clone()
-    }
-
-    pub fn connection_options(&self) -> SshConnectionOptions {
+    pub fn connection_options(&self) -> RemoteConnectionOptions {
         self.connection_options.clone()
     }
 
@@ -836,14 +860,14 @@ impl RemoteClient {
     pub fn fake_server(
         client_cx: &mut gpui::TestAppContext,
         server_cx: &mut gpui::TestAppContext,
-    ) -> (SshConnectionOptions, AnyProtoClient) {
+    ) -> (RemoteConnectionOptions, AnyProtoClient) {
         let port = client_cx
             .update(|cx| cx.default_global::<ConnectionPool>().connections.len() as u16 + 1);
-        let opts = SshConnectionOptions {
+        let opts = RemoteConnectionOptions::Ssh(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 =
@@ -874,13 +898,13 @@ impl RemoteClient {
 
     #[cfg(any(test, feature = "test-support"))]
     pub async fn fake_client(
-        opts: SshConnectionOptions,
+        opts: RemoteConnectionOptions,
         client_cx: &mut gpui::TestAppContext,
     ) -> Entity<Self> {
         let (_tx, rx) = oneshot::channel();
         client_cx
             .update(|cx| {
-                Self::ssh(
+                Self::new(
                     ConnectionIdentifier::setup(),
                     opts,
                     rx,
@@ -901,7 +925,7 @@ enum ConnectionPoolEntry {
 
 #[derive(Default)]
 struct ConnectionPool {
-    connections: HashMap<SshConnectionOptions, ConnectionPoolEntry>,
+    connections: HashMap<RemoteConnectionOptions, ConnectionPoolEntry>,
 }
 
 impl Global for ConnectionPool {}
@@ -909,7 +933,7 @@ impl Global for ConnectionPool {}
 impl ConnectionPool {
     pub fn connect(
         &mut self,
-        opts: SshConnectionOptions,
+        opts: RemoteConnectionOptions,
         delegate: &Arc<dyn RemoteClientDelegate>,
         cx: &mut App,
     ) -> Shared<Task<Result<Arc<dyn RemoteConnection>, Arc<anyhow::Error>>>> {
@@ -939,9 +963,18 @@ impl ConnectionPool {
                 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>);
+                    let connection = match opts.clone() {
+                        RemoteConnectionOptions::Ssh(opts) => {
+                            SshRemoteConnection::new(opts, delegate, cx)
+                                .await
+                                .map(|connection| Arc::new(connection) as Arc<dyn RemoteConnection>)
+                        }
+                        RemoteConnectionOptions::Wsl(opts) => {
+                            WslRemoteConnection::new(opts, delegate, cx)
+                                .await
+                                .map(|connection| Arc::new(connection) as Arc<dyn RemoteConnection>)
+                        }
+                    };
 
                     cx.update_global(|pool: &mut Self, _| {
                         debug_assert!(matches!(
@@ -972,6 +1005,33 @@ impl ConnectionPool {
     }
 }
 
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub enum RemoteConnectionOptions {
+    Ssh(SshConnectionOptions),
+    Wsl(WslConnectionOptions),
+}
+
+impl RemoteConnectionOptions {
+    pub fn display_name(&self) -> String {
+        match self {
+            RemoteConnectionOptions::Ssh(opts) => opts.host.clone(),
+            RemoteConnectionOptions::Wsl(opts) => opts.distro_name.clone(),
+        }
+    }
+}
+
+impl From<SshConnectionOptions> for RemoteConnectionOptions {
+    fn from(opts: SshConnectionOptions) -> Self {
+        RemoteConnectionOptions::Ssh(opts)
+    }
+}
+
+impl From<WslConnectionOptions> for RemoteConnectionOptions {
+    fn from(opts: WslConnectionOptions) -> Self {
+        RemoteConnectionOptions::Wsl(opts)
+    }
+}
+
 #[async_trait(?Send)]
 pub(crate) trait RemoteConnection: Send + Sync {
     fn start_proxy(
@@ -992,6 +1052,9 @@ pub(crate) trait RemoteConnection: Send + Sync {
     ) -> Task<Result<()>>;
     async fn kill(&self) -> Result<()>;
     fn has_been_killed(&self) -> bool;
+    fn shares_network_interface(&self) -> bool {
+        false
+    }
     fn build_command(
         &self,
         program: Option<String>,
@@ -1000,7 +1063,7 @@ pub(crate) trait RemoteConnection: Send + Sync {
         working_dir: Option<String>,
         port_forward: Option<(u16, String, u16)>,
     ) -> Result<CommandTemplate>;
-    fn connection_options(&self) -> SshConnectionOptions;
+    fn connection_options(&self) -> RemoteConnectionOptions;
     fn path_style(&self) -> PathStyle;
     fn shell(&self) -> String;
 
@@ -1307,7 +1370,7 @@ impl ProtoClient for ChannelClient {
 #[cfg(any(test, feature = "test-support"))]
 mod fake {
     use super::{ChannelClient, RemoteClientDelegate, RemoteConnection, RemotePlatform};
-    use crate::{SshConnectionOptions, remote_client::CommandTemplate};
+    use crate::remote_client::{CommandTemplate, RemoteConnectionOptions};
     use anyhow::Result;
     use async_trait::async_trait;
     use collections::HashMap;
@@ -1326,7 +1389,7 @@ mod fake {
     use util::paths::{PathStyle, RemotePathBuf};
 
     pub(super) struct FakeRemoteConnection {
-        pub(super) connection_options: SshConnectionOptions,
+        pub(super) connection_options: RemoteConnectionOptions,
         pub(super) server_channel: Arc<ChannelClient>,
         pub(super) server_cx: SendableCx,
     }
@@ -1386,7 +1449,7 @@ mod fake {
             unreachable!()
         }
 
-        fn connection_options(&self) -> SshConnectionOptions {
+        fn connection_options(&self) -> RemoteConnectionOptions {
             self.connection_options.clone()
         }
 

crates/remote/src/transport.rs 🔗

@@ -1 +1,336 @@
+use crate::{
+    json_log::LogRecord,
+    protocol::{MESSAGE_LEN_SIZE, message_len_from_buffer, read_message_with_len, write_message},
+};
+use anyhow::{Context as _, Result};
+use futures::{
+    AsyncReadExt as _, FutureExt as _, StreamExt as _,
+    channel::mpsc::{Sender, UnboundedReceiver, UnboundedSender},
+};
+use gpui::{AppContext as _, AsyncApp, Task};
+use rpc::proto::Envelope;
+use smol::process::Child;
+
 pub mod ssh;
+pub mod wsl;
+
+fn handle_rpc_messages_over_child_process_stdio(
+    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),
+        }
+    })
+}
+
+#[cfg(debug_assertions)]
+async fn build_remote_server_from_source(
+    platform: &crate::RemotePlatform,
+    delegate: &dyn crate::RemoteClientDelegate,
+    cx: &mut AsyncApp,
+) -> Result<Option<std::path::PathBuf>> {
+    use std::path::Path;
+
+    let Some(build_remote_server) = std::env::var("ZED_BUILD_REMOTE_SERVER").ok() else {
+        return Ok(None);
+    };
+
+    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!(
+        "{}-{}",
+        platform.arch,
+        match platform.os {
+            "linux" =>
+                if use_musl {
+                    "unknown-linux-musl"
+                } else {
+                    "unknown-linux-gnu"
+                },
+            "macos" => "apple-darwin",
+            _ => anyhow::bail!("can't cross compile for: {:?}", 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 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 platform.arch == std::env::consts::ARCH && 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::new(&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(Some(path))
+}

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

@@ -1,14 +1,12 @@
 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},
+    remote_client::{CommandTemplate, RemoteConnection, RemoteConnectionOptions},
 };
 use anyhow::{Context as _, Result, anyhow};
 use async_trait::async_trait;
 use collections::HashMap;
 use futures::{
-    AsyncReadExt as _, FutureExt as _, StreamExt as _,
+    AsyncReadExt as _, FutureExt as _,
     channel::mpsc::{Sender, UnboundedReceiver, UnboundedSender},
     select_biased,
 };
@@ -99,8 +97,8 @@ impl RemoteConnection for SshRemoteConnection {
         self.master_process.lock().is_none()
     }
 
-    fn connection_options(&self) -> SshConnectionOptions {
-        self.socket.connection_options.clone()
+    fn connection_options(&self) -> RemoteConnectionOptions {
+        RemoteConnectionOptions::Ssh(self.socket.connection_options.clone())
     }
 
     fn shell(&self) -> String {
@@ -267,7 +265,7 @@ impl RemoteConnection for SshRemoteConnection {
             }
         };
 
-        Self::multiplex(
+        super::handle_rpc_messages_over_child_process_stdio(
             ssh_proxy_process,
             incoming_tx,
             outgoing_rx,
@@ -415,109 +413,6 @@ impl SshRemoteConnection {
         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>,
@@ -544,19 +439,20 @@ impl SshRemoteConnection {
             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?;
+        if let Some(remote_server_path) =
+            super::build_remote_server_from_source(&self.ssh_platform, delegate.as_ref(), 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()
+                    remote_server_path.file_name().unwrap().to_string_lossy()
                 )),
                 self.ssh_path_style,
             );
-            self.upload_local_server_binary(&src_path, &tmp_path, delegate, cx)
+            self.upload_local_server_binary(&remote_server_path, &tmp_path, delegate, cx)
                 .await?;
             self.extract_server_binary(&dst_path, &tmp_path, delegate, cx)
                 .await?;
@@ -794,221 +690,6 @@ impl SshRemoteConnection {
         );
         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::new(&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 {

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

@@ -0,0 +1,494 @@
+use crate::{
+    RemoteClientDelegate, RemotePlatform,
+    remote_client::{CommandTemplate, RemoteConnection, RemoteConnectionOptions},
+};
+use anyhow::{Result, anyhow, bail};
+use async_trait::async_trait;
+use collections::HashMap;
+use futures::channel::mpsc::{Sender, UnboundedReceiver, UnboundedSender};
+use gpui::{App, AppContext as _, AsyncApp, SemanticVersion, Task};
+use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
+use rpc::proto::Envelope;
+use smol::{fs, process};
+use std::{
+    fmt::Write as _,
+    path::{Path, PathBuf},
+    process::Stdio,
+    sync::Arc,
+    time::Instant,
+};
+use util::paths::{PathStyle, RemotePathBuf};
+
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct WslConnectionOptions {
+    pub distro_name: String,
+    pub user: Option<String>,
+}
+
+pub(crate) struct WslRemoteConnection {
+    remote_binary_path: Option<RemotePathBuf>,
+    platform: RemotePlatform,
+    shell: String,
+    connection_options: WslConnectionOptions,
+}
+
+impl WslRemoteConnection {
+    pub(crate) async fn new(
+        connection_options: WslConnectionOptions,
+        delegate: Arc<dyn RemoteClientDelegate>,
+        cx: &mut AsyncApp,
+    ) -> Result<Self> {
+        log::info!(
+            "Connecting to WSL distro {} with user {:?}",
+            connection_options.distro_name,
+            connection_options.user
+        );
+        let (release_channel, version, commit) = cx.update(|cx| {
+            (
+                ReleaseChannel::global(cx),
+                AppVersion::global(cx),
+                AppCommitSha::try_global(cx),
+            )
+        })?;
+
+        let mut this = Self {
+            connection_options,
+            remote_binary_path: None,
+            platform: RemotePlatform { os: "", arch: "" },
+            shell: String::new(),
+        };
+        delegate.set_status(Some("Detecting WSL environment"), cx);
+        this.platform = this.detect_platform().await?;
+        this.shell = this.detect_shell().await?;
+        this.remote_binary_path = Some(
+            this.ensure_server_binary(&delegate, release_channel, version, commit, cx)
+                .await?,
+        );
+
+        Ok(this)
+    }
+
+    async fn detect_platform(&self) -> Result<RemotePlatform> {
+        let arch_str = self.run_wsl_command("uname", &["-m"]).await?;
+        let arch_str = arch_str.trim().to_string();
+        let arch = match arch_str.as_str() {
+            "x86_64" => "x86_64",
+            "aarch64" | "arm64" => "aarch64",
+            _ => "x86_64",
+        };
+        Ok(RemotePlatform { os: "linux", arch })
+    }
+
+    async fn detect_shell(&self) -> Result<String> {
+        Ok(self
+            .run_wsl_command("sh", &["-c", "echo $SHELL"])
+            .await
+            .ok()
+            .and_then(|shell_path| shell_path.trim().split('/').next_back().map(str::to_string))
+            .unwrap_or_else(|| "bash".to_string()))
+    }
+
+    async fn windows_path_to_wsl_path(&self, source: &Path) -> Result<String> {
+        windows_path_to_wsl_path_impl(&self.connection_options, source).await
+    }
+
+    fn wsl_command(&self, program: &str, args: &[&str]) -> process::Command {
+        wsl_command_impl(&self.connection_options, program, args)
+    }
+
+    async fn run_wsl_command(&self, program: &str, args: &[&str]) -> Result<String> {
+        run_wsl_command_impl(&self.connection_options, program, args).await
+    }
+
+    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_wsl_server_dir_relative().join(binary_name),
+            PathStyle::Posix,
+        );
+
+        if let Some(parent) = dst_path.parent() {
+            self.run_wsl_command("mkdir", &["-p", &parent.to_string()])
+                .await
+                .map_err(|e| anyhow!("Failed to create directory: {}", e))?;
+        }
+
+        #[cfg(debug_assertions)]
+        if let Some(remote_server_path) =
+            super::build_remote_server_from_source(&self.platform, delegate.as_ref(), cx).await?
+        {
+            let tmp_path = RemotePathBuf::new(
+                paths::remote_wsl_server_dir_relative().join(format!(
+                    "download-{}-{}",
+                    std::process::id(),
+                    remote_server_path.file_name().unwrap().to_string_lossy()
+                )),
+                PathStyle::Posix,
+            );
+            self.upload_file(&remote_server_path, &tmp_path, delegate, cx)
+                .await?;
+            self.extract_and_install(&tmp_path, &dst_path, delegate, cx)
+                .await?;
+            return Ok(dst_path);
+        }
+
+        if self
+            .run_wsl_command(&dst_path.to_string(), &["version"])
+            .await
+            .is_ok()
+        {
+            return Ok(dst_path);
+        }
+
+        delegate.set_status(Some("Installing remote server"), cx);
+
+        let wanted_version = match release_channel {
+            ReleaseChannel::Nightly => None,
+            ReleaseChannel::Dev => {
+                return Err(anyhow!("Dev builds require manual installation"));
+            }
+            _ => Some(cx.update(|cx| AppVersion::global(cx))?),
+        };
+
+        let src_path = delegate
+            .download_server_binary_locally(self.platform, release_channel, wanted_version, cx)
+            .await?;
+
+        let tmp_path = RemotePathBuf::new(
+            PathBuf::from(format!("{}.{}.tmp", dst_path, std::process::id())),
+            PathStyle::Posix,
+        );
+
+        self.upload_file(&src_path, &tmp_path, delegate, cx).await?;
+        self.extract_and_install(&tmp_path, &dst_path, delegate, cx)
+            .await?;
+
+        Ok(dst_path)
+    }
+
+    async fn upload_file(
+        &self,
+        src_path: &Path,
+        dst_path: &RemotePathBuf,
+        delegate: &Arc<dyn RemoteClientDelegate>,
+        cx: &mut AsyncApp,
+    ) -> Result<()> {
+        delegate.set_status(Some("Uploading remote server to WSL"), cx);
+
+        if let Some(parent) = dst_path.parent() {
+            self.run_wsl_command("mkdir", &["-p", &parent.to_string()])
+                .await
+                .map_err(|e| anyhow!("Failed to create directory when uploading file: {}", e))?;
+        }
+
+        let t0 = Instant::now();
+        let src_stat = fs::metadata(&src_path).await?;
+        let size = src_stat.len();
+        log::info!(
+            "uploading remote server to WSL {:?} ({}kb)",
+            dst_path,
+            size / 1024
+        );
+
+        let src_path_in_wsl = self.windows_path_to_wsl_path(src_path).await?;
+        self.run_wsl_command("cp", &["-f", &src_path_in_wsl, &dst_path.to_string()])
+            .await
+            .map_err(|e| {
+                anyhow!(
+                    "Failed to copy file {}({}) to WSL {:?}: {}",
+                    src_path.display(),
+                    src_path_in_wsl,
+                    dst_path,
+                    e
+                )
+            })?;
+
+        log::info!("uploaded remote server in {:?}", t0.elapsed());
+        Ok(())
+    }
+
+    async fn extract_and_install(
+        &self,
+        tmp_path: &RemotePathBuf,
+        dst_path: &RemotePathBuf,
+        delegate: &Arc<dyn RemoteClientDelegate>,
+        cx: &mut AsyncApp,
+    ) -> Result<()> {
+        delegate.set_status(Some("Extracting remote server"), cx);
+
+        let tmp_path_str = tmp_path.to_string();
+        let dst_path_str = dst_path.to_string();
+
+        // Build extraction script with proper error handling
+        let script = if tmp_path_str.ends_with(".gz") {
+            let uncompressed = tmp_path_str.trim_end_matches(".gz");
+            format!(
+                "set -e; gunzip -f '{}' && chmod 755 '{}' && mv -f '{}' '{}'",
+                tmp_path_str, uncompressed, uncompressed, dst_path_str
+            )
+        } else {
+            format!(
+                "set -e; chmod 755 '{}' && mv -f '{}' '{}'",
+                tmp_path_str, tmp_path_str, dst_path_str
+            )
+        };
+
+        self.run_wsl_command("sh", &["-c", &script])
+            .await
+            .map_err(|e| anyhow!("Failed to extract server binary: {}", e))?;
+        Ok(())
+    }
+}
+
+#[async_trait(?Send)]
+impl RemoteConnection for WslRemoteConnection {
+    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 else {
+            return Task::ready(Err(anyhow!("Remote binary path not set")));
+        };
+
+        let mut proxy_command = format!(
+            "exec {} proxy --identifier {}",
+            remote_binary_path, unique_identifier
+        );
+
+        if reconnect {
+            proxy_command.push_str(" --reconnect");
+        }
+
+        for env_var in ["RUST_LOG", "RUST_BACKTRACE", "ZED_GENERATE_MINIDUMPS"] {
+            if let Some(value) = std::env::var(env_var).ok() {
+                proxy_command = format!("{}='{}' {}", env_var, value, proxy_command);
+            }
+        }
+        let proxy_process = match self
+            .wsl_command("sh", &["-lc", &proxy_command])
+            .kill_on_drop(true)
+            .spawn()
+        {
+            Ok(process) => process,
+            Err(error) => {
+                return Task::ready(Err(anyhow!("failed to spawn remote server: {}", error)));
+            }
+        };
+
+        super::handle_rpc_messages_over_child_process_stdio(
+            proxy_process,
+            incoming_tx,
+            outgoing_rx,
+            connection_activity_tx,
+            cx,
+        )
+    }
+
+    fn upload_directory(
+        &self,
+        src_path: PathBuf,
+        dest_path: RemotePathBuf,
+        cx: &App,
+    ) -> Task<Result<()>> {
+        cx.background_spawn({
+            let options = self.connection_options.clone();
+            async move {
+                let wsl_src = windows_path_to_wsl_path_impl(&options, &src_path).await?;
+
+                run_wsl_command_impl(&options, "cp", &["-r", &wsl_src, &dest_path.to_string()])
+                    .await
+                    .map_err(|e| {
+                        anyhow!(
+                            "failed to upload directory {} -> {}: {}",
+                            src_path.display(),
+                            dest_path.to_string(),
+                            e
+                        )
+                    })?;
+
+                Ok(())
+            }
+        })
+    }
+
+    async fn kill(&self) -> Result<()> {
+        Ok(())
+    }
+
+    fn has_been_killed(&self) -> bool {
+        false
+    }
+
+    fn shares_network_interface(&self) -> bool {
+        true
+    }
+
+    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> {
+        if port_forward.is_some() {
+            bail!("WSL shares the network interface with the host system");
+        }
+
+        let working_dir = working_dir
+            .map(|working_dir| RemotePathBuf::new(working_dir.into(), PathStyle::Posix).to_string())
+            .unwrap_or("~".to_string());
+
+        let mut script = String::new();
+
+        for (k, v) in env.iter() {
+            write!(&mut script, "{}='{}' ", k, v).unwrap();
+        }
+
+        if let Some(program) = program {
+            let command = shlex::try_quote(&program)?;
+            script.push_str(&command);
+            for arg in args {
+                let arg = shlex::try_quote(&arg)?;
+                script.push_str(" ");
+                script.push_str(&arg);
+            }
+        } else {
+            write!(&mut script, "exec {} -l", self.shell).unwrap();
+        }
+
+        let wsl_args = if let Some(user) = &self.connection_options.user {
+            vec![
+                "--distribution".to_string(),
+                self.connection_options.distro_name.clone(),
+                "--user".to_string(),
+                user.clone(),
+                "--cd".to_string(),
+                working_dir,
+                "--".to_string(),
+                self.shell.clone(),
+                "-c".to_string(),
+                shlex::try_quote(&script)?.to_string(),
+            ]
+        } else {
+            vec![
+                "--distribution".to_string(),
+                self.connection_options.distro_name.clone(),
+                "--cd".to_string(),
+                working_dir,
+                "--".to_string(),
+                self.shell.clone(),
+                "-c".to_string(),
+                shlex::try_quote(&script)?.to_string(),
+            ]
+        };
+
+        Ok(CommandTemplate {
+            program: "wsl.exe".to_string(),
+            args: wsl_args,
+            env: HashMap::default(),
+        })
+    }
+
+    fn connection_options(&self) -> RemoteConnectionOptions {
+        RemoteConnectionOptions::Wsl(self.connection_options.clone())
+    }
+
+    fn path_style(&self) -> PathStyle {
+        PathStyle::Posix
+    }
+
+    fn shell(&self) -> String {
+        self.shell.clone()
+    }
+}
+
+/// `wslpath` is a executable available in WSL, it's a linux binary.
+/// So it doesn't support Windows style paths.
+async fn sanitize_path(path: &Path) -> Result<String> {
+    let path = smol::fs::canonicalize(path).await?;
+    let path_str = path.to_string_lossy();
+
+    let sanitized = path_str.strip_prefix(r"\\?\").unwrap_or(&path_str);
+    Ok(sanitized.replace('\\', "/"))
+}
+
+async fn windows_path_to_wsl_path_impl(
+    options: &WslConnectionOptions,
+    source: &Path,
+) -> Result<String> {
+    let source = sanitize_path(source).await?;
+    run_wsl_command_impl(options, "wslpath", &["-u", &source]).await
+}
+
+fn wsl_command_impl(
+    options: &WslConnectionOptions,
+    program: &str,
+    args: &[&str],
+) -> process::Command {
+    let mut command = util::command::new_smol_command("wsl.exe");
+
+    if let Some(user) = &options.user {
+        command.arg("--user").arg(user);
+    }
+
+    command
+        .stdin(Stdio::piped())
+        .stdout(Stdio::piped())
+        .stderr(Stdio::piped())
+        .arg("--distribution")
+        .arg(&options.distro_name)
+        .arg("--cd")
+        .arg("~")
+        .arg(program)
+        .args(args);
+
+    command
+}
+
+async fn run_wsl_command_impl(
+    options: &WslConnectionOptions,
+    program: &str,
+    args: &[&str],
+) -> Result<String> {
+    let output = wsl_command_impl(options, program, args).output().await?;
+
+    if !output.status.success() {
+        return Err(anyhow!(
+            "Command '{}' failed: {}",
+            program,
+            String::from_utf8_lossy(&output.stderr).trim()
+        ));
+    }
+
+    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
+}

crates/title_bar/src/title_bar.rs 🔗

@@ -32,6 +32,7 @@ use gpui::{
 use keymap_editor;
 use onboarding_banner::OnboardingBanner;
 use project::Project;
+use remote::RemoteConnectionOptions;
 use settings::Settings as _;
 use std::sync::Arc;
 use theme::ActiveTheme;
@@ -304,12 +305,14 @@ impl TitleBar {
 
     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 host: SharedString = options.display_name().into();
 
-        let nickname = options
-            .nickname
-            .map(|nick| nick.into())
-            .unwrap_or_else(|| host.clone());
+        let nickname = if let RemoteConnectionOptions::Ssh(options) = options {
+            options.nickname.map(|nick| nick.into())
+        } else {
+            None
+        };
+        let nickname = nickname.unwrap_or_else(|| host.clone());
 
         let (indicator_color, meta) = match self.project.read(cx).remote_connection_state(cx)? {
             remote::ConnectionState::Connecting => (Color::Info, format!("Connecting to: {host}")),

crates/workspace/src/persistence.rs 🔗

@@ -20,6 +20,7 @@ use project::debugger::breakpoint_store::{BreakpointState, SourceBreakpoint};
 
 use language::{LanguageName, Toolchain};
 use project::WorktreeId;
+use remote::{RemoteConnectionOptions, SshConnectionOptions, WslConnectionOptions};
 use sqlez::{
     bindable::{Bind, Column, StaticColumnCount},
     statement::{SqlType, Statement},
@@ -33,11 +34,12 @@ use uuid::Uuid;
 use crate::{
     WorkspaceId,
     path_list::{PathList, SerializedPathList},
+    persistence::model::RemoteConnectionKind,
 };
 
 use model::{
-    GroupId, ItemId, PaneId, SerializedItem, SerializedPane, SerializedPaneGroup,
-    SerializedSshConnection, SerializedWorkspace, SshConnectionId,
+    GroupId, ItemId, PaneId, RemoteConnectionId, SerializedItem, SerializedPane,
+    SerializedPaneGroup, SerializedWorkspace,
 };
 
 use self::model::{DockStructure, SerializedWorkspaceLocation};
@@ -627,6 +629,88 @@ impl Domain for WorkspaceDb {
             END
             WHERE paths IS NOT NULL
         ),
+        sql!(
+            CREATE TABLE remote_connections(
+                id INTEGER PRIMARY KEY,
+                kind TEXT NOT NULL,
+                host TEXT,
+                port INTEGER,
+                user TEXT,
+                distro TEXT
+            );
+
+            CREATE TABLE workspaces_2(
+                workspace_id INTEGER PRIMARY KEY,
+                paths TEXT,
+                paths_order TEXT,
+                remote_connection_id INTEGER REFERENCES remote_connections(id),
+                timestamp TEXT DEFAULT CURRENT_TIMESTAMP NOT NULL,
+                window_state TEXT,
+                window_x REAL,
+                window_y REAL,
+                window_width REAL,
+                window_height REAL,
+                display BLOB,
+                left_dock_visible INTEGER,
+                left_dock_active_panel TEXT,
+                right_dock_visible INTEGER,
+                right_dock_active_panel TEXT,
+                bottom_dock_visible INTEGER,
+                bottom_dock_active_panel TEXT,
+                left_dock_zoom INTEGER,
+                right_dock_zoom INTEGER,
+                bottom_dock_zoom INTEGER,
+                fullscreen INTEGER,
+                centered_layout INTEGER,
+                session_id TEXT,
+                window_id INTEGER
+            ) STRICT;
+
+            INSERT INTO remote_connections
+            SELECT
+                id,
+                "ssh" as kind,
+                host,
+                port,
+                user,
+                NULL as distro
+            FROM ssh_connections;
+
+            INSERT
+            INTO workspaces_2
+            SELECT
+                workspace_id,
+                paths,
+                paths_order,
+                ssh_connection_id as remote_connection_id,
+                timestamp,
+                window_state,
+                window_x,
+                window_y,
+                window_width,
+                window_height,
+                display,
+                left_dock_visible,
+                left_dock_active_panel,
+                right_dock_visible,
+                right_dock_active_panel,
+                bottom_dock_visible,
+                bottom_dock_active_panel,
+                left_dock_zoom,
+                right_dock_zoom,
+                bottom_dock_zoom,
+                fullscreen,
+                centered_layout,
+                session_id,
+                window_id
+            FROM
+                workspaces;
+
+            DROP TABLE workspaces;
+            ALTER TABLE workspaces_2 RENAME TO workspaces;
+
+            CREATE UNIQUE INDEX ix_workspaces_location ON workspaces(remote_connection_id, paths);
+        ),
     ];
 
     // Allow recovering from bad migration that was initially shipped to nightly
@@ -650,10 +734,10 @@ impl WorkspaceDb {
         self.workspace_for_roots_internal(worktree_roots, None)
     }
 
-    pub(crate) fn ssh_workspace_for_roots<P: AsRef<Path>>(
+    pub(crate) fn remote_workspace_for_roots<P: AsRef<Path>>(
         &self,
         worktree_roots: &[P],
-        ssh_project_id: SshConnectionId,
+        ssh_project_id: RemoteConnectionId,
     ) -> Option<SerializedWorkspace> {
         self.workspace_for_roots_internal(worktree_roots, Some(ssh_project_id))
     }
@@ -661,7 +745,7 @@ impl WorkspaceDb {
     pub(crate) fn workspace_for_roots_internal<P: AsRef<Path>>(
         &self,
         worktree_roots: &[P],
-        ssh_connection_id: Option<SshConnectionId>,
+        remote_connection_id: Option<RemoteConnectionId>,
     ) -> Option<SerializedWorkspace> {
         // paths are sorted before db interactions to ensure that the order of the paths
         // doesn't affect the workspace selection for existing workspaces
@@ -713,13 +797,13 @@ impl WorkspaceDb {
                 FROM workspaces
                 WHERE
                     paths IS ? AND
-                    ssh_connection_id IS ?
+                    remote_connection_id IS ?
                 LIMIT 1
             })
             .map(|mut prepared_statement| {
                 (prepared_statement)((
                     root_paths.serialize().paths,
-                    ssh_connection_id.map(|id| id.0 as i32),
+                    remote_connection_id.map(|id| id.0 as i32),
                 ))
                 .unwrap()
             })
@@ -803,14 +887,12 @@ impl WorkspaceDb {
         log::debug!("Saving workspace at location: {:?}", workspace.location);
         self.write(move |conn| {
             conn.with_savepoint("update_worktrees", || {
-                let ssh_connection_id = match &workspace.location {
+                let remote_connection_id = match workspace.location.clone() {
                     SerializedWorkspaceLocation::Local => None,
-                    SerializedWorkspaceLocation::Ssh(connection) => {
-                        Some(Self::get_or_create_ssh_connection_query(
+                    SerializedWorkspaceLocation::Remote(connection_options) => {
+                        Some(Self::get_or_create_remote_connection_internal(
                             conn,
-                            connection.host.clone(),
-                            connection.port,
-                            connection.user.clone(),
+                            connection_options
                         )?.0)
                     }
                 };
@@ -860,11 +942,11 @@ impl WorkspaceDb {
                     WHERE
                         workspace_id != ?1 AND
                         paths IS ?2 AND
-                        ssh_connection_id IS ?3
+                        remote_connection_id IS ?3
                 ))?((
                     workspace.id,
                     paths.paths.clone(),
-                    ssh_connection_id,
+                    remote_connection_id,
                 ))
                 .context("clearing out old locations")?;
 
@@ -874,7 +956,7 @@ impl WorkspaceDb {
                         workspace_id,
                         paths,
                         paths_order,
-                        ssh_connection_id,
+                        remote_connection_id,
                         left_dock_visible,
                         left_dock_active_panel,
                         left_dock_zoom,
@@ -893,7 +975,7 @@ impl WorkspaceDb {
                     UPDATE SET
                         paths = ?2,
                         paths_order = ?3,
-                        ssh_connection_id = ?4,
+                        remote_connection_id = ?4,
                         left_dock_visible = ?5,
                         left_dock_active_panel = ?6,
                         left_dock_zoom = ?7,
@@ -912,7 +994,7 @@ impl WorkspaceDb {
                     workspace.id,
                     paths.paths.clone(),
                     paths.order.clone(),
-                    ssh_connection_id,
+                    remote_connection_id,
                     workspace.docks,
                     workspace.session_id,
                     workspace.window_id,
@@ -931,39 +1013,78 @@ impl WorkspaceDb {
         .await;
     }
 
-    pub(crate) async fn get_or_create_ssh_connection(
+    pub(crate) async fn get_or_create_remote_connection(
         &self,
-        host: String,
-        port: Option<u16>,
-        user: Option<String>,
-    ) -> Result<SshConnectionId> {
-        self.write(move |conn| Self::get_or_create_ssh_connection_query(conn, host, port, user))
+        options: RemoteConnectionOptions,
+    ) -> Result<RemoteConnectionId> {
+        self.write(move |conn| Self::get_or_create_remote_connection_internal(conn, options))
             .await
     }
 
-    fn get_or_create_ssh_connection_query(
+    fn get_or_create_remote_connection_internal(
+        this: &Connection,
+        options: RemoteConnectionOptions,
+    ) -> Result<RemoteConnectionId> {
+        let kind;
+        let user;
+        let mut host = None;
+        let mut port = None;
+        let mut distro = None;
+        match options {
+            RemoteConnectionOptions::Ssh(options) => {
+                kind = RemoteConnectionKind::Ssh;
+                host = Some(options.host);
+                port = options.port;
+                user = options.username;
+            }
+            RemoteConnectionOptions::Wsl(options) => {
+                kind = RemoteConnectionKind::Wsl;
+                distro = Some(options.distro_name);
+                user = options.user;
+            }
+        }
+        Self::get_or_create_remote_connection_query(this, kind, host, port, user, distro)
+    }
+
+    fn get_or_create_remote_connection_query(
         this: &Connection,
-        host: String,
+        kind: RemoteConnectionKind,
+        host: Option<String>,
         port: Option<u16>,
         user: Option<String>,
-    ) -> Result<SshConnectionId> {
+        distro: Option<String>,
+    ) -> Result<RemoteConnectionId> {
         if let Some(id) = this.select_row_bound(sql!(
-            SELECT id FROM ssh_connections WHERE host IS ? AND port IS ? AND user IS ? LIMIT 1
-        ))?((host.clone(), port, user.clone()))?
-        {
-            Ok(SshConnectionId(id))
+            SELECT id
+            FROM remote_connections
+            WHERE
+                kind IS ? AND
+                host IS ? AND
+                port IS ? AND
+                user IS ? AND
+                distro IS ?
+            LIMIT 1
+        ))?((
+            kind.serialize(),
+            host.clone(),
+            port,
+            user.clone(),
+            distro.clone(),
+        ))? {
+            Ok(RemoteConnectionId(id))
         } else {
-            log::debug!("Inserting SSH project at host {host}");
             let id = this.select_row_bound(sql!(
-                INSERT INTO ssh_connections (
+                INSERT INTO remote_connections (
+                    kind,
                     host,
                     port,
-                    user
-                ) VALUES (?1, ?2, ?3)
+                    user,
+                    distro
+                ) VALUES (?1, ?2, ?3, ?4, ?5)
                 RETURNING id
-            ))?((host, port, user))?
-            .context("failed to insert ssh project")?;
-            Ok(SshConnectionId(id))
+            ))?((kind.serialize(), host, port, user, distro))?
+            .context("failed to insert remote project")?;
+            Ok(RemoteConnectionId(id))
         }
     }
 
@@ -973,15 +1094,17 @@ impl WorkspaceDb {
         }
     }
 
-    fn recent_workspaces(&self) -> Result<Vec<(WorkspaceId, PathList, Option<u64>)>> {
+    fn recent_workspaces(
+        &self,
+    ) -> Result<Vec<(WorkspaceId, PathList, Option<RemoteConnectionId>)>> {
         Ok(self
             .recent_workspaces_query()?
             .into_iter()
-            .map(|(id, paths, order, ssh_connection_id)| {
+            .map(|(id, paths, order, remote_connection_id)| {
                 (
                     id,
                     PathList::deserialize(&SerializedPathList { paths, order }),
-                    ssh_connection_id,
+                    remote_connection_id.map(RemoteConnectionId),
                 )
             })
             .collect())
@@ -1001,7 +1124,7 @@ impl WorkspaceDb {
     fn session_workspaces(
         &self,
         session_id: String,
-    ) -> Result<Vec<(PathList, Option<u64>, Option<SshConnectionId>)>> {
+    ) -> Result<Vec<(PathList, Option<u64>, Option<RemoteConnectionId>)>> {
         Ok(self
             .session_workspaces_query(session_id)?
             .into_iter()
@@ -1009,7 +1132,7 @@ impl WorkspaceDb {
                 (
                     PathList::deserialize(&SerializedPathList { paths, order }),
                     window_id,
-                    ssh_connection_id.map(SshConnectionId),
+                    ssh_connection_id.map(RemoteConnectionId),
                 )
             })
             .collect())
@@ -1017,7 +1140,7 @@ impl WorkspaceDb {
 
     query! {
         fn session_workspaces_query(session_id: String) -> Result<Vec<(String, String, Option<u64>, Option<u64>)>> {
-            SELECT paths, paths_order, window_id, ssh_connection_id
+            SELECT paths, paths_order, window_id, remote_connection_id
             FROM workspaces
             WHERE session_id = ?1
             ORDER BY timestamp DESC
@@ -1039,40 +1162,55 @@ impl WorkspaceDb {
         }
     }
 
-    fn ssh_connections(&self) -> Result<HashMap<SshConnectionId, SerializedSshConnection>> {
-        Ok(self
-            .ssh_connections_query()?
-            .into_iter()
-            .map(|(id, host, port, user)| {
-                (
-                    SshConnectionId(id),
-                    SerializedSshConnection { host, port, user },
-                )
-            })
-            .collect())
-    }
-
-    query! {
-        pub fn ssh_connections_query() -> Result<Vec<(u64, String, Option<u16>, Option<String>)>> {
-            SELECT id, host, port, user
-            FROM ssh_connections
-        }
-    }
-
-    pub(crate) fn ssh_connection(&self, id: SshConnectionId) -> Result<SerializedSshConnection> {
-        let row = self.ssh_connection_query(id.0)?;
-        Ok(SerializedSshConnection {
-            host: row.0,
-            port: row.1,
-            user: row.2,
+    fn remote_connections(&self) -> Result<HashMap<RemoteConnectionId, RemoteConnectionOptions>> {
+        Ok(self.select(sql!(
+            SELECT
+                id, kind, host, port, user, distro
+            FROM
+                remote_connections
+        ))?()?
+        .into_iter()
+        .filter_map(|(id, kind, host, port, user, distro)| {
+            Some((
+                RemoteConnectionId(id),
+                Self::remote_connection_from_row(kind, host, port, user, distro)?,
+            ))
         })
+        .collect())
     }
 
-    query! {
-        fn ssh_connection_query(id: u64) -> Result<(String, Option<u16>, Option<String>)> {
-            SELECT host, port, user
-            FROM ssh_connections
+    pub(crate) fn remote_connection(
+        &self,
+        id: RemoteConnectionId,
+    ) -> Result<RemoteConnectionOptions> {
+        let (kind, host, port, user, distro) = self.select_row_bound(sql!(
+            SELECT kind, host, port, user, distro
+            FROM remote_connections
             WHERE id = ?
+        ))?(id.0)?
+        .context("no such remote connection")?;
+        Self::remote_connection_from_row(kind, host, port, user, distro)
+            .context("invalid remote_connection row")
+    }
+
+    fn remote_connection_from_row(
+        kind: String,
+        host: Option<String>,
+        port: Option<u16>,
+        user: Option<String>,
+        distro: Option<String>,
+    ) -> Option<RemoteConnectionOptions> {
+        match RemoteConnectionKind::deserialize(&kind)? {
+            RemoteConnectionKind::Wsl => Some(RemoteConnectionOptions::Wsl(WslConnectionOptions {
+                distro_name: distro?,
+                user: user,
+            })),
+            RemoteConnectionKind::Ssh => Some(RemoteConnectionOptions::Ssh(SshConnectionOptions {
+                host: host?,
+                port,
+                username: user,
+                ..Default::default()
+            })),
         }
     }
 
@@ -1108,14 +1246,14 @@ impl WorkspaceDb {
     ) -> Result<Vec<(WorkspaceId, SerializedWorkspaceLocation, PathList)>> {
         let mut result = Vec::new();
         let mut delete_tasks = Vec::new();
-        let ssh_connections = self.ssh_connections()?;
+        let remote_connections = self.remote_connections()?;
 
-        for (id, paths, ssh_connection_id) in self.recent_workspaces()? {
-            if let Some(ssh_connection_id) = ssh_connection_id.map(SshConnectionId) {
-                if let Some(ssh_connection) = ssh_connections.get(&ssh_connection_id) {
+        for (id, paths, remote_connection_id) in self.recent_workspaces()? {
+            if let Some(remote_connection_id) = remote_connection_id {
+                if let Some(connection_options) = remote_connections.get(&remote_connection_id) {
                     result.push((
                         id,
-                        SerializedWorkspaceLocation::Ssh(ssh_connection.clone()),
+                        SerializedWorkspaceLocation::Remote(connection_options.clone()),
                         paths,
                     ));
                 } else {
@@ -1157,12 +1295,14 @@ impl WorkspaceDb {
     ) -> Result<Vec<(SerializedWorkspaceLocation, PathList)>> {
         let mut workspaces = Vec::new();
 
-        for (paths, window_id, ssh_connection_id) in
+        for (paths, window_id, remote_connection_id) in
             self.session_workspaces(last_session_id.to_owned())?
         {
-            if let Some(ssh_connection_id) = ssh_connection_id {
+            if let Some(remote_connection_id) = remote_connection_id {
                 workspaces.push((
-                    SerializedWorkspaceLocation::Ssh(self.ssh_connection(ssh_connection_id)?),
+                    SerializedWorkspaceLocation::Remote(
+                        self.remote_connection(remote_connection_id)?,
+                    ),
                     paths,
                     window_id.map(WindowId::from),
                 ));
@@ -1545,6 +1685,7 @@ mod tests {
     };
     use gpui;
     use pretty_assertions::assert_eq;
+    use remote::SshConnectionOptions;
     use std::{thread, time::Duration};
 
     #[gpui::test]
@@ -2196,14 +2337,20 @@ mod tests {
         };
 
         let connection_id = db
-            .get_or_create_ssh_connection("my-host".to_string(), Some(1234), None)
+            .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
+                host: "my-host".to_string(),
+                port: Some(1234),
+                ..Default::default()
+            }))
             .await
             .unwrap();
 
         let workspace_5 = SerializedWorkspace {
             id: WorkspaceId(5),
             paths: PathList::default(),
-            location: SerializedWorkspaceLocation::Ssh(db.ssh_connection(connection_id).unwrap()),
+            location: SerializedWorkspaceLocation::Remote(
+                db.remote_connection(connection_id).unwrap(),
+            ),
             center_group: Default::default(),
             window_bounds: Default::default(),
             display: Default::default(),
@@ -2362,13 +2509,12 @@ mod tests {
     }
 
     #[gpui::test]
-    async fn test_last_session_workspace_locations_ssh_projects() {
-        let db = WorkspaceDb::open_test_db(
-            "test_serializing_workspaces_last_session_workspaces_ssh_projects",
-        )
-        .await;
+    async fn test_last_session_workspace_locations_remote() {
+        let db =
+            WorkspaceDb::open_test_db("test_serializing_workspaces_last_session_workspaces_remote")
+                .await;
 
-        let ssh_connections = [
+        let remote_connections = [
             ("host-1", "my-user-1"),
             ("host-2", "my-user-2"),
             ("host-3", "my-user-3"),
@@ -2376,30 +2522,31 @@ mod tests {
         ]
         .into_iter()
         .map(|(host, user)| async {
-            db.get_or_create_ssh_connection(host.to_string(), None, Some(user.to_string()))
+            let options = RemoteConnectionOptions::Ssh(SshConnectionOptions {
+                host: host.to_string(),
+                username: Some(user.to_string()),
+                ..Default::default()
+            });
+            db.get_or_create_remote_connection(options.clone())
                 .await
                 .unwrap();
-            SerializedSshConnection {
-                host: host.into(),
-                port: None,
-                user: Some(user.into()),
-            }
+            options
         })
         .collect::<Vec<_>>();
 
-        let ssh_connections = futures::future::join_all(ssh_connections).await;
+        let remote_connections = futures::future::join_all(remote_connections).await;
 
         let workspaces = [
-            (1, ssh_connections[0].clone(), 9),
-            (2, ssh_connections[1].clone(), 5),
-            (3, ssh_connections[2].clone(), 8),
-            (4, ssh_connections[3].clone(), 2),
+            (1, remote_connections[0].clone(), 9),
+            (2, remote_connections[1].clone(), 5),
+            (3, remote_connections[2].clone(), 8),
+            (4, remote_connections[3].clone(), 2),
         ]
         .into_iter()
-        .map(|(id, ssh_connection, window_id)| SerializedWorkspace {
+        .map(|(id, remote_connection, window_id)| SerializedWorkspace {
             id: WorkspaceId(id),
             paths: PathList::default(),
-            location: SerializedWorkspaceLocation::Ssh(ssh_connection),
+            location: SerializedWorkspaceLocation::Remote(remote_connection),
             center_group: Default::default(),
             window_bounds: Default::default(),
             display: Default::default(),
@@ -2429,28 +2576,28 @@ mod tests {
         assert_eq!(
             have[0],
             (
-                SerializedWorkspaceLocation::Ssh(ssh_connections[3].clone()),
+                SerializedWorkspaceLocation::Remote(remote_connections[3].clone()),
                 PathList::default()
             )
         );
         assert_eq!(
             have[1],
             (
-                SerializedWorkspaceLocation::Ssh(ssh_connections[2].clone()),
+                SerializedWorkspaceLocation::Remote(remote_connections[2].clone()),
                 PathList::default()
             )
         );
         assert_eq!(
             have[2],
             (
-                SerializedWorkspaceLocation::Ssh(ssh_connections[1].clone()),
+                SerializedWorkspaceLocation::Remote(remote_connections[1].clone()),
                 PathList::default()
             )
         );
         assert_eq!(
             have[3],
             (
-                SerializedWorkspaceLocation::Ssh(ssh_connections[0].clone()),
+                SerializedWorkspaceLocation::Remote(remote_connections[0].clone()),
                 PathList::default()
             )
         );
@@ -2465,13 +2612,23 @@ mod tests {
         let user = Some("user".to_string());
 
         let connection_id = db
-            .get_or_create_ssh_connection(host.clone(), port, user.clone())
+            .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
+                host: host.clone(),
+                port,
+                username: user.clone(),
+                ..Default::default()
+            }))
             .await
             .unwrap();
 
         // Test that calling the function again with the same parameters returns the same project
         let same_connection = db
-            .get_or_create_ssh_connection(host.clone(), port, user.clone())
+            .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
+                host: host.clone(),
+                port,
+                username: user.clone(),
+                ..Default::default()
+            }))
             .await
             .unwrap();
 
@@ -2483,7 +2640,12 @@ mod tests {
         let user2 = Some("otheruser".to_string());
 
         let different_connection = db
-            .get_or_create_ssh_connection(host2.clone(), port2, user2.clone())
+            .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
+                host: host2.clone(),
+                port: port2,
+                username: user2.clone(),
+                ..Default::default()
+            }))
             .await
             .unwrap();
 
@@ -2497,12 +2659,22 @@ mod tests {
         let (host, port, user) = ("example.com".to_string(), None, None);
 
         let connection_id = db
-            .get_or_create_ssh_connection(host.clone(), port, None)
+            .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
+                host: host.clone(),
+                port,
+                username: None,
+                ..Default::default()
+            }))
             .await
             .unwrap();
 
         let same_connection_id = db
-            .get_or_create_ssh_connection(host.clone(), port, user.clone())
+            .get_or_create_remote_connection(RemoteConnectionOptions::Ssh(SshConnectionOptions {
+                host: host.clone(),
+                port,
+                username: user.clone(),
+                ..Default::default()
+            }))
             .await
             .unwrap();
 
@@ -2510,8 +2682,8 @@ mod tests {
     }
 
     #[gpui::test]
-    async fn test_get_ssh_connections() {
-        let db = WorkspaceDb::open_test_db("test_get_ssh_connections").await;
+    async fn test_get_remote_connections() {
+        let db = WorkspaceDb::open_test_db("test_get_remote_connections").await;
 
         let connections = [
             ("example.com".to_string(), None, None),
@@ -2526,39 +2698,49 @@ mod tests {
         let mut ids = Vec::new();
         for (host, port, user) in connections.iter() {
             ids.push(
-                db.get_or_create_ssh_connection(host.clone(), *port, user.clone())
-                    .await
-                    .unwrap(),
+                db.get_or_create_remote_connection(RemoteConnectionOptions::Ssh(
+                    SshConnectionOptions {
+                        host: host.clone(),
+                        port: *port,
+                        username: user.clone(),
+                        ..Default::default()
+                    },
+                ))
+                .await
+                .unwrap(),
             );
         }
 
-        let stored_projects = db.ssh_connections().unwrap();
+        let stored_connections = db.remote_connections().unwrap();
         assert_eq!(
-            stored_projects,
+            stored_connections,
             [
                 (
                     ids[0],
-                    SerializedSshConnection {
+                    RemoteConnectionOptions::Ssh(SshConnectionOptions {
                         host: "example.com".into(),
                         port: None,
-                        user: None,
-                    }
+                        username: None,
+                        ..Default::default()
+                    }),
                 ),
                 (
                     ids[1],
-                    SerializedSshConnection {
+                    RemoteConnectionOptions::Ssh(SshConnectionOptions {
                         host: "anotherexample.com".into(),
                         port: Some(123),
-                        user: Some("user2".into()),
-                    }
+                        username: Some("user2".into()),
+                        ..Default::default()
+                    }),
                 ),
                 (
                     ids[2],
-                    SerializedSshConnection {
+                    RemoteConnectionOptions::Ssh(SshConnectionOptions {
                         host: "yetanother.com".into(),
                         port: Some(345),
-                        user: None,
-                    }
+                        username: None,
+                        ..Default::default()
+                    }),
                 ),
             ]
             .into_iter()

crates/workspace/src/persistence/model.rs 🔗

@@ -12,7 +12,7 @@ use db::sqlez::{
 use gpui::{AsyncWindowContext, Entity, WeakEntity};
 
 use project::{Project, debugger::breakpoint_store::SourceBreakpoint};
-use serde::{Deserialize, Serialize};
+use remote::RemoteConnectionOptions;
 use std::{
     collections::BTreeMap,
     path::{Path, PathBuf},
@@ -24,19 +24,18 @@ use uuid::Uuid;
 #[derive(
     Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, serde::Serialize, serde::Deserialize,
 )]
-pub(crate) struct SshConnectionId(pub u64);
+pub(crate) struct RemoteConnectionId(pub u64);
 
-#[derive(Debug, Clone, PartialEq, Deserialize, Serialize)]
-pub struct SerializedSshConnection {
-    pub host: String,
-    pub port: Option<u16>,
-    pub user: Option<String>,
+#[derive(Debug, PartialEq, Eq, Clone, Copy)]
+pub(crate) enum RemoteConnectionKind {
+    Ssh,
+    Wsl,
 }
 
 #[derive(Debug, PartialEq, Clone)]
 pub enum SerializedWorkspaceLocation {
     Local,
-    Ssh(SerializedSshConnection),
+    Remote(RemoteConnectionOptions),
 }
 
 impl SerializedWorkspaceLocation {
@@ -68,6 +67,23 @@ pub struct DockStructure {
     pub(crate) bottom: DockData,
 }
 
+impl RemoteConnectionKind {
+    pub(crate) fn serialize(&self) -> &'static str {
+        match self {
+            RemoteConnectionKind::Ssh => "ssh",
+            RemoteConnectionKind::Wsl => "wsl",
+        }
+    }
+
+    pub(crate) fn deserialize(text: &str) -> Option<Self> {
+        match text {
+            "ssh" => Some(Self::Ssh),
+            "wsl" => Some(Self::Wsl),
+            _ => None,
+        }
+    }
+}
+
 impl Column for DockStructure {
     fn column(statement: &mut Statement, start_index: i32) -> Result<(Self, i32)> {
         let (left, next_index) = DockData::column(statement, start_index)?;

crates/workspace/src/workspace.rs 🔗

@@ -67,14 +67,14 @@ pub use pane_group::*;
 use persistence::{DB, SerializedWindowBounds, model::SerializedWorkspace};
 pub use persistence::{
     DB as WORKSPACE_DB, WorkspaceDb, delete_unloaded_items,
-    model::{ItemId, SerializedSshConnection, SerializedWorkspaceLocation},
+    model::{ItemId, SerializedWorkspaceLocation},
 };
 use postage::stream::Stream;
 use project::{
     DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree, WorktreeId,
     debugger::{breakpoint_store::BreakpointStoreEvent, session::ThreadStatus},
 };
-use remote::{RemoteClientDelegate, SshConnectionOptions, remote_client::ConnectionIdentifier};
+use remote::{RemoteClientDelegate, RemoteConnectionOptions, remote_client::ConnectionIdentifier};
 use schemars::JsonSchema;
 use serde::Deserialize;
 use session::AppSession;
@@ -5262,14 +5262,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).remote_connection_options(cx) {
-            WorkspaceLocation::Location(
-                SerializedWorkspaceLocation::Ssh(SerializedSshConnection {
-                    host: connection.host,
-                    port: connection.port,
-                    user: connection.username,
-                }),
-                paths,
-            )
+            WorkspaceLocation::Location(SerializedWorkspaceLocation::Remote(connection), paths)
         } else if self.project.read(cx).is_local() {
             if !paths.is_empty() {
                 WorkspaceLocation::Location(SerializedWorkspaceLocation::Local, paths)
@@ -7282,9 +7275,9 @@ pub fn create_and_open_local_file(
     })
 }
 
-pub fn open_ssh_project_with_new_connection(
+pub fn open_remote_project_with_new_connection(
     window: WindowHandle<Workspace>,
-    connection_options: SshConnectionOptions,
+    connection_options: RemoteConnectionOptions,
     cancel_rx: oneshot::Receiver<()>,
     delegate: Arc<dyn RemoteClientDelegate>,
     app_state: Arc<AppState>,
@@ -7293,11 +7286,11 @@ pub fn open_ssh_project_with_new_connection(
 ) -> Task<Result<()>> {
     cx.spawn(async move |cx| {
         let (workspace_id, serialized_workspace) =
-            serialize_ssh_project(connection_options.clone(), paths.clone(), cx).await?;
+            serialize_remote_project(connection_options.clone(), paths.clone(), cx).await?;
 
         let session = match cx
             .update(|cx| {
-                remote::RemoteClient::ssh(
+                remote::RemoteClient::new(
                     ConnectionIdentifier::Workspace(workspace_id.0),
                     connection_options,
                     cancel_rx,
@@ -7323,7 +7316,7 @@ pub fn open_ssh_project_with_new_connection(
             )
         })?;
 
-        open_ssh_project_inner(
+        open_remote_project_inner(
             project,
             paths,
             workspace_id,
@@ -7336,8 +7329,8 @@ pub fn open_ssh_project_with_new_connection(
     })
 }
 
-pub fn open_ssh_project_with_existing_connection(
-    connection_options: SshConnectionOptions,
+pub fn open_remote_project_with_existing_connection(
+    connection_options: RemoteConnectionOptions,
     project: Entity<Project>,
     paths: Vec<PathBuf>,
     app_state: Arc<AppState>,
@@ -7346,9 +7339,9 @@ pub fn open_ssh_project_with_existing_connection(
 ) -> Task<Result<()>> {
     cx.spawn(async move |cx| {
         let (workspace_id, serialized_workspace) =
-            serialize_ssh_project(connection_options.clone(), paths.clone(), cx).await?;
+            serialize_remote_project(connection_options.clone(), paths.clone(), cx).await?;
 
-        open_ssh_project_inner(
+        open_remote_project_inner(
             project,
             paths,
             workspace_id,
@@ -7361,7 +7354,7 @@ pub fn open_ssh_project_with_existing_connection(
     })
 }
 
-async fn open_ssh_project_inner(
+async fn open_remote_project_inner(
     project: Entity<Project>,
     paths: Vec<PathBuf>,
     workspace_id: WorkspaceId,
@@ -7448,22 +7441,18 @@ async fn open_ssh_project_inner(
     Ok(())
 }
 
-fn serialize_ssh_project(
-    connection_options: SshConnectionOptions,
+fn serialize_remote_project(
+    connection_options: RemoteConnectionOptions,
     paths: Vec<PathBuf>,
     cx: &AsyncApp,
 ) -> Task<Result<(WorkspaceId, Option<SerializedWorkspace>)>> {
     cx.background_spawn(async move {
-        let ssh_connection_id = persistence::DB
-            .get_or_create_ssh_connection(
-                connection_options.host.clone(),
-                connection_options.port,
-                connection_options.username.clone(),
-            )
+        let remote_connection_id = persistence::DB
+            .get_or_create_remote_connection(connection_options)
             .await?;
 
         let serialized_workspace =
-            persistence::DB.ssh_workspace_for_roots(&paths, ssh_connection_id);
+            persistence::DB.remote_workspace_for_roots(&paths, remote_connection_id);
 
         let workspace_id = if let Some(workspace_id) =
             serialized_workspace.as_ref().map(|workspace| workspace.id)
@@ -8013,22 +8002,20 @@ pub struct WorkspacePosition {
     pub centered_layout: bool,
 }
 
-pub fn ssh_workspace_position_from_db(
-    host: String,
-    port: Option<u16>,
-    user: Option<String>,
+pub fn remote_workspace_position_from_db(
+    connection_options: RemoteConnectionOptions,
     paths_to_open: &[PathBuf],
     cx: &App,
 ) -> Task<Result<WorkspacePosition>> {
     let paths = paths_to_open.to_vec();
 
     cx.background_spawn(async move {
-        let ssh_connection_id = persistence::DB
-            .get_or_create_ssh_connection(host, port, user)
+        let remote_connection_id = persistence::DB
+            .get_or_create_remote_connection(connection_options)
             .await
             .context("fetching serialized ssh project")?;
         let serialized_workspace =
-            persistence::DB.ssh_workspace_for_roots(&paths, ssh_connection_id);
+            persistence::DB.remote_workspace_for_roots(&paths, remote_connection_id);
 
         let (window_bounds, display) = if let Some(bounds) = window_bounds_env_override() {
             (Some(WindowBounds::Windowed(bounds)), None)

crates/zed/resources/windows/zed-wsl 🔗

@@ -0,0 +1,25 @@
+#!/usr/bin/env sh
+
+if [ "$ZED_WSL_DEBUG_INFO" = true ]; then
+	set -x
+fi
+
+ZED_PATH="$(dirname "$(realpath "$0")")"
+
+IN_WSL=false
+if [ -n "$WSL_DISTRO_NAME" ]; then
+	# $WSL_DISTRO_NAME is available since WSL builds 18362, also for WSL2
+	IN_WSL=true
+fi
+
+if [ $IN_WSL = true ]; then
+    WSL_USER="$USER"
+    if [ -z "$WSL_USER" ]; then
+        WSL_USER="$USERNAME"
+    fi
+    "$ZED_PATH/zed.exe" --wsl "$WSL_USER@$WSL_DISTRO_NAME" "$@"
+    exit $?
+else
+    echo "Only WSL is supported for now" >&2
+    exit 1
+fi

crates/zed/src/main.rs 🔗

@@ -23,13 +23,14 @@ use http_client::{Url, read_proxy_from_env};
 use language::LanguageRegistry;
 use onboarding::{FIRST_OPEN, show_onboarding_view};
 use prompt_store::PromptBuilder;
+use remote::RemoteConnectionOptions;
 use reqwest_client::ReqwestClient;
 
 use assets::Assets;
 use node_runtime::{NodeBinaryOptions, NodeRuntime};
 use parking_lot::Mutex;
 use project::project_settings::ProjectSettings;
-use recent_projects::{SshSettings, open_ssh_project};
+use recent_projects::{SshSettings, open_remote_project};
 use release_channel::{AppCommitSha, AppVersion, ReleaseChannel};
 use session::{AppSession, Session};
 use settings::{BaseKeymap, Settings, SettingsStore, watch_config_file};
@@ -360,6 +361,7 @@ pub fn main() {
             open_listener.open(RawOpenRequest {
                 urls,
                 diff_paths: Vec::new(),
+                ..Default::default()
             })
         }
     });
@@ -696,7 +698,7 @@ pub fn main() {
         let urls: Vec<_> = args
             .paths_or_urls
             .iter()
-            .filter_map(|arg| parse_url_arg(arg, cx).log_err())
+            .map(|arg| parse_url_arg(arg, cx))
             .collect();
 
         let diff_paths: Vec<[String; 2]> = args
@@ -706,7 +708,11 @@ pub fn main() {
             .collect();
 
         if !urls.is_empty() || !diff_paths.is_empty() {
-            open_listener.open(RawOpenRequest { urls, diff_paths })
+            open_listener.open(RawOpenRequest {
+                urls,
+                diff_paths,
+                wsl: args.wsl,
+            })
         }
 
         match open_rx
@@ -792,10 +798,10 @@ fn handle_open_request(request: OpenRequest, app_state: Arc<AppState>, cx: &mut
         return;
     }
 
-    if let Some(connection_options) = request.ssh_connection {
+    if let Some(connection_options) = request.remote_connection {
         cx.spawn(async move |cx| {
             let paths: Vec<PathBuf> = request.open_paths.into_iter().map(PathBuf::from).collect();
-            open_ssh_project(
+            open_remote_project(
                 connection_options,
                 paths,
                 app_state,
@@ -978,31 +984,24 @@ async fn restore_or_create_workspace(app_state: Arc<AppState>, cx: &mut AsyncApp
                         tasks.push(task);
                     }
                 }
-                SerializedWorkspaceLocation::Ssh(ssh) => {
+                SerializedWorkspaceLocation::Remote(mut connection_options) => {
                     let app_state = app_state.clone();
-                    let ssh_host = ssh.host.clone();
-                    let task = cx.spawn(async move |cx| {
-                        let connection_options = cx.update(|cx| {
+                    if let RemoteConnectionOptions::Ssh(options) = &mut connection_options {
+                        cx.update(|cx| {
                             SshSettings::get_global(cx)
-                                .connection_options_for(ssh.host, ssh.port, ssh.user)
-                        });
-
-                        match connection_options {
-                            Ok(connection_options) => recent_projects::open_ssh_project(
-                                connection_options,
-                                paths.paths().into_iter().map(PathBuf::from).collect(),
-                                app_state,
-                                workspace::OpenOptions::default(),
-                                cx,
-                            )
-                            .await
-                            .map_err(|e| anyhow::anyhow!(e)),
-                            Err(e) => Err(anyhow::anyhow!(
-                                "Failed to get SSH connection options for {}: {}",
-                                ssh_host,
-                                e
-                            )),
-                        }
+                                .fill_connection_options_from_settings(options)
+                        })?;
+                    }
+                    let task = cx.spawn(async move |cx| {
+                        recent_projects::open_remote_project(
+                            connection_options,
+                            paths.paths().into_iter().map(PathBuf::from).collect(),
+                            app_state,
+                            workspace::OpenOptions::default(),
+                            cx,
+                        )
+                        .await
+                        .map_err(|e| anyhow::anyhow!(e))
                     });
                     tasks.push(task);
                 }
@@ -1184,6 +1183,16 @@ struct Args {
     #[arg(long, value_name = "DIR")]
     user_data_dir: Option<String>,
 
+    /// The username and WSL distribution to use when opening paths. ,If not specified,
+    /// Zed will attempt to open the paths directly.
+    ///
+    /// The username is optional, and if not specified, the default user for the distribution
+    /// will be used.
+    ///
+    /// Example: `me@Ubuntu` or `Ubuntu` for default distribution.
+    #[arg(long, value_name = "USER@DISTRO")]
+    wsl: Option<String>,
+
     /// Instructs zed to run as a dev server on this machine. (not implemented)
     #[arg(long)]
     dev_server_token: Option<String>,
@@ -1242,18 +1251,18 @@ impl ToString for IdType {
     }
 }
 
-fn parse_url_arg(arg: &str, cx: &App) -> Result<String> {
+fn parse_url_arg(arg: &str, cx: &App) -> String {
     match std::fs::canonicalize(Path::new(&arg)) {
-        Ok(path) => Ok(format!("file://{}", path.display())),
-        Err(error) => {
+        Ok(path) => format!("file://{}", path.display()),
+        Err(_) => {
             if arg.starts_with("file://")
                 || arg.starts_with("zed-cli://")
                 || arg.starts_with("ssh://")
                 || parse_zed_link(arg, cx).is_some()
             {
-                Ok(arg.into())
+                arg.into()
             } else {
-                anyhow::bail!("error parsing path argument: {error}")
+                format!("file://{arg}")
             }
         }
     }

crates/zed/src/zed.rs 🔗

@@ -48,7 +48,7 @@ use project::{DirectoryLister, ProjectItem};
 use project_panel::ProjectPanel;
 use prompt_store::PromptBuilder;
 use quick_action_bar::QuickActionBar;
-use recent_projects::open_ssh_project;
+use recent_projects::open_remote_project;
 use release_channel::{AppCommitSha, ReleaseChannel};
 use rope::Rope;
 use search::project_search::ProjectSearchBar;
@@ -1557,7 +1557,7 @@ pub fn open_new_ssh_project_from_project(
     };
     let connection_options = ssh_client.read(cx).connection_options();
     cx.spawn_in(window, async move |_, cx| {
-        open_ssh_project(
+        open_remote_project(
             connection_options,
             paths,
             app_state,

crates/zed/src/zed/open_listener.rs 🔗

@@ -17,8 +17,8 @@ use gpui::{App, AsyncApp, Global, WindowHandle};
 use language::Point;
 use onboarding::FIRST_OPEN;
 use onboarding::show_onboarding_view;
-use recent_projects::{SshSettings, open_ssh_project};
-use remote::SshConnectionOptions;
+use recent_projects::{SshSettings, open_remote_project};
+use remote::{RemoteConnectionOptions, WslConnectionOptions};
 use settings::Settings;
 use std::path::{Path, PathBuf};
 use std::sync::Arc;
@@ -37,7 +37,7 @@ pub struct OpenRequest {
     pub diff_paths: Vec<[String; 2]>,
     pub open_channel_notes: Vec<(u64, Option<String>)>,
     pub join_channel: Option<u64>,
-    pub ssh_connection: Option<SshConnectionOptions>,
+    pub remote_connection: Option<RemoteConnectionOptions>,
 }
 
 #[derive(Debug)]
@@ -51,6 +51,23 @@ pub enum OpenRequestKind {
 impl OpenRequest {
     pub fn parse(request: RawOpenRequest, cx: &App) -> Result<Self> {
         let mut this = Self::default();
+
+        this.diff_paths = request.diff_paths;
+        if let Some(wsl) = request.wsl {
+            let (user, distro_name) = if let Some((user, distro)) = wsl.split_once('@') {
+                if user.is_empty() {
+                    anyhow::bail!("user is empty in wsl argument");
+                }
+                (Some(user.to_string()), distro.to_string())
+            } else {
+                (None, wsl)
+            };
+            this.remote_connection = Some(RemoteConnectionOptions::Wsl(WslConnectionOptions {
+                distro_name,
+                user,
+            }));
+        }
+
         for url in request.urls {
             if let Some(server_name) = url.strip_prefix("zed-cli://") {
                 this.kind = Some(OpenRequestKind::CliConnection(connect_to_cli(server_name)?));
@@ -80,8 +97,6 @@ impl OpenRequest {
             }
         }
 
-        this.diff_paths = request.diff_paths;
-
         Ok(this)
     }
 
@@ -108,13 +123,15 @@ impl OpenRequest {
         if let Some(password) = url.password() {
             connection_options.password = Some(password.to_string());
         }
-        if let Some(ssh_connection) = &self.ssh_connection {
+
+        let connection_options = RemoteConnectionOptions::Ssh(connection_options);
+        if let Some(ssh_connection) = &self.remote_connection {
             anyhow::ensure!(
                 *ssh_connection == connection_options,
-                "cannot open multiple ssh connections"
+                "cannot open multiple different remote connections"
             );
         }
-        self.ssh_connection = Some(connection_options);
+        self.remote_connection = Some(connection_options);
         self.parse_file_path(url.path());
         Ok(())
     }
@@ -152,6 +169,7 @@ pub struct OpenListener(UnboundedSender<RawOpenRequest>);
 pub struct RawOpenRequest {
     pub urls: Vec<String>,
     pub diff_paths: Vec<[String; 2]>,
+    pub wsl: Option<String>,
 }
 
 impl Global for OpenListener {}
@@ -303,13 +321,21 @@ pub async fn handle_cli_connection(
                 paths,
                 diff_paths,
                 wait,
+                wsl,
                 open_new_workspace,
                 env,
                 user_data_dir: _,
             } => {
                 if !urls.is_empty() {
                     cx.update(|cx| {
-                        match OpenRequest::parse(RawOpenRequest { urls, diff_paths }, cx) {
+                        match OpenRequest::parse(
+                            RawOpenRequest {
+                                urls,
+                                diff_paths,
+                                wsl,
+                            },
+                            cx,
+                        ) {
                             Ok(open_request) => {
                                 handle_open_request(open_request, app_state.clone(), cx);
                                 responses.send(CliResponse::Exit { status: 0 }).log_err();
@@ -422,30 +448,26 @@ async fn open_workspaces(
                         errored = true
                     }
                 }
-                SerializedWorkspaceLocation::Ssh(ssh) => {
+                SerializedWorkspaceLocation::Remote(mut connection) => {
                     let app_state = app_state.clone();
-                    let connection_options = cx.update(|cx| {
-                        SshSettings::get_global(cx)
-                            .connection_options_for(ssh.host, ssh.port, ssh.user)
-                    });
-                    if let Ok(connection_options) = connection_options {
-                        cx.spawn(async move |cx| {
-                            open_ssh_project(
-                                connection_options,
-                                workspace_paths.paths().to_vec(),
-                                app_state,
-                                OpenOptions::default(),
-                                cx,
-                            )
-                            .await
-                            .log_err();
-                        })
-                        .detach();
-                        // We don't set `errored` here if `open_ssh_project` fails, because for ssh projects, the
-                        // error is displayed in the window.
-                    } else {
-                        errored = false;
+                    if let RemoteConnectionOptions::Ssh(options) = &mut connection {
+                        cx.update(|cx| {
+                            SshSettings::get_global(cx)
+                                .fill_connection_options_from_settings(options)
+                        })?;
                     }
+                    cx.spawn(async move |cx| {
+                        open_remote_project(
+                            connection,
+                            workspace_paths.paths().to_vec(),
+                            app_state,
+                            OpenOptions::default(),
+                            cx,
+                        )
+                        .await
+                        .log_err();
+                    })
+                    .detach();
                 }
             }
         }
@@ -587,6 +609,7 @@ mod tests {
     };
     use editor::Editor;
     use gpui::TestAppContext;
+    use remote::SshConnectionOptions;
     use serde_json::json;
     use std::sync::Arc;
     use util::path;
@@ -609,8 +632,8 @@ mod tests {
             .unwrap()
         });
         assert_eq!(
-            request.ssh_connection.unwrap(),
-            SshConnectionOptions {
+            request.remote_connection.unwrap(),
+            RemoteConnectionOptions::Ssh(SshConnectionOptions {
                 host: "localhost".into(),
                 username: Some("me".into()),
                 port: None,
@@ -619,7 +642,7 @@ mod tests {
                 port_forwards: None,
                 nickname: None,
                 upload_binary_over_ssh: false,
-            }
+            })
         );
         assert_eq!(request.open_paths, vec!["/"]);
     }

crates/zed/src/zed/windows_only_instance.rs 🔗

@@ -153,6 +153,7 @@ fn send_args_to_instance(args: &Args) -> anyhow::Result<()> {
             urls,
             diff_paths,
             wait: false,
+            wsl: args.wsl.clone(),
             open_new_workspace: None,
             env: None,
             user_data_dir: args.user_data_dir.clone(),

script/bundle-windows.ps1 🔗

@@ -150,6 +150,7 @@ function CollectFiles {
     Move-Item -Path "$innoDir\zed_explorer_command_injector.appx" -Destination "$innoDir\appx\zed_explorer_command_injector.appx" -Force
     Move-Item -Path "$innoDir\zed_explorer_command_injector.dll" -Destination "$innoDir\appx\zed_explorer_command_injector.dll" -Force
     Move-Item -Path "$innoDir\cli.exe" -Destination "$innoDir\bin\zed.exe" -Force
+    Move-Item -Path "$innoDir\zed-wsl" -Destination "$innoDir\bin\zed" -Force
     Move-Item -Path "$innoDir\auto_update_helper.exe" -Destination "$innoDir\tools\auto_update_helper.exe" -Force
     Move-Item -Path ".\AGS_SDK-6.3.0\ags_lib\lib\amd_ags_x64.dll" -Destination "$innoDir\amd_ags_x64.dll" -Force
 }