forked from 1b-agent/opencadstudio-upstream
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:
1 parent
f3482e8b3e
commit
6032a024d2
8 files changed
+212
-526
No files matched your search
-156
@@ -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
|
||||
|
||||
Reference in new issue
Block a user