Allow opening a local folder inside WSL (#38335)

localcc created

This PR adds an option to allow opening local folders inside WSL
containers. (wsl_actions::OpenFolderInWsl). It is accessible via the
command palette and should be available to keybind.

- [x] Open wsl from open remote
- [x] Open local folder in wsl action
- [ ] Open wsl shortcut (shortcuts to open remote)

Release Notes:

- N/A

Change summary

Cargo.lock                                    |   1 
crates/recent_projects/Cargo.toml             |   1 
crates/recent_projects/src/recent_projects.rs |  59 ++++
crates/recent_projects/src/remote_servers.rs  | 184 ------------
crates/recent_projects/src/wsl_picker.rs      | 295 +++++++++++++++++++++
crates/util/src/paths.rs                      |  21 +
crates/zed_actions/src/lib.rs                 |  16 +
7 files changed, 397 insertions(+), 180 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -13770,6 +13770,7 @@ dependencies = [
  "futures 0.3.31",
  "fuzzy",
  "gpui",
+ "indoc",
  "language",
  "log",
  "markdown",

crates/recent_projects/Cargo.toml 🔗

@@ -43,6 +43,7 @@ util.workspace = true
 workspace.workspace = true
 zed_actions.workspace = true
 workspace-hack.workspace = true
+indoc.workspace = true
 
 [target.'cfg(target_os = "windows")'.dependencies]
 windows-registry = "0.6.0"

crates/recent_projects/src/recent_projects.rs 🔗

@@ -3,6 +3,9 @@ mod remote_connections;
 mod remote_servers;
 mod ssh_config;
 
+#[cfg(target_os = "windows")]
+mod wsl_picker;
+
 use remote::RemoteConnectionOptions;
 pub use remote_connections::open_remote_project;
 
@@ -31,6 +34,62 @@ use zed_actions::{OpenRecent, OpenRemote};
 
 pub fn init(cx: &mut App) {
     SshSettings::register(cx);
+
+    #[cfg(target_os = "windows")]
+    cx.on_action(|open_wsl: &zed_actions::wsl_actions::OpenFolderInWsl, cx| {
+        let create_new_window = open_wsl.create_new_window;
+        with_active_or_new_workspace(cx, move |workspace, window, cx| {
+            use gpui::PathPromptOptions;
+            use project::DirectoryLister;
+
+            let paths = workspace.prompt_for_open_path(
+                PathPromptOptions {
+                    files: true,
+                    directories: true,
+                    multiple: false,
+                    prompt: None,
+                },
+                DirectoryLister::Local(
+                    workspace.project().clone(),
+                    workspace.app_state().fs.clone(),
+                ),
+                window,
+                cx,
+            );
+
+            cx.spawn_in(window, async move |workspace, cx| {
+                use util::paths::SanitizedPath;
+
+                let Some(paths) = paths.await.log_err().flatten() else {
+                    return;
+                };
+
+                let paths = paths
+                    .into_iter()
+                    .filter_map(|path| SanitizedPath::new(&path).local_to_wsl())
+                    .collect::<Vec<_>>();
+
+                if paths.is_empty() {
+                    let message = indoc::indoc! { r#"
+                        Invalid path specified when trying to open a folder inside WSL.
+
+                        Please note that Zed currently does not support opening network share folders inside wsl.
+                    "#};
+
+                    let _ = cx.prompt(gpui::PromptLevel::Critical, "Invalid path", Some(&message), &["Ok"]).await;
+                    return;
+                }
+
+                workspace.update_in(cx, |workspace, window, cx| {
+                    workspace.toggle_modal(window, cx, |window, cx| {
+                        crate::wsl_picker::WslOpenModal::new(paths, create_new_window, window, cx)
+                    });
+                }).log_err();
+            })
+            .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| {

crates/recent_projects/src/remote_servers.rs 🔗

@@ -83,7 +83,7 @@ impl CreateRemoteServer {
 
 #[cfg(target_os = "windows")]
 struct AddWslDistro {
-    picker: Entity<Picker<WslPickerDelegate>>,
+    picker: Entity<Picker<crate::wsl_picker::WslPickerDelegate>>,
     connection_prompt: Option<Entity<RemoteConnectionPrompt>>,
     _creating: Option<Task<()>>,
 }
@@ -91,6 +91,8 @@ struct AddWslDistro {
 #[cfg(target_os = "windows")]
 impl AddWslDistro {
     fn new(window: &mut Window, cx: &mut Context<RemoteServerProjects>) -> Self {
+        use crate::wsl_picker::{WslDistroSelected, WslPickerDelegate, WslPickerDismissed};
+
         let delegate = WslPickerDelegate::new();
         let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false));
 
@@ -120,184 +122,6 @@ impl AddWslDistro {
     }
 }
 
-#[cfg(target_os = "windows")]
-#[derive(Clone, Debug)]
-pub struct WslDistroSelected(pub String);
-
-#[cfg(target_os = "windows")]
-#[derive(Clone, Debug)]
-pub struct WslPickerDismissed;
-
-#[cfg(target_os = "windows")]
-struct WslPickerDelegate {
-    selected_index: usize,
-    distro_list: Option<Vec<String>>,
-    matches: Vec<fuzzy::StringMatch>,
-}
-
-#[cfg(target_os = "windows")]
-impl WslPickerDelegate {
-    fn new() -> Self {
-        WslPickerDelegate {
-            selected_index: 0,
-            distro_list: None,
-            matches: Vec::new(),
-        }
-    }
-
-    pub fn selected_distro(&self) -> Option<String> {
-        self.matches
-            .get(self.selected_index)
-            .map(|m| m.string.clone())
-    }
-}
-
-#[cfg(target_os = "windows")]
-impl WslPickerDelegate {
-    fn fetch_distros() -> anyhow::Result<Vec<String>> {
-        use anyhow::Context;
-        use windows_registry::CURRENT_USER;
-
-        let lxss_key = CURRENT_USER
-            .open("Software\\Microsoft\\Windows\\CurrentVersion\\Lxss")
-            .context("failed to get lxss wsl key")?;
-
-        let distros = lxss_key
-            .keys()
-            .context("failed to get wsl distros")?
-            .filter_map(|key| {
-                lxss_key
-                    .open(&key)
-                    .context("failed to open subkey for distro")
-                    .log_err()
-            })
-            .filter_map(|distro| distro.get_string("DistributionName").ok())
-            .collect::<Vec<_>>();
-
-        Ok(distros)
-    }
-}
-
-#[cfg(target_os = "windows")]
-impl EventEmitter<WslDistroSelected> for Picker<WslPickerDelegate> {}
-
-#[cfg(target_os = "windows")]
-impl EventEmitter<WslPickerDismissed> for Picker<WslPickerDelegate> {}
-
-#[cfg(target_os = "windows")]
-impl picker::PickerDelegate for WslPickerDelegate {
-    type ListItem = ListItem;
-
-    fn match_count(&self) -> usize {
-        self.matches.len()
-    }
-
-    fn selected_index(&self) -> usize {
-        self.selected_index
-    }
-
-    fn set_selected_index(
-        &mut self,
-        ix: usize,
-        _window: &mut Window,
-        cx: &mut Context<Picker<Self>>,
-    ) {
-        self.selected_index = ix;
-        cx.notify();
-    }
-
-    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
-        Arc::from("Enter WSL distro name")
-    }
-
-    fn update_matches(
-        &mut self,
-        query: String,
-        _window: &mut Window,
-        cx: &mut Context<Picker<Self>>,
-    ) -> Task<()> {
-        use fuzzy::StringMatchCandidate;
-
-        let needs_fetch = self.distro_list.is_none();
-        if needs_fetch {
-            let distros = Self::fetch_distros().log_err();
-            self.distro_list = distros;
-        }
-
-        if let Some(distro_list) = &self.distro_list {
-            use ordered_float::OrderedFloat;
-
-            let candidates = distro_list
-                .iter()
-                .enumerate()
-                .map(|(id, distro)| StringMatchCandidate::new(id, distro))
-                .collect::<Vec<_>>();
-
-            let query = query.trim_start();
-            let smart_case = query.chars().any(|c| c.is_uppercase());
-            self.matches = smol::block_on(fuzzy::match_strings(
-                candidates.as_slice(),
-                query,
-                smart_case,
-                true,
-                100,
-                &Default::default(),
-                cx.background_executor().clone(),
-            ));
-            self.matches.sort_unstable_by_key(|m| m.candidate_id);
-
-            self.selected_index = self
-                .matches
-                .iter()
-                .enumerate()
-                .rev()
-                .max_by_key(|(_, m)| OrderedFloat(m.score))
-                .map(|(index, _)| index)
-                .unwrap_or(0);
-        }
-
-        Task::ready(())
-    }
-
-    fn confirm(&mut self, _secondary: bool, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
-        if let Some(distro) = self.matches.get(self.selected_index) {
-            cx.emit(WslDistroSelected(distro.string.clone()));
-        }
-    }
-
-    fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
-        cx.emit(WslPickerDismissed);
-    }
-
-    fn render_match(
-        &self,
-        ix: usize,
-        selected: bool,
-        _: &mut Window,
-        _: &mut Context<Picker<Self>>,
-    ) -> Option<Self::ListItem> {
-        use ui::HighlightedLabel;
-
-        let matched = self.matches.get(ix)?;
-        Some(
-            ListItem::new(ix)
-                .toggle_state(selected)
-                .inset(true)
-                .spacing(ui::ListItemSpacing::Sparse)
-                .child(
-                    h_flex()
-                        .flex_grow()
-                        .gap_3()
-                        .child(Icon::new(IconName::Linux))
-                        .child(v_flex().child(HighlightedLabel::new(
-                            matched.string.clone(),
-                            matched.positions.clone(),
-                        ))),
-                ),
-        )
-    }
-}
-
 enum ProjectPickerData {
     Ssh {
         connection_string: SharedString,
@@ -862,7 +686,7 @@ impl RemoteServerProjects {
     #[cfg(target_os = "windows")]
     fn connect_wsl_distro(
         &mut self,
-        picker: Entity<Picker<WslPickerDelegate>>,
+        picker: Entity<Picker<crate::wsl_picker::WslPickerDelegate>>,
         distro: String,
         window: &mut Window,
         cx: &mut Context<Self>,

crates/recent_projects/src/wsl_picker.rs 🔗

@@ -0,0 +1,295 @@
+use std::{path::PathBuf, sync::Arc};
+
+use gpui::{AppContext, DismissEvent, Entity, EventEmitter, Focusable, Subscription, Task};
+use picker::Picker;
+use remote::{RemoteConnectionOptions, WslConnectionOptions};
+use ui::{
+    App, Context, HighlightedLabel, Icon, IconName, InteractiveElement, ListItem, ParentElement,
+    Render, Styled, StyledExt, Toggleable, Window, div, h_flex, rems, v_flex,
+};
+use util::ResultExt as _;
+use workspace::{ModalView, Workspace};
+
+use crate::open_remote_project;
+
+#[derive(Clone, Debug)]
+pub struct WslDistroSelected {
+    pub secondary: bool,
+    pub distro: String,
+}
+
+#[derive(Clone, Debug)]
+pub struct WslPickerDismissed;
+
+pub(crate) struct WslPickerDelegate {
+    selected_index: usize,
+    distro_list: Option<Vec<String>>,
+    matches: Vec<fuzzy::StringMatch>,
+}
+
+impl WslPickerDelegate {
+    pub fn new() -> Self {
+        WslPickerDelegate {
+            selected_index: 0,
+            distro_list: None,
+            matches: Vec::new(),
+        }
+    }
+
+    pub fn selected_distro(&self) -> Option<String> {
+        self.matches
+            .get(self.selected_index)
+            .map(|m| m.string.clone())
+    }
+}
+
+impl WslPickerDelegate {
+    fn fetch_distros() -> anyhow::Result<Vec<String>> {
+        use anyhow::Context;
+        use windows_registry::CURRENT_USER;
+
+        let lxss_key = CURRENT_USER
+            .open("Software\\Microsoft\\Windows\\CurrentVersion\\Lxss")
+            .context("failed to get lxss wsl key")?;
+
+        let distros = lxss_key
+            .keys()
+            .context("failed to get wsl distros")?
+            .filter_map(|key| {
+                lxss_key
+                    .open(&key)
+                    .context("failed to open subkey for distro")
+                    .log_err()
+            })
+            .filter_map(|distro| distro.get_string("DistributionName").ok())
+            .collect::<Vec<_>>();
+
+        Ok(distros)
+    }
+}
+
+impl EventEmitter<WslDistroSelected> for Picker<WslPickerDelegate> {}
+
+impl EventEmitter<WslPickerDismissed> for Picker<WslPickerDelegate> {}
+
+impl picker::PickerDelegate for WslPickerDelegate {
+    type ListItem = ListItem;
+
+    fn match_count(&self) -> usize {
+        self.matches.len()
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_index
+    }
+
+    fn set_selected_index(
+        &mut self,
+        ix: usize,
+        _window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) {
+        self.selected_index = ix;
+        cx.notify();
+    }
+
+    fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
+        Arc::from("Enter WSL distro name")
+    }
+
+    fn update_matches(
+        &mut self,
+        query: String,
+        _window: &mut Window,
+        cx: &mut Context<Picker<Self>>,
+    ) -> Task<()> {
+        use fuzzy::StringMatchCandidate;
+
+        let needs_fetch = self.distro_list.is_none();
+        if needs_fetch {
+            let distros = Self::fetch_distros().log_err();
+            self.distro_list = distros;
+        }
+
+        if let Some(distro_list) = &self.distro_list {
+            use ordered_float::OrderedFloat;
+
+            let candidates = distro_list
+                .iter()
+                .enumerate()
+                .map(|(id, distro)| StringMatchCandidate::new(id, distro))
+                .collect::<Vec<_>>();
+
+            let query = query.trim_start();
+            let smart_case = query.chars().any(|c| c.is_uppercase());
+            self.matches = smol::block_on(fuzzy::match_strings(
+                candidates.as_slice(),
+                query,
+                smart_case,
+                true,
+                100,
+                &Default::default(),
+                cx.background_executor().clone(),
+            ));
+            self.matches.sort_unstable_by_key(|m| m.candidate_id);
+
+            self.selected_index = self
+                .matches
+                .iter()
+                .enumerate()
+                .rev()
+                .max_by_key(|(_, m)| OrderedFloat(m.score))
+                .map(|(index, _)| index)
+                .unwrap_or(0);
+        }
+
+        Task::ready(())
+    }
+
+    fn confirm(&mut self, secondary: bool, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
+        if let Some(distro) = self.matches.get(self.selected_index) {
+            cx.emit(WslDistroSelected {
+                secondary,
+                distro: distro.string.clone(),
+            });
+        }
+    }
+
+    fn dismissed(&mut self, _window: &mut Window, cx: &mut Context<Picker<Self>>) {
+        cx.emit(WslPickerDismissed);
+    }
+
+    fn render_match(
+        &self,
+        ix: usize,
+        selected: bool,
+        _: &mut Window,
+        _: &mut Context<Picker<Self>>,
+    ) -> Option<Self::ListItem> {
+        let matched = self.matches.get(ix)?;
+        Some(
+            ListItem::new(ix)
+                .toggle_state(selected)
+                .inset(true)
+                .spacing(ui::ListItemSpacing::Sparse)
+                .child(
+                    h_flex()
+                        .flex_grow()
+                        .gap_3()
+                        .child(Icon::new(IconName::Linux))
+                        .child(v_flex().child(HighlightedLabel::new(
+                            matched.string.clone(),
+                            matched.positions.clone(),
+                        ))),
+                ),
+        )
+    }
+}
+
+pub(crate) struct WslOpenModal {
+    paths: Vec<PathBuf>,
+    create_new_window: bool,
+    picker: Entity<Picker<WslPickerDelegate>>,
+    _subscriptions: [Subscription; 2],
+}
+
+impl WslOpenModal {
+    pub fn new(
+        paths: Vec<PathBuf>,
+        create_new_window: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let delegate = WslPickerDelegate::new();
+        let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx).modal(false));
+
+        let selected = cx.subscribe_in(
+            &picker,
+            window,
+            |this, _, event: &WslDistroSelected, window, cx| {
+                this.confirm(&event.distro, event.secondary, window, cx);
+            },
+        );
+
+        let dismissed = cx.subscribe_in(
+            &picker,
+            window,
+            |this, _, _: &WslPickerDismissed, window, cx| {
+                this.cancel(&menu::Cancel, window, cx);
+            },
+        );
+
+        WslOpenModal {
+            paths,
+            create_new_window,
+            picker,
+            _subscriptions: [selected, dismissed],
+        }
+    }
+
+    fn confirm(
+        &mut self,
+        distro: &str,
+        secondary: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        let app_state = workspace::AppState::global(cx);
+        let Some(app_state) = app_state.upgrade() else {
+            return;
+        };
+
+        let connection_options = RemoteConnectionOptions::Wsl(WslConnectionOptions {
+            distro_name: distro.to_string(),
+            user: None,
+        });
+
+        let replace_current_window = match self.create_new_window {
+            true => secondary,
+            false => !secondary,
+        };
+        let replace_window = match replace_current_window {
+            true => window.window_handle().downcast::<Workspace>(),
+            false => None,
+        };
+
+        let paths = self.paths.clone();
+        let open_options = workspace::OpenOptions {
+            replace_window,
+            ..Default::default()
+        };
+
+        cx.emit(DismissEvent);
+        cx.spawn_in(window, async move |_, cx| {
+            open_remote_project(connection_options, paths, app_state, open_options, cx).await
+        })
+        .detach();
+    }
+
+    fn cancel(&mut self, _: &menu::Cancel, _: &mut Window, cx: &mut Context<Self>) {
+        cx.emit(DismissEvent);
+    }
+}
+
+impl ModalView for WslOpenModal {}
+
+impl Focusable for WslOpenModal {
+    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
+        self.picker.focus_handle(cx)
+    }
+}
+
+impl EventEmitter<DismissEvent> for WslOpenModal {}
+
+impl Render for WslOpenModal {
+    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl ui::IntoElement {
+        div()
+            .on_mouse_down_out(cx.listener(|_, _, _, cx| cx.emit(DismissEvent)))
+            .on_action(cx.listener(Self::cancel))
+            .elevation_3(cx)
+            .w(rems(34.))
+            .flex_1()
+            .overflow_hidden()
+            .child(self.picker.clone())
+    }
+}

crates/util/src/paths.rs 🔗

@@ -65,6 +65,7 @@ pub trait PathExt {
                 .with_context(|| format!("Invalid WTF-8 sequence: {bytes:?}"))
         }
     }
+    fn local_to_wsl(&self) -> Option<PathBuf>;
 }
 
 impl<T: AsRef<Path>> PathExt for T {
@@ -118,6 +119,26 @@ impl<T: AsRef<Path>> PathExt for T {
             self.as_ref().to_string_lossy().to_string()
         }
     }
+
+    /// Converts a local path to one that can be used inside of WSL.
+    /// Returns `None` if the path cannot be converted into a WSL one (network share).
+    fn local_to_wsl(&self) -> Option<PathBuf> {
+        let mut new_path = PathBuf::new();
+        for component in self.as_ref().components() {
+            match component {
+                std::path::Component::Prefix(prefix) => {
+                    let drive_letter = prefix.as_os_str().to_string_lossy().to_lowercase();
+                    let drive_letter = drive_letter.strip_suffix(':')?;
+
+                    new_path.push(format!("/mnt/{}", drive_letter));
+                }
+                std::path::Component::RootDir => {}
+                _ => new_path.push(component),
+            }
+        }
+
+        Some(new_path)
+    }
 }
 
 /// In memory, this is identical to `Path`. On non-Windows conversions to this type are no-ops. On

crates/zed_actions/src/lib.rs 🔗

@@ -497,3 +497,19 @@ actions!(
         OpenProjectDebugTasks,
     ]
 );
+
+#[cfg(target_os = "windows")]
+pub mod wsl_actions {
+    use gpui::Action;
+    use schemars::JsonSchema;
+    use serde::Deserialize;
+
+    /// Opens a folder inside Wsl.
+    #[derive(PartialEq, Clone, Deserialize, Default, JsonSchema, Action)]
+    #[action(namespace = projects)]
+    #[serde(deny_unknown_fields)]
+    pub struct OpenFolderInWsl {
+        #[serde(default)]
+        pub create_new_window: bool,
+    }
+}