diff options
| author | divens <divens@users.noreply.github.com> | 2026-03-21 19:20:32 +0100 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-03-21 19:20:32 +0100 |
| commit | 3fa7744906d5704bdec771c74829841bd06cf7fc (patch) | |
| tree | 6c4f0bd64c2e6087ce069a466cca942f4669f513 | |
| parent | Release notes and fixes (#4879) (diff) | |
| download | sl0ppij-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.rs | 11 | ||||
| -rw-r--r-- | default-plugins/strider/src/main.rs | 53 | ||||
| -rw-r--r-- | default-plugins/strider/src/platform.rs | 240 | ||||
| -rw-r--r-- | default-plugins/strider/src/search_view.rs | 7 | ||||
| -rw-r--r-- | default-plugins/strider/src/shared.rs | 25 | ||||
| -rw-r--r-- | default-plugins/strider/src/state.rs | 156 | ||||
| -rw-r--r-- | zellij-server/src/plugins/zellij_exports.rs | 92 | ||||
| -rw-r--r-- | zellij-tile/src/shim.rs | 10 | ||||
| -rw-r--r-- | zellij-utils/assets/prost/api.plugin_command.rs | 3 | ||||
| -rw-r--r-- | zellij-utils/src/data.rs | 1 | ||||
| -rw-r--r-- | zellij-utils/src/plugin_api/plugin_command.proto | 1 | ||||
| -rw-r--r-- | zellij-utils/src/plugin_api/plugin_command.rs | 8 |
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| { |
