Cargo.lock 🔗
@@ -13770,6 +13770,7 @@ dependencies = [
"futures 0.3.31",
"fuzzy",
"gpui",
+ "indoc",
"language",
"log",
"markdown",
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
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(-)
@@ -13770,6 +13770,7 @@ dependencies = [
"futures 0.3.31",
"fuzzy",
"gpui",
+ "indoc",
"language",
"log",
"markdown",
@@ -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| {
@@ -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>,
@@ -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())
+ }
+}
@@ -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
@@ -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,
+ }
+}