feat(save): native OS dialog for Save As, keep in-app format picker

Replace the custom in-app file-browser save dialog with the native OS
save dialog for choosing the destination path — folder browsing and
overwrite confirmation now come from the OS. A compact in-app modal
still selects the DWG/DXF format and version (a native dialog cannot
carry the version year) and appears only for Save As. Plain Save on an
unsaved drawing defaults to DWG 2018 with no picker. The file-name field
is shown only on web, where there is no native dialog.

Remove the hand-rolled directory listing, folder navigation, and
overwrite modal.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-07-11 09:12:36 +03:00
1 parent f3482e8b3e
commit 6032a024d2
8 files changed
+212 -526

No files matched your search

-156
View File
@@ -266,162 +266,6 @@ fn resolve_image_file(raw: &str, base_dir: Option<&Path>) -> Option<String> {
None
}
// ── Directory listing (used by the custom Save As dialog) ────────────────
/// 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()
}
/// True if `p` is the drives-root sentinel (see [`drives_root`]).
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.
///
/// 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<PathBuf> {
if is_drives_root(dir) {
return None; // already at the top
}
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 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)> {
#[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<PathBuf> = 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("/"))]
}
}
/// 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)> {
// Volumes root ("This PC" / "Computer"): list the mounted volumes instead
// of reading a directory.
if is_drives_root(dir) {
return available_drives();
}
let mut dirs: Vec<(String, bool, PathBuf)> = Vec::new();
let mut files: Vec<(String, bool, PathBuf)> = Vec::new();
if let Some(parent) = parent_folder(dir) {
dirs.push(("..".to_string(), true, parent));
}
if let Ok(rd) = std::fs::read_dir(dir) {
for entry in rd.flatten() {
let name = entry.file_name().to_string_lossy().into_owned();
if name.starts_with('.') {
continue;
}
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
if is_dir {
dirs.push((name, true, entry.path()));
} else {
files.push((name, false, entry.path()));
}
}
}
dirs.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
files.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()));
dirs.extend(files);
dirs
}
// ── Save ──────────────────────────────────────────────────────────────────
/// Parse a format string like "DWG 2013" or "DXF 2007" into