From 4a7784cf67fc677360bfa08e14841416a5131202 Mon Sep 17 00:00:00 2001 From: localcc Date: Wed, 17 Sep 2025 17:39:47 +0200 Subject: [PATCH] Allow opening a local folder inside WSL (#38335) 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 --- 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(-) create mode 100644 crates/recent_projects/src/wsl_picker.rs diff --git a/Cargo.lock b/Cargo.lock index cdf4109334af2e6f98a028970d124c7b9221371d..298ef0a4b260d57166f46674817f7717a25c185c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -13770,6 +13770,7 @@ dependencies = [ "futures 0.3.31", "fuzzy", "gpui", + "indoc", "language", "log", "markdown", diff --git a/crates/recent_projects/Cargo.toml b/crates/recent_projects/Cargo.toml index 91879c5d4175ad66428a255655f3c8bd4a5059e3..2ba6293ad2cf63c7ca664dba43f53d7facc70a57 100644 --- a/crates/recent_projects/Cargo.toml +++ b/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" diff --git a/crates/recent_projects/src/recent_projects.rs b/crates/recent_projects/src/recent_projects.rs index 2fc57a52fcb55f62b213cd7bb842009384b6ec91..35ef024743475fc2036600724224f9f3c06bca4a 100644 --- a/crates/recent_projects/src/recent_projects.rs +++ b/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::>(); + + 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| { diff --git a/crates/recent_projects/src/remote_servers.rs b/crates/recent_projects/src/remote_servers.rs index 1ef9ab671f35dc477d1113e1b924c1272e13de2f..f7b9001444dea0371009a2ca878d12ab808a8823 100644 --- a/crates/recent_projects/src/remote_servers.rs +++ b/crates/recent_projects/src/remote_servers.rs @@ -83,7 +83,7 @@ impl CreateRemoteServer { #[cfg(target_os = "windows")] struct AddWslDistro { - picker: Entity>, + picker: Entity>, connection_prompt: Option>, _creating: Option>, } @@ -91,6 +91,8 @@ struct AddWslDistro { #[cfg(target_os = "windows")] impl AddWslDistro { fn new(window: &mut Window, cx: &mut Context) -> 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>, - matches: Vec, -} - -#[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 { - self.matches - .get(self.selected_index) - .map(|m| m.string.clone()) - } -} - -#[cfg(target_os = "windows")] -impl WslPickerDelegate { - fn fetch_distros() -> anyhow::Result> { - 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::>(); - - Ok(distros) - } -} - -#[cfg(target_os = "windows")] -impl EventEmitter for Picker {} - -#[cfg(target_os = "windows")] -impl EventEmitter for Picker {} - -#[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>, - ) { - self.selected_index = ix; - cx.notify(); - } - - fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { - Arc::from("Enter WSL distro name") - } - - fn update_matches( - &mut self, - query: String, - _window: &mut Window, - cx: &mut Context>, - ) -> 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::>(); - - 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>) { - 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>) { - cx.emit(WslPickerDismissed); - } - - fn render_match( - &self, - ix: usize, - selected: bool, - _: &mut Window, - _: &mut Context>, - ) -> Option { - 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: Entity>, distro: String, window: &mut Window, cx: &mut Context, diff --git a/crates/recent_projects/src/wsl_picker.rs b/crates/recent_projects/src/wsl_picker.rs new file mode 100644 index 0000000000000000000000000000000000000000..e386b723fa43777e496565c11b8308f16031d837 --- /dev/null +++ b/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>, + matches: Vec, +} + +impl WslPickerDelegate { + pub fn new() -> Self { + WslPickerDelegate { + selected_index: 0, + distro_list: None, + matches: Vec::new(), + } + } + + pub fn selected_distro(&self) -> Option { + self.matches + .get(self.selected_index) + .map(|m| m.string.clone()) + } +} + +impl WslPickerDelegate { + fn fetch_distros() -> anyhow::Result> { + 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::>(); + + Ok(distros) + } +} + +impl EventEmitter for Picker {} + +impl EventEmitter for Picker {} + +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>, + ) { + self.selected_index = ix; + cx.notify(); + } + + fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc { + Arc::from("Enter WSL distro name") + } + + fn update_matches( + &mut self, + query: String, + _window: &mut Window, + cx: &mut Context>, + ) -> 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::>(); + + 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>) { + 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>) { + cx.emit(WslPickerDismissed); + } + + fn render_match( + &self, + ix: usize, + selected: bool, + _: &mut Window, + _: &mut Context>, + ) -> Option { + 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, + create_new_window: bool, + picker: Entity>, + _subscriptions: [Subscription; 2], +} + +impl WslOpenModal { + pub fn new( + paths: Vec, + create_new_window: bool, + window: &mut Window, + cx: &mut Context, + ) -> 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, + ) { + 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::(), + 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) { + 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 for WslOpenModal {} + +impl Render for WslOpenModal { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> 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()) + } +} diff --git a/crates/util/src/paths.rs b/crates/util/src/paths.rs index 1658052e6f4894b54c83fecf29e729959c9cfe6e..72753b026e2194e0b083acb1f9d6d69864286c6b 100644 --- a/crates/util/src/paths.rs +++ b/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; } impl> PathExt for T { @@ -118,6 +119,26 @@ impl> 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 { + 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 diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index fd979b3648b9a84aa89039386f8ac300e28d4771..2015f6db3b6c5754fe6b7e433866f05f43de440f 100644 --- a/crates/zed_actions/src/lib.rs +++ b/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, + } +}