WSL browsing support
This commit is contained in:
@@ -11,6 +11,7 @@ use std::path::PathBuf;
|
||||
use iced::widget::image::Handle;
|
||||
use librarian_win::{
|
||||
IconImage, ShellWorker, computer_icon, folder_icon, icon_for_extension, icon_for_path,
|
||||
wsl_icon,
|
||||
};
|
||||
|
||||
/// What an icon represents. `Path` is used for things with a per-item icon
|
||||
@@ -20,6 +21,9 @@ pub enum IconKey {
|
||||
Folder,
|
||||
/// The shell's "This PC" / Computer icon (the drives container node).
|
||||
Computer,
|
||||
/// The shell's "Linux" / WSL icon (the penguin), shared by the WSL group node
|
||||
/// and every distro — one local extraction, cached and reused.
|
||||
Wsl,
|
||||
Ext(String),
|
||||
Path(PathBuf),
|
||||
}
|
||||
@@ -62,6 +66,7 @@ pub fn extract_icons(worker: &ShellWorker, keys: Vec<IconKey>) -> Vec<(IconKey,
|
||||
let image = match &key {
|
||||
IconKey::Folder => worker.run(|apt| folder_icon(apt, false)),
|
||||
IconKey::Computer => worker.run(|apt| computer_icon(apt, false)),
|
||||
IconKey::Wsl => worker.run(|apt| wsl_icon(apt, false)),
|
||||
IconKey::Ext(ext) => {
|
||||
let ext = ext.clone();
|
||||
worker.run(move |apt| icon_for_extension(apt, &ext, false))
|
||||
|
||||
@@ -31,8 +31,9 @@ use librarian_core::{
|
||||
sort_entries,
|
||||
};
|
||||
use librarian_win::{
|
||||
Apartment, DriveInfo, IconImage, KnownFolder, ShellWorker, copy_items, create_folder,
|
||||
delete_to_recycle, known_folders, list_drives, move_items, rename, user_home,
|
||||
Apartment, DriveInfo, IconImage, KnownFolder, ShellWorker, WslDistro, copy_items,
|
||||
create_folder, delete_to_recycle, known_folders, list_drives, list_wsl_distros, move_items,
|
||||
rename, user_home,
|
||||
};
|
||||
|
||||
use columns::{ColRule, Column, ColumnLayout};
|
||||
@@ -289,6 +290,12 @@ enum Content {
|
||||
// separately.
|
||||
drives: Vec<DriveInfo>,
|
||||
},
|
||||
/// The "Linux" landing list: installed WSL distributions. Like `ThisPc`, a
|
||||
/// pathless virtual root whose rows navigate into real `\\wsl.localhost\`
|
||||
/// (UNC) paths.
|
||||
Wsl {
|
||||
distros: Vec<WslDistro>,
|
||||
},
|
||||
Folder {
|
||||
entries: Vec<Entry>,
|
||||
loading: bool,
|
||||
@@ -568,6 +575,7 @@ enum Message {
|
||||
SelectAll,
|
||||
Activate,
|
||||
ThisPcLoaded(Vec<DriveInfo>),
|
||||
WslLoaded(Vec<WslDistro>),
|
||||
Loaded(u64, Result<Vec<Entry>, String>),
|
||||
/// The current directory changed on disk; re-enumerate it in place.
|
||||
DirChanged,
|
||||
@@ -805,7 +813,7 @@ impl Librarian {
|
||||
fn column_layout_for(&self, location: &Location) -> ColumnLayout {
|
||||
match location {
|
||||
Location::Path(path) => self.col_store.get(path).copied().unwrap_or_default(),
|
||||
Location::ThisPc => ColumnLayout::default(),
|
||||
Location::ThisPc | Location::Wsl => ColumnLayout::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -878,7 +886,7 @@ impl Librarian {
|
||||
self.pending_reveal = Some(path);
|
||||
self.drive_reveal()
|
||||
}
|
||||
Location::ThisPc => {
|
||||
Location::ThisPc | Location::Wsl => {
|
||||
self.pending_reveal = None;
|
||||
Task::none()
|
||||
}
|
||||
@@ -977,7 +985,7 @@ impl Librarian {
|
||||
// the old watcher and starts one on the new directory.
|
||||
let watch = match self.history.current() {
|
||||
Location::Path(path) => Subscription::run_with(path.clone(), watch_stream),
|
||||
Location::ThisPc => Subscription::none(),
|
||||
Location::ThisPc | Location::Wsl => Subscription::none(),
|
||||
};
|
||||
|
||||
// Animate the loading spinner only while the overlay is up, so an idle
|
||||
@@ -1132,6 +1140,12 @@ impl Librarian {
|
||||
self.status = format!("{} items", self.rows.len());
|
||||
return Task::batch([self.request_icons(), self.begin_grid_session(true)]);
|
||||
}
|
||||
Message::WslLoaded(distros) => {
|
||||
self.content = Content::Wsl { distros };
|
||||
self.recompute_rows();
|
||||
self.status = format!("{} items", self.rows.len());
|
||||
return Task::batch([self.request_icons(), self.begin_grid_session(true)]);
|
||||
}
|
||||
Message::Loaded(token, result) => {
|
||||
if token != self.load_token {
|
||||
return Task::none(); // a newer navigation won
|
||||
@@ -1644,7 +1658,7 @@ impl Librarian {
|
||||
self.pending_reveal = Some(path.clone());
|
||||
self.drive_reveal()
|
||||
}
|
||||
Location::ThisPc => {
|
||||
Location::ThisPc | Location::Wsl => {
|
||||
self.pending_reveal = None;
|
||||
Task::none()
|
||||
}
|
||||
@@ -1656,6 +1670,13 @@ impl Librarian {
|
||||
self.rows.clear();
|
||||
Task::perform(offload(list_drives), Message::ThisPcLoaded)
|
||||
}
|
||||
Location::Wsl => {
|
||||
self.content = Content::Wsl {
|
||||
distros: Vec::new(),
|
||||
};
|
||||
self.rows.clear();
|
||||
Task::perform(offload(list_wsl_distros), Message::WslLoaded)
|
||||
}
|
||||
Location::Path(path) => {
|
||||
self.next_token += 1;
|
||||
let token = self.next_token;
|
||||
@@ -1730,7 +1751,7 @@ impl Librarian {
|
||||
fn current_dir(&self) -> Option<PathBuf> {
|
||||
match self.history.current() {
|
||||
Location::Path(path) => Some(path.clone()),
|
||||
Location::ThisPc => None,
|
||||
Location::ThisPc | Location::Wsl => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1745,7 +1766,7 @@ impl Librarian {
|
||||
.filter_map(|i| self.rows.get(i))
|
||||
.filter_map(|row| match &row.target {
|
||||
Location::Path(path) => Some(path.clone()),
|
||||
Location::ThisPc => None,
|
||||
Location::ThisPc | Location::Wsl => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
@@ -1756,7 +1777,7 @@ impl Librarian {
|
||||
self.current_dir()?;
|
||||
match &self.rows.get(self.selection.lead()?)?.target {
|
||||
Location::Path(path) => Some(path.clone()),
|
||||
Location::ThisPc => None,
|
||||
Location::ThisPc | Location::Wsl => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1939,6 +1960,7 @@ impl Librarian {
|
||||
fn recompute_rows(&mut self) {
|
||||
self.rows = match &self.content {
|
||||
Content::ThisPc { drives } => drives.iter().map(rows::row_from_drive).collect(),
|
||||
Content::Wsl { distros } => distros.iter().map(rows::row_from_distro).collect(),
|
||||
Content::Folder { entries, .. } => {
|
||||
let mut visible: Vec<Entry> = entries
|
||||
.iter()
|
||||
@@ -2251,7 +2273,7 @@ impl Librarian {
|
||||
// Separate the user's folders (above) from the "This PC" drives
|
||||
// section (below) with a divider. Skip it when "This PC" is the very
|
||||
// first row, so we never lead with a stray rule.
|
||||
if i > 0 && matches!(row.location, Location::ThisPc) {
|
||||
if i > 0 && matches!(row.location, Location::ThisPc | Location::Wsl) {
|
||||
list = list.push(tree_section_divider());
|
||||
}
|
||||
list = list.push(self.view_tree_row(row));
|
||||
@@ -2845,7 +2867,7 @@ fn gap() -> Element<'static, Message> {
|
||||
fn full_path_text(row: &Row) -> String {
|
||||
match &row.target {
|
||||
Location::Path(path) => path.display().to_string(),
|
||||
Location::ThisPc => row.label.clone(),
|
||||
Location::ThisPc | Location::Wsl => row.label.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3245,6 +3267,7 @@ fn menu_panel_style(theme: &Theme) -> container::Style {
|
||||
fn address_text(location: &Location) -> String {
|
||||
match location {
|
||||
Location::ThisPc => "This PC".to_string(),
|
||||
Location::Wsl => "Linux".to_string(),
|
||||
Location::Path(path) => path.display().to_string(),
|
||||
}
|
||||
}
|
||||
@@ -3254,6 +3277,7 @@ fn address_text(location: &Location) -> String {
|
||||
fn tab_title(location: &Location) -> String {
|
||||
match location {
|
||||
Location::ThisPc => "This PC".to_string(),
|
||||
Location::Wsl => "Linux".to_string(),
|
||||
Location::Path(path) => path
|
||||
.file_name()
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
@@ -3277,6 +3301,12 @@ fn fetch_tree_roots(worker: &ShellWorker) -> Vec<TreeChild> {
|
||||
None => roots.extend(known),
|
||||
}
|
||||
roots.push(this_pc_tree_child());
|
||||
// Surface the "Linux" group only when WSL is actually present — i.e. the
|
||||
// Lxss registry key lists at least one distro. This startup check is the
|
||||
// sole gate: when it's empty the node (and its penguin icon) never exist.
|
||||
if !list_wsl_distros().is_empty() {
|
||||
roots.push(wsl_tree_child());
|
||||
}
|
||||
roots
|
||||
}
|
||||
|
||||
@@ -3297,14 +3327,18 @@ fn home_tree_child(home: PathBuf, folders: Vec<TreeChild>) -> TreeChild {
|
||||
}
|
||||
|
||||
/// Load the children for a folder-tree node: the drives under the "This PC"
|
||||
/// node, or the (visible) subdirectories of a real folder, sorted by name. Runs
|
||||
/// on a worker thread via [`offload`]. (Neither path needs the COM worker — the
|
||||
/// top-level known folders that do are loaded by [`fetch_tree_roots`].)
|
||||
/// node, the WSL distros under the "Linux" node, or the (visible) subdirectories
|
||||
/// of a real folder, sorted by name. Runs on a worker thread via [`offload`].
|
||||
/// (None of these need the COM worker — the top-level known folders that do are
|
||||
/// loaded by [`fetch_tree_roots`].)
|
||||
fn fetch_tree_children(location: &Location, show_hidden: bool) -> Result<Vec<TreeChild>, String> {
|
||||
match location {
|
||||
// "This PC" now contains only the system drives; the user's folders are
|
||||
// their own top-level nodes (see [`fetch_tree_roots`]).
|
||||
Location::ThisPc => Ok(list_drives().iter().map(tree_child_from_drive).collect()),
|
||||
// The "Linux" group lists the installed WSL distros (registry-sourced,
|
||||
// so listing them never starts a distro).
|
||||
Location::Wsl => Ok(list_wsl_distros().iter().map(tree_child_from_distro).collect()),
|
||||
Location::Path(dir) => {
|
||||
let mut dirs = read_subdirs(dir).map_err(|e| e.to_string())?;
|
||||
dirs.retain(|e| is_visible(e, show_hidden, ""));
|
||||
@@ -3323,6 +3357,12 @@ fn this_pc_tree_child() -> TreeChild {
|
||||
TreeChild::lazy("This PC".to_string(), IconKey::Computer, Location::ThisPc)
|
||||
}
|
||||
|
||||
/// The standalone "Linux" tree node — an expandable container for the WSL
|
||||
/// distros, mirroring the "This PC" node and carrying the shared penguin icon.
|
||||
fn wsl_tree_child() -> TreeChild {
|
||||
TreeChild::lazy("Linux".to_string(), IconKey::Wsl, Location::Wsl)
|
||||
}
|
||||
|
||||
/// A tree child built from a drive, reusing the file-list row mapping so the
|
||||
/// label and icon match what the "This PC" listing shows.
|
||||
fn tree_child_from_drive(drive: &DriveInfo) -> TreeChild {
|
||||
@@ -3330,6 +3370,13 @@ fn tree_child_from_drive(drive: &DriveInfo) -> TreeChild {
|
||||
TreeChild::lazy(row.label, row.icon, row.target)
|
||||
}
|
||||
|
||||
/// A tree child built from a WSL distro, reusing the landing-list row mapping so
|
||||
/// the label, icon, and target match what the "Linux" listing shows.
|
||||
fn tree_child_from_distro(distro: &WslDistro) -> TreeChild {
|
||||
let row = rows::row_from_distro(distro);
|
||||
TreeChild::lazy(row.label, row.icon, row.target)
|
||||
}
|
||||
|
||||
fn tree_child_from_known(folder: &KnownFolder) -> TreeChild {
|
||||
let row = rows::row_from_known(folder);
|
||||
TreeChild::lazy(row.label, row.icon, row.target)
|
||||
@@ -3659,7 +3706,7 @@ fn thumb_key(row: &Row, px: u16) -> Option<ThumbKey> {
|
||||
size: px,
|
||||
mtime: row.modified,
|
||||
}),
|
||||
Location::ThisPc => None,
|
||||
Location::ThisPc | Location::Wsl => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ use std::time::SystemTime;
|
||||
|
||||
use chrono::{DateTime, Local, Utc};
|
||||
use librarian_core::{Entry, Location};
|
||||
use librarian_win::{DriveInfo, DriveKind, KnownFolder};
|
||||
use librarian_win::{DriveInfo, DriveKind, KnownFolder, WslDistro, distro_unc_path};
|
||||
|
||||
use crate::icons::IconKey;
|
||||
use crate::search::SearchHit;
|
||||
@@ -75,6 +75,21 @@ pub fn row_from_known(folder: &KnownFolder) -> Row {
|
||||
}
|
||||
}
|
||||
|
||||
/// A row for one WSL distribution in the "Linux" landing list. Activating it
|
||||
/// navigates into the distro's `\\wsl.localhost\<name>` root; it shares the
|
||||
/// Linux/penguin icon with the WSL group node.
|
||||
pub fn row_from_distro(distro: &WslDistro) -> Row {
|
||||
Row {
|
||||
label: distro.name.clone(),
|
||||
icon: IconKey::Wsl,
|
||||
is_container: true,
|
||||
target: Location::Path(distro_unc_path(&distro.name)),
|
||||
size: None,
|
||||
modified: None,
|
||||
type_label: "Linux distribution".to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// A row for one search result. The label is the path *relative to the search
|
||||
/// root* (so the result's location is visible at a glance, like an editor's
|
||||
/// search panel). A directory hit navigates when activated; a file hit opens.
|
||||
|
||||
@@ -323,12 +323,15 @@ fn reveal_node(node: &mut TreeNode, target: &Path) -> Reveal {
|
||||
return reveal_node(&mut children[i], target);
|
||||
}
|
||||
// No path child matched. At the top level the drives live under the
|
||||
// "This PC" node (whose own location is pathless), so fall back to
|
||||
// descending into it — one of its drives will prefix the target.
|
||||
if let Some(i) = children
|
||||
.iter()
|
||||
.position(|c| matches!(c.location, Location::ThisPc))
|
||||
{
|
||||
// pathless "This PC" node and the distros under the pathless "Linux"
|
||||
// (Wsl) node, so fall back into whichever virtual root owns this
|
||||
// target — one of its children will prefix it.
|
||||
let virtual_root = if is_wsl_path(target) {
|
||||
Location::Wsl
|
||||
} else {
|
||||
Location::ThisPc
|
||||
};
|
||||
if let Some(i) = children.iter().position(|c| c.location == virtual_root) {
|
||||
return reveal_node(&mut children[i], target);
|
||||
}
|
||||
Reveal::Stop // target not under any child (hidden, gone, …)
|
||||
@@ -336,6 +339,23 @@ fn reveal_node(node: &mut TreeNode, target: &Path) -> Reveal {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `target` lives under the WSL "Linux" group — a `\\wsl.localhost\`
|
||||
/// (or legacy `\\wsl$\`) UNC path — so reveal descends into the `Wsl` node
|
||||
/// rather than "This PC". A cheap host-segment check keeps this module
|
||||
/// filesystem-free.
|
||||
fn is_wsl_path(target: &Path) -> bool {
|
||||
let lossy = target.to_string_lossy();
|
||||
let Some(rest) = lossy
|
||||
.strip_prefix(r"\\")
|
||||
.or_else(|| lossy.strip_prefix("//"))
|
||||
else {
|
||||
return false;
|
||||
};
|
||||
let host_len = rest.find(['\\', '/']).unwrap_or(rest.len());
|
||||
let host = &rest[..host_len];
|
||||
host.eq_ignore_ascii_case("wsl.localhost") || host.eq_ignore_ascii_case("wsl$")
|
||||
}
|
||||
|
||||
/// Among `children`, the index of the one whose path is the longest prefix of
|
||||
/// `target` (the deepest ancestor to descend into). `starts_with` is
|
||||
/// component-wise, so `C:\Users` matches `C:\Users\me` but not `C:\UsersX`.
|
||||
@@ -367,6 +387,11 @@ mod tests {
|
||||
TreeChild::lazy("This PC".to_string(), IconKey::Folder, Location::ThisPc)
|
||||
}
|
||||
|
||||
/// A pathless "Linux" child — the WSL distros' container at the top level.
|
||||
fn wsl_group() -> TreeChild {
|
||||
TreeChild::lazy("Linux".to_string(), IconKey::Wsl, Location::Wsl)
|
||||
}
|
||||
|
||||
/// Find a visible row by label, for assertions.
|
||||
fn row_id(tree: &Tree, label: &str) -> NodeId {
|
||||
tree.visible_rows()
|
||||
@@ -555,6 +580,40 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reveal_descends_through_wsl_to_a_distro_path() {
|
||||
// Distros sit under the pathless "Linux" node, so revealing a
|
||||
// \\wsl.localhost\<distro> path must fall back into the Wsl group
|
||||
// (not This PC, which a non-WSL path would).
|
||||
let mut tree = Tree::new();
|
||||
tree.set_children(
|
||||
ROOT_ID,
|
||||
vec![
|
||||
child("Desktop", "C:\\Users\\me\\Desktop"),
|
||||
this_pc(),
|
||||
wsl_group(),
|
||||
],
|
||||
);
|
||||
let target = PathBuf::from(r"\\wsl.localhost\Ubuntu\home");
|
||||
|
||||
// No top-level path child prefixes the target and it's a WSL path, so
|
||||
// reveal descends into the "Linux" node and asks to load it.
|
||||
let wsl = row_id(&tree, "Linux");
|
||||
match tree.reveal(&target) {
|
||||
Reveal::Load(id, Location::Wsl) => assert_eq!(id, wsl),
|
||||
other => panic!("expected to load the Wsl node, got {other:?}"),
|
||||
}
|
||||
|
||||
// Its distros arrive; the next step descends into the Ubuntu root.
|
||||
tree.set_children(wsl, vec![child("Ubuntu", r"\\wsl.localhost\Ubuntu")]);
|
||||
match tree.reveal(&target) {
|
||||
Reveal::Load(_, Location::Path(p)) => {
|
||||
assert_eq!(p, PathBuf::from(r"\\wsl.localhost\Ubuntu"))
|
||||
}
|
||||
other => panic!("expected to load the Ubuntu distro root, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn branch_grafts_children_and_starts_expanded() {
|
||||
// A `branch` node arrives with its children already attached and shown,
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
//! Windows file attributes are read from the standard library's cached
|
||||
//! `MetadataExt` so enumeration needs no extra syscalls per entry.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::path::{Component, Path, PathBuf, Prefix};
|
||||
use std::time::SystemTime;
|
||||
|
||||
// Raw Windows file-attribute bits (see `MetadataExt::file_attributes`). Defined
|
||||
@@ -75,13 +75,17 @@ impl Entry {
|
||||
}
|
||||
}
|
||||
|
||||
/// A browsable location. `ThisPc` is the virtual root (drives + known folders);
|
||||
/// everything reachable below a real folder is a plain `Path`. More virtual
|
||||
/// roots (Recycle Bin, Network) can be added here later.
|
||||
/// A browsable location. `ThisPc` and `Wsl` are the virtual roots — `ThisPc`
|
||||
/// holds the drives (and known folders), `Wsl` the installed Linux distros;
|
||||
/// everything reachable below a real folder (including inside a distro, via its
|
||||
/// `\\wsl.localhost\` UNC path) is a plain `Path`. More virtual roots (Recycle
|
||||
/// Bin, Network) can be added here later.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Location {
|
||||
Path(PathBuf),
|
||||
ThisPc,
|
||||
/// The "Linux" group: the landing list of WSL distributions.
|
||||
Wsl,
|
||||
}
|
||||
|
||||
impl Location {
|
||||
@@ -89,6 +93,7 @@ impl Location {
|
||||
pub fn label(&self) -> String {
|
||||
match self {
|
||||
Location::ThisPc => "This PC".to_string(),
|
||||
Location::Wsl => "Linux".to_string(),
|
||||
Location::Path(p) => p
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
@@ -102,7 +107,7 @@ impl Location {
|
||||
pub fn as_path(&self) -> Option<&Path> {
|
||||
match self {
|
||||
Location::Path(p) => Some(p),
|
||||
Location::ThisPc => None,
|
||||
Location::ThisPc | Location::Wsl => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,16 +116,20 @@ impl Location {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() || trimmed.eq_ignore_ascii_case("This PC") {
|
||||
Location::ThisPc
|
||||
} else if trimmed.eq_ignore_ascii_case("Linux") || trimmed.eq_ignore_ascii_case("WSL") {
|
||||
Location::Wsl
|
||||
} else {
|
||||
Location::Path(PathBuf::from(trimmed))
|
||||
}
|
||||
}
|
||||
|
||||
/// The parent location, used by the "Up" command. The parent of a drive
|
||||
/// root (or any path with no parent) is `ThisPc`.
|
||||
/// root (or any path with no parent) is `ThisPc`; the parent of a distro
|
||||
/// root (`\\wsl.localhost\<name>`) is the `Wsl` group.
|
||||
pub fn parent(&self) -> Option<Location> {
|
||||
match self {
|
||||
Location::ThisPc => None,
|
||||
Location::ThisPc | Location::Wsl => None,
|
||||
Location::Path(p) if is_wsl_root_path(p) => Some(Location::Wsl),
|
||||
Location::Path(p) => match p.parent() {
|
||||
Some(parent) if !parent.as_os_str().is_empty() => {
|
||||
Some(Location::Path(parent.to_path_buf()))
|
||||
@@ -130,3 +139,72 @@ impl Location {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `p` is the root of a WSL distro — a `\\wsl.localhost\<name>` (or
|
||||
/// legacy `\\wsl$\<name>`) UNC path with nothing below the distro share. Such a
|
||||
/// path has no filesystem parent, so "Up" routes to the `Wsl` group instead.
|
||||
fn is_wsl_root_path(p: &Path) -> bool {
|
||||
let mut comps = p.components();
|
||||
let Some(Component::Prefix(prefix)) = comps.next() else {
|
||||
return false;
|
||||
};
|
||||
let Prefix::UNC(server, _share) = prefix.kind() else {
|
||||
return false;
|
||||
};
|
||||
let server = server.to_string_lossy();
|
||||
if !server.eq_ignore_ascii_case("wsl.localhost") && !server.eq_ignore_ascii_case("wsl$") {
|
||||
return false;
|
||||
}
|
||||
// The UNC prefix already includes the distro (the share); a bare distro root
|
||||
// leaves only the root directory, with no further components below it.
|
||||
matches!(comps.next(), Some(Component::RootDir)) && comps.next().is_none()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn parses_virtual_roots() {
|
||||
assert_eq!(Location::parse(""), Location::ThisPc);
|
||||
assert_eq!(Location::parse("This PC"), Location::ThisPc);
|
||||
assert_eq!(Location::parse("linux"), Location::Wsl);
|
||||
assert_eq!(Location::parse("WSL"), Location::Wsl);
|
||||
assert_eq!(
|
||||
Location::parse(r"\\wsl.localhost\Ubuntu"),
|
||||
Location::Path(PathBuf::from(r"\\wsl.localhost\Ubuntu"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wsl_group_labels_and_has_no_path_or_parent() {
|
||||
assert_eq!(Location::Wsl.label(), "Linux");
|
||||
assert_eq!(Location::Wsl.as_path(), None);
|
||||
assert_eq!(Location::Wsl.parent(), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn distro_root_parent_is_the_wsl_group() {
|
||||
let root = Location::Path(PathBuf::from(r"\\wsl.localhost\Ubuntu"));
|
||||
assert_eq!(root.parent(), Some(Location::Wsl));
|
||||
// The legacy alias resolves the same way.
|
||||
let legacy = Location::Path(PathBuf::from(r"\\wsl$\Ubuntu"));
|
||||
assert_eq!(legacy.parent(), Some(Location::Wsl));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn inside_a_distro_walks_up_normally() {
|
||||
let sub = Location::Path(PathBuf::from(r"\\wsl.localhost\Ubuntu\home"));
|
||||
assert_eq!(
|
||||
sub.parent(),
|
||||
Some(Location::Path(PathBuf::from(r"\\wsl.localhost\Ubuntu")))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ordinary_unc_share_is_not_a_distro_root() {
|
||||
// A normal network share must keep its This PC fallback, not become Wsl.
|
||||
let share = Location::Path(PathBuf::from(r"\\server\share"));
|
||||
assert_eq!(share.parent(), Some(Location::ThisPc));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ windows = { workspace = true, features = [
|
||||
"Win32_UI_WindowsAndMessaging",
|
||||
"Win32_UI_Controls",
|
||||
"Win32_Storage_FileSystem",
|
||||
"Win32_System_Registry",
|
||||
"Win32_Graphics_Gdi",
|
||||
"Win32_Graphics_Dwm",
|
||||
] }
|
||||
|
||||
@@ -116,6 +116,31 @@ pub fn computer_icon(_apt: &Apartment, large: bool) -> Option<IconImage> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The shell's "Linux" (WSL) icon — the penguin Explorer shows for its Linux
|
||||
/// navigation node and for each distro under it. The Linux root is a virtual
|
||||
/// shell namespace extension addressed by CLSID (not a file path), so we resolve
|
||||
/// it by parsing name. This is purely local: it touches the registered icon
|
||||
/// handler, never the `\\wsl.localhost` redirector, so it can't wake a distro.
|
||||
/// Returns `None` when the extension isn't registered (i.e. WSL absent). Must run
|
||||
/// on the COM STA thread.
|
||||
pub fn wsl_icon(_apt: &Apartment, large: bool) -> Option<IconImage> {
|
||||
// CLSID of the "Linux" shell folder registered by WSL.
|
||||
const LINUX_FOLDER: &str = "::{B2B4A4D1-2754-4140-A2EB-9A76D9D7CDC6}";
|
||||
let size = if large { 32 } else { 16 };
|
||||
let wide = to_wide(LINUX_FOLDER);
|
||||
unsafe {
|
||||
let factory: IShellItemImageFactory =
|
||||
SHCreateItemFromParsingName(PCWSTR(wide.as_ptr()), None).ok()?;
|
||||
// No thumbnail for a namespace folder, so the shell returns its icon.
|
||||
let hbm = factory
|
||||
.GetImage(SIZE { cx: size, cy: size }, SIIGBF_RESIZETOFIT)
|
||||
.ok()?;
|
||||
let image = hbitmap_to_rgba(hbm);
|
||||
_ = DeleteObject(HGDIOBJ(hbm.0));
|
||||
image
|
||||
}
|
||||
}
|
||||
|
||||
/// A thumbnail (or, for items without one, the scaled shell icon) for `path`,
|
||||
/// fit within a `size`×`size` box, as straight-alpha RGBA. This is the same data
|
||||
/// Explorer's icon views show. `size` should be one of the Windows thumbnail
|
||||
@@ -440,6 +465,18 @@ mod tests {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wsl_icon_is_valid_when_present() {
|
||||
// The Linux shell extension is only registered when WSL is installed, so
|
||||
// this returns `None` on a machine without it — both outcomes are fine.
|
||||
// When present, the decoded icon must be well-formed and visible.
|
||||
if let Some(icon) = worker().run(|apt| wsl_icon(apt, false)) {
|
||||
assert!(icon.width > 0 && icon.height > 0);
|
||||
assert_eq!(icon.rgba.len(), (icon.width * icon.height * 4) as usize);
|
||||
assert!(icon.rgba.chunks_exact(4).any(|px| px[3] != 0));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_the_computer_icon() {
|
||||
let icon = worker()
|
||||
|
||||
@@ -16,13 +16,15 @@ pub mod fileop;
|
||||
pub mod icon;
|
||||
pub mod known;
|
||||
pub mod open;
|
||||
pub mod wsl;
|
||||
|
||||
pub use chrome::apply_window_chrome;
|
||||
pub use com::{Apartment, ShellWorker};
|
||||
pub use drives::{DriveInfo, DriveKind, list_drives};
|
||||
pub use fileop::{copy_items, create_folder, delete_to_recycle, move_items, rename};
|
||||
pub use icon::{
|
||||
IconImage, computer_icon, folder_icon, icon_for_extension, icon_for_path, thumbnail,
|
||||
IconImage, computer_icon, folder_icon, icon_for_extension, icon_for_path, thumbnail, wsl_icon,
|
||||
};
|
||||
pub use known::{KnownFolder, known_folders, user_home};
|
||||
pub use open::open_path;
|
||||
pub use wsl::{WslDistro, distro_unc_path, list_wsl_distros};
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
//! WSL distro discovery for the navigation pane's "Linux" group.
|
||||
//!
|
||||
//! Installed distros are read straight from the registry
|
||||
//! (`HKCU\Software\Microsoft\Windows\CurrentVersion\Lxss`) — the same source the
|
||||
//! Windows shell uses for its Linux node. This is a handful of local registry
|
||||
//! reads: no `wsl.exe` process spawn, no network, and (critically) it never
|
||||
//! *starts* a distro, so listing them in the tree stays cheap and side-effect
|
||||
//! free. Browsing into one then goes through the normal `\\wsl.localhost\<name>`
|
||||
//! UNC path like any other directory.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use windows::Win32::Foundation::ERROR_SUCCESS;
|
||||
use windows::Win32::System::Registry::{
|
||||
HKEY, HKEY_CURRENT_USER, KEY_READ, REG_SZ, REG_VALUE_TYPE, RegCloseKey, RegEnumKeyExW,
|
||||
RegOpenKeyExW, RegQueryValueExW,
|
||||
};
|
||||
use windows::core::{PCWSTR, PWSTR};
|
||||
|
||||
use crate::util::{to_wide, wide_to_string};
|
||||
|
||||
/// The registry key, under `HKEY_CURRENT_USER`, whose child keys are the
|
||||
/// installed WSL distributions (one per distro GUID).
|
||||
const LXSS_KEY: &str = r"Software\Microsoft\Windows\CurrentVersion\Lxss";
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct WslDistro {
|
||||
/// The distro's registered name (e.g. `Ubuntu`), which is also its
|
||||
/// `\\wsl.localhost\` share name.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// The `\\wsl.localhost\<name>` root of a distro's filesystem (the Windows
|
||||
/// 11-canonical form; `\\wsl$\` is the legacy alias). Browsing this path uses
|
||||
/// the normal directory enumeration — the WSL P9 redirector serves it like any
|
||||
/// other UNC share.
|
||||
pub fn distro_unc_path(name: &str) -> PathBuf {
|
||||
PathBuf::from(format!(r"\\wsl.localhost\{name}"))
|
||||
}
|
||||
|
||||
/// Enumerate installed WSL distributions from the registry, sorted by name.
|
||||
/// Returns an empty list when WSL isn't present (the `Lxss` key is absent) — the
|
||||
/// caller uses that to omit the "Linux" group from the nav pane entirely.
|
||||
///
|
||||
/// Plain Win32 registry calls (no COM), so this is safe to run from any thread,
|
||||
/// e.g. off the UI thread via `offload`.
|
||||
pub fn list_wsl_distros() -> Vec<WslDistro> {
|
||||
let mut distros = Vec::new();
|
||||
let lxss_path = to_wide(LXSS_KEY);
|
||||
|
||||
unsafe {
|
||||
let mut lxss = HKEY::default();
|
||||
// SAFETY: `lxss_path` outlives the call backing `PCWSTR`; `lxss` receives
|
||||
// the opened key handle, closed below.
|
||||
let opened = RegOpenKeyExW(
|
||||
HKEY_CURRENT_USER,
|
||||
PCWSTR(lxss_path.as_ptr()),
|
||||
None,
|
||||
KEY_READ,
|
||||
&mut lxss,
|
||||
);
|
||||
if opened != ERROR_SUCCESS {
|
||||
return distros; // No Lxss key → WSL not installed.
|
||||
}
|
||||
|
||||
// Each child key is named after a distro GUID; its `DistributionName`
|
||||
// value holds the display name (and `\\wsl.localhost\` share).
|
||||
let mut index = 0u32;
|
||||
loop {
|
||||
let mut name_buf = [0u16; 256]; // Registry key names cap at 255 chars.
|
||||
let mut name_len = name_buf.len() as u32;
|
||||
// SAFETY: `name_buf`/`name_len` are a valid buffer + its char length;
|
||||
// the unused class/reserved/time out-params are passed as `None`.
|
||||
let res = RegEnumKeyExW(
|
||||
lxss,
|
||||
index,
|
||||
Some(PWSTR(name_buf.as_mut_ptr())),
|
||||
&mut name_len,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
if res != ERROR_SUCCESS {
|
||||
break; // ERROR_NO_MORE_ITEMS (or any error) ends enumeration.
|
||||
}
|
||||
index += 1;
|
||||
|
||||
if let Some(name) = read_distribution_name(lxss, &name_buf) {
|
||||
distros.push(WslDistro { name });
|
||||
}
|
||||
}
|
||||
let _ = RegCloseKey(lxss);
|
||||
}
|
||||
|
||||
distros.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
distros
|
||||
}
|
||||
|
||||
/// Open the distro subkey named by the NUL-terminated `guid` buffer (under the
|
||||
/// open `lxss` key) and read its `DistributionName` string value, if present and
|
||||
/// non-empty.
|
||||
fn read_distribution_name(lxss: HKEY, guid: &[u16]) -> Option<String> {
|
||||
unsafe {
|
||||
let mut sub = HKEY::default();
|
||||
let opened = RegOpenKeyExW(lxss, PCWSTR(guid.as_ptr()), None, KEY_READ, &mut sub);
|
||||
if opened != ERROR_SUCCESS {
|
||||
return None;
|
||||
}
|
||||
|
||||
let value_name = to_wide("DistributionName");
|
||||
let mut buf = [0u16; 512];
|
||||
let mut kind = REG_VALUE_TYPE::default();
|
||||
let mut bytes = std::mem::size_of_val(&buf) as u32;
|
||||
// SAFETY: `value_name` backs the `PCWSTR`; `buf`/`bytes` are a valid byte
|
||||
// buffer and its size; `kind` receives the value type.
|
||||
let res = RegQueryValueExW(
|
||||
sub,
|
||||
PCWSTR(value_name.as_ptr()),
|
||||
None,
|
||||
Some(&mut kind),
|
||||
Some(buf.as_mut_ptr() as *mut u8),
|
||||
Some(&mut bytes),
|
||||
);
|
||||
let _ = RegCloseKey(sub);
|
||||
|
||||
if res != ERROR_SUCCESS || kind != REG_SZ {
|
||||
return None;
|
||||
}
|
||||
let name = wide_to_string(&buf);
|
||||
(!name.is_empty()).then_some(name)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn distro_unc_path_is_the_localhost_share() {
|
||||
assert_eq!(
|
||||
distro_unc_path("Ubuntu"),
|
||||
PathBuf::from(r"\\wsl.localhost\Ubuntu")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn list_is_well_formed() {
|
||||
// WSL may or may not be installed on the test machine; either way the
|
||||
// call must not panic, and any distro it returns has a non-empty name.
|
||||
let distros = list_wsl_distros();
|
||||
assert!(distros.iter().all(|d| !d.name.is_empty()));
|
||||
// Sorted case-insensitively by name.
|
||||
let mut sorted = distros.clone();
|
||||
sorted.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
let names: Vec<&str> = distros.iter().map(|d| d.name.as_str()).collect();
|
||||
let sorted_names: Vec<&str> = sorted.iter().map(|d| d.name.as_str()).collect();
|
||||
assert_eq!(names, sorted_names);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user