From 125b89d8daedd9d0a77f0851f416133c8a4cd2d6 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Wed, 24 Jun 2026 23:44:40 +0300 Subject: [PATCH] feat(save-dialog): extend drive navigation to macOS and Linux Issue #170's fix added a "This PC" volumes root above the drive letters on Windows. Generalize it so the Save As dialog can switch volumes on every platform: - parent_folder() now climbs the filesystem root into the volumes list on all platforms (`/` -> Computer), not just Windows. - available_drives() is platform-specific: Windows probes A:-Z:, macOS lists `/` + /Volumes/*, Linux lists `/` + the removable mounts under /run/media/$USER, /media/$USER and /mnt. Other Unix and wasm fall back to `/`. - drives_root_label() shows "This PC" on Windows, "Computer" elsewhere. - read_dir_entries() lists the volumes at the drives root on every platform. Also gitignore the dist/ web build output so the wasm binary is never committed by accident. Co-Authored-By: Claude Opus 4.8 --- src/app/view.rs | 2 +- src/io/mod.rs | 129 ++++++++++++++++++++++++++++++++++++------------ 2 files changed, 99 insertions(+), 32 deletions(-) diff --git a/src/app/view.rs b/src/app/view.rs index e1be26759..2d36fe35a 100644 --- a/src/app/view.rs +++ b/src/app/view.rs @@ -3472,7 +3472,7 @@ fn save_as_dialog_window<'a>( // ── Path bar ───────────────────────────────────────────────────────── let path_str = if crate::io::is_drives_root(folder) { - "This PC".to_string() + crate::io::drives_root_label().to_string() } else { folder.to_string_lossy().into_owned() }; diff --git a/src/io/mod.rs b/src/io/mod.rs index 4828d0ca8..5953243b0 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -239,9 +239,11 @@ fn resolve_image_file(raw: &str, base_dir: Option<&Path>) -> Option { // ── Directory listing (used by the custom Save As dialog) ──────────────── -/// Sentinel path standing in for the Windows "This PC" drives root — the -/// pseudo-folder above every drive letter, whose children are the volumes. -/// A real filesystem path is never empty, so an empty path is a safe marker. +/// Sentinel path standing in for the volumes root — the pseudo-folder above +/// every drive/volume, whose children are the mounted volumes. On Windows this +/// is "This PC" (above the drive letters); on macOS/Linux it is "Computer" +/// (above `/` and the removable mounts). A real filesystem path is never empty, +/// so an empty path is a safe marker on every platform. pub fn drives_root() -> PathBuf { PathBuf::new() } @@ -251,51 +253,116 @@ pub fn is_drives_root(p: &Path) -> bool { p.as_os_str().is_empty() } +/// Human label for the drives-root, matching each platform's file manager. +pub fn drives_root_label() -> &'static str { + #[cfg(windows)] + { + "This PC" + } + #[cfg(not(windows))] + { + "Computer" + } +} + /// The folder one level above `dir`, or `None` if already at the top. /// -/// On Windows the chain reaches past a drive root into the drives list, so the -/// Save As dialog can switch volumes: `…\sub` → `C:\` → This PC → `None`. -/// Without this, `Path::parent()` returns `None` at `C:\` and navigation -/// dead-ends on the starting drive (issue #170). +/// The chain reaches past a filesystem root into the volumes list, so the Save +/// As dialog can switch volumes on every platform: +/// `…\sub` → `C:\` → This PC → `None` (Windows), or +/// `/home/me` → `/home` → `/` → Computer → `None` (macOS/Linux). +/// Without this, `Path::parent()` returns `None` at the root and navigation +/// dead-ends on the starting volume (issue #170). pub fn parent_folder(dir: &Path) -> Option { if is_drives_root(dir) { return None; // already at the top } - if let Some(parent) = dir.parent() { - return Some(parent.to_path_buf()); - } - // No parent: we're at a filesystem root (e.g. `C:\`). - #[cfg(windows)] - { - Some(drives_root()) - } - #[cfg(not(windows))] - { - None + match dir.parent() { + // A real parent exists (and is not the empty sentinel). + Some(parent) if !parent.as_os_str().is_empty() => Some(parent.to_path_buf()), + // No real parent: we're at a filesystem root (`C:\` or `/`) — step up + // into the volumes list rather than dead-ending. + _ => Some(drives_root()), } } -/// Enumerate the accessible Windows volumes as directory entries. -/// Probes `A:\`–`Z:\` by stat rather than calling into the Win32 API, so it -/// needs no extra dependency. Drives with no inserted media are skipped. -#[cfg(windows)] +/// Enumerate the accessible volumes as directory entries. +/// +/// Windows probes `A:\`–`Z:\` by stat (no Win32 binding, no extra dependency); +/// drives with no inserted media fail the stat and are skipped. macOS lists `/` +/// plus the mounts under `/Volumes`. Linux lists `/` plus removable mounts under +/// the usual desktop locations. Other Unix falls back to just `/`. fn available_drives() -> Vec<(String, bool, PathBuf)> { - let mut out: Vec<(String, bool, PathBuf)> = Vec::new(); - for letter in b'A'..=b'Z' { - let root = format!("{}:\\", letter as char); - let path = PathBuf::from(&root); - if path.is_dir() { - out.push((root, true, path)); + #[cfg(windows)] + { + let mut out: Vec<(String, bool, PathBuf)> = Vec::new(); + for letter in b'A'..=b'Z' { + let root = format!("{}:\\", letter as char); + let path = PathBuf::from(&root); + if path.is_dir() { + out.push((root, true, path)); + } } + out + } + + #[cfg(target_os = "macos")] + { + let mut out: Vec<(String, bool, PathBuf)> = vec![("/".to_string(), true, PathBuf::from("/"))]; + if let Ok(rd) = std::fs::read_dir("/Volumes") { + for entry in rd.flatten() { + let path = entry.path(); + if path.is_dir() { + out.push(( + entry.file_name().to_string_lossy().into_owned(), + true, + path, + )); + } + } + } + out + } + + #[cfg(target_os = "linux")] + { + let mut out: Vec<(String, bool, PathBuf)> = vec![("/".to_string(), true, PathBuf::from("/"))]; + let user = std::env::var("USER").unwrap_or_default(); + // Per-user removable mounts first (udisks2 / older udisks), then /mnt. + let bases = [ + format!("/run/media/{user}"), + format!("/media/{user}"), + "/mnt".to_string(), + ]; + let mut seen: std::collections::HashSet = std::collections::HashSet::new(); + for base in bases { + if let Ok(rd) = std::fs::read_dir(&base) { + for entry in rd.flatten() { + let path = entry.path(); + if path.is_dir() && seen.insert(path.clone()) { + out.push(( + entry.file_name().to_string_lossy().into_owned(), + true, + path, + )); + } + } + } + } + out + } + + #[cfg(not(any(windows, target_os = "macos", target_os = "linux")))] + { + vec![("/".to_string(), true, PathBuf::from("/"))] } - out } /// Read `dir` and return sorted entries: `(display_name, is_dir, full_path)`. /// Directories come first, then files. Hidden entries (`.`) are skipped. pub fn read_dir_entries(dir: &std::path::Path) -> Vec<(String, bool, PathBuf)> { - // Windows "This PC": list the available volumes instead of reading a dir. - #[cfg(windows)] + // Volumes root ("This PC" / "Computer"): list the mounted volumes instead + // of reading a directory. if is_drives_root(dir) { return available_drives(); }