aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authordivens <divens@users.noreply.github.com>2026-03-21 19:20:32 +0100
committerGitHub <noreply@github.com>2026-03-21 19:20:32 +0100
commit3fa7744906d5704bdec771c74829841bd06cf7fc (patch)
tree6c4f0bd64c2e6087ce069a466cca942f4669f513
parentRelease notes and fixes (#4879) (diff)
downloadsl0ppij-3fa7744906d5704bdec771c74829841bd06cf7fc.tar.gz
sl0ppij-3fa7744906d5704bdec771c74829841bd06cf7fc.tar.xz
sl0ppij-3fa7744906d5704bdec771c74829841bd06cf7fc.zip
Add windows support in strider (#4854)
* Add Windows path support to strider plugin Strider is a WASM plugin where std::path::PathBuf uses Unix semantics, so Windows backslash paths break parent(), join(), strip_prefix(), etc. Fix by normalizing all incoming paths to forward slashes at plugin boundaries and converting back to native backslashes for display. A new platform.rs module detects the host OS at runtime from the initial_cwd format and centralizes all platform-aware logic. Also fix scan_host_folder on the server side: canonicalize plugin_host_folder so the starts_with security check works on Windows, where canonicalize() adds the \?\ prefix. * Add virtual root view for drive and WSL navigation in strider On Windows, navigating up from a drive root (e.g. C:\) now shows a "Computer" view listing all available drives and WSL distributions. Drives are enumerated via GetLogicalDriveStringsW and WSL distros via `wsl -l -q` (since read_dir on \wsl.localhost fails with OS error 64/67). Drive letters appear before WSL entries, which are displayed as "Ubuntu-24.04 (WSL)" etc. On non-Windows platforms, behavior is unchanged. * Rename ListHostEntries to ListWindowsVolumes and gate behind FullHdAccess The old name was misleading — on Unix it just returned `/`, which isn't "listing host entries." Rename to ListWindowsVolumes to make the Windows-specific intent explicit, gate behind FullHdAccess permission, and replace the Unix implementation with a log::error since the command is not meaningful on non-Windows platforms. * Replace direct array indexing with .get() in strider platform detection Use safe accessors and matches! macro instead of direct byte indexing for bounds-checked access patterns. --------- Co-authored-by: divens <divens.dev@gmail.com>
-rw-r--r--default-plugins/strider/src/file_list_view.rs11
-rw-r--r--default-plugins/strider/src/main.rs53
-rw-r--r--default-plugins/strider/src/platform.rs240
-rw-r--r--default-plugins/strider/src/search_view.rs7
-rw-r--r--default-plugins/strider/src/shared.rs25
-rw-r--r--default-plugins/strider/src/state.rs156
-rw-r--r--zellij-server/src/plugins/zellij_exports.rs92
-rw-r--r--zellij-tile/src/shim.rs10
-rw-r--r--zellij-utils/assets/prost/api.plugin_command.rs3
-rw-r--r--zellij-utils/src/data.rs1
-rw-r--r--zellij-utils/src/plugin_api/plugin_command.proto1
-rw-r--r--zellij-utils/src/plugin_api/plugin_command.rs8
12 files changed, 571 insertions, 36 deletions
diff --git a/default-plugins/strider/src/file_list_view.rs b/default-plugins/strider/src/file_list_view.rs
index 2226e618..ee620970 100644
--- a/default-plugins/strider/src/file_list_view.rs
+++ b/default-plugins/strider/src/file_list_view.rs
@@ -1,3 +1,4 @@
+use crate::platform::Platform;
use crate::shared::{calculate_list_bounds, render_list_tip};
use crate::state::refresh_directory;
use pretty_bytes::converter::convert as pretty_bytes;
@@ -12,6 +13,7 @@ pub struct FileListView {
pub path_is_dir: bool,
pub files: Vec<FsEntry>,
pub cursor_hist: HashMap<PathBuf, usize>,
+ pub platform: Platform,
}
impl Default for FileListView {
@@ -21,14 +23,18 @@ impl Default for FileListView {
path: PathBuf::new(),
files: Default::default(),
cursor_hist: Default::default(),
+ platform: Platform::default(),
}
}
}
impl FileListView {
pub fn descend_to_previous_path(&mut self) {
+ if Platform::is_root(&self.path, self.platform) {
+ return;
+ }
if let Some(parent) = self.path.parent() {
- self.path = parent.to_path_buf();
+ self.path = Platform::ensure_drive_root(parent.to_path_buf(), self.platform);
} else {
self.path = PathBuf::new();
}
@@ -65,6 +71,7 @@ impl FileListView {
) {
let mut files = vec![];
for (entry, entry_metadata) in paths {
+ let entry = Platform::normalize(&entry);
let entry = self
.path
.join(entry.strip_prefix("/host").unwrap_or(&entry));
@@ -126,7 +133,7 @@ impl FileListView {
.map(|s| pretty_bytes(s as f64))
.unwrap_or("".to_owned());
if entry.is_folder() {
- file_or_folder_name.push('/');
+ file_or_folder_name.push(self.platform.separator());
}
let file_or_folder_name_width = file_or_folder_name.width();
let size_width = size.width();
diff --git a/default-plugins/strider/src/main.rs b/default-plugins/strider/src/main.rs
index 14571532..02ae6a95 100644
--- a/default-plugins/strider/src/main.rs
+++ b/default-plugins/strider/src/main.rs
@@ -1,9 +1,13 @@
mod file_list_view;
+mod platform;
mod search_view;
mod shared;
mod state;
-use shared::{render_current_path, render_instruction_line, render_search_term};
+use platform::Platform;
+use shared::{
+ render_current_path, render_instruction_line, render_search_term, render_virtual_root_header,
+};
use state::{refresh_directory, State};
use std::collections::BTreeMap;
use std::path::PathBuf;
@@ -14,7 +18,12 @@ register_plugin!(State);
impl ZellijPlugin for State {
fn load(&mut self, configuration: BTreeMap<String, String>) {
let plugin_ids = get_plugin_ids();
- self.initial_cwd = plugin_ids.initial_cwd;
+ let initial_cwd_str = plugin_ids.initial_cwd.to_string_lossy().to_string();
+ let platform = Platform::detect(&initial_cwd_str);
+ self.platform = platform;
+ self.initial_cwd = Platform::normalize(&plugin_ids.initial_cwd);
+ self.file_list_view.platform = platform;
+ self.search_view.platform = platform;
let show_hidden_files = configuration
.get("show_hidden_files")
.map(|v| v == "true")
@@ -35,7 +44,10 @@ impl ZellijPlugin for State {
]);
self.file_list_view.clear_selected();
- match configuration.get("caller_cwd").map(|c| PathBuf::from(c)) {
+ match configuration
+ .get("caller_cwd")
+ .map(|c| Platform::normalize(&PathBuf::from(c)))
+ {
Some(caller_cwd) => {
self.file_list_view.path = caller_cwd;
},
@@ -71,7 +83,9 @@ impl ZellijPlugin for State {
should_render = true;
},
BareKey::Esc if key.has_no_modifiers() => {
- if self.is_searching {
+ if self.is_in_virtual_root {
+ self.exit_virtual_root();
+ } else if self.is_searching {
self.clear_search_term();
} else {
self.file_list_view.clear_selected();
@@ -98,7 +112,9 @@ impl ZellijPlugin for State {
should_render = true;
},
BareKey::Left if key.has_no_modifiers() => {
- self.descend_to_previous_path();
+ if !self.is_in_virtual_root {
+ self.descend_to_previous_path();
+ }
should_render = true;
},
BareKey::Char('e') if key.has_modifiers(&[KeyModifier::Ctrl]) => {
@@ -167,17 +183,24 @@ impl ZellijPlugin for State {
fn render(&mut self, rows: usize, cols: usize) {
self.current_rows = Some(rows);
let rows_for_list = rows.saturating_sub(6);
- render_search_term(&self.search_term);
- render_current_path(
- &self.file_list_view.path,
- self.file_list_view.path_is_dir,
- self.handling_filepick_request_from.is_some(),
- cols,
- );
- if self.is_searching {
- self.search_view.render(rows_for_list, cols);
+ if self.is_in_virtual_root {
+ render_search_term("");
+ render_virtual_root_header(cols);
+ self.render_virtual_root(rows_for_list, cols);
} else {
- self.file_list_view.render(rows_for_list, cols);
+ render_search_term(&self.search_term);
+ render_current_path(
+ &self.file_list_view.path,
+ self.file_list_view.path_is_dir,
+ self.handling_filepick_request_from.is_some(),
+ cols,
+ self.platform,
+ );
+ if self.is_searching {
+ self.search_view.render(rows_for_list, cols);
+ } else {
+ self.file_list_view.render(rows_for_list, cols);
+ }
}
render_instruction_line(rows, cols);
}
diff --git a/default-plugins/strider/src/platform.rs b/default-plugins/strider/src/platform.rs
new file mode 100644
index 00000000..f9eecccb
--- /dev/null
+++ b/default-plugins/strider/src/platform.rs
@@ -0,0 +1,240 @@
+use std::path::{Path, PathBuf};
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Platform {
+ Unix,
+ Windows,
+}
+
+impl Default for Platform {
+ fn default() -> Self {
+ Platform::Unix
+ }
+}
+
+impl Platform {
+ /// Detect the host platform from the initial_cwd string.
+ /// Checks for drive letter (e.g. `C:\` or `C:/`) or UNC path (`\\server\`).
+ pub fn detect(initial_cwd: &str) -> Self {
+ let bytes = initial_cwd.as_bytes();
+ // Drive letter: X:\ or X:/
+ let is_drive_letter = matches!(
+ (bytes.get(0), bytes.get(1), bytes.get(2)),
+ (Some(c), Some(b':'), Some(b'\\' | b'/')) if c.is_ascii_alphabetic()
+ );
+ if is_drive_letter {
+ return Platform::Windows;
+ }
+ // UNC path: \\server\share
+ let is_unc_path = matches!((bytes.get(0), bytes.get(1)), (Some(b'\\'), Some(b'\\')));
+ if is_unc_path {
+ return Platform::Windows;
+ }
+ Platform::Unix
+ }
+
+ /// Replace `\` with `/` so WASM PathBuf can parse the path correctly.
+ /// No-op for paths that don't contain backslashes.
+ pub fn normalize(path: &Path) -> PathBuf {
+ let s = path.to_string_lossy();
+ if s.contains('\\') {
+ PathBuf::from(s.replace('\\', "/"))
+ } else {
+ path.to_path_buf()
+ }
+ }
+
+ /// Convert internal forward-slash path back to native backslashes for Windows display.
+ /// Identity on Unix.
+ pub fn to_host_display(path: &Path, platform: Platform) -> String {
+ let s = path.to_string_lossy();
+ match platform {
+ Platform::Windows => s.replace('/', "\\"),
+ Platform::Unix => s.into_owned(),
+ }
+ }
+
+ /// Path separator character for the host platform.
+ pub fn separator(self) -> char {
+ match self {
+ Platform::Windows => '\\',
+ Platform::Unix => '/',
+ }
+ }
+
+ /// Ensure bare drive letters like `C:` become `C:/`.
+ /// WASM PathBuf's `parent()` strips the trailing slash, but on Windows
+ /// `C:` means "current directory on drive C", not the drive root.
+ pub fn ensure_drive_root(path: PathBuf, platform: Platform) -> PathBuf {
+ if platform == Platform::Windows {
+ let s = path.to_string_lossy();
+ let bytes = s.as_bytes();
+ let is_bare_drive = s.len() == 2
+ && matches!(bytes.get(0), Some(c) if c.is_ascii_alphabetic())
+ && matches!(bytes.get(1), Some(b':'));
+ if is_bare_drive {
+ return PathBuf::from(format!("{}/", s));
+ }
+ }
+ path
+ }
+
+ /// Display name for a virtual root entry.
+ /// `C:/` → `C:\`, `//wsl.localhost/Ubuntu/` → `Ubuntu (WSL)`, `/` → `/`.
+ pub fn virtual_root_display_name(path: &Path, platform: Platform) -> String {
+ let s = path.to_string_lossy();
+ match platform {
+ Platform::Windows => {
+ if s.starts_with("//wsl.localhost/") {
+ let rest = &s["//wsl.localhost/".len()..];
+ let distro = rest.trim_end_matches('/');
+ return format!("{} (WSL)", distro);
+ }
+ Platform::to_host_display(path, platform)
+ },
+ Platform::Unix => s.into_owned(),
+ }
+ }
+
+ /// Check if a path is a filesystem root.
+ /// Unix: `/` or empty.
+ /// Windows: `X:/` or `X:` (drive root), or `//server/share` (UNC root with <= 4 components).
+ pub fn is_root(path: &Path, platform: Platform) -> bool {
+ let s = path.to_string_lossy();
+ match platform {
+ Platform::Unix => s == "/" || s.is_empty(),
+ Platform::Windows => {
+ let bytes = s.as_bytes();
+ // Drive root: "C:/" or "C:"
+ let is_drive_root_slash = s.len() == 3
+ && matches!(bytes.get(0), Some(c) if c.is_ascii_alphabetic())
+ && matches!(bytes.get(1), Some(b':'))
+ && matches!(bytes.get(2), Some(b'/'));
+ if is_drive_root_slash {
+ return true;
+ }
+ let is_bare_drive = s.len() == 2
+ && matches!(bytes.get(0), Some(c) if c.is_ascii_alphabetic())
+ && matches!(bytes.get(1), Some(b':'));
+ if is_bare_drive {
+ return true;
+ }
+ // UNC root: //server/share (at most 4 path components)
+ if s.starts_with("//") {
+ let without_prefix = &s[2..];
+ let parts: Vec<&str> = without_prefix
+ .split('/')
+ .filter(|p| !p.is_empty())
+ .collect();
+ return parts.len() <= 2;
+ }
+ s.is_empty()
+ },
+ }
+ }
+}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+
+ #[test]
+ fn detect_unix() {
+ assert_eq!(Platform::detect("/home/user"), Platform::Unix);
+ assert_eq!(Platform::detect("/"), Platform::Unix);
+ }
+
+ #[test]
+ fn detect_windows_drive() {
+ assert_eq!(Platform::detect("C:\\Users\\user"), Platform::Windows);
+ assert_eq!(Platform::detect("D:/Projects"), Platform::Windows);
+ }
+
+ #[test]
+ fn detect_windows_unc() {
+ assert_eq!(
+ Platform::detect("\\\\wsl.localhost\\Ubuntu"),
+ Platform::Windows
+ );
+ }
+
+ #[test]
+ fn normalize_backslashes() {
+ let p = PathBuf::from("C:\\Users\\user");
+ assert_eq!(Platform::normalize(&p), PathBuf::from("C:/Users/user"));
+ }
+
+ #[test]
+ fn normalize_noop_unix() {
+ let p = PathBuf::from("/home/user");
+ assert_eq!(Platform::normalize(&p), PathBuf::from("/home/user"));
+ }
+
+ #[test]
+ fn to_host_display_windows() {
+ let p = PathBuf::from("C:/Users/user");
+ assert_eq!(
+ Platform::to_host_display(&p, Platform::Windows),
+ "C:\\Users\\user"
+ );
+ }
+
+ #[test]
+ fn to_host_display_unix() {
+ let p = PathBuf::from("/home/user");
+ assert_eq!(Platform::to_host_display(&p, Platform::Unix), "/home/user");
+ }
+
+ #[test]
+ fn is_root_unix() {
+ assert!(Platform::is_root(&PathBuf::from("/"), Platform::Unix));
+ assert!(Platform::is_root(&PathBuf::from(""), Platform::Unix));
+ assert!(!Platform::is_root(&PathBuf::from("/home"), Platform::Unix));
+ }
+
+ #[test]
+ fn is_root_windows_drive() {
+ assert!(Platform::is_root(&PathBuf::from("C:/"), Platform::Windows));
+ assert!(Platform::is_root(&PathBuf::from("C:"), Platform::Windows));
+ assert!(!Platform::is_root(
+ &PathBuf::from("C:/Users"),
+ Platform::Windows
+ ));
+ }
+
+ #[test]
+ fn ensure_drive_root_fixes_bare_drive() {
+ assert_eq!(
+ Platform::ensure_drive_root(PathBuf::from("C:"), Platform::Windows),
+ PathBuf::from("C:/")
+ );
+ }
+
+ #[test]
+ fn ensure_drive_root_noop_with_slash() {
+ assert_eq!(
+ Platform::ensure_drive_root(PathBuf::from("C:/"), Platform::Windows),
+ PathBuf::from("C:/")
+ );
+ }
+
+ #[test]
+ fn ensure_drive_root_noop_unix() {
+ assert_eq!(
+ Platform::ensure_drive_root(PathBuf::from("/"), Platform::Unix),
+ PathBuf::from("/")
+ );
+ }
+
+ #[test]
+ fn is_root_windows_unc() {
+ assert!(Platform::is_root(
+ &PathBuf::from("//server/share"),
+ Platform::Windows
+ ));
+ assert!(!Platform::is_root(
+ &PathBuf::from("//server/share/folder"),
+ Platform::Windows
+ ));
+ }
+}
diff --git a/default-plugins/strider/src/search_view.rs b/default-plugins/strider/src/search_view.rs
index bcc38bc8..59c606a8 100644
--- a/default-plugins/strider/src/search_view.rs
+++ b/default-plugins/strider/src/search_view.rs
@@ -1,3 +1,5 @@
+use crate::file_list_view::FsEntry;
+use crate::platform::Platform;
use crate::shared::{calculate_list_bounds, render_list_tip};
use fuzzy_matcher::skim::SkimMatcherV2;
use fuzzy_matcher::FuzzyMatcher;
@@ -5,12 +7,11 @@ use pretty_bytes::converter::convert as pretty_bytes;
use unicode_width::UnicodeWidthStr;
use zellij_tile::prelude::*;
-use crate::file_list_view::FsEntry;
-
#[derive(Default, Debug)]
pub struct SearchView {
pub search_results: Vec<SearchResult>,
pub selected_search_result: usize,
+ pub platform: Platform,
}
impl SearchView {
@@ -67,7 +68,7 @@ impl SearchView {
.map(|s| pretty_bytes(s as f64))
.unwrap_or("".to_owned());
if search_result.is_folder() {
- search_result_text.push('/');
+ search_result_text.push(self.platform.separator());
}
let search_result_text_width = search_result_text.width();
diff --git a/default-plugins/strider/src/shared.rs b/default-plugins/strider/src/shared.rs
index 19728034..a8539e86 100644
--- a/default-plugins/strider/src/shared.rs
+++ b/default-plugins/strider/src/shared.rs
@@ -1,3 +1,4 @@
+use crate::platform::Platform;
use std::path::PathBuf;
use unicode_width::UnicodeWidthStr;
use zellij_tile::prelude::*;
@@ -78,14 +79,28 @@ pub fn render_search_term(search_term: &str) {
println!("")
}
+pub fn render_virtual_root_header(_cols: usize) {
+ let prompt = "PATH: ";
+ let title = "Computer";
+ let prompt_len = prompt.width();
+ let path_end = prompt_len + title.width();
+ let text = Text::new(format!("{}{}", prompt, title))
+ .color_range(2, 0..prompt_len)
+ .color_range(0, prompt_len..path_end);
+ print_text(text);
+ println!();
+ println!();
+}
+
pub fn render_current_path(
full_path: &PathBuf,
path_is_dir: bool,
handling_filepick: bool,
max_cols: usize,
+ platform: Platform,
) {
let prompt = "PATH: ";
- let current_path = full_path.display().to_string();
+ let current_path = Platform::to_host_display(full_path, platform);
let prompt_len = prompt.width();
let current_path_len = current_path.width();
@@ -117,6 +132,7 @@ pub fn render_current_path(
truncate_path(
full_path.clone(),
current_path_len.saturating_sub(max_path_len),
+ platform,
)
};
let current_path_len = current_path.width();
@@ -131,7 +147,8 @@ pub fn render_current_path(
println!();
}
-fn truncate_path(path: PathBuf, mut char_count_to_remove: usize) -> String {
+fn truncate_path(path: PathBuf, mut char_count_to_remove: usize, platform: Platform) -> String {
+ let sep = platform.separator();
let mut truncated = String::new();
let component_count = path.iter().count();
for (i, component) in path.iter().enumerate() {
@@ -139,13 +156,13 @@ fn truncate_path(path: PathBuf, mut char_count_to_remove: usize) -> String {
if char_count_to_remove > 0 {
truncated.push(component_str.remove(0));
if i != 0 && i + 1 != component_count {
- truncated.push('/');
+ truncated.push(sep);
}
char_count_to_remove = char_count_to_remove.saturating_sub(component_str.width() + 1);
} else {
truncated.push_str(&component_str);
if i != 0 && i + 1 != component_count {
- truncated.push('/');
+ truncated.push(sep);
}
}
}
diff --git a/default-plugins/strider/src/state.rs b/default-plugins/strider/src/state.rs
index 7932764a..a2c03a76 100644
--- a/default-plugins/strider/src/state.rs
+++ b/default-plugins/strider/src/state.rs
@@ -1,10 +1,12 @@
use crate::file_list_view::{FileListView, FsEntry};
+use crate::platform::Platform;
use crate::search_view::SearchView;
-use crate::shared::calculate_list_bounds;
+use crate::shared::{calculate_list_bounds, render_list_tip};
use std::{
collections::BTreeMap,
path::{Path, PathBuf},
};
+use unicode_width::UnicodeWidthStr;
use zellij_tile::prelude::*;
#[derive(Default)]
@@ -18,10 +20,20 @@ pub struct State {
pub is_searching: bool,
pub search_term: String,
pub close_on_selection: bool,
+ pub platform: Platform,
+ pub is_in_virtual_root: bool,
+ pub virtual_root_entries: Vec<FsEntry>,
+ pub virtual_root_selected: usize,
}
impl State {
pub fn update_search_term(&mut self, character: char) {
+ if self.is_in_virtual_root {
+ if character == '/' {
+ self.descend_to_root_path();
+ }
+ return;
+ }
self.search_term.push(character);
if &self.search_term == ".." {
self.descend_to_previous_path();
@@ -34,6 +46,9 @@ impl State {
}
}
pub fn handle_backspace(&mut self) {
+ if self.is_in_virtual_root {
+ return;
+ }
if self.search_term.is_empty() {
self.descend_to_previous_path();
} else {
@@ -52,6 +67,9 @@ impl State {
self.is_searching = false;
}
pub fn clear_search_term_or_descend(&mut self) {
+ if self.is_in_virtual_root {
+ return;
+ }
if self.search_term.is_empty() {
self.descend_to_previous_path();
} else {
@@ -62,14 +80,23 @@ impl State {
}
}
pub fn move_selection_up(&mut self) {
- if self.is_searching {
+ if self.is_in_virtual_root {
+ self.virtual_root_selected = self.virtual_root_selected.saturating_sub(1);
+ } else if self.is_searching {
self.search_view.move_selection_up();
} else {
self.file_list_view.move_selection_up();
}
}
pub fn move_selection_down(&mut self) {
- if self.is_searching {
+ if self.is_in_virtual_root {
+ if !self.virtual_root_entries.is_empty() {
+ self.virtual_root_selected = std::cmp::min(
+ self.virtual_root_selected.saturating_add(1),
+ self.virtual_root_entries.len().saturating_sub(1),
+ );
+ }
+ } else if self.is_searching {
self.search_view.move_selection_down();
} else {
self.file_list_view.move_selection_down();
@@ -78,7 +105,18 @@ impl State {
pub fn handle_left_click(&mut self, line: isize) {
if let Some(current_rows) = self.current_rows {
let rows_for_list = current_rows.saturating_sub(5);
- if self.is_searching {
+ if self.is_in_virtual_root {
+ let (start_index, _selected_index_in_range, _end_index) = calculate_list_bounds(
+ self.virtual_root_entries.len(),
+ rows_for_list,
+ Some(self.virtual_root_selected),
+ );
+ let prev_selected = self.virtual_root_selected;
+ self.virtual_root_selected = (line as usize).saturating_sub(4) + start_index;
+ if prev_selected == self.virtual_root_selected {
+ self.traverse_dir();
+ }
+ } else if self.is_searching {
let (start_index, _selected_index_in_range, _end_index) = calculate_list_bounds(
self.search_view.search_result_count(),
rows_for_list,
@@ -108,7 +146,14 @@ impl State {
pub fn handle_mouse_hover(&mut self, line: isize) {
if let Some(current_rows) = self.current_rows {
let rows_for_list = current_rows.saturating_sub(5);
- if self.is_searching {
+ if self.is_in_virtual_root {
+ let (start_index, _selected_index_in_range, _end_index) = calculate_list_bounds(
+ self.virtual_root_entries.len(),
+ rows_for_list,
+ Some(self.virtual_root_selected),
+ );
+ self.virtual_root_selected = (line as usize).saturating_sub(4) + start_index;
+ } else if self.is_searching {
let (start_index, _selected_index_in_range, _end_index) = calculate_list_bounds(
self.search_view.search_result_count(),
rows_for_list,
@@ -128,11 +173,21 @@ impl State {
}
}
pub fn descend_to_previous_path(&mut self) {
+ if self.is_in_virtual_root {
+ return;
+ }
self.search_term.clear();
self.search_view.clear_and_reset_selection();
- self.file_list_view.descend_to_previous_path();
+ if self.platform == Platform::Windows
+ && Platform::is_root(&self.file_list_view.path, self.platform)
+ {
+ self.enter_virtual_root();
+ } else {
+ self.file_list_view.descend_to_previous_path();
+ }
}
pub fn descend_to_root_path(&mut self) {
+ self.is_in_virtual_root = false;
self.search_term.clear();
self.search_view.clear_and_reset_selection();
self.file_list_view.descend_to_root_path(&self.initial_cwd);
@@ -142,6 +197,18 @@ impl State {
self.hide_hidden_files = !self.hide_hidden_files;
}
pub fn traverse_dir(&mut self) {
+ if self.is_in_virtual_root {
+ if let Some(entry) = self.virtual_root_entries.get(self.virtual_root_selected) {
+ let path = entry.get_full_pathbuf();
+ self.is_in_virtual_root = false;
+ self.file_list_view.path = path.clone();
+ self.file_list_view.path_is_dir = true;
+ self.file_list_view.files.clear();
+ self.file_list_view.clear_selected();
+ change_host_folder(path);
+ }
+ return;
+ }
let entry = if self.is_searching {
self.search_view.get_selected_entry()
} else {
@@ -174,8 +241,12 @@ impl State {
self.search_view.clear_and_reset_selection();
}
pub fn update_files(&mut self, paths: Vec<(PathBuf, Option<FileMetadata>)>) {
- self.file_list_view
- .update_files(paths, self.hide_hidden_files);
+ if self.is_in_virtual_root {
+ self.update_virtual_root_entries(paths);
+ } else {
+ self.file_list_view
+ .update_files(paths, self.hide_hidden_files);
+ }
}
pub fn open_selected_path(&mut self) {
if self.file_list_view.path_is_dir {
@@ -211,15 +282,80 @@ impl State {
}
}
}
+ pub fn enter_virtual_root(&mut self) {
+ self.is_in_virtual_root = true;
+ self.virtual_root_entries.clear();
+ self.virtual_root_selected = 0;
+ self.search_term.clear();
+ self.is_searching = false;
+ list_windows_volumes();
+ }
+ pub fn exit_virtual_root(&mut self) {
+ self.is_in_virtual_root = false;
+ refresh_directory(&self.file_list_view.path);
+ }
+ fn update_virtual_root_entries(&mut self, paths: Vec<(PathBuf, Option<FileMetadata>)>) {
+ let mut entries = vec![];
+ for (path, _metadata) in paths {
+ let path = Platform::normalize(&path);
+ entries.push(FsEntry::Dir(path));
+ }
+ // Drive letters first (e.g. "C:/"), then WSL distros (e.g. "//wsl.localhost/...")
+ entries.sort_unstable_by(|a, b| {
+ let a_is_drive = !a.get_full_pathbuf().to_string_lossy().starts_with("//");
+ let b_is_drive = !b.get_full_pathbuf().to_string_lossy().starts_with("//");
+ b_is_drive.cmp(&a_is_drive).then_with(|| a.cmp(b))
+ });
+ self.virtual_root_entries = entries;
+ if self.virtual_root_selected >= self.virtual_root_entries.len() {
+ self.virtual_root_selected = 0;
+ }
+ }
+ pub fn render_virtual_root(&self, rows: usize, cols: usize) {
+ let (start_index, selected_index_in_range, end_index) = calculate_list_bounds(
+ self.virtual_root_entries.len(),
+ rows.saturating_sub(1),
+ Some(self.virtual_root_selected),
+ );
+ render_list_tip(3, cols);
+ for i in start_index..end_index {
+ if let Some(entry) = self.virtual_root_entries.get(i) {
+ let is_selected = Some(i) == selected_index_in_range;
+ let display_name =
+ Platform::virtual_root_display_name(&entry.get_full_pathbuf(), self.platform);
+ let display_name_width = display_name.width();
+ let text = if display_name_width < cols {
+ let padding = " ".repeat(cols.saturating_sub(display_name_width));
+ format!("{}{}", display_name, padding)
+ } else {
+ display_name
+ };
+ let mut text_element = if is_selected {
+ Text::new(text).selected()
+ } else {
+ Text::new(text)
+ };
+ text_element = text_element.color_range(0, ..);
+ print_text_with_coordinates(
+ text_element,
+ 0,
+ 4 + i.saturating_sub(start_index),
+ Some(cols),
+ None,
+ );
+ }
+ }
+ }
pub fn send_filepick_response(&mut self) {
let selected_path = &self.file_list_view.path;
+ let host_path = Platform::to_host_display(selected_path, self.platform);
match &self.handling_filepick_request_from {
Some((PipeSource::Plugin(plugin_id), args)) => {
pipe_message_to_plugin(
MessageToPlugin::new("filepicker_result")
.with_destination_plugin_id(*plugin_id)
.with_args(args.clone())
- .with_payload(selected_path.display().to_string()),
+ .with_payload(host_path),
);
#[cfg(target_family = "wasm")]
close_self();
@@ -228,7 +364,7 @@ impl State {
// pipe_id is used inside #[cfg(target_family = "wasm")] blocks
Some((PipeSource::Cli(pipe_id), _args)) => {
#[cfg(target_family = "wasm")]
- cli_pipe_output(pipe_id, &selected_path.display().to_string());
+ cli_pipe_output(pipe_id, &host_path);
#[cfg(target_family = "wasm")]
unblock_cli_pipe_input(pipe_id);
#[cfg(target_family = "wasm")]
diff --git a/zellij-server/src/plugins/zellij_exports.rs b/zellij-server/src/plugins/zellij_exports.rs
index 9b6f022f..026be937 100644
--- a/zellij-server/src/plugins/zellij_exports.rs
+++ b/zellij-server/src/plugins/zellij_exports.rs
@@ -437,6 +437,7 @@ fn host_run_plugin_command(mut caller: Caller<'_, PluginEnv>) {
scan_host_folder(env, folder_to_scan)
},
PluginCommand::WatchFilesystem => watch_filesystem(env),
+ PluginCommand::ListWindowsVolumes => list_windows_volumes(env),
PluginCommand::DumpSessionLayout { tab_index } => {
dump_session_layout(env, tab_index)
},
@@ -3725,7 +3726,10 @@ fn scan_host_folder(env: &PluginEnv, folder_to_scan: PathBuf) {
);
return;
}
- let plugin_host_folder = env.plugin_cwd.clone();
+ let plugin_host_folder = env
+ .plugin_cwd
+ .canonicalize()
+ .unwrap_or_else(|_| env.plugin_cwd.clone());
let folder_to_scan = plugin_host_folder.join(folder_to_scan.strip_prefix("/host").unwrap());
match folder_to_scan.canonicalize() {
Ok(folder_to_scan) => {
@@ -3784,6 +3788,88 @@ fn scan_host_folder(env: &PluginEnv, folder_to_scan: PathBuf) {
}
}
+#[cfg(not(windows))]
+fn list_windows_volumes(_env: &PluginEnv) {
+ log::error!("ListWindowsVolumes is only supported on Windows");
+}
+
+#[cfg(windows)]
+fn list_windows_volumes(env: &PluginEnv) {
+ let send_plugin_instructions = env.senders.to_plugin.clone();
+ let update_target = Some(env.plugin_id);
+ let client_id = env.client_id;
+ thread::spawn(move || {
+ let mut entries = enumerate_drives();
+ entries.extend(enumerate_wsl_distributions());
+ let _ = send_plugin_instructions
+ .ok_or(anyhow!("found no sender to send plugin instruction to"))
+ .map(|sender| {
+ let _ = sender.send(PluginInstruction::Update(vec![(
+ update_target,
+ Some(client_id),
+ Event::FileSystemUpdate(entries),
+ )]));
+ })
+ .non_fatal();
+ });
+}
+
+#[cfg(windows)]
+fn enumerate_drives() -> Vec<(PathBuf, Option<zellij_utils::data::FileMetadata>)> {
+ use windows_sys::Win32::Storage::FileSystem::GetLogicalDriveStringsW;
+ let mut buf = [0u16; 256];
+ let len = unsafe { GetLogicalDriveStringsW(buf.len() as u32, buf.as_mut_ptr()) };
+ if len == 0 {
+ return vec![];
+ }
+ let drive_strings = &buf[..len as usize];
+ let mut entries = vec![];
+ for chunk in drive_strings.split(|&c| c == 0) {
+ if chunk.is_empty() {
+ continue;
+ }
+ let drive = String::from_utf16_lossy(chunk);
+ let normalized = drive.replace('\\', "/");
+ let drive_path = PathBuf::from(&normalized);
+ let metadata = drive_path.metadata().ok().map(|m| m.into());
+ entries.push((drive_path, metadata));
+ }
+ entries
+}
+
+#[cfg(windows)]
+fn enumerate_wsl_distributions() -> Vec<(PathBuf, Option<zellij_utils::data::FileMetadata>)> {
+ // read_dir on \\wsl.localhost\ or \\wsl$\ can fail with OS error 64/67
+ // even when WSL is running, so enumerate distros via `wsl -l -q` instead.
+ let output = match std::process::Command::new("wsl")
+ .args(["-l", "-q"])
+ .output()
+ {
+ Ok(o) if o.status.success() => o,
+ _ => return vec![],
+ };
+ // wsl -l -q outputs UTF-16LE on Windows
+ let stdout: Vec<u16> = output
+ .stdout
+ .chunks(2)
+ .map(|c| u16::from_le_bytes([c[0], *c.get(1).unwrap_or(&0)]))
+ .collect();
+ let text = String::from_utf16_lossy(&stdout);
+ let mut entries = vec![];
+ for line in text.lines() {
+ let name = line.trim().trim_matches('\0');
+ if name.is_empty() {
+ continue;
+ }
+ // Verify the distro is reachable before listing it
+ let unc = format!("\\\\wsl.localhost\\{}", name);
+ let metadata = std::fs::metadata(&unc).ok().map(|m| m.into());
+ let wsl_path = PathBuf::from(format!("//wsl.localhost/{}/", name));
+ entries.push((wsl_path, metadata));
+ }
+ entries
+}
+
fn resize_pane_with_id(env: &PluginEnv, resize: ResizeStrategy, pane_id: PaneId) {
let _ = env
.senders
@@ -5218,7 +5304,9 @@ fn check_command_permission(
PluginCommand::RebindKeys { .. } | PluginCommand::Reconfigure(..) => {
PermissionType::Reconfigure
},
- PluginCommand::ChangeHostFolder(..) => PermissionType::FullHdAccess,
+ PluginCommand::ChangeHostFolder(..) | PluginCommand::ListWindowsVolumes => {
+ PermissionType::FullHdAccess
+ },
PluginCommand::ShareCurrentSession
| PluginCommand::StopSharingCurrentSession
| PluginCommand::StopWebServer
diff --git a/zellij-tile/src/shim.rs b/zellij-tile/src/shim.rs
index db07548f..5382dde4 100644
--- a/zellij-tile/src/shim.rs
+++ b/zellij-tile/src/shim.rs
@@ -1587,6 +1587,16 @@ where
unsafe { host_run_plugin_command() };
}
+/// List Windows volumes (drives and WSL distributions).
+/// Results are returned via the `FileSystemUpdate` event.
+/// This command is only supported on Windows and requires FullHdAccess permission.
+pub fn list_windows_volumes() {
+ let plugin_command = PluginCommand::ListWindowsVolumes;
+ let protobuf_plugin_command: ProtobufPluginCommand = plugin_command.try_into().unwrap();
+ object_to_stdout(&protobuf_plugin_command.encode_to_vec());
+ unsafe { host_run_plugin_command() };
+}
+
/// Scan a specific folder in the host filesystem (this is a hack around some WASI runtime performance
/// issues), will not follow symlinks
pub fn scan_host_folder<S: AsRef<Path>>(folder_to_scan: &S) {
diff --git a/zellij-utils/assets/prost/api.plugin_command.rs b/zellij-utils/assets/prost/api.plugin_command.rs
index 52626ac6..71a843dd 100644
--- a/zellij-utils/assets/prost/api.plugin_command.rs
+++ b/zellij-utils/assets/prost/api.plugin_command.rs
@@ -2091,6 +2091,7 @@ pub enum CommandName {
SetPaneRegexHighlights = 207,
ClearPaneHighlights = 208,
OpenPluginPaneFloating = 209,
+ ListWindowsVolumes = 210,
}
impl CommandName {
/// String value of the enum field names used in the ProtoBuf definition.
@@ -2288,6 +2289,7 @@ impl CommandName {
CommandName::SetPaneRegexHighlights => "SetPaneRegexHighlights",
CommandName::ClearPaneHighlights => "ClearPaneHighlights",
CommandName::OpenPluginPaneFloating => "OpenPluginPaneFloating",
+ CommandName::ListWindowsVolumes => "ListWindowsVolumes",
}
}
/// Creates an enum from field names used in the ProtoBuf definition.
@@ -2482,6 +2484,7 @@ impl CommandName {
"SetPaneRegexHighlights" => Some(Self::SetPaneRegexHighlights),
"ClearPaneHighlights" => Some(Self::ClearPaneHighlights),
"OpenPluginPaneFloating" => Some(Self::OpenPluginPaneFloating),
+ "ListWindowsVolumes" => Some(Self::ListWindowsVolumes),
_ => None,
}
}
diff --git a/zellij-utils/src/data.rs b/zellij-utils/src/data.rs
index 1fdea34e..badf106b 100644
--- a/zellij-utils/src/data.rs
+++ b/zellij-utils/src/data.rs
@@ -3561,6 +3561,7 @@ pub enum PluginCommand {
floating_pane_coordinates: Option<FloatingPaneCoordinates>,
context: BTreeMap<String, String>,
},
+ ListWindowsVolumes,
}
// Response type for plugin API methods that open a pane in a new tab
diff --git a/zellij-utils/src/plugin_api/plugin_command.proto b/zellij-utils/src/plugin_api/plugin_command.proto
index f00ffdd1..63baa443 100644
--- a/zellij-utils/src/plugin_api/plugin_command.proto
+++ b/zellij-utils/src/plugin_api/plugin_command.proto
@@ -202,6 +202,7 @@ enum CommandName {
SetPaneRegexHighlights = 207;
ClearPaneHighlights = 208;
OpenPluginPaneFloating = 209;
+ ListWindowsVolumes = 210;
}
message PluginCommand {
diff --git a/zellij-utils/src/plugin_api/plugin_command.rs b/zellij-utils/src/plugin_api/plugin_command.rs
index fc55dd91..a457f720 100644
--- a/zellij-utils/src/plugin_api/plugin_command.rs
+++ b/zellij-utils/src/plugin_api/plugin_command.rs
@@ -1296,6 +1296,10 @@ impl TryFrom<ProtobufPluginCommand> for PluginCommand {
Some(_) => Err("WatchFilesystem should have no payload, found a payload"),
None => Ok(PluginCommand::WatchFilesystem),
},
+ Some(CommandName::ListWindowsVolumes) => match protobuf_plugin_command.payload {
+ Some(_) => Err("ListWindowsVolumes should have no payload"),
+ None => Ok(PluginCommand::ListWindowsVolumes),
+ },
Some(CommandName::DumpSessionLayout) => match protobuf_plugin_command.payload {
Some(Payload::DumpSessionLayoutPayload(payload)) => {
Ok(PluginCommand::DumpSessionLayout {
@@ -3137,6 +3141,10 @@ impl TryFrom<PluginCommand> for ProtobufPluginCommand {
name: CommandName::WatchFilesystem as i32,
payload: None,
}),
+ PluginCommand::ListWindowsVolumes => Ok(ProtobufPluginCommand {
+ name: CommandName::ListWindowsVolumes as i32,
+ payload: None,
+ }),
PluginCommand::DumpSessionLayout { tab_index } => Ok(ProtobufPluginCommand {
name: CommandName::DumpSessionLayout as i32,
payload: tab_index.map(|idx| {