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
This commit is contained in:
localcc
2025-09-17 15:39:47 +00:00
committed by GitHub
parent f3b8c619e3
commit 4a7784cf67
7 changed files with 397 additions and 180 deletions
Generated
+1
View File
@@ -13770,6 +13770,7 @@ dependencies = [
"futures 0.3.31",
"fuzzy",
"gpui",
"indoc",
"language",
"log",
"markdown",
+1
View File
@@ -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"
@@ -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| {
+4 -180
View File
@@ -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>,
+295
View File
@@ -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())
}
}
+21
View File
@@ -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
+16
View File
@@ -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,
}
}