windows: Add support for SSH (#29145)

张小白 and Kirill Bulatov created

Closes #19892

This PR builds on top of #20587 and improves upon it.

Release Notes:

- N/A

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>

Change summary

Cargo.lock                                       |  21 
Cargo.toml                                       |   3 
crates/askpass/Cargo.toml                        |   2 
crates/askpass/src/askpass.rs                    | 156 ++++--
crates/extension_host/src/extension_host.rs      |  10 
crates/extension_host/src/headless_host.rs       |   7 
crates/file_finder/src/open_path_prompt.rs       | 194 ++++++-
crates/file_finder/src/open_path_prompt_tests.rs |  56 ++
crates/net/Cargo.toml                            |  25 +
crates/net/LICENSE-GPL                           |   1 
crates/net/src/async_net.rs                      |  69 ++
crates/net/src/listener.rs                       |  45 +
crates/net/src/net.rs                            | 107 ++++
crates/net/src/socket.rs                         |  59 ++
crates/net/src/stream.rs                         |  60 ++
crates/net/src/util.rs                           |  76 +++
crates/project/src/debugger/dap_store.rs         |  20 
crates/project/src/project.rs                    |  22 
crates/project/src/terminals.rs                  |  96 ++-
crates/project/src/worktree_store.rs             |  29 
crates/proto/Cargo.toml                          |   1 
crates/proto/src/typed_envelope.rs               | 147 +++++-
crates/recent_projects/src/remote_servers.rs     |  49 +
crates/remote/src/ssh_session.rs                 | 422 +++++++++++------
crates/util/src/paths.rs                         |  92 +++
crates/zed/src/main.rs                           |  20 
26 files changed, 1,435 insertions(+), 354 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -538,6 +538,8 @@ dependencies = [
  "anyhow",
  "futures 0.3.31",
  "gpui",
+ "net",
+ "parking_lot",
  "smol",
  "tempfile",
  "util",
@@ -10231,6 +10233,18 @@ dependencies = [
  "jni-sys",
 ]
 
+[[package]]
+name = "net"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "async-io",
+ "smol",
+ "tempfile",
+ "windows 0.61.1",
+ "workspace-hack",
+]
+
 [[package]]
 name = "new_debug_unreachable"
 version = "1.0.6"
@@ -12535,6 +12549,7 @@ dependencies = [
  "prost 0.9.0",
  "prost-build 0.9.0",
  "serde",
+ "typed-path",
  "workspace-hack",
 ]
 
@@ -17036,6 +17051,12 @@ dependencies = [
  "utf-8",
 ]
 
+[[package]]
+name = "typed-path"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c462d18470a2857aa657d338af5fa67170bb48bcc80a296710ce3b0802a32566"
+
 [[package]]
 name = "typeid"
 version = "1.0.3"

Cargo.toml 🔗

@@ -99,6 +99,7 @@ members = [
     "crates/migrator",
     "crates/mistral",
     "crates/multi_buffer",
+    "crates/net",
     "crates/node_runtime",
     "crates/notifications",
     "crates/ollama",
@@ -311,6 +312,7 @@ menu = { path = "crates/menu" }
 migrator = { path = "crates/migrator" }
 mistral = { path = "crates/mistral" }
 multi_buffer = { path = "crates/multi_buffer" }
+net = { path = "crates/net" }
 node_runtime = { path = "crates/node_runtime" }
 notifications = { path = "crates/notifications" }
 ollama = { path = "crates/ollama" }
@@ -660,6 +662,7 @@ features = [
     "Win32_Graphics_Gdi",
     "Win32_Graphics_Imaging",
     "Win32_Graphics_Imaging_D2D",
+    "Win32_Networking_WinSock",
     "Win32_Security",
     "Win32_Security_Credentials",
     "Win32_Storage_FileSystem",

crates/askpass/Cargo.toml 🔗

@@ -15,6 +15,8 @@ path = "src/askpass.rs"
 anyhow.workspace = true
 futures.workspace = true
 gpui.workspace = true
+net.workspace = true
+parking_lot.workspace = true
 smol.workspace = true
 tempfile.workspace = true
 util.workspace = true

crates/askpass/src/askpass.rs 🔗

@@ -1,21 +1,14 @@
-use std::path::{Path, PathBuf};
-use std::time::Duration;
+use std::{ffi::OsStr, time::Duration};
 
-#[cfg(unix)]
-use anyhow::Context as _;
+use anyhow::{Context as _, Result};
 use futures::channel::{mpsc, oneshot};
-#[cfg(unix)]
-use futures::{AsyncBufReadExt as _, io::BufReader};
-#[cfg(unix)]
-use futures::{AsyncWriteExt as _, FutureExt as _, select_biased};
-use futures::{SinkExt, StreamExt};
+use futures::{
+    AsyncBufReadExt as _, AsyncWriteExt as _, FutureExt as _, SinkExt, StreamExt, io::BufReader,
+    select_biased,
+};
 use gpui::{AsyncApp, BackgroundExecutor, Task};
-#[cfg(unix)]
 use smol::fs;
-#[cfg(unix)]
-use smol::net::unix::UnixListener;
-#[cfg(unix)]
-use util::{ResultExt as _, fs::make_file_executable, get_shell_safe_zed_path};
+use util::ResultExt as _;
 
 #[derive(PartialEq, Eq)]
 pub enum AskPassResult {
@@ -42,41 +35,56 @@ impl AskPassDelegate {
         Self { tx, _task: task }
     }
 
-    pub async fn ask_password(&mut self, prompt: String) -> anyhow::Result<String> {
+    pub async fn ask_password(&mut self, prompt: String) -> Result<String> {
         let (tx, rx) = oneshot::channel();
         self.tx.send((prompt, tx)).await?;
         Ok(rx.await?)
     }
 }
 
-#[cfg(unix)]
 pub struct AskPassSession {
-    script_path: PathBuf,
+    #[cfg(not(target_os = "windows"))]
+    script_path: std::path::PathBuf,
+    #[cfg(target_os = "windows")]
+    askpass_helper: String,
+    #[cfg(target_os = "windows")]
+    secret: std::sync::Arc<parking_lot::Mutex<String>>,
     _askpass_task: Task<()>,
     askpass_opened_rx: Option<oneshot::Receiver<()>>,
     askpass_kill_master_rx: Option<oneshot::Receiver<()>>,
 }
 
-#[cfg(unix)]
+#[cfg(not(target_os = "windows"))]
+const ASKPASS_SCRIPT_NAME: &str = "askpass.sh";
+#[cfg(target_os = "windows")]
+const ASKPASS_SCRIPT_NAME: &str = "askpass.ps1";
+
 impl AskPassSession {
     /// This will create a new AskPassSession.
     /// You must retain this session until the master process exits.
     #[must_use]
-    pub async fn new(
-        executor: &BackgroundExecutor,
-        mut delegate: AskPassDelegate,
-    ) -> anyhow::Result<Self> {
+    pub async fn new(executor: &BackgroundExecutor, mut delegate: AskPassDelegate) -> Result<Self> {
+        use net::async_net::UnixListener;
+        use util::fs::make_file_executable;
+
+        #[cfg(target_os = "windows")]
+        let secret = std::sync::Arc::new(parking_lot::Mutex::new(String::new()));
         let temp_dir = tempfile::Builder::new().prefix("zed-askpass").tempdir()?;
         let askpass_socket = temp_dir.path().join("askpass.sock");
-        let askpass_script_path = temp_dir.path().join("askpass.sh");
+        let askpass_script_path = temp_dir.path().join(ASKPASS_SCRIPT_NAME);
         let (askpass_opened_tx, askpass_opened_rx) = oneshot::channel::<()>();
-        let listener =
-            UnixListener::bind(&askpass_socket).context("failed to create askpass socket")?;
-        let zed_path = get_shell_safe_zed_path()?;
+        let listener = UnixListener::bind(&askpass_socket).context("creating askpass socket")?;
+        #[cfg(not(target_os = "windows"))]
+        let zed_path = util::get_shell_safe_zed_path()?;
+        #[cfg(target_os = "windows")]
+        let zed_path = std::env::current_exe()
+            .context("finding current executable path for use in askpass")?;
 
         let (askpass_kill_master_tx, askpass_kill_master_rx) = oneshot::channel::<()>();
         let mut kill_tx = Some(askpass_kill_master_tx);
 
+        #[cfg(target_os = "windows")]
+        let askpass_secret = secret.clone();
         let askpass_task = executor.spawn(async move {
             let mut askpass_opened_tx = Some(askpass_opened_tx);
 
@@ -93,10 +101,14 @@ impl AskPassSession {
                 if let Some(password) = delegate
                     .ask_password(prompt.to_string())
                     .await
-                    .context("failed to get askpass password")
+                    .context("getting askpass password")
                     .log_err()
                 {
                     stream.write_all(password.as_bytes()).await.log_err();
+                    #[cfg(target_os = "windows")]
+                    {
+                        *askpass_secret.lock() = password;
+                    }
                 } else {
                     if let Some(kill_tx) = kill_tx.take() {
                         kill_tx.send(()).log_err();
@@ -112,34 +124,49 @@ impl AskPassSession {
         });
 
         // Create an askpass script that communicates back to this process.
-        let askpass_script = format!(
-            "{shebang}\n{print_args} | {zed_exe} --askpass={askpass_socket} 2> /dev/null \n",
-            zed_exe = zed_path,
-            askpass_socket = askpass_socket.display(),
-            print_args = "printf '%s\\0' \"$@\"",
-            shebang = "#!/bin/sh",
-        );
-        fs::write(&askpass_script_path, askpass_script).await?;
+        let askpass_script = generate_askpass_script(&zed_path, &askpass_socket);
+        fs::write(&askpass_script_path, askpass_script)
+            .await
+            .with_context(|| format!("creating askpass script at {askpass_script_path:?}"))?;
         make_file_executable(&askpass_script_path).await?;
+        #[cfg(target_os = "windows")]
+        let askpass_helper = format!(
+            "powershell.exe -ExecutionPolicy Bypass -File {}",
+            askpass_script_path.display()
+        );
 
         Ok(Self {
+            #[cfg(not(target_os = "windows"))]
             script_path: askpass_script_path,
+
+            #[cfg(target_os = "windows")]
+            secret,
+            #[cfg(target_os = "windows")]
+            askpass_helper,
+
             _askpass_task: askpass_task,
             askpass_kill_master_rx: Some(askpass_kill_master_rx),
             askpass_opened_rx: Some(askpass_opened_rx),
         })
     }
 
-    pub fn script_path(&self) -> &Path {
+    #[cfg(not(target_os = "windows"))]
+    pub fn script_path(&self) -> impl AsRef<OsStr> {
         &self.script_path
     }
 
+    #[cfg(target_os = "windows")]
+    pub fn script_path(&self) -> impl AsRef<OsStr> {
+        &self.askpass_helper
+    }
+
     // This will run the askpass task forever, resolving as many authentication requests as needed.
     // The caller is responsible for examining the result of their own commands and cancelling this
     // future when this is no longer needed. Note that this can only be called once, but due to the
     // drop order this takes an &mut, so you can `drop()` it after you're done with the master process.
     pub async fn run(&mut self) -> AskPassResult {
-        let connection_timeout = Duration::from_secs(10);
+        // This is the default timeout setting used by VSCode.
+        let connection_timeout = Duration::from_secs(17);
         let askpass_opened_rx = self.askpass_opened_rx.take().expect("Only call run once");
         let askpass_kill_master_rx = self
             .askpass_kill_master_rx
@@ -158,14 +185,19 @@ impl AskPassSession {
             }
         }
     }
+
+    /// This will return the password that was last set by the askpass script.
+    #[cfg(target_os = "windows")]
+    pub fn get_password(&self) -> String {
+        self.secret.lock().clone()
+    }
 }
 
 /// The main function for when Zed is running in netcat mode for use in askpass.
 /// Called from both the remote server binary and the zed binary in their respective main functions.
-#[cfg(unix)]
 pub fn main(socket: &str) {
+    use net::UnixStream;
     use std::io::{self, Read, Write};
-    use std::os::unix::net::UnixStream;
     use std::process::exit;
 
     let mut stream = match UnixStream::connect(socket) {
@@ -182,6 +214,10 @@ pub fn main(socket: &str) {
         exit(1);
     }
 
+    #[cfg(target_os = "windows")]
+    while buffer.last().map_or(false, |&b| b == b'\n' || b == b'\r') {
+        buffer.pop();
+    }
     if buffer.last() != Some(&b'\0') {
         buffer.push(b'\0');
     }
@@ -202,28 +238,28 @@ pub fn main(socket: &str) {
         exit(1);
     }
 }
-#[cfg(not(unix))]
-pub fn main(_socket: &str) {}
 
-#[cfg(not(unix))]
-pub struct AskPassSession {
-    path: PathBuf,
+#[inline]
+#[cfg(not(target_os = "windows"))]
+fn generate_askpass_script(zed_path: &str, askpass_socket: &std::path::Path) -> String {
+    format!(
+        "{shebang}\n{print_args} | {zed_exe} --askpass={askpass_socket} 2> /dev/null \n",
+        zed_exe = zed_path,
+        askpass_socket = askpass_socket.display(),
+        print_args = "printf '%s\\0' \"$@\"",
+        shebang = "#!/bin/sh",
+    )
 }
 
-#[cfg(not(unix))]
-impl AskPassSession {
-    pub async fn new(_: &BackgroundExecutor, _: AskPassDelegate) -> anyhow::Result<Self> {
-        Ok(Self {
-            path: PathBuf::new(),
-        })
-    }
-
-    pub fn script_path(&self) -> &Path {
-        &self.path
-    }
-
-    pub async fn run(&mut self) -> AskPassResult {
-        futures::FutureExt::fuse(smol::Timer::after(Duration::from_secs(20))).await;
-        AskPassResult::Timedout
-    }
+#[inline]
+#[cfg(target_os = "windows")]
+fn generate_askpass_script(zed_path: &std::path::Path, askpass_socket: &std::path::Path) -> String {
+    format!(
+        r#"
+        $ErrorActionPreference = 'Stop';
+        ($args -join [char]0) | & "{zed_exe}" --askpass={askpass_socket} 2> $null
+        "#,
+        zed_exe = zed_path.display(),
+        askpass_socket = askpass_socket.display(),
+    )
 }

crates/extension_host/src/extension_host.rs 🔗

@@ -54,7 +54,7 @@ use std::{
     time::{Duration, Instant},
 };
 use url::Url;
-use util::ResultExt;
+use util::{ResultExt, paths::RemotePathBuf};
 use wasm_host::{
     WasmExtension, WasmHost,
     wit::{is_supported_wasm_api_version, wasm_api_version_range},
@@ -1689,6 +1689,7 @@ impl ExtensionStore {
                     .request(proto::SyncExtensions { extensions })
             })?
             .await?;
+        let path_style = client.read_with(cx, |client, _| client.path_style())?;
 
         for missing_extension in response.missing_extensions.into_iter() {
             let tmp_dir = tempfile::tempdir()?;
@@ -1701,7 +1702,10 @@ impl ExtensionStore {
                 )
             })?
             .await?;
-            let dest_dir = PathBuf::from(&response.tmp_dir).join(missing_extension.clone().id);
+            let dest_dir = RemotePathBuf::new(
+                PathBuf::from(&response.tmp_dir).join(missing_extension.clone().id),
+                path_style,
+            );
             log::info!("Uploading extension {}", missing_extension.clone().id);
 
             client
@@ -1718,7 +1722,7 @@ impl ExtensionStore {
             client
                 .update(cx, |client, _cx| {
                     client.proto_client().request(proto::InstallExtension {
-                        tmp_dir: dest_dir.to_string_lossy().to_string(),
+                        tmp_dir: dest_dir.to_proto(),
                         extension: Some(missing_extension),
                     })
                 })?

crates/extension_host/src/headless_host.rs 🔗

@@ -1,7 +1,10 @@
 use std::{path::PathBuf, sync::Arc};
 
 use anyhow::{Context as _, Result};
-use client::{TypedEnvelope, proto};
+use client::{
+    TypedEnvelope,
+    proto::{self, FromProto},
+};
 use collections::{HashMap, HashSet};
 use extension::{
     Extension, ExtensionDebugAdapterProviderProxy, ExtensionHostProxy, ExtensionLanguageProxy,
@@ -328,7 +331,7 @@ impl HeadlessExtensionStore {
                         version: extension.version,
                         dev: extension.dev,
                     },
-                    PathBuf::from(envelope.payload.tmp_dir),
+                    PathBuf::from_proto(envelope.payload.tmp_dir),
                     cx,
                 )
             })?

crates/file_finder/src/open_path_prompt.rs 🔗

@@ -15,16 +15,14 @@ use std::{
 };
 use ui::{Context, LabelLike, ListItem, Window};
 use ui::{HighlightedLabel, ListItemSpacing, prelude::*};
-use util::{maybe, paths::compare_paths};
+use util::{
+    maybe,
+    paths::{PathStyle, compare_paths},
+};
 use workspace::Workspace;
 
 pub(crate) struct OpenPathPrompt;
 
-#[cfg(target_os = "windows")]
-const PROMPT_ROOT: &str = "C:\\";
-#[cfg(not(target_os = "windows"))]
-const PROMPT_ROOT: &str = "/";
-
 #[derive(Debug)]
 pub struct OpenPathDelegate {
     tx: Option<oneshot::Sender<Option<Vec<PathBuf>>>>,
@@ -34,6 +32,8 @@ pub struct OpenPathDelegate {
     string_matches: Vec<StringMatch>,
     cancel_flag: Arc<AtomicBool>,
     should_dismiss: bool,
+    prompt_root: String,
+    path_style: PathStyle,
     replace_prompt: Task<()>,
 }
 
@@ -42,6 +42,7 @@ impl OpenPathDelegate {
         tx: oneshot::Sender<Option<Vec<PathBuf>>>,
         lister: DirectoryLister,
         creating_path: bool,
+        path_style: PathStyle,
     ) -> Self {
         Self {
             tx: Some(tx),
@@ -53,6 +54,11 @@ impl OpenPathDelegate {
             string_matches: Vec::new(),
             cancel_flag: Arc::new(AtomicBool::new(false)),
             should_dismiss: true,
+            prompt_root: match path_style {
+                PathStyle::Posix => "/".to_string(),
+                PathStyle::Windows => "C:\\".to_string(),
+            },
+            path_style,
             replace_prompt: Task::ready(()),
         }
     }
@@ -185,7 +191,8 @@ impl OpenPathPrompt {
         cx: &mut Context<Workspace>,
     ) {
         workspace.toggle_modal(window, cx, |window, cx| {
-            let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path);
+            let delegate =
+                OpenPathDelegate::new(tx, lister.clone(), creating_path, PathStyle::current());
             let picker = Picker::uniform_list(delegate, window, cx).width(rems(34.));
             let query = lister.default_query(cx);
             picker.set_query(query, window, cx);
@@ -226,18 +233,7 @@ impl PickerDelegate for OpenPathDelegate {
         cx: &mut Context<Picker<Self>>,
     ) -> Task<()> {
         let lister = &self.lister;
-        let last_item = Path::new(&query)
-            .file_name()
-            .unwrap_or_default()
-            .to_string_lossy();
-        let (mut dir, suffix) = if let Some(dir) = query.strip_suffix(last_item.as_ref()) {
-            (dir.to_string(), last_item.into_owned())
-        } else {
-            (query, String::new())
-        };
-        if dir == "" {
-            dir = PROMPT_ROOT.to_string();
-        }
+        let (dir, suffix) = get_dir_and_suffix(query, self.path_style);
 
         let query = match &self.directory_state {
             DirectoryState::List { parent_path, .. } => {
@@ -266,6 +262,7 @@ impl PickerDelegate for OpenPathDelegate {
         self.cancel_flag = Arc::new(AtomicBool::new(false));
         let cancel_flag = self.cancel_flag.clone();
 
+        let parent_path_is_root = self.prompt_root == dir;
         cx.spawn_in(window, async move |this, cx| {
             if let Some(query) = query {
                 let paths = query.await;
@@ -279,7 +276,7 @@ impl PickerDelegate for OpenPathDelegate {
                             DirectoryState::None { create: false }
                             | DirectoryState::List { .. } => match paths {
                                 Ok(paths) => DirectoryState::List {
-                                    entries: path_candidates(&dir, paths),
+                                    entries: path_candidates(parent_path_is_root, paths),
                                     parent_path: dir.clone(),
                                     error: None,
                                 },
@@ -292,7 +289,7 @@ impl PickerDelegate for OpenPathDelegate {
                             DirectoryState::None { create: true }
                             | DirectoryState::Create { .. } => match paths {
                                 Ok(paths) => {
-                                    let mut entries = path_candidates(&dir, paths);
+                                    let mut entries = path_candidates(parent_path_is_root, paths);
                                     let mut exists = false;
                                     let mut is_dir = false;
                                     let mut new_id = None;
@@ -488,6 +485,7 @@ impl PickerDelegate for OpenPathDelegate {
         _: &mut Context<Picker<Self>>,
     ) -> Option<String> {
         let candidate = self.get_entry(self.selected_index)?;
+        let path_style = self.path_style;
         Some(
             maybe!({
                 match &self.directory_state {
@@ -496,7 +494,7 @@ impl PickerDelegate for OpenPathDelegate {
                         parent_path,
                         candidate.path.string,
                         if candidate.is_dir {
-                            MAIN_SEPARATOR_STR
+                            path_style.separator()
                         } else {
                             ""
                         }
@@ -506,7 +504,7 @@ impl PickerDelegate for OpenPathDelegate {
                         parent_path,
                         candidate.path.string,
                         if candidate.is_dir {
-                            MAIN_SEPARATOR_STR
+                            path_style.separator()
                         } else {
                             ""
                         }
@@ -527,8 +525,8 @@ impl PickerDelegate for OpenPathDelegate {
             DirectoryState::None { .. } => return,
             DirectoryState::List { parent_path, .. } => {
                 let confirmed_path =
-                    if parent_path == PROMPT_ROOT && candidate.path.string.is_empty() {
-                        PathBuf::from(PROMPT_ROOT)
+                    if parent_path == &self.prompt_root && candidate.path.string.is_empty() {
+                        PathBuf::from(&self.prompt_root)
                     } else {
                         Path::new(self.lister.resolve_tilde(parent_path, cx).as_ref())
                             .join(&candidate.path.string)
@@ -548,8 +546,8 @@ impl PickerDelegate for OpenPathDelegate {
                         return;
                     }
                     let prompted_path =
-                        if parent_path == PROMPT_ROOT && user_input.file.string.is_empty() {
-                            PathBuf::from(PROMPT_ROOT)
+                        if parent_path == &self.prompt_root && user_input.file.string.is_empty() {
+                            PathBuf::from(&self.prompt_root)
                         } else {
                             Path::new(self.lister.resolve_tilde(parent_path, cx).as_ref())
                                 .join(&user_input.file.string)
@@ -652,8 +650,8 @@ impl PickerDelegate for OpenPathDelegate {
                     .inset(true)
                     .toggle_state(selected)
                     .child(HighlightedLabel::new(
-                        if parent_path == PROMPT_ROOT {
-                            format!("{}{}", PROMPT_ROOT, candidate.path.string)
+                        if parent_path == &self.prompt_root {
+                            format!("{}{}", self.prompt_root, candidate.path.string)
                         } else {
                             candidate.path.string.clone()
                         },
@@ -665,10 +663,10 @@ impl PickerDelegate for OpenPathDelegate {
                 user_input,
                 ..
             } => {
-                let (label, delta) = if parent_path == PROMPT_ROOT {
+                let (label, delta) = if parent_path == &self.prompt_root {
                     (
-                        format!("{}{}", PROMPT_ROOT, candidate.path.string),
-                        PROMPT_ROOT.len(),
+                        format!("{}{}", self.prompt_root, candidate.path.string),
+                        self.prompt_root.len(),
                     )
                 } else {
                     (candidate.path.string.clone(), 0)
@@ -751,8 +749,11 @@ impl PickerDelegate for OpenPathDelegate {
     }
 }
 
-fn path_candidates(parent_path: &String, mut children: Vec<DirectoryItem>) -> Vec<CandidateInfo> {
-    if *parent_path == PROMPT_ROOT {
+fn path_candidates(
+    parent_path_is_root: bool,
+    mut children: Vec<DirectoryItem>,
+) -> Vec<CandidateInfo> {
+    if parent_path_is_root {
         children.push(DirectoryItem {
             is_dir: true,
             path: PathBuf::default(),
@@ -769,3 +770,128 @@ fn path_candidates(parent_path: &String, mut children: Vec<DirectoryItem>) -> Ve
         })
         .collect()
 }
+
+#[cfg(target_os = "windows")]
+fn get_dir_and_suffix(query: String, path_style: PathStyle) -> (String, String) {
+    let last_item = Path::new(&query)
+        .file_name()
+        .unwrap_or_default()
+        .to_string_lossy();
+    let (mut dir, suffix) = if let Some(dir) = query.strip_suffix(last_item.as_ref()) {
+        (dir.to_string(), last_item.into_owned())
+    } else {
+        (query.to_string(), String::new())
+    };
+    match path_style {
+        PathStyle::Posix => {
+            if dir.is_empty() {
+                dir = "/".to_string();
+            }
+        }
+        PathStyle::Windows => {
+            if dir.len() < 3 {
+                dir = "C:\\".to_string();
+            }
+        }
+    }
+    (dir, suffix)
+}
+
+#[cfg(not(target_os = "windows"))]
+fn get_dir_and_suffix(query: String, path_style: PathStyle) -> (String, String) {
+    match path_style {
+        PathStyle::Posix => {
+            let (mut dir, suffix) = if let Some(index) = query.rfind('/') {
+                (query[..index].to_string(), query[index + 1..].to_string())
+            } else {
+                (query, String::new())
+            };
+            if !dir.ends_with('/') {
+                dir.push('/');
+            }
+            (dir, suffix)
+        }
+        PathStyle::Windows => {
+            let (mut dir, suffix) = if let Some(index) = query.rfind('\\') {
+                (query[..index].to_string(), query[index + 1..].to_string())
+            } else {
+                (query, String::new())
+            };
+            if dir.len() < 3 {
+                dir = "C:\\".to_string();
+            }
+            if !dir.ends_with('\\') {
+                dir.push('\\');
+            }
+            (dir, suffix)
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use util::paths::PathStyle;
+
+    use crate::open_path_prompt::get_dir_and_suffix;
+
+    #[test]
+    fn test_get_dir_and_suffix_with_windows_style() {
+        let (dir, suffix) = get_dir_and_suffix("".into(), PathStyle::Windows);
+        assert_eq!(dir, "C:\\");
+        assert_eq!(suffix, "");
+
+        let (dir, suffix) = get_dir_and_suffix("C:".into(), PathStyle::Windows);
+        assert_eq!(dir, "C:\\");
+        assert_eq!(suffix, "");
+
+        let (dir, suffix) = get_dir_and_suffix("C:\\".into(), PathStyle::Windows);
+        assert_eq!(dir, "C:\\");
+        assert_eq!(suffix, "");
+
+        let (dir, suffix) = get_dir_and_suffix("C:\\Use".into(), PathStyle::Windows);
+        assert_eq!(dir, "C:\\");
+        assert_eq!(suffix, "Use");
+
+        let (dir, suffix) =
+            get_dir_and_suffix("C:\\Users\\Junkui\\Docum".into(), PathStyle::Windows);
+        assert_eq!(dir, "C:\\Users\\Junkui\\");
+        assert_eq!(suffix, "Docum");
+
+        let (dir, suffix) =
+            get_dir_and_suffix("C:\\Users\\Junkui\\Documents".into(), PathStyle::Windows);
+        assert_eq!(dir, "C:\\Users\\Junkui\\");
+        assert_eq!(suffix, "Documents");
+
+        let (dir, suffix) =
+            get_dir_and_suffix("C:\\Users\\Junkui\\Documents\\".into(), PathStyle::Windows);
+        assert_eq!(dir, "C:\\Users\\Junkui\\Documents\\");
+        assert_eq!(suffix, "");
+    }
+
+    #[test]
+    fn test_get_dir_and_suffix_with_posix_style() {
+        let (dir, suffix) = get_dir_and_suffix("".into(), PathStyle::Posix);
+        assert_eq!(dir, "/");
+        assert_eq!(suffix, "");
+
+        let (dir, suffix) = get_dir_and_suffix("/".into(), PathStyle::Posix);
+        assert_eq!(dir, "/");
+        assert_eq!(suffix, "");
+
+        let (dir, suffix) = get_dir_and_suffix("/Use".into(), PathStyle::Posix);
+        assert_eq!(dir, "/");
+        assert_eq!(suffix, "Use");
+
+        let (dir, suffix) = get_dir_and_suffix("/Users/Junkui/Docum".into(), PathStyle::Posix);
+        assert_eq!(dir, "/Users/Junkui/");
+        assert_eq!(suffix, "Docum");
+
+        let (dir, suffix) = get_dir_and_suffix("/Users/Junkui/Documents".into(), PathStyle::Posix);
+        assert_eq!(dir, "/Users/Junkui/");
+        assert_eq!(suffix, "Documents");
+
+        let (dir, suffix) = get_dir_and_suffix("/Users/Junkui/Documents/".into(), PathStyle::Posix);
+        assert_eq!(dir, "/Users/Junkui/Documents/");
+        assert_eq!(suffix, "");
+    }
+}

crates/file_finder/src/open_path_prompt_tests.rs 🔗

@@ -5,7 +5,7 @@ use picker::{Picker, PickerDelegate};
 use project::Project;
 use serde_json::json;
 use ui::rems;
-use util::path;
+use util::{path, paths::PathStyle};
 use workspace::{AppState, Workspace};
 
 use crate::OpenPathDelegate;
@@ -37,7 +37,7 @@ async fn test_open_path_prompt(cx: &mut TestAppContext) {
 
     let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
 
-    let (picker, cx) = build_open_path_prompt(project, false, cx);
+    let (picker, cx) = build_open_path_prompt(project, false, PathStyle::current(), cx);
 
     let query = path!("/root");
     insert_query(query, &picker, cx).await;
@@ -111,7 +111,7 @@ async fn test_open_path_prompt_completion(cx: &mut TestAppContext) {
 
     let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
 
-    let (picker, cx) = build_open_path_prompt(project, false, cx);
+    let (picker, cx) = build_open_path_prompt(project, false, PathStyle::current(), cx);
 
     // Confirm completion for the query "/root", since it's a directory, it should add a trailing slash.
     let query = path!("/root");
@@ -186,7 +186,7 @@ async fn test_open_path_prompt_completion(cx: &mut TestAppContext) {
 }
 
 #[gpui::test]
-#[cfg(target_os = "windows")]
+#[cfg_attr(not(target_os = "windows"), ignore)]
 async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) {
     let app_state = init_test(cx);
     app_state
@@ -204,7 +204,7 @@ async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) {
 
     let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
 
-    let (picker, cx) = build_open_path_prompt(project, false, cx);
+    let (picker, cx) = build_open_path_prompt(project, false, PathStyle::current(), cx);
 
     // Support both forward and backward slashes.
     let query = "C:/root/";
@@ -251,6 +251,47 @@ async fn test_open_path_prompt_on_windows(cx: &mut TestAppContext) {
     );
 }
 
+#[gpui::test]
+#[cfg_attr(not(target_os = "windows"), ignore)]
+async fn test_open_path_prompt_on_windows_with_remote(cx: &mut TestAppContext) {
+    let app_state = init_test(cx);
+    app_state
+        .fs
+        .as_fake()
+        .insert_tree(
+            "/root",
+            json!({
+                "a": "A",
+                "dir1": {},
+                "dir2": {}
+            }),
+        )
+        .await;
+
+    let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
+
+    let (picker, cx) = build_open_path_prompt(project, false, PathStyle::Posix, cx);
+
+    let query = "/root/";
+    insert_query(query, &picker, cx).await;
+    assert_eq!(
+        collect_match_candidates(&picker, cx),
+        vec!["a", "dir1", "dir2"]
+    );
+    assert_eq!(confirm_completion(query, 0, &picker, cx), "/root/a");
+
+    // Confirm completion for the query "/root/d", selecting the second candidate "dir2", since it's a directory, it should add a trailing slash.
+    let query = "/root/d";
+    insert_query(query, &picker, cx).await;
+    assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1", "dir2"]);
+    assert_eq!(confirm_completion(query, 1, &picker, cx), "/root/dir2/");
+
+    let query = "/root/d";
+    insert_query(query, &picker, cx).await;
+    assert_eq!(collect_match_candidates(&picker, cx), vec!["dir1", "dir2"]);
+    assert_eq!(confirm_completion(query, 0, &picker, cx), "/root/dir1/");
+}
+
 #[gpui::test]
 async fn test_new_path_prompt(cx: &mut TestAppContext) {
     let app_state = init_test(cx);
@@ -278,7 +319,7 @@ async fn test_new_path_prompt(cx: &mut TestAppContext) {
 
     let project = Project::test(app_state.fs.clone(), [path!("/root").as_ref()], cx).await;
 
-    let (picker, cx) = build_open_path_prompt(project, true, cx);
+    let (picker, cx) = build_open_path_prompt(project, true, PathStyle::current(), cx);
 
     insert_query(path!("/root"), &picker, cx).await;
     assert_eq!(collect_match_candidates(&picker, cx), vec!["root"]);
@@ -315,11 +356,12 @@ fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
 fn build_open_path_prompt(
     project: Entity<Project>,
     creating_path: bool,
+    path_style: PathStyle,
     cx: &mut TestAppContext,
 ) -> (Entity<Picker<OpenPathDelegate>>, &mut VisualTestContext) {
     let (tx, _) = futures::channel::oneshot::channel();
     let lister = project::DirectoryLister::Project(project.clone());
-    let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path);
+    let delegate = OpenPathDelegate::new(tx, lister.clone(), creating_path, path_style);
 
     let (workspace, cx) = cx.add_window_view(|window, cx| Workspace::test_new(project, window, cx));
     (

crates/net/Cargo.toml 🔗

@@ -0,0 +1,25 @@
+[package]
+name = "net"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/net.rs"
+doctest = false
+
+[dependencies]
+smol.workspace = true
+workspace-hack.workspace = true
+
+[target.'cfg(target_os = "windows")'.dependencies]
+anyhow.workspace = true
+async-io = "2.4"
+windows.workspace = true
+
+[dev-dependencies]
+tempfile.workspace = true

crates/net/src/async_net.rs 🔗

@@ -0,0 +1,69 @@
+#[cfg(not(target_os = "windows"))]
+pub use smol::net::unix::{UnixListener, UnixStream};
+
+#[cfg(target_os = "windows")]
+pub use windows::{UnixListener, UnixStream};
+
+#[cfg(target_os = "windows")]
+pub mod windows {
+    use std::{
+        io::Result,
+        path::Path,
+        pin::Pin,
+        task::{Context, Poll},
+    };
+
+    use smol::{
+        Async,
+        io::{AsyncRead, AsyncWrite},
+    };
+
+    pub struct UnixListener(Async<crate::UnixListener>);
+
+    impl UnixListener {
+        pub fn bind<P: AsRef<Path>>(path: P) -> Result<Self> {
+            Ok(UnixListener(Async::new(crate::UnixListener::bind(path)?)?))
+        }
+
+        pub async fn accept(&self) -> Result<(UnixStream, ())> {
+            let (sock, _) = self.0.read_with(|listener| listener.accept()).await?;
+            Ok((UnixStream(Async::new(sock)?), ()))
+        }
+    }
+
+    pub struct UnixStream(Async<crate::UnixStream>);
+
+    impl UnixStream {
+        pub async fn connect<P: AsRef<Path>>(path: P) -> Result<Self> {
+            Ok(UnixStream(Async::new(crate::UnixStream::connect(path)?)?))
+        }
+    }
+
+    impl AsyncRead for UnixStream {
+        fn poll_read(
+            mut self: Pin<&mut Self>,
+            cx: &mut Context<'_>,
+            buf: &mut [u8],
+        ) -> Poll<Result<usize>> {
+            Pin::new(&mut self.0).poll_read(cx, buf)
+        }
+    }
+
+    impl AsyncWrite for UnixStream {
+        fn poll_write(
+            mut self: Pin<&mut Self>,
+            cx: &mut Context<'_>,
+            buf: &[u8],
+        ) -> Poll<Result<usize>> {
+            Pin::new(&mut self.0).poll_write(cx, buf)
+        }
+
+        fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<()>> {
+            Pin::new(&mut self.0).poll_flush(cx)
+        }
+
+        fn poll_close(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Result<()>> {
+            Pin::new(&mut self.0).poll_close(cx)
+        }
+    }
+}

crates/net/src/listener.rs 🔗

@@ -0,0 +1,45 @@
+use std::{
+    io::Result,
+    os::windows::io::{AsSocket, BorrowedSocket},
+    path::Path,
+};
+
+use windows::Win32::Networking::WinSock::{SOCKADDR_UN, SOMAXCONN, bind, listen};
+
+use crate::{
+    socket::UnixSocket,
+    stream::UnixStream,
+    util::{init, map_ret, sockaddr_un},
+};
+
+pub struct UnixListener(UnixSocket);
+
+impl UnixListener {
+    pub fn bind<P: AsRef<Path>>(path: P) -> Result<Self> {
+        init();
+        let socket = UnixSocket::new()?;
+        let (addr, len) = sockaddr_un(path)?;
+        unsafe {
+            map_ret(bind(
+                socket.as_raw(),
+                &addr as *const _ as *const _,
+                len as i32,
+            ))?;
+            map_ret(listen(socket.as_raw(), SOMAXCONN as _))?;
+        }
+        Ok(Self(socket))
+    }
+
+    pub fn accept(&self) -> Result<(UnixStream, ())> {
+        let mut storage = SOCKADDR_UN::default();
+        let mut len = std::mem::size_of_val(&storage) as i32;
+        let raw = self.0.accept(&mut storage as *mut _ as *mut _, &mut len)?;
+        Ok((UnixStream::new(raw), ()))
+    }
+}
+
+impl AsSocket for UnixListener {
+    fn as_socket(&self) -> BorrowedSocket<'_> {
+        unsafe { BorrowedSocket::borrow_raw(self.0.as_raw().0 as _) }
+    }
+}

crates/net/src/net.rs 🔗

@@ -0,0 +1,107 @@
+pub mod async_net;
+#[cfg(target_os = "windows")]
+pub mod listener;
+#[cfg(target_os = "windows")]
+pub mod socket;
+#[cfg(target_os = "windows")]
+pub mod stream;
+#[cfg(target_os = "windows")]
+mod util;
+
+#[cfg(target_os = "windows")]
+pub use listener::*;
+#[cfg(target_os = "windows")]
+pub use socket::*;
+#[cfg(not(target_os = "windows"))]
+pub use std::os::unix::net::{UnixListener, UnixStream};
+#[cfg(target_os = "windows")]
+pub use stream::*;
+
+#[cfg(test)]
+mod tests {
+    use std::io::{Read, Write};
+
+    use smol::io::{AsyncReadExt, AsyncWriteExt};
+
+    const SERVER_MESSAGE: &str = "Connection closed";
+    const CLIENT_MESSAGE: &str = "Hello, server!";
+    const BUFFER_SIZE: usize = 32;
+
+    #[test]
+    fn test_windows_listener() -> std::io::Result<()> {
+        use crate::{UnixListener, UnixStream};
+
+        let temp = tempfile::tempdir()?;
+        let socket = temp.path().join("socket.sock");
+        let listener = UnixListener::bind(&socket)?;
+
+        // Server
+        let server = std::thread::spawn(move || {
+            let (mut stream, _) = listener.accept().unwrap();
+
+            // Read data from the client
+            let mut buffer = [0; BUFFER_SIZE];
+            let bytes_read = stream.read(&mut buffer).unwrap();
+            let string = String::from_utf8_lossy(&buffer[..bytes_read]);
+            assert_eq!(string, CLIENT_MESSAGE);
+
+            // Send a message back to the client
+            stream.write_all(SERVER_MESSAGE.as_bytes()).unwrap();
+        });
+
+        // Client
+        let mut client = UnixStream::connect(&socket)?;
+
+        // Send data to the server
+        client.write_all(CLIENT_MESSAGE.as_bytes())?;
+        let mut buffer = [0; BUFFER_SIZE];
+
+        // Read the response from the server
+        let bytes_read = client.read(&mut buffer)?;
+        let string = String::from_utf8_lossy(&buffer[..bytes_read]);
+        assert_eq!(string, SERVER_MESSAGE);
+        client.flush()?;
+
+        server.join().unwrap();
+        Ok(())
+    }
+
+    #[test]
+    fn test_unix_listener() -> std::io::Result<()> {
+        use crate::async_net::{UnixListener, UnixStream};
+
+        smol::block_on(async {
+            let temp = tempfile::tempdir()?;
+            let socket = temp.path().join("socket.sock");
+            let listener = UnixListener::bind(&socket)?;
+
+            // Server
+            let server = smol::spawn(async move {
+                let (mut stream, _) = listener.accept().await.unwrap();
+
+                // Read data from the client
+                let mut buffer = [0; BUFFER_SIZE];
+                let bytes_read = stream.read(&mut buffer).await.unwrap();
+                let string = String::from_utf8_lossy(&buffer[..bytes_read]);
+                assert_eq!(string, CLIENT_MESSAGE);
+
+                // Send a message back to the client
+                stream.write_all(SERVER_MESSAGE.as_bytes()).await.unwrap();
+            });
+
+            // Client
+            let mut client = UnixStream::connect(&socket).await?;
+            client.write_all(CLIENT_MESSAGE.as_bytes()).await?;
+
+            // Read the response from the server
+            let mut buffer = [0; BUFFER_SIZE];
+            let bytes_read = client.read(&mut buffer).await?;
+            let string = String::from_utf8_lossy(&buffer[..bytes_read]);
+            assert_eq!(string, "Connection closed");
+            client.flush().await?;
+
+            server.await;
+            Ok(())
+        })
+    }
+}

crates/net/src/socket.rs 🔗

@@ -0,0 +1,59 @@
+use std::io::{Error, ErrorKind, Result};
+
+use windows::Win32::{
+    Foundation::{HANDLE, HANDLE_FLAG_INHERIT, HANDLE_FLAGS, SetHandleInformation},
+    Networking::WinSock::{
+        AF_UNIX, SEND_RECV_FLAGS, SOCK_STREAM, SOCKADDR, SOCKET, WSA_FLAG_OVERLAPPED,
+        WSAEWOULDBLOCK, WSASocketW, accept, closesocket, recv, send,
+    },
+};
+
+use crate::util::map_ret;
+
+pub struct UnixSocket(SOCKET);
+
+impl UnixSocket {
+    pub fn new() -> Result<Self> {
+        unsafe {
+            let raw = WSASocketW(AF_UNIX as _, SOCK_STREAM.0, 0, None, 0, WSA_FLAG_OVERLAPPED)?;
+            SetHandleInformation(
+                HANDLE(raw.0 as _),
+                HANDLE_FLAG_INHERIT.0,
+                HANDLE_FLAGS::default(),
+            )?;
+            Ok(Self(raw))
+        }
+    }
+
+    pub(crate) fn as_raw(&self) -> SOCKET {
+        self.0
+    }
+
+    pub fn accept(&self, storage: *mut SOCKADDR, len: &mut i32) -> Result<Self> {
+        match unsafe { accept(self.0, Some(storage), Some(len)) } {
+            Ok(sock) => Ok(Self(sock)),
+            Err(err) => {
+                let wsa_err = unsafe { windows::Win32::Networking::WinSock::WSAGetLastError().0 };
+                if wsa_err == WSAEWOULDBLOCK.0 {
+                    Err(Error::new(ErrorKind::WouldBlock, "accept would block"))
+                } else {
+                    Err(err.into())
+                }
+            }
+        }
+    }
+
+    pub(crate) fn recv(&self, buf: &mut [u8]) -> Result<usize> {
+        map_ret(unsafe { recv(self.0, buf, SEND_RECV_FLAGS::default()) })
+    }
+
+    pub(crate) fn send(&self, buf: &[u8]) -> Result<usize> {
+        map_ret(unsafe { send(self.0, buf, SEND_RECV_FLAGS::default()) })
+    }
+}
+
+impl Drop for UnixSocket {
+    fn drop(&mut self) {
+        unsafe { closesocket(self.0) };
+    }
+}

crates/net/src/stream.rs 🔗

@@ -0,0 +1,60 @@
+use std::{
+    io::{Read, Result, Write},
+    os::windows::io::{AsSocket, BorrowedSocket},
+    path::Path,
+};
+
+use async_io::IoSafe;
+use windows::Win32::Networking::WinSock::connect;
+
+use crate::{
+    socket::UnixSocket,
+    util::{init, map_ret, sockaddr_un},
+};
+
+pub struct UnixStream(UnixSocket);
+
+unsafe impl IoSafe for UnixStream {}
+
+impl UnixStream {
+    pub fn new(socket: UnixSocket) -> Self {
+        Self(socket)
+    }
+
+    pub fn connect<P: AsRef<Path>>(path: P) -> Result<Self> {
+        init();
+        unsafe {
+            let inner = UnixSocket::new()?;
+            let (addr, len) = sockaddr_un(path)?;
+
+            map_ret(connect(
+                inner.as_raw(),
+                &addr as *const _ as *const _,
+                len as i32,
+            ))?;
+            Ok(Self(inner))
+        }
+    }
+}
+
+impl Read for UnixStream {
+    fn read(&mut self, buf: &mut [u8]) -> Result<usize> {
+        self.0.recv(buf)
+    }
+}
+
+impl Write for UnixStream {
+    fn write(&mut self, buf: &[u8]) -> Result<usize> {
+        self.0.send(buf)
+    }
+
+    fn flush(&mut self) -> Result<()> {
+        Ok(())
+    }
+}
+
+impl AsSocket for UnixStream {
+    fn as_socket(&self) -> BorrowedSocket<'_> {
+        unsafe { BorrowedSocket::borrow_raw(self.0.as_raw().0 as _) }
+    }
+}

crates/net/src/util.rs 🔗

@@ -0,0 +1,76 @@
+use std::{
+    io::{Error, ErrorKind, Result},
+    path::Path,
+    sync::Once,
+};
+
+use windows::Win32::Networking::WinSock::{
+    ADDRESS_FAMILY, AF_UNIX, SOCKADDR_UN, SOCKET_ERROR, WSAGetLastError, WSAStartup,
+};
+
+pub(crate) fn init() {
+    static ONCE: Once = Once::new();
+
+    ONCE.call_once(|| unsafe {
+        let mut wsa_data = std::mem::zeroed();
+        let result = WSAStartup(0x202, &mut wsa_data);
+        if result != 0 {
+            panic!("WSAStartup failed: {}", result);
+        }
+    });
+}
+
+// https://devblogs.microsoft.com/commandline/af_unix-comes-to-windows/
+pub(crate) fn sockaddr_un<P: AsRef<Path>>(path: P) -> Result<(SOCKADDR_UN, usize)> {
+    let mut addr = SOCKADDR_UN::default();
+    addr.sun_family = ADDRESS_FAMILY(AF_UNIX);
+
+    let bytes = path
+        .as_ref()
+        .to_str()
+        .map(|s| s.as_bytes())
+        .ok_or(ErrorKind::InvalidInput)?;
+
+    if bytes.contains(&0) {
+        return Err(Error::new(
+            ErrorKind::InvalidInput,
+            "paths may not contain interior null bytes",
+        ));
+    }
+    if bytes.len() >= addr.sun_path.len() {
+        return Err(Error::new(
+            ErrorKind::InvalidInput,
+            "path must be shorter than SUN_LEN",
+        ));
+    }
+
+    unsafe {
+        std::ptr::copy_nonoverlapping(
+            bytes.as_ptr(),
+            addr.sun_path.as_mut_ptr().cast(),
+            bytes.len(),
+        );
+    }
+
+    let mut len = sun_path_offset(&addr) + bytes.len();
+    match bytes.first() {
+        Some(&0) | None => {}
+        Some(_) => len += 1,
+    }
+    Ok((addr, len))
+}
+
+pub(crate) fn map_ret(ret: i32) -> Result<usize> {
+    if ret == SOCKET_ERROR {
+        Err(Error::from_raw_os_error(unsafe { WSAGetLastError().0 }))
+    } else {
+        Ok(ret as usize)
+    }
+}
+
+fn sun_path_offset(addr: &SOCKADDR_UN) -> usize {
+    // Work with an actual instance of the type since using a null pointer is UB
+    let base = addr as *const _ as usize;
+    let path = &addr.sun_path as *const _ as usize;
+    path - base
+}

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

@@ -33,7 +33,7 @@ use http_client::HttpClient;
 use language::{Buffer, LanguageToolchainStore, language_settings::InlayHintKind};
 use node_runtime::NodeRuntime;
 
-use remote::SshRemoteClient;
+use remote::{SshRemoteClient, ssh_session::SshArgs};
 use rpc::{
     AnyProtoClient, TypedEnvelope,
     proto::{self},
@@ -253,11 +253,16 @@ impl DapStore {
                 cx.spawn(async move |_, cx| {
                     let response = request.await?;
                     let binary = DebugAdapterBinary::from_proto(response)?;
-                    let mut ssh_command = ssh_client.read_with(cx, |ssh, _| {
-                        anyhow::Ok(SshCommand {
-                            arguments: ssh.ssh_args().context("SSH arguments not found")?,
-                        })
-                    })??;
+                    let (mut ssh_command, envs, path_style) =
+                        ssh_client.read_with(cx, |ssh, _| {
+                            let (SshArgs { arguments, envs }, path_style) =
+                                ssh.ssh_info().context("SSH arguments not found")?;
+                            anyhow::Ok((
+                                SshCommand { arguments },
+                                envs.unwrap_or_default(),
+                                path_style,
+                            ))
+                        })??;
 
                     let mut connection = None;
                     if let Some(c) = binary.connection {
@@ -282,12 +287,13 @@ impl DapStore {
                         binary.cwd.as_deref(),
                         binary.envs,
                         None,
+                        path_style,
                     );
 
                     Ok(DebugAdapterBinary {
                         command: Some(program),
                         arguments: args,
-                        envs: HashMap::default(),
+                        envs,
                         cwd: None,
                         connection,
                         request_args: binary.request_args,

crates/project/src/project.rs 🔗

@@ -117,7 +117,7 @@ use text::{Anchor, BufferId, Point};
 use toolchain_store::EmptyToolchainStore;
 use util::{
     ResultExt as _,
-    paths::{SanitizedPath, compare_paths},
+    paths::{PathStyle, RemotePathBuf, SanitizedPath, compare_paths},
 };
 use worktree::{CreatedEntry, Snapshot, Traversal};
 pub use worktree::{
@@ -1159,9 +1159,11 @@ impl Project {
             let snippets =
                 SnippetProvider::new(fs.clone(), BTreeSet::from_iter([global_snippets_dir]), cx);
 
-            let ssh_proto = ssh.read(cx).proto_client();
-            let worktree_store =
-                cx.new(|_| WorktreeStore::remote(false, ssh_proto.clone(), SSH_PROJECT_ID));
+            let (ssh_proto, path_style) =
+                ssh.read_with(cx, |ssh, _| (ssh.proto_client(), ssh.path_style()));
+            let worktree_store = cx.new(|_| {
+                WorktreeStore::remote(false, ssh_proto.clone(), SSH_PROJECT_ID, path_style)
+            });
             cx.subscribe(&worktree_store, Self::on_worktree_store_event)
                 .detach();
 
@@ -1410,8 +1412,15 @@ impl Project {
         let remote_id = response.payload.project_id;
         let role = response.payload.role();
 
+        // todo(zjk)
+        // Set the proper path style based on the remote
         let worktree_store = cx.new(|_| {
-            WorktreeStore::remote(true, client.clone().into(), response.payload.project_id)
+            WorktreeStore::remote(
+                true,
+                client.clone().into(),
+                response.payload.project_id,
+                PathStyle::Posix,
+            )
         })?;
         let buffer_store = cx.new(|cx| {
             BufferStore::remote(worktree_store.clone(), client.clone().into(), remote_id, cx)
@@ -4039,7 +4048,8 @@ impl Project {
                 })
             })
         } else if let Some(ssh_client) = self.ssh_client.as_ref() {
-            let request_path = Path::new(path);
+            let path_style = ssh_client.read(cx).path_style();
+            let request_path = RemotePathBuf::from_str(path, path_style);
             let request = ssh_client
                 .read(cx)
                 .proto_client()

crates/project/src/terminals.rs 🔗

@@ -4,6 +4,7 @@ use collections::HashMap;
 use gpui::{AnyWindowHandle, App, AppContext as _, Context, Entity, Task, WeakEntity};
 use itertools::Itertools;
 use language::LanguageName;
+use remote::ssh_session::SshArgs;
 use settings::{Settings, SettingsLocation};
 use smol::channel::bounded;
 use std::{
@@ -17,7 +18,10 @@ use terminal::{
     TaskState, TaskStatus, Terminal, TerminalBuilder,
     terminal_settings::{self, TerminalSettings, VenvSettings},
 };
-use util::ResultExt;
+use util::{
+    ResultExt,
+    paths::{PathStyle, RemotePathBuf},
+};
 
 pub struct Terminals {
     pub(crate) local_handles: Vec<WeakEntity<terminal::Terminal>>,
@@ -47,6 +51,13 @@ impl SshCommand {
     }
 }
 
+pub struct SshDetails {
+    pub host: String,
+    pub ssh_command: SshCommand,
+    pub envs: Option<HashMap<String, String>>,
+    pub path_style: PathStyle,
+}
+
 impl Project {
     pub fn active_project_directory(&self, cx: &App) -> Option<Arc<Path>> {
         let worktree = self
@@ -68,14 +79,16 @@ impl Project {
         }
     }
 
-    pub fn ssh_details(&self, cx: &App) -> Option<(String, SshCommand)> {
+    pub fn ssh_details(&self, cx: &App) -> Option<SshDetails> {
         if let Some(ssh_client) = &self.ssh_client {
             let ssh_client = ssh_client.read(cx);
-            if let Some(args) = ssh_client.ssh_args() {
-                return Some((
-                    ssh_client.connection_options().host.clone(),
-                    SshCommand { arguments: args },
-                ));
+            if let Some((SshArgs { arguments, envs }, path_style)) = ssh_client.ssh_info() {
+                return Some(SshDetails {
+                    host: ssh_client.connection_options().host.clone(),
+                    ssh_command: SshCommand { arguments },
+                    envs,
+                    path_style,
+                });
             }
         }
 
@@ -158,17 +171,26 @@ impl Project {
             .unwrap_or_default();
         env.extend(settings.env.clone());
 
-        match &self.ssh_details(cx) {
-            Some((_, ssh_command)) => {
+        match self.ssh_details(cx) {
+            Some(SshDetails {
+                ssh_command,
+                envs,
+                path_style,
+                ..
+            }) => {
                 let (command, args) = wrap_for_ssh(
-                    ssh_command,
+                    &ssh_command,
                     Some((&command, &args)),
                     path.as_deref(),
                     env,
                     None,
+                    path_style,
                 );
                 let mut command = std::process::Command::new(command);
                 command.args(args);
+                if let Some(envs) = envs {
+                    command.envs(envs);
+                }
                 command
             }
             None => {
@@ -202,6 +224,7 @@ impl Project {
             }
         };
         let ssh_details = this.ssh_details(cx);
+        let is_ssh_terminal = ssh_details.is_some();
 
         let mut settings_location = None;
         if let Some(path) = path.as_ref() {
@@ -226,11 +249,7 @@ impl Project {
         // precedence.
         env.extend(settings.env.clone());
 
-        let local_path = if ssh_details.is_none() {
-            path.clone()
-        } else {
-            None
-        };
+        let local_path = if is_ssh_terminal { None } else { path.clone() };
 
         let mut python_venv_activate_command = None;
 
@@ -241,8 +260,13 @@ impl Project {
                         this.python_activate_command(python_venv_directory, &settings.detect_venv);
                 }
 
-                match &ssh_details {
-                    Some((host, ssh_command)) => {
+                match ssh_details {
+                    Some(SshDetails {
+                        host,
+                        ssh_command,
+                        envs,
+                        path_style,
+                    }) => {
                         log::debug!("Connecting to a remote server: {ssh_command:?}");
 
                         // Alacritty sets its terminfo to `alacritty`, this requiring hosts to have it installed
@@ -252,9 +276,18 @@ impl Project {
                         env.entry("TERM".to_string())
                             .or_insert_with(|| "xterm-256color".to_string());
 
-                        let (program, args) =
-                            wrap_for_ssh(&ssh_command, None, path.as_deref(), env, None);
+                        let (program, args) = wrap_for_ssh(
+                            &ssh_command,
+                            None,
+                            path.as_deref(),
+                            env,
+                            None,
+                            path_style,
+                        );
                         env = HashMap::default();
+                        if let Some(envs) = envs {
+                            env.extend(envs);
+                        }
                         (
                             Option::<TaskState>::None,
                             Shell::WithArguments {
@@ -290,8 +323,13 @@ impl Project {
                     );
                 }
 
-                match &ssh_details {
-                    Some((host, ssh_command)) => {
+                match ssh_details {
+                    Some(SshDetails {
+                        host,
+                        ssh_command,
+                        envs,
+                        path_style,
+                    }) => {
                         log::debug!("Connecting to a remote server: {ssh_command:?}");
                         env.entry("TERM".to_string())
                             .or_insert_with(|| "xterm-256color".to_string());
@@ -304,8 +342,12 @@ impl Project {
                             path.as_deref(),
                             env,
                             python_venv_directory.as_deref(),
+                            path_style,
                         );
                         env = HashMap::default();
+                        if let Some(envs) = envs {
+                            env.extend(envs);
+                        }
                         (
                             task_state,
                             Shell::WithArguments {
@@ -343,7 +385,7 @@ impl Project {
             settings.cursor_shape.unwrap_or_default(),
             settings.alternate_scroll,
             settings.max_scroll_history_lines,
-            ssh_details.is_some(),
+            is_ssh_terminal,
             window,
             completion_tx,
             cx,
@@ -533,6 +575,7 @@ pub fn wrap_for_ssh(
     path: Option<&Path>,
     env: HashMap<String, String>,
     venv_directory: Option<&Path>,
+    path_style: PathStyle,
 ) -> (String, Vec<String>) {
     let to_run = if let Some((command, args)) = command {
         // DEFAULT_REMOTE_SHELL is '"${SHELL:-sh}"' so must not be escaped
@@ -555,24 +598,25 @@ pub fn wrap_for_ssh(
     }
     if let Some(venv_directory) = venv_directory {
         if let Ok(str) = shlex::try_quote(venv_directory.to_string_lossy().as_ref()) {
-            env_changes.push_str(&format!("PATH={}:$PATH ", str));
+            let path = RemotePathBuf::new(PathBuf::from(str.to_string()), path_style).to_string();
+            env_changes.push_str(&format!("PATH={}:$PATH ", path));
         }
     }
 
     let commands = if let Some(path) = path {
-        let path_string = path.to_string_lossy().to_string();
+        let path = RemotePathBuf::new(path.to_path_buf(), path_style).to_string();
         // shlex will wrap the command in single quotes (''), disabling ~ expansion,
         // replace ith with something that works
         let tilde_prefix = "~/";
         if path.starts_with(tilde_prefix) {
-            let trimmed_path = path_string
+            let trimmed_path = path
                 .trim_start_matches("/")
                 .trim_start_matches("~")
                 .trim_start_matches("/");
 
             format!("cd \"$HOME/{trimmed_path}\"; {env_changes} {to_run}")
         } else {
-            format!("cd {path:?}; {env_changes} {to_run}")
+            format!("cd {path}; {env_changes} {to_run}")
         }
     } else {
         format!("cd; {env_changes} {to_run}")

crates/project/src/worktree_store.rs 🔗

@@ -25,7 +25,10 @@ use smol::{
     stream::StreamExt,
 };
 use text::ReplicaId;
-use util::{ResultExt, paths::SanitizedPath};
+use util::{
+    ResultExt,
+    paths::{PathStyle, RemotePathBuf, SanitizedPath},
+};
 use worktree::{
     Entry, ProjectEntryId, UpdatedEntriesSet, UpdatedGitRepositoriesSet, Worktree, WorktreeId,
     WorktreeSettings,
@@ -46,6 +49,7 @@ enum WorktreeStoreState {
     Remote {
         upstream_client: AnyProtoClient,
         upstream_project_id: u64,
+        path_style: PathStyle,
     },
 }
 
@@ -100,6 +104,7 @@ impl WorktreeStore {
         retain_worktrees: bool,
         upstream_client: AnyProtoClient,
         upstream_project_id: u64,
+        path_style: PathStyle,
     ) -> Self {
         Self {
             next_entry_id: Default::default(),
@@ -111,6 +116,7 @@ impl WorktreeStore {
             state: WorktreeStoreState::Remote {
                 upstream_client,
                 upstream_project_id,
+                path_style,
             },
         }
     }
@@ -214,17 +220,16 @@ impl WorktreeStore {
         if !self.loading_worktrees.contains_key(&abs_path) {
             let task = match &self.state {
                 WorktreeStoreState::Remote {
-                    upstream_client, ..
+                    upstream_client,
+                    path_style,
+                    ..
                 } => {
                     if upstream_client.is_via_collab() {
                         Task::ready(Err(Arc::new(anyhow!("cannot create worktrees via collab"))))
                     } else {
-                        self.create_ssh_worktree(
-                            upstream_client.clone(),
-                            abs_path.clone(),
-                            visible,
-                            cx,
-                        )
+                        let abs_path =
+                            RemotePathBuf::new(abs_path.as_path().to_path_buf(), *path_style);
+                        self.create_ssh_worktree(upstream_client.clone(), abs_path, visible, cx)
                     }
                 }
                 WorktreeStoreState::Local { fs } => {
@@ -250,11 +255,12 @@ impl WorktreeStore {
     fn create_ssh_worktree(
         &mut self,
         client: AnyProtoClient,
-        abs_path: impl Into<SanitizedPath>,
+        abs_path: RemotePathBuf,
         visible: bool,
         cx: &mut Context<Self>,
     ) -> Task<Result<Entity<Worktree>, Arc<anyhow::Error>>> {
-        let mut abs_path = Into::<SanitizedPath>::into(abs_path).to_string();
+        let path_style = abs_path.path_style();
+        let mut abs_path = abs_path.to_string();
         // If we start with `/~` that means the ssh path was something like `ssh://user@host/~/home-dir-folder/`
         // in which case want to strip the leading the `/`.
         // On the host-side, the `~` will get expanded.
@@ -265,10 +271,11 @@ impl WorktreeStore {
         if abs_path.is_empty() {
             abs_path = "~/".to_string();
         }
+
         cx.spawn(async move |this, cx| {
             let this = this.upgrade().context("Dropped worktree store")?;
 
-            let path = Path::new(abs_path.as_str());
+            let path = RemotePathBuf::new(abs_path.into(), path_style);
             let response = client
                 .request(proto::AddWorktree {
                     project_id: SSH_PROJECT_ID,

crates/proto/Cargo.toml 🔗

@@ -27,3 +27,4 @@ prost-build.workspace = true
 
 [dev-dependencies]
 collections = { workspace = true, features = ["test-support"] }
+typed-path = "0.11"

crates/proto/src/typed_envelope.rs 🔗

@@ -127,51 +127,46 @@ pub trait ToProto {
     fn to_proto(self) -> String;
 }
 
-impl FromProto for PathBuf {
+#[inline]
+fn from_proto_path(proto: String) -> PathBuf {
     #[cfg(target_os = "windows")]
-    fn from_proto(proto: String) -> Self {
-        proto.split("/").collect()
-    }
+    let proto = proto.replace('/', "\\");
+
+    PathBuf::from(proto)
+}
+
+#[inline]
+fn to_proto_path(path: &Path) -> String {
+    #[cfg(target_os = "windows")]
+    let proto = path.to_string_lossy().replace('\\', "/");
 
     #[cfg(not(target_os = "windows"))]
+    let proto = path.to_string_lossy().to_string();
+
+    proto
+}
+
+impl FromProto for PathBuf {
     fn from_proto(proto: String) -> Self {
-        PathBuf::from(proto)
+        from_proto_path(proto)
     }
 }
 
 impl FromProto for Arc<Path> {
     fn from_proto(proto: String) -> Self {
-        PathBuf::from_proto(proto).into()
+        from_proto_path(proto).into()
     }
 }
 
 impl ToProto for PathBuf {
-    #[cfg(target_os = "windows")]
-    fn to_proto(self) -> String {
-        self.components()
-            .map(|comp| comp.as_os_str().to_string_lossy().to_string())
-            .collect::<Vec<_>>()
-            .join("/")
-    }
-
-    #[cfg(not(target_os = "windows"))]
     fn to_proto(self) -> String {
-        self.to_string_lossy().to_string()
+        to_proto_path(&self)
     }
 }
 
 impl ToProto for &Path {
-    #[cfg(target_os = "windows")]
     fn to_proto(self) -> String {
-        self.components()
-            .map(|comp| comp.as_os_str().to_string_lossy().to_string())
-            .collect::<Vec<_>>()
-            .join("/")
-    }
-
-    #[cfg(not(target_os = "windows"))]
-    fn to_proto(self) -> String {
-        self.to_string_lossy().to_string()
+        to_proto_path(self)
     }
 }
 
@@ -214,3 +209,103 @@ impl<T: RequestMessage> TypedEnvelope<T> {
         }
     }
 }
+
+#[cfg(test)]
+mod tests {
+    use typed_path::{UnixPath, UnixPathBuf, WindowsPath, WindowsPathBuf};
+
+    fn windows_path_from_proto(proto: String) -> WindowsPathBuf {
+        let proto = proto.replace('/', "\\");
+        WindowsPathBuf::from(proto)
+    }
+
+    fn unix_path_from_proto(proto: String) -> UnixPathBuf {
+        UnixPathBuf::from(proto)
+    }
+
+    fn windows_path_to_proto(path: &WindowsPath) -> String {
+        path.to_string_lossy().replace('\\', "/")
+    }
+
+    fn unix_path_to_proto(path: &UnixPath) -> String {
+        path.to_string_lossy().to_string()
+    }
+
+    #[test]
+    fn test_path_proto_interop() {
+        const WINDOWS_PATHS: &[&str] = &[
+            "C:\\Users\\User\\Documents\\file.txt",
+            "C:/Program Files/App/app.exe",
+            "projects\\zed\\crates\\proto\\src\\typed_envelope.rs",
+            "projects/my project/src/main.rs",
+        ];
+        const UNIX_PATHS: &[&str] = &[
+            "/home/user/documents/file.txt",
+            "/usr/local/bin/my app/app",
+            "projects/zed/crates/proto/src/typed_envelope.rs",
+            "projects/my project/src/main.rs",
+        ];
+
+        // Windows path to proto and back
+        for &windows_path_str in WINDOWS_PATHS {
+            let windows_path = WindowsPathBuf::from(windows_path_str);
+            let proto = windows_path_to_proto(&windows_path);
+            let recovered_path = windows_path_from_proto(proto);
+            assert_eq!(windows_path, recovered_path);
+            assert_eq!(
+                recovered_path.to_string_lossy(),
+                windows_path_str.replace('/', "\\")
+            );
+        }
+        // Unix path to proto and back
+        for &unix_path_str in UNIX_PATHS {
+            let unix_path = UnixPathBuf::from(unix_path_str);
+            let proto = unix_path_to_proto(&unix_path);
+            let recovered_path = unix_path_from_proto(proto);
+            assert_eq!(unix_path, recovered_path);
+            assert_eq!(recovered_path.to_string_lossy(), unix_path_str);
+        }
+        // Windows host, Unix client, host sends Windows path to client
+        for &windows_path_str in WINDOWS_PATHS {
+            let windows_host_path = WindowsPathBuf::from(windows_path_str);
+            let proto = windows_path_to_proto(&windows_host_path);
+            let unix_client_received_path = unix_path_from_proto(proto);
+            let proto = unix_path_to_proto(&unix_client_received_path);
+            let windows_host_recovered_path = windows_path_from_proto(proto);
+            assert_eq!(windows_host_path, windows_host_recovered_path);
+            assert_eq!(
+                windows_host_recovered_path.to_string_lossy(),
+                windows_path_str.replace('/', "\\")
+            );
+        }
+        // Unix host, Windows client, host sends Unix path to client
+        for &unix_path_str in UNIX_PATHS {
+            let unix_host_path = UnixPathBuf::from(unix_path_str);
+            let proto = unix_path_to_proto(&unix_host_path);
+            let windows_client_received_path = windows_path_from_proto(proto);
+            let proto = windows_path_to_proto(&windows_client_received_path);
+            let unix_host_recovered_path = unix_path_from_proto(proto);
+            assert_eq!(unix_host_path, unix_host_recovered_path);
+            assert_eq!(unix_host_recovered_path.to_string_lossy(), unix_path_str);
+        }
+    }
+
+    // todo(zjk)
+    #[test]
+    fn test_unsolved_case() {
+        // Unix host, Windows client
+        // The Windows client receives a Unix path with backslashes in it, then
+        // sends it back to the host.
+        // This currently fails.
+        let unix_path = UnixPathBuf::from("/home/user/projects/my\\project/src/main.rs");
+        let proto = unix_path_to_proto(&unix_path);
+        let windows_client_received_path = windows_path_from_proto(proto);
+        let proto = windows_path_to_proto(&windows_client_received_path);
+        let unix_host_recovered_path = unix_path_from_proto(proto);
+        assert_ne!(unix_path, unix_host_recovered_path);
+        assert_eq!(
+            unix_host_recovered_path.to_string_lossy(),
+            "/home/user/projects/my/project/src/main.rs"
+        );
+    }
+}

crates/recent_projects/src/remote_servers.rs 🔗

@@ -28,9 +28,8 @@ use paths::user_ssh_config_file;
 use picker::Picker;
 use project::Fs;
 use project::Project;
-use remote::SshConnectionOptions;
-use remote::SshRemoteClient;
 use remote::ssh_session::ConnectionIdentifier;
+use remote::{SshConnectionOptions, SshRemoteClient};
 use settings::Settings;
 use settings::SettingsStore;
 use settings::update_settings_file;
@@ -42,7 +41,10 @@ use ui::{
     IconButtonShape, List, ListItem, ListSeparator, Modal, ModalHeader, Scrollbar, ScrollbarState,
     Section, Tooltip, prelude::*,
 };
-use util::ResultExt;
+use util::{
+    ResultExt,
+    paths::{PathStyle, RemotePathBuf},
+};
 use workspace::OpenOptions;
 use workspace::Toast;
 use workspace::notifications::NotificationId;
@@ -142,20 +144,21 @@ impl ProjectPicker {
         ix: usize,
         connection: SshConnectionOptions,
         project: Entity<Project>,
-        home_dir: PathBuf,
+        home_dir: RemotePathBuf,
+        path_style: PathStyle,
         workspace: WeakEntity<Workspace>,
         window: &mut Window,
         cx: &mut Context<RemoteServerProjects>,
     ) -> Entity<Self> {
         let (tx, rx) = oneshot::channel();
         let lister = project::DirectoryLister::Project(project.clone());
-        let delegate = file_finder::OpenPathDelegate::new(tx, lister, false);
+        let delegate = file_finder::OpenPathDelegate::new(tx, lister, false, path_style);
 
         let picker = cx.new(|cx| {
             let picker = Picker::uniform_list(delegate, window, cx)
                 .width(rems(34.))
                 .modal(false);
-            picker.set_query(home_dir.to_string_lossy().to_string(), window, cx);
+            picker.set_query(home_dir.to_string(), window, cx);
             picker
         });
         let connection_string = connection.connection_string().into();
@@ -422,7 +425,8 @@ impl RemoteServerProjects {
         ix: usize,
         connection_options: remote::SshConnectionOptions,
         project: Entity<Project>,
-        home_dir: PathBuf,
+        home_dir: RemotePathBuf,
+        path_style: PathStyle,
         window: &mut Window,
         cx: &mut Context<Self>,
         workspace: WeakEntity<Workspace>,
@@ -435,6 +439,7 @@ impl RemoteServerProjects {
             connection_options,
             project,
             home_dir,
+            path_style,
             workspace,
             window,
             cx,
@@ -589,15 +594,18 @@ impl RemoteServerProjects {
                         });
                     };
 
-                    let project = cx.update(|_, cx| {
-                        project::Project::ssh(
-                            session,
-                            app_state.client.clone(),
-                            app_state.node_runtime.clone(),
-                            app_state.user_store.clone(),
-                            app_state.languages.clone(),
-                            app_state.fs.clone(),
-                            cx,
+                    let (path_style, project) = cx.update(|_, cx| {
+                        (
+                            session.read(cx).path_style(),
+                            project::Project::ssh(
+                                session,
+                                app_state.client.clone(),
+                                app_state.node_runtime.clone(),
+                                app_state.user_store.clone(),
+                                app_state.languages.clone(),
+                                app_state.fs.clone(),
+                                cx,
+                            ),
                         )
                     })?;
 
@@ -605,7 +613,13 @@ impl RemoteServerProjects {
                         .read_with(cx, |project, cx| project.resolve_abs_path("~", cx))?
                         .await
                         .and_then(|path| path.into_abs_path())
-                        .unwrap_or(PathBuf::from("/"));
+                        .map(|path| RemotePathBuf::new(path, path_style))
+                        .unwrap_or_else(|| match path_style {
+                            PathStyle::Posix => RemotePathBuf::from_str("/", PathStyle::Posix),
+                            PathStyle::Windows => {
+                                RemotePathBuf::from_str("C:\\", PathStyle::Windows)
+                            }
+                        });
 
                     workspace
                         .update_in(cx, |workspace, window, cx| {
@@ -617,6 +631,7 @@ impl RemoteServerProjects {
                                     connection_options,
                                     project,
                                     home_dir,
+                                    path_style,
                                     window,
                                     cx,
                                     weak,

crates/remote/src/ssh_session.rs 🔗

@@ -49,7 +49,10 @@ use std::{
     time::{Duration, Instant},
 };
 use tempfile::TempDir;
-use util::ResultExt;
+use util::{
+    ResultExt,
+    paths::{PathStyle, RemotePathBuf},
+};
 
 #[derive(
     Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Clone, Copy, serde::Serialize, serde::Deserialize,
@@ -59,7 +62,10 @@ pub struct SshProjectId(pub u64);
 #[derive(Clone)]
 pub struct SshSocket {
     connection_options: SshConnectionOptions,
+    #[cfg(not(target_os = "windows"))]
     socket_path: PathBuf,
+    #[cfg(target_os = "windows")]
+    envs: HashMap<String, String>,
 }
 
 #[derive(Debug, Clone, PartialEq, Eq, Hash, Deserialize, Serialize, JsonSchema)]
@@ -85,6 +91,11 @@ pub struct SshConnectionOptions {
     pub upload_binary_over_ssh: bool,
 }
 
+pub struct SshArgs {
+    pub arguments: Vec<String>,
+    pub envs: Option<HashMap<String, String>>,
+}
+
 #[macro_export]
 macro_rules! shell_script {
     ($fmt:expr, $($name:ident = $arg:expr),+ $(,)?) => {{
@@ -338,6 +349,28 @@ pub trait SshClientDelegate: Send + Sync {
 }
 
 impl SshSocket {
+    #[cfg(not(target_os = "windows"))]
+    fn new(options: SshConnectionOptions, socket_path: PathBuf) -> Result<Self> {
+        Ok(Self {
+            connection_options: options,
+            socket_path,
+        })
+    }
+
+    #[cfg(target_os = "windows")]
+    fn new(options: SshConnectionOptions, temp_dir: &TempDir, secret: String) -> Result<Self> {
+        let askpass_script = temp_dir.path().join("askpass.bat");
+        std::fs::write(&askpass_script, "@ECHO OFF\necho %ZED_SSH_ASKPASS%")?;
+        let mut envs = HashMap::default();
+        envs.insert("SSH_ASKPASS_REQUIRE".into(), "force".into());
+        envs.insert("SSH_ASKPASS".into(), askpass_script.display().to_string());
+        envs.insert("ZED_SSH_ASKPASS".into(), secret);
+        Ok(Self {
+            connection_options: options,
+            envs,
+        })
+    }
+
     // :WARNING: ssh unquotes arguments when executing on the remote :WARNING:
     // e.g. $ ssh host sh -c 'ls -l' is equivalent to $ ssh host sh -c ls -l
     // and passes -l as an argument to sh, not to ls.
@@ -375,6 +408,7 @@ impl SshSocket {
         Ok(String::from_utf8_lossy(&output.stdout).to_string())
     }
 
+    #[cfg(not(target_os = "windows"))]
     fn ssh_options<'a>(&self, command: &'a mut process::Command) -> &'a mut process::Command {
         command
             .stdin(Stdio::piped())
@@ -384,14 +418,68 @@ impl SshSocket {
             .arg(format!("ControlPath={}", self.socket_path.display()))
     }
 
-    fn ssh_args(&self) -> Vec<String> {
-        vec![
-            "-o".to_string(),
-            "ControlMaster=no".to_string(),
-            "-o".to_string(),
-            format!("ControlPath={}", self.socket_path.display()),
-            self.connection_options.ssh_url(),
-        ]
+    #[cfg(target_os = "windows")]
+    fn ssh_options<'a>(&self, command: &'a mut process::Command) -> &'a mut process::Command {
+        command
+            .stdin(Stdio::piped())
+            .stdout(Stdio::piped())
+            .stderr(Stdio::piped())
+            .envs(self.envs.clone())
+    }
+
+    // On Windows, we need to use `SSH_ASKPASS` to provide the password to ssh.
+    // On Linux, we use the `ControlPath` option to create a socket file that ssh can use to
+    #[cfg(not(target_os = "windows"))]
+    fn ssh_args(&self) -> SshArgs {
+        SshArgs {
+            arguments: vec![
+                "-o".to_string(),
+                "ControlMaster=no".to_string(),
+                "-o".to_string(),
+                format!("ControlPath={}", self.socket_path.display()),
+                self.connection_options.ssh_url(),
+            ],
+            envs: None,
+        }
+    }
+
+    #[cfg(target_os = "windows")]
+    fn ssh_args(&self) -> SshArgs {
+        SshArgs {
+            arguments: vec![self.connection_options.ssh_url()],
+            envs: Some(self.envs.clone()),
+        }
+    }
+
+    async fn platform(&self) -> Result<SshPlatform> {
+        let uname = self.run_command("sh", &["-c", "uname -sm"]).await?;
+        let Some((os, arch)) = uname.split_once(" ") else {
+            anyhow::bail!("unknown uname: {uname:?}")
+        };
+
+        let os = match os.trim() {
+            "Darwin" => "macos",
+            "Linux" => "linux",
+            _ => anyhow::bail!(
+                "Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development"
+            ),
+        };
+        // exclude armv5,6,7 as they are 32-bit.
+        let arch = if arch.starts_with("armv8")
+            || arch.starts_with("armv9")
+            || arch.starts_with("arm64")
+            || arch.starts_with("aarch64")
+        {
+            "aarch64"
+        } else if arch.starts_with("x86") {
+            "x86_64"
+        } else {
+            anyhow::bail!(
+                "Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development"
+            )
+        };
+
+        Ok(SshPlatform { os, arch })
     }
 }
 
@@ -560,6 +648,7 @@ pub struct SshRemoteClient {
     client: Arc<ChannelClient>,
     unique_identifier: String,
     connection_options: SshConnectionOptions,
+    path_style: PathStyle,
     state: Arc<Mutex<Option<State>>>,
 }
 
@@ -620,22 +709,25 @@ impl SshRemoteClient {
 
                 let client =
                     cx.update(|cx| ChannelClient::new(incoming_rx, outgoing_tx, cx, "client"))?;
-                let this = cx.new(|_| Self {
-                    client: client.clone(),
-                    unique_identifier: unique_identifier.clone(),
-                    connection_options: connection_options.clone(),
-                    state: Arc::new(Mutex::new(Some(State::Connecting))),
-                })?;
 
                 let ssh_connection = cx
                     .update(|cx| {
                         cx.update_default_global(|pool: &mut ConnectionPool, cx| {
-                            pool.connect(connection_options, &delegate, cx)
+                            pool.connect(connection_options.clone(), &delegate, cx)
                         })
                     })?
                     .await
                     .map_err(|e| e.cloned())?;
 
+                let path_style = ssh_connection.path_style();
+                let this = cx.new(|_| Self {
+                    client: client.clone(),
+                    unique_identifier: unique_identifier.clone(),
+                    connection_options,
+                    path_style,
+                    state: Arc::new(Mutex::new(Some(State::Connecting))),
+                })?;
+
                 let io_task = ssh_connection.start_proxy(
                     unique_identifier,
                     false,
@@ -1065,18 +1157,18 @@ impl SshRemoteClient {
         self.client.subscribe_to_entity(remote_id, entity);
     }
 
-    pub fn ssh_args(&self) -> Option<Vec<String>> {
+    pub fn ssh_info(&self) -> Option<(SshArgs, PathStyle)> {
         self.state
             .lock()
             .as_ref()
             .and_then(|state| state.ssh_connection())
-            .map(|ssh_connection| ssh_connection.ssh_args())
+            .map(|ssh_connection| (ssh_connection.ssh_args(), ssh_connection.path_style()))
     }
 
     pub fn upload_directory(
         &self,
         src_path: PathBuf,
-        dest_path: PathBuf,
+        dest_path: RemotePathBuf,
         cx: &App,
     ) -> Task<Result<()>> {
         let state = self.state.lock();
@@ -1110,6 +1202,10 @@ impl SshRemoteClient {
         self.connection_state() == ConnectionState::Disconnected
     }
 
+    pub fn path_style(&self) -> PathStyle {
+        self.path_style
+    }
+
     #[cfg(any(test, feature = "test-support"))]
     pub fn simulate_disconnect(&self, client_cx: &mut App) -> Task<()> {
         let opts = self.connection_options();
@@ -1288,12 +1384,19 @@ trait RemoteConnection: Send + Sync {
         delegate: Arc<dyn SshClientDelegate>,
         cx: &mut AsyncApp,
     ) -> Task<Result<i32>>;
-    fn upload_directory(&self, src_path: PathBuf, dest_path: PathBuf, cx: &App)
-    -> Task<Result<()>>;
+    fn upload_directory(
+        &self,
+        src_path: PathBuf,
+        dest_path: RemotePathBuf,
+        cx: &App,
+    ) -> Task<Result<()>>;
     async fn kill(&self) -> Result<()>;
     fn has_been_killed(&self) -> bool;
-    fn ssh_args(&self) -> Vec<String>;
+    /// On Windows, we need to use `SSH_ASKPASS` to provide the password to ssh.
+    /// On Linux, we use the `ControlPath` option to create a socket file that ssh can use to
+    fn ssh_args(&self) -> SshArgs;
     fn connection_options(&self) -> SshConnectionOptions;
+    fn path_style(&self) -> PathStyle;
 
     #[cfg(any(test, feature = "test-support"))]
     fn simulate_disconnect(&self, _: &AsyncApp) {}
@@ -1302,7 +1405,9 @@ trait RemoteConnection: Send + Sync {
 struct SshRemoteConnection {
     socket: SshSocket,
     master_process: Mutex<Option<Child>>,
-    remote_binary_path: Option<PathBuf>,
+    remote_binary_path: Option<RemotePathBuf>,
+    ssh_platform: SshPlatform,
+    ssh_path_style: PathStyle,
     _temp_dir: TempDir,
 }
 
@@ -1321,7 +1426,7 @@ impl RemoteConnection for SshRemoteConnection {
         self.master_process.lock().is_none()
     }
 
-    fn ssh_args(&self) -> Vec<String> {
+    fn ssh_args(&self) -> SshArgs {
         self.socket.ssh_args()
     }
 
@@ -1332,7 +1437,7 @@ impl RemoteConnection for SshRemoteConnection {
     fn upload_directory(
         &self,
         src_path: PathBuf,
-        dest_path: PathBuf,
+        dest_path: RemotePathBuf,
         cx: &App,
     ) -> Task<Result<()>> {
         let mut command = util::command::new_smol_command("scp");
@@ -1352,7 +1457,7 @@ impl RemoteConnection for SshRemoteConnection {
             .arg(format!(
                 "{}:{}",
                 self.socket.connection_options.scp_url(),
-                dest_path.display()
+                dest_path.to_string()
             ))
             .output();
 
@@ -1363,7 +1468,7 @@ impl RemoteConnection for SshRemoteConnection {
                 output.status.success(),
                 "failed to upload directory {} -> {}: {}",
                 src_path.display(),
-                dest_path.display(),
+                dest_path.to_string(),
                 String::from_utf8_lossy(&output.stderr)
             );
 
@@ -1389,7 +1494,7 @@ impl RemoteConnection for SshRemoteConnection {
 
         let mut start_proxy_command = shell_script!(
             "exec {binary_path} proxy --identifier {identifier}",
-            binary_path = &remote_binary_path.to_string_lossy(),
+            binary_path = &remote_binary_path.to_string(),
             identifier = &unique_identifier,
         );
 
@@ -1432,19 +1537,13 @@ impl RemoteConnection for SshRemoteConnection {
             &cx,
         )
     }
-}
 
-impl SshRemoteConnection {
-    #[cfg(not(unix))]
-    async fn new(
-        _connection_options: SshConnectionOptions,
-        _delegate: Arc<dyn SshClientDelegate>,
-        _cx: &mut AsyncApp,
-    ) -> Result<Self> {
-        anyhow::bail!("ssh is not supported on this platform");
+    fn path_style(&self) -> PathStyle {
+        self.ssh_path_style
     }
+}
 
-    #[cfg(unix)]
+impl SshRemoteConnection {
     async fn new(
         connection_options: SshConnectionOptions,
         delegate: Arc<dyn SshClientDelegate>,
@@ -1470,27 +1569,38 @@ impl SshRemoteConnection {
         // Start the master SSH process, which does not do anything except for establish
         // the connection and keep it open, allowing other ssh commands to reuse it
         // via a control socket.
+        #[cfg(not(target_os = "windows"))]
         let socket_path = temp_dir.path().join("ssh.sock");
 
-        let mut master_process = process::Command::new("ssh")
-            .stdin(Stdio::null())
-            .stdout(Stdio::piped())
-            .stderr(Stdio::piped())
-            .env("SSH_ASKPASS_REQUIRE", "force")
-            .env("SSH_ASKPASS", &askpass.script_path())
-            .args(connection_options.additional_args())
-            .args([
+        let mut master_process = {
+            #[cfg(not(target_os = "windows"))]
+            let args = [
                 "-N",
                 "-o",
                 "ControlPersist=no",
                 "-o",
                 "ControlMaster=yes",
                 "-o",
-            ])
-            .arg(format!("ControlPath={}", socket_path.display()))
-            .arg(&url)
-            .kill_on_drop(true)
-            .spawn()?;
+            ];
+            // On Windows, `ControlMaster` and `ControlPath` are not supported:
+            // https://github.com/PowerShell/Win32-OpenSSH/issues/405
+            // https://github.com/PowerShell/Win32-OpenSSH/wiki/Project-Scope
+            #[cfg(target_os = "windows")]
+            let args = ["-N"];
+            let mut master_process = process::Command::new("ssh");
+            master_process
+                .kill_on_drop(true)
+                .stdin(Stdio::null())
+                .stdout(Stdio::piped())
+                .stderr(Stdio::piped())
+                .env("SSH_ASKPASS_REQUIRE", "force")
+                .env("SSH_ASKPASS", askpass.script_path())
+                .args(connection_options.additional_args())
+                .args(args);
+            #[cfg(not(target_os = "windows"))]
+            master_process.arg(format!("ControlPath={}", socket_path.display()));
+            master_process.arg(&url).spawn()?
+        };
         // Wait for this ssh process to close its stdout, indicating that authentication
         // has completed.
         let mut stdout = master_process.stdout.take().unwrap();
@@ -1529,11 +1639,16 @@ impl SshRemoteConnection {
             anyhow::bail!(error_message);
         }
 
+        #[cfg(not(target_os = "windows"))]
+        let socket = SshSocket::new(connection_options, socket_path)?;
+        #[cfg(target_os = "windows")]
+        let socket = SshSocket::new(connection_options, &temp_dir, askpass.get_password())?;
         drop(askpass);
 
-        let socket = SshSocket {
-            connection_options,
-            socket_path,
+        let ssh_platform = socket.platform().await?;
+        let ssh_path_style = match ssh_platform.os {
+            "windows" => PathStyle::Windows,
+            _ => PathStyle::Posix,
         };
 
         let mut this = Self {
@@ -1541,6 +1656,8 @@ impl SshRemoteConnection {
             master_process: Mutex::new(Some(master_process)),
             _temp_dir: temp_dir,
             remote_binary_path: None,
+            ssh_path_style,
+            ssh_platform,
         };
 
         let (release_channel, version, commit) = cx.update(|cx| {
@@ -1558,37 +1675,6 @@ impl SshRemoteConnection {
         Ok(this)
     }
 
-    async fn platform(&self) -> Result<SshPlatform> {
-        let uname = self.socket.run_command("sh", &["-c", "uname -sm"]).await?;
-        let Some((os, arch)) = uname.split_once(" ") else {
-            anyhow::bail!("unknown uname: {uname:?}")
-        };
-
-        let os = match os.trim() {
-            "Darwin" => "macos",
-            "Linux" => "linux",
-            _ => anyhow::bail!(
-                "Prebuilt remote servers are not yet available for {os:?}. See https://zed.dev/docs/remote-development"
-            ),
-        };
-        // exclude armv5,6,7 as they are 32-bit.
-        let arch = if arch.starts_with("armv8")
-            || arch.starts_with("armv9")
-            || arch.starts_with("arm64")
-            || arch.starts_with("aarch64")
-        {
-            "aarch64"
-        } else if arch.starts_with("x86") {
-            "x86_64"
-        } else {
-            anyhow::bail!(
-                "Prebuilt remote servers are not yet available for {arch:?}. See https://zed.dev/docs/remote-development"
-            )
-        };
-
-        Ok(SshPlatform { os, arch })
-    }
-
     fn multiplex(
         mut ssh_proxy_process: Child,
         incoming_tx: UnboundedSender<Envelope>,
@@ -1699,11 +1785,10 @@ impl SshRemoteConnection {
         version: SemanticVersion,
         commit: Option<AppCommitSha>,
         cx: &mut AsyncApp,
-    ) -> Result<PathBuf> {
+    ) -> 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(),
@@ -1714,19 +1799,23 @@ impl SshRemoteConnection {
             release_channel.dev_name(),
             version_str
         );
-        let dst_path = paths::remote_server_dir_relative().join(binary_name);
+        let dst_path = RemotePathBuf::new(
+            paths::remote_server_dir_relative().join(binary_name),
+            self.ssh_path_style,
+        );
 
         let build_remote_server = std::env::var("ZED_BUILD_REMOTE_SERVER").ok();
         #[cfg(debug_assertions)]
         if let Some(build_remote_server) = build_remote_server {
-            let src_path = self
-                .build_local(build_remote_server, self.platform().await?, delegate, cx)
-                .await?;
-            let tmp_path = paths::remote_server_dir_relative().join(format!(
-                "download-{}-{}",
-                std::process::id(),
-                src_path.file_name().unwrap().to_string_lossy()
-            ));
+            let src_path = self.build_local(build_remote_server, delegate, cx).await?;
+            let tmp_path = RemotePathBuf::new(
+                paths::remote_server_dir_relative().join(format!(
+                    "download-{}-{}",
+                    std::process::id(),
+                    src_path.file_name().unwrap().to_string_lossy()
+                )),
+                self.ssh_path_style,
+            );
             self.upload_local_server_binary(&src_path, &tmp_path, delegate, cx)
                 .await?;
             self.extract_server_binary(&dst_path, &tmp_path, delegate, cx)
@@ -1736,7 +1825,7 @@ impl SshRemoteConnection {
 
         if self
             .socket
-            .run_command(&dst_path.to_string_lossy(), &["version"])
+            .run_command(&dst_path.to_string(), &["version"])
             .await
             .is_ok()
         {
@@ -1754,16 +1843,17 @@ impl SshRemoteConnection {
             _ => Ok(Some(AppVersion::global(cx))),
         })??;
 
-        let platform = self.platform().await?;
-
-        let tmp_path_gz = PathBuf::from(format!(
-            "{}-download-{}.gz",
-            dst_path.to_string_lossy(),
-            std::process::id()
-        ));
+        let tmp_path_gz = RemotePathBuf::new(
+            PathBuf::from(format!(
+                "{}-download-{}.gz",
+                dst_path.to_string(),
+                std::process::id()
+            )),
+            self.ssh_path_style,
+        );
         if !self.socket.connection_options.upload_binary_over_ssh {
             if let Some((url, body)) = delegate
-                .get_download_params(platform, release_channel, wanted_version, cx)
+                .get_download_params(self.ssh_platform, release_channel, wanted_version, cx)
                 .await?
             {
                 match self
@@ -1786,7 +1876,7 @@ impl SshRemoteConnection {
         }
 
         let src_path = delegate
-            .download_server_binary_locally(platform, release_channel, wanted_version, cx)
+            .download_server_binary_locally(self.ssh_platform, release_channel, wanted_version, cx)
             .await?;
         self.upload_local_server_binary(&src_path, &tmp_path_gz, delegate, cx)
             .await?;
@@ -1799,7 +1889,7 @@ impl SshRemoteConnection {
         &self,
         url: &str,
         body: &str,
-        tmp_path_gz: &Path,
+        tmp_path_gz: &RemotePathBuf,
         delegate: &Arc<dyn SshClientDelegate>,
         cx: &mut AsyncApp,
     ) -> Result<()> {
@@ -1809,10 +1899,7 @@ impl SshRemoteConnection {
                     "sh",
                     &[
                         "-c",
-                        &shell_script!(
-                            "mkdir -p {parent}",
-                            parent = parent.to_string_lossy().as_ref()
-                        ),
+                        &shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()),
                     ],
                 )
                 .await?;
@@ -1835,7 +1922,7 @@ impl SshRemoteConnection {
                     &body,
                     &url,
                     "-o",
-                    &tmp_path_gz.to_string_lossy(),
+                    &tmp_path_gz.to_string(),
                 ],
             )
             .await
@@ -1857,7 +1944,7 @@ impl SshRemoteConnection {
                             &body,
                             &url,
                             "-O",
-                            &tmp_path_gz.to_string_lossy(),
+                            &tmp_path_gz.to_string(),
                         ],
                     )
                     .await
@@ -1880,7 +1967,7 @@ impl SshRemoteConnection {
     async fn upload_local_server_binary(
         &self,
         src_path: &Path,
-        tmp_path_gz: &Path,
+        tmp_path_gz: &RemotePathBuf,
         delegate: &Arc<dyn SshClientDelegate>,
         cx: &mut AsyncApp,
     ) -> Result<()> {
@@ -1890,10 +1977,7 @@ impl SshRemoteConnection {
                     "sh",
                     &[
                         "-c",
-                        &shell_script!(
-                            "mkdir -p {parent}",
-                            parent = parent.to_string_lossy().as_ref()
-                        ),
+                        &shell_script!("mkdir -p {parent}", parent = parent.to_string().as_ref()),
                     ],
                 )
                 .await?;
@@ -1918,33 +2002,33 @@ impl SshRemoteConnection {
 
     async fn extract_server_binary(
         &self,
-        dst_path: &Path,
-        tmp_path: &Path,
+        dst_path: &RemotePathBuf,
+        tmp_path: &RemotePathBuf,
         delegate: &Arc<dyn SshClientDelegate>,
         cx: &mut AsyncApp,
     ) -> Result<()> {
         delegate.set_status(Some("Extracting remote development server"), cx);
         let server_mode = 0o755;
 
-        let orig_tmp_path = tmp_path.to_string_lossy();
+        let orig_tmp_path = tmp_path.to_string();
         let script = if let Some(tmp_path) = orig_tmp_path.strip_suffix(".gz") {
             shell_script!(
                 "gunzip -f {orig_tmp_path} && chmod {server_mode} {tmp_path} && mv {tmp_path} {dst_path}",
                 server_mode = &format!("{:o}", server_mode),
-                dst_path = &dst_path.to_string_lossy()
+                dst_path = &dst_path.to_string(),
             )
         } else {
             shell_script!(
                 "chmod {server_mode} {orig_tmp_path} && mv {orig_tmp_path} {dst_path}",
                 server_mode = &format!("{:o}", server_mode),
-                dst_path = &dst_path.to_string_lossy()
+                dst_path = &dst_path.to_string()
             )
         };
         self.socket.run_command("sh", &["-c", &script]).await?;
         Ok(())
     }
 
-    async fn upload_file(&self, src_path: &Path, dest_path: &Path) -> Result<()> {
+    async fn upload_file(&self, src_path: &Path, dest_path: &RemotePathBuf) -> Result<()> {
         log::debug!("uploading file {:?} to {:?}", src_path, dest_path);
         let mut command = util::command::new_smol_command("scp");
         let output = self
@@ -1961,7 +2045,7 @@ impl SshRemoteConnection {
             .arg(format!(
                 "{}:{}",
                 self.socket.connection_options.scp_url(),
-                dest_path.display()
+                dest_path.to_string()
             ))
             .output()
             .await?;
@@ -1970,7 +2054,7 @@ impl SshRemoteConnection {
             output.status.success(),
             "failed to upload file {} -> {}: {}",
             src_path.display(),
-            dest_path.display(),
+            dest_path.to_string(),
             String::from_utf8_lossy(&output.stderr)
         );
         Ok(())
@@ -1980,7 +2064,6 @@ impl SshRemoteConnection {
     async fn build_local(
         &self,
         build_remote_server: String,
-        platform: SshPlatform,
         delegate: &Arc<dyn SshClientDelegate>,
         cx: &mut AsyncApp,
     ) -> Result<PathBuf> {
@@ -1999,7 +2082,9 @@ impl SshRemoteConnection {
             Ok(())
         }
 
-        if platform.arch == std::env::consts::ARCH && platform.os == std::env::consts::OS {
+        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([
@@ -2025,12 +2110,15 @@ impl SshRemoteConnection {
             let path = std::env::current_dir()?.join("target/remote_server/debug/remote_server.gz");
             return Ok(path);
         }
-        let Some(triple) = platform.triple() else {
-            anyhow::bail!("can't cross compile for: {:?}", platform);
+        let Some(triple) = self.ssh_platform.triple() else {
+            anyhow::bail!("can't cross compile for: {:?}", self.ssh_platform);
         };
         smol::fs::create_dir_all("target/remote_server").await?;
 
         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([
@@ -2049,6 +2137,13 @@ impl SshRemoteConnection {
                 cx,
             );
             log::info!("building remote server binary from source for {}", &triple);
+
+            // On Windows, the binding needs to be set to the canonical path
+            #[cfg(target_os = "windows")]
+            let src =
+                SanitizedPath::from(smol::fs::canonicalize("./target").await?).to_glob_string();
+            #[cfg(not(target_os = "windows"))]
+            let src = "./target";
             run_cmd(
                 Command::new("cross")
                     .args([
@@ -2064,7 +2159,7 @@ impl SshRemoteConnection {
                     ])
                     .env(
                         "CROSS_CONTAINER_OPTS",
-                        "--mount type=bind,src=./target,dst=/app/target",
+                        format!("--mount type=bind,src={src},dst=/app/target"),
                     ),
             )
             .await?;
@@ -2074,9 +2169,18 @@ impl SshRemoteConnection {
                 .await;
 
             if which.is_err() {
-                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(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);
@@ -2112,12 +2216,31 @@ impl SshRemoteConnection {
         if !build_remote_server.contains("nocompress") {
             delegate.set_status(Some("Compressing binary"), cx);
 
-            run_cmd(Command::new("gzip").args([
-                "-9",
-                "-f",
-                &format!("target/remote_server/{}/debug/remote_server", triple),
-            ]))
-            .await?;
+            #[cfg(not(target_os = "windows"))]
+            {
+                run_cmd(Command::new("gzip").args([
+                    "-9",
+                    "-f",
+                    &format!("target/remote_server/{}/debug/remote_server", triple),
+                ]))
+                .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,
+                    &format!("target/remote_server/{}/debug/remote_server", triple),
+                ]))
+                .await?;
+            }
 
             path = std::env::current_dir()?.join(format!(
                 "target/remote_server/{triple}/debug/remote_server.gz"
@@ -2450,9 +2573,11 @@ mod fake {
     use gpui::{App, AppContext as _, AsyncApp, SemanticVersion, Task, TestAppContext};
     use release_channel::ReleaseChannel;
     use rpc::proto::Envelope;
+    use util::paths::{PathStyle, RemotePathBuf};
 
     use super::{
-        ChannelClient, RemoteConnection, SshClientDelegate, SshConnectionOptions, SshPlatform,
+        ChannelClient, RemoteConnection, SshArgs, SshClientDelegate, SshConnectionOptions,
+        SshPlatform,
     };
 
     pub(super) struct FakeRemoteConnection {
@@ -2488,13 +2613,17 @@ mod fake {
             false
         }
 
-        fn ssh_args(&self) -> Vec<String> {
-            Vec::new()
+        fn ssh_args(&self) -> SshArgs {
+            SshArgs {
+                arguments: Vec::new(),
+                envs: None,
+            }
         }
+
         fn upload_directory(
             &self,
             _src_path: PathBuf,
-            _dest_path: PathBuf,
+            _dest_path: RemotePathBuf,
             _cx: &App,
         ) -> Task<Result<()>> {
             unreachable!()
@@ -2513,7 +2642,6 @@ mod fake {
 
         fn start_proxy(
             &self,
-
             _unique_identifier: String,
             _reconnect: bool,
             mut client_incoming_tx: mpsc::UnboundedSender<Envelope>,
@@ -2551,6 +2679,10 @@ mod fake {
                 }
             })
         }
+
+        fn path_style(&self) -> PathStyle {
+            PathStyle::current()
+        }
     }
 
     pub(super) struct Delegate;

crates/util/src/paths.rs 🔗

@@ -166,6 +166,98 @@ impl<T: AsRef<Path>> From<T> for SanitizedPath {
     }
 }
 
+#[derive(Debug, Clone, Copy)]
+pub enum PathStyle {
+    Posix,
+    Windows,
+}
+
+impl PathStyle {
+    #[cfg(target_os = "windows")]
+    pub const fn current() -> Self {
+        PathStyle::Windows
+    }
+
+    #[cfg(not(target_os = "windows"))]
+    pub const fn current() -> Self {
+        PathStyle::Posix
+    }
+
+    #[inline]
+    pub fn separator(&self) -> &str {
+        match self {
+            PathStyle::Posix => "/",
+            PathStyle::Windows => "\\",
+        }
+    }
+}
+
+#[derive(Debug, Clone)]
+pub struct RemotePathBuf {
+    inner: PathBuf,
+    style: PathStyle,
+    string: String, // Cached string representation
+}
+
+impl RemotePathBuf {
+    pub fn new(path: PathBuf, style: PathStyle) -> Self {
+        #[cfg(target_os = "windows")]
+        let string = match style {
+            PathStyle::Posix => path.to_string_lossy().replace('\\', "/"),
+            PathStyle::Windows => path.to_string_lossy().into(),
+        };
+        #[cfg(not(target_os = "windows"))]
+        let string = match style {
+            PathStyle::Posix => path.to_string_lossy().to_string(),
+            PathStyle::Windows => path.to_string_lossy().replace('/', "\\"),
+        };
+        Self {
+            inner: path,
+            style,
+            string,
+        }
+    }
+
+    pub fn from_str(path: &str, style: PathStyle) -> Self {
+        let path_buf = PathBuf::from(path);
+        Self::new(path_buf, style)
+    }
+
+    pub fn to_string(&self) -> String {
+        self.string.clone()
+    }
+
+    #[cfg(target_os = "windows")]
+    pub fn to_proto(self) -> String {
+        match self.path_style() {
+            PathStyle::Posix => self.to_string(),
+            PathStyle::Windows => self.inner.to_string_lossy().replace('\\', "/"),
+        }
+    }
+
+    #[cfg(not(target_os = "windows"))]
+    pub fn to_proto(self) -> String {
+        match self.path_style() {
+            PathStyle::Posix => self.inner.to_string_lossy().to_string(),
+            PathStyle::Windows => self.to_string(),
+        }
+    }
+
+    pub fn as_path(&self) -> &Path {
+        &self.inner
+    }
+
+    pub fn path_style(&self) -> PathStyle {
+        self.style
+    }
+
+    pub fn parent(&self) -> Option<RemotePathBuf> {
+        self.inner
+            .parent()
+            .map(|p| RemotePathBuf::new(p.to_path_buf(), self.style))
+    }
+}
+
 /// A delimiter to use in `path_query:row_number:column_number` strings parsing.
 pub const FILE_ROW_COLUMN_DELIMITER: char = ':';
 

crates/zed/src/main.rs 🔗

@@ -167,16 +167,6 @@ pub fn main() {
     #[cfg(unix)]
     util::prevent_root_execution();
 
-    // Check if there is a pending installer
-    // If there is, run the installer and exit
-    // And we don't want to run the installer if we are not the first instance
-    #[cfg(target_os = "windows")]
-    let is_first_instance = crate::zed::windows_only_instance::is_first_instance();
-    #[cfg(target_os = "windows")]
-    if is_first_instance && auto_update::check_pending_installation() {
-        return;
-    }
-
     let args = Args::parse();
 
     // `zed --askpass` Makes zed operate in nc/netcat mode for use with askpass
@@ -191,6 +181,16 @@ pub fn main() {
         return;
     }
 
+    // Check if there is a pending installer
+    // If there is, run the installer and exit
+    // And we don't want to run the installer if we are not the first instance
+    #[cfg(target_os = "windows")]
+    let is_first_instance = crate::zed::windows_only_instance::is_first_instance();
+    #[cfg(target_os = "windows")]
+    if is_first_instance && auto_update::check_pending_installation() {
+        return;
+    }
+
     if args.dump_all_actions {
         dump_all_gpui_actions();
         return;