Notify on opening WSL paths outside of wsl (#40195)

Julia Ryan and John Tur created

Closes #27340

Release Notes:

- N/A

---------

Co-authored-by: John Tur <john-tur@outlook.com>

Change summary

Cargo.lock                                    |   1 
crates/recent_projects/src/recent_projects.rs |  59 ++++++++++++
crates/recent_projects/src/remote_servers.rs  |  75 +++++---------
crates/remote/Cargo.toml                      |   1 
crates/remote/src/remote.rs                   |   2 
crates/remote/src/remote_client.rs            |   9 +
crates/remote/src/transport/wsl.rs            |   2 
crates/util/src/paths.rs                      | 103 +++++++++++++++++++++
crates/workspace/src/persistence.rs           |  26 ----
crates/workspace/src/workspace.rs             |  38 +++++++
crates/zed_actions/src/lib.rs                 |   6 +
11 files changed, 250 insertions(+), 72 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -13833,6 +13833,7 @@ dependencies = [
  "prost 0.9.0",
  "release_channel",
  "rpc",
+ "schemars 1.0.4",
  "serde",
  "serde_json",
  "settings",

crates/recent_projects/src/recent_projects.rs 🔗

@@ -102,6 +102,33 @@ pub fn init(cx: &mut App) {
         });
     });
 
+    #[cfg(target_os = "windows")]
+    cx.on_action(|open_wsl: &remote::OpenWslPath, cx| {
+        let open_wsl = open_wsl.clone();
+        with_active_or_new_workspace(cx, move |workspace, window, cx| {
+            let fs = workspace.project().read(cx).fs().clone();
+            add_wsl_distro(fs, &open_wsl.distro, cx);
+            let open_options = OpenOptions {
+                replace_window: window.window_handle().downcast::<Workspace>(),
+                ..Default::default()
+            };
+
+            let app_state = workspace.app_state().clone();
+
+            cx.spawn_in(window, async move |_, cx| {
+                open_remote_project(
+                    RemoteConnectionOptions::Wsl(open_wsl.distro.clone()),
+                    open_wsl.paths,
+                    app_state,
+                    open_options,
+                    cx,
+                )
+                .await
+            })
+            .detach();
+        });
+    });
+
     cx.on_action(|open_recent: &OpenRecent, cx| {
         let create_new_window = open_recent.create_new_window;
         with_active_or_new_workspace(cx, move |workspace, window, cx| {
@@ -136,6 +163,38 @@ pub fn init(cx: &mut App) {
     cx.observe_new(DisconnectedOverlay::register).detach();
 }
 
+#[cfg(target_os = "windows")]
+pub fn add_wsl_distro(
+    fs: Arc<dyn project::Fs>,
+    connection_options: &remote::WslConnectionOptions,
+    cx: &App,
+) {
+    use gpui::ReadGlobal;
+    use settings::SettingsStore;
+
+    let distro_name = SharedString::from(&connection_options.distro_name);
+    let user = connection_options.user.clone();
+    SettingsStore::global(cx).update_settings_file(fs, move |setting, _| {
+        let connections = setting
+            .remote
+            .wsl_connections
+            .get_or_insert(Default::default());
+
+        if !connections
+            .iter()
+            .any(|conn| conn.distro_name == distro_name && conn.user == user)
+        {
+            use std::collections::BTreeSet;
+
+            connections.push(settings::WslConnection {
+                distro_name,
+                user,
+                projects: BTreeSet::new(),
+            })
+        }
+    });
+}
+
 pub struct RecentProjects {
     pub picker: Entity<Picker<RecentProjectsDelegate>>,
     rem_width: f32,

crates/recent_projects/src/remote_servers.rs 🔗

@@ -794,28 +794,34 @@ impl RemoteServerProjects {
         let wsl_picker = picker.clone();
         let creating = cx.spawn_in(window, async move |this, cx| {
             match connection.await {
-                Some(Some(client)) => this
-                    .update_in(cx, |this, window, cx| {
-                        telemetry::event!("WSL Distro Added");
-                        this.retained_connections.push(client);
-                        this.add_wsl_distro(connection_options, cx);
-                        this.mode = Mode::default_mode(&BTreeSet::new(), cx);
-                        this.focus_handle(cx).focus(window);
-                        cx.notify()
-                    })
-                    .log_err(),
-                _ => this
-                    .update(cx, |this, cx| {
-                        this.mode = Mode::AddWslDistro(AddWslDistro {
-                            picker: wsl_picker,
-                            connection_prompt: None,
-                            _creating: None,
-                        });
-                        cx.notify()
-                    })
-                    .log_err(),
-            };
-            ()
+                Some(Some(client)) => this.update_in(cx, |this, window, cx| {
+                    telemetry::event!("WSL Distro Added");
+                    this.retained_connections.push(client);
+                    let Some(fs) = this
+                        .workspace
+                        .read_with(cx, |workspace, cx| {
+                            workspace.project().read(cx).fs().clone()
+                        })
+                        .log_err()
+                    else {
+                        return;
+                    };
+
+                    crate::add_wsl_distro(fs, &connection_options, cx);
+                    this.mode = Mode::default_mode(&BTreeSet::new(), cx);
+                    this.focus_handle(cx).focus(window);
+                    cx.notify();
+                }),
+                _ => this.update(cx, |this, cx| {
+                    this.mode = Mode::AddWslDistro(AddWslDistro {
+                        picker: wsl_picker,
+                        connection_prompt: None,
+                        _creating: None,
+                    });
+                    cx.notify();
+                }),
+            }
+            .log_err();
         });
 
         self.mode = Mode::AddWslDistro(AddWslDistro {
@@ -1415,31 +1421,6 @@ impl RemoteServerProjects {
         });
     }
 
-    #[cfg(target_os = "windows")]
-    fn add_wsl_distro(
-        &mut self,
-        connection_options: remote::WslConnectionOptions,
-        cx: &mut Context<Self>,
-    ) {
-        self.update_settings_file(cx, move |setting, _| {
-            let connections = setting.wsl_connections.get_or_insert(Default::default());
-
-            let distro_name = SharedString::from(connection_options.distro_name);
-            let user = connection_options.user;
-
-            if !connections
-                .iter()
-                .any(|conn| conn.distro_name == distro_name && conn.user == user)
-            {
-                connections.push(settings::WslConnection {
-                    distro_name,
-                    user,
-                    projects: BTreeSet::new(),
-                })
-            }
-        });
-    }
-
     fn delete_wsl_distro(&mut self, server: WslServerIndex, cx: &mut Context<Self>) {
         self.update_settings_file(cx, move |setting, _| {
             if let Some(connections) = setting.wsl_connections.as_mut() {

crates/remote/Cargo.toml 🔗

@@ -31,6 +31,7 @@ paths.workspace = true
 prost.workspace = true
 release_channel.workspace = true
 rpc = { workspace = true, features = ["gpui"] }
+schemars.workspace = true
 serde.workspace = true
 serde_json.workspace = true
 settings.workspace = true

crates/remote/src/remote.rs 🔗

@@ -4,6 +4,8 @@ pub mod proxy;
 pub mod remote_client;
 mod transport;
 
+#[cfg(target_os = "windows")]
+pub use remote_client::OpenWslPath;
 pub use remote_client::{
     ConnectionIdentifier, ConnectionState, RemoteClient, RemoteClientDelegate, RemoteClientEvent,
     RemoteConnection, RemoteConnectionOptions, RemotePlatform, connect,

crates/remote/src/remote_client.rs 🔗

@@ -1090,6 +1090,15 @@ impl From<WslConnectionOptions> for RemoteConnectionOptions {
     }
 }
 
+#[cfg(target_os = "windows")]
+/// Open a wsl path (\\wsl.localhost\<distro>\path)
+#[derive(Debug, Clone, PartialEq, Eq, gpui::Action)]
+#[action(namespace = workspace, no_json, no_register)]
+pub struct OpenWslPath {
+    pub distro: WslConnectionOptions,
+    pub paths: Vec<PathBuf>,
+}
+
 #[async_trait(?Send)]
 pub trait RemoteConnection: Send + Sync {
     fn start_proxy(

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

@@ -24,7 +24,7 @@ use util::{
     shell::ShellKind,
 };
 
-#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Deserialize, schemars::JsonSchema)]
 pub struct WslConnectionOptions {
     pub distro_name: String,
     pub user: Option<String>,

crates/util/src/paths.rs 🔗

@@ -1087,6 +1087,68 @@ pub fn compare_paths(
     }
 }
 
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct WslPath {
+    pub distro: String,
+
+    // the reason this is an OsString and not any of the path types is that it needs to
+    // represent a unix path (with '/' separators) on windows. `from_path` does this by
+    // manually constructing it from the path components of a given windows path.
+    pub path: std::ffi::OsString,
+}
+
+impl WslPath {
+    pub fn from_path<P: AsRef<Path>>(path: P) -> Option<WslPath> {
+        if cfg!(not(target_os = "windows")) {
+            return None;
+        }
+        use std::{
+            ffi::OsString,
+            path::{Component, Prefix},
+        };
+
+        let mut components = path.as_ref().components();
+        let Some(Component::Prefix(prefix)) = components.next() else {
+            return None;
+        };
+        let (server, distro) = match prefix.kind() {
+            Prefix::UNC(server, distro) => (server, distro),
+            Prefix::VerbatimUNC(server, distro) => (server, distro),
+            _ => return None,
+        };
+        let Some(Component::RootDir) = components.next() else {
+            return None;
+        };
+
+        let server_str = server.to_string_lossy();
+        if server_str == "wsl.localhost" || server_str == "wsl$" {
+            let mut result = OsString::from("");
+            for c in components {
+                use Component::*;
+                match c {
+                    Prefix(p) => unreachable!("got {p:?}, but already stripped prefix"),
+                    RootDir => unreachable!("got root dir, but already stripped root"),
+                    CurDir => continue,
+                    ParentDir => result.push("/.."),
+                    Normal(s) => {
+                        result.push("/");
+                        result.push(s);
+                    }
+                }
+            }
+            if result.is_empty() {
+                result.push("/");
+            }
+            Some(WslPath {
+                distro: distro.to_string_lossy().to_string(),
+                path: result,
+            })
+        } else {
+            None
+        }
+    }
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -1921,4 +1983,45 @@ mod tests {
         let suffix = Path::new("app.tar.gz");
         assert_eq!(strip_path_suffix(base, suffix), None);
     }
+
+    #[cfg(target_os = "windows")]
+    #[test]
+    fn test_wsl_path() {
+        use super::WslPath;
+        let path = "/a/b/c";
+        assert_eq!(WslPath::from_path(&path), None);
+
+        let path = r"\\wsl.localhost";
+        assert_eq!(WslPath::from_path(&path), None);
+
+        let path = r"\\wsl.localhost\Distro";
+        assert_eq!(
+            WslPath::from_path(&path),
+            Some(WslPath {
+                distro: "Distro".to_owned(),
+                path: "/".into(),
+            })
+        );
+
+        let path = r"\\wsl.localhost\Distro\blue";
+        assert_eq!(
+            WslPath::from_path(&path),
+            Some(WslPath {
+                distro: "Distro".to_owned(),
+                path: "/blue".into()
+            })
+        );
+
+        let path = r"\\wsl$\archlinux\tomato\.\paprika\..\aubergine.txt";
+        assert_eq!(
+            WslPath::from_path(&path),
+            Some(WslPath {
+                distro: "archlinux".to_owned(),
+                path: "/tomato/paprika/../aubergine.txt".into()
+            })
+        );
+
+        let path = r"\\windows.localhost\Distro\foo";
+        assert_eq!(WslPath::from_path(&path), None);
+    }
 }

crates/workspace/src/persistence.rs 🔗

@@ -1348,28 +1348,10 @@ impl WorkspaceDb {
             }
 
             let has_wsl_path = if cfg!(windows) {
-                fn is_wsl_path(path: &PathBuf) -> bool {
-                    use std::path::{Component, Prefix};
-
-                    path.components()
-                        .next()
-                        .and_then(|component| match component {
-                            Component::Prefix(prefix) => Some(prefix),
-                            _ => None,
-                        })
-                        .and_then(|prefix| match prefix.kind() {
-                            Prefix::UNC(server, _) => Some(server),
-                            Prefix::VerbatimUNC(server, _) => Some(server),
-                            _ => None,
-                        })
-                        .map(|server| {
-                            let server_str = server.to_string_lossy();
-                            server_str == "wsl.localhost" || server_str == "wsl$"
-                        })
-                        .unwrap_or(false)
-                }
-
-                paths.paths().iter().any(|path| is_wsl_path(path))
+                paths
+                    .paths()
+                    .iter()
+                    .any(|path| util::paths::WslPath::from_path(path).is_some())
             } else {
                 false
             };

crates/workspace/src/workspace.rs 🔗

@@ -7318,6 +7318,10 @@ pub fn open_paths(
     let mut existing = None;
     let mut best_match = None;
     let mut open_visible = OpenVisible::All;
+    #[cfg(target_os = "windows")]
+    let wsl_path = abs_paths
+        .iter()
+        .find_map(|p| util::paths::WslPath::from_path(p));
 
     cx.spawn(async move |cx| {
         if open_options.open_new_workspace != Some(true) {
@@ -7381,7 +7385,7 @@ pub fn open_paths(
             }
         }
 
-        if let Some(existing) = existing {
+        let result = if let Some(existing) = existing {
             let open_task = existing
                 .update(cx, |workspace, window, cx| {
                     window.activate_window();
@@ -7418,7 +7422,37 @@ pub fn open_paths(
                 )
             })?
             .await
-        }
+        };
+
+        #[cfg(target_os = "windows")]
+        if let Some(util::paths::WslPath{distro, path}) = wsl_path
+            && let Ok((workspace, _)) = &result
+        {
+            workspace
+                .update(cx, move |workspace, _window, cx| {
+                    struct OpenInWsl;
+                    workspace.show_notification(NotificationId::unique::<OpenInWsl>(), cx, move |cx| {
+                        let display_path = util::markdown::MarkdownInlineCode(&path.to_string_lossy());
+                        let msg = format!("{display_path} is inside a WSL filesystem, some features may not work unless you open it with WSL remote");
+                        cx.new(move |cx| {
+                            MessageNotification::new(msg, cx)
+                                .primary_message("Open in WSL")
+                                .primary_icon(IconName::FolderOpen)
+                                .primary_on_click(move |window, cx| {
+                                    window.dispatch_action(Box::new(remote::OpenWslPath {
+                                            distro: remote::WslConnectionOptions {
+                                                    distro_name: distro.clone(),
+                                                user: None,
+                                            },
+                                            paths: vec![path.clone().into()],
+                                        }), cx)
+                                })
+                        })
+                    });
+                })
+                .unwrap();
+        };
+        result
     })
 }
 

crates/zed_actions/src/lib.rs 🔗

@@ -519,6 +519,12 @@ actions!(
     ]
 );
 
+#[derive(Debug, Clone, PartialEq, Eq, Hash)]
+pub struct WslConnectionOptions {
+    pub distro_name: String,
+    pub user: Option<String>,
+}
+
 #[cfg(target_os = "windows")]
 pub mod wsl_actions {
     use gpui::Action;