fix(io): guard concurrent drawing saves

Hold edit leases and compare disk fingerprints before replacement so another editor cannot be silently overwritten.

Offer retry, reload, Save As, or explicit overwrite recovery.

Closes #498
This commit is contained in:
Hakan Seven committed 2026-07-27 13:18:31 +03:00
1 parent 9d973a0fd6
commit 2acc4f7978
12 files changed
+1424 -23

No files matched your search

+7 -2
View File
@@ -350,9 +350,14 @@ impl OpenCADStudio {
let Some(path) = path else {
return err("save: no \"path\" and the document has none");
};
match crate::io::save(&self.tabs[i].scene.document, &path) {
#[cfg(not(target_arch = "wasm32"))]
let result =
self.save_tab_synchronously_protected(i, path.clone(), true);
#[cfg(target_arch = "wasm32")]
let result = crate::io::save(&self.tabs[i].scene.document, &path)
.map_err(crate::io::SaveFailure::other);
match result {
Ok(()) => {
self.tabs[i].current_path = Some(path.clone());
json!({ "ok": true, "saved": path.to_string_lossy() })
}
Err(e) => err(format!("save: {e}")),
+16 -5
View File
@@ -22,13 +22,24 @@ impl OpenCADStudio {
if self.tabs[t].is_start {
continue;
}
self.sync_vport_display(t);
if let Some(path) = self.tabs[t].current_path.clone() {
self.tabs[t].scene.document.header.user_real1 =
self.tabs[t].scene.annotation_scale as f64;
match crate::io::save(&self.tabs[t].scene.document, &path) {
if self
.active_save_jobs
.contains_key(&self.tabs[t].id)
{
self.command_line.push_info(&format!(
"SAVEALL: {} already has a save running",
path.display()
));
skipped += 1;
continue;
}
match self.save_tab_synchronously_protected(
t,
path.clone(),
false,
) {
Ok(()) => {
self.tabs[t].dirty = false;
saved += 1;
}
Err(e) => self.command_line.push_error(&format!(
+17
View File
@@ -162,6 +162,17 @@ pub(super) struct DocumentTab {
pub(super) id: u64,
pub(super) scene: Scene,
pub(super) current_path: Option<PathBuf>,
/// Persistent native edit lease for the drawing currently at
/// `current_path`. The sidecar portion survives atomic file replacement.
#[cfg(not(target_arch = "wasm32"))]
pub(super) edit_lease: Option<crate::io::edit_lock::EditLease>,
/// True when another editor owns the drawing lease. Direct Save stays
/// blocked until Retry acquires it or the user chooses Save As.
#[cfg(not(target_arch = "wasm32"))]
pub(super) edit_lock_conflict: bool,
/// Disk state observed after open or the latest successful save.
#[cfg(not(target_arch = "wasm32"))]
pub(super) disk_fingerprint: Option<crate::io::edit_lock::FileFingerprint>,
pub(super) dirty: bool,
/// Monotonic committed-edit/undo/redo revision. Background save completion
/// uses it with scene epochs so an older snapshot never clears newer work.
@@ -429,6 +440,12 @@ impl DocumentTab {
id: NEXT_DOCUMENT_TAB_ID.fetch_add(1, Ordering::Relaxed),
scene,
current_path: None,
#[cfg(not(target_arch = "wasm32"))]
edit_lease: None,
#[cfg(not(target_arch = "wasm32"))]
edit_lock_conflict: false,
#[cfg(not(target_arch = "wasm32"))]
disk_fingerprint: None,
dirty: false,
edit_revision: 0,
prev_selection: Vec::new(),
+70 -1
View File
@@ -188,6 +188,10 @@ pub struct OpenProgress {
pub size_bytes: u64,
pub state: Arc<crate::io::OpenProgressState>,
pub started: Instant,
/// Disk state captured before parsing starts. If another editor changes the
/// file while it loads, the first Save must not silently overwrite it.
#[cfg(not(target_arch = "wasm32"))]
pub fingerprint: Option<crate::io::edit_lock::FileFingerprint>,
}
// ── Application state ──────────────────────────────────────────────────────
@@ -765,6 +769,16 @@ pub(super) struct OpenCADStudio {
active_save_jobs: std::collections::HashMap<u64, u64>,
#[cfg(not(target_arch = "wasm32"))]
save_job_serial: u64,
/// Destination leases held while Save As workers are active.
#[cfg(not(target_arch = "wasm32"))]
pending_save_leases:
std::collections::HashMap<u64, crate::io::edit_lock::EditLease>,
/// Locked-file failure currently shown in the recovery dialog.
#[cfg(not(target_arch = "wasm32"))]
pending_save_failure: Option<PendingSaveFailure>,
/// Save stopped because the drawing changed outside this editor.
#[cfg(not(target_arch = "wasm32"))]
pending_external_change: Option<PendingExternalChange>,
/// OS window for the unsaved-changes confirmation dialog.
// ── Custom Save-As dialog ─────────────────────────────────────────────
@@ -889,6 +903,29 @@ pub(super) enum SaveContinuation {
Quit,
}
#[derive(Debug, Clone)]
#[cfg(not(target_arch = "wasm32"))]
pub(super) struct PendingSaveFailure {
tab_id: u64,
path: PathBuf,
version: acadrust::DxfVersion,
purpose: SavePurpose,
continuation: SaveContinuation,
set_current_path: bool,
error: String,
}
#[derive(Debug, Clone)]
#[cfg(not(target_arch = "wasm32"))]
pub(super) struct PendingExternalChange {
tab_id: u64,
path: PathBuf,
version: acadrust::DxfVersion,
purpose: SavePurpose,
continuation: SaveContinuation,
set_current_path: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg(not(target_arch = "wasm32"))]
pub(super) struct ThumbnailCacheKey {
@@ -908,13 +945,14 @@ pub struct SaveOutcome {
revision: u64,
camera_generation: u64,
path: PathBuf,
version: acadrust::DxfVersion,
previous_autosave: Option<PathBuf>,
set_current_path: bool,
purpose: SavePurpose,
continuation: SaveContinuation,
thumbnail_key: Option<ThumbnailCacheKey>,
refreshed_preview: Option<Option<acadrust::Preview>>,
result: Result<(), String>,
result: Result<(), crate::io::SaveFailure>,
}
/// Where a colour chosen in the standalone palette window should be applied.
@@ -1262,6 +1300,10 @@ pub enum ModalKind {
Unsaved,
SaveDialog,
AecDropWarning,
#[cfg(not(target_arch = "wasm32"))]
FileInUse,
#[cfg(not(target_arch = "wasm32"))]
ExternalChange,
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
AssocPrompt,
PointStyle,
@@ -1490,6 +1532,27 @@ pub enum Message {
/// Native background save/autosave completed.
#[cfg(not(target_arch = "wasm32"))]
SaveFinished(SaveOutcome),
/// Retry the failed save after the other application releases the file.
#[cfg(not(target_arch = "wasm32"))]
SaveFileInUseRetry,
/// Choose a different destination for the failed save.
#[cfg(not(target_arch = "wasm32"))]
SaveFileInUseSaveAs,
/// Cancel the failed save and any pending close/quit continuation.
#[cfg(not(target_arch = "wasm32"))]
SaveFileInUseCancel,
/// Reload the externally changed drawing and discard local edits.
#[cfg(not(target_arch = "wasm32"))]
ExternalChangeReload,
/// Keep local edits but choose a different destination.
#[cfg(not(target_arch = "wasm32"))]
ExternalChangeSaveAs,
/// Replace the externally changed disk copy with the local drawing.
#[cfg(not(target_arch = "wasm32"))]
ExternalChangeOverwrite,
/// Cancel the conflicted save and any pending close/quit continuation.
#[cfg(not(target_arch = "wasm32"))]
ExternalChangeCancel,
// ─────────────────────────────────────────────────────────────────────
CommandInput(String),
CommandSubmit,
@@ -2537,6 +2600,12 @@ impl OpenCADStudio {
active_save_jobs: std::collections::HashMap::new(),
#[cfg(not(target_arch = "wasm32"))]
save_job_serial: 0,
#[cfg(not(target_arch = "wasm32"))]
pending_save_leases: std::collections::HashMap::new(),
#[cfg(not(target_arch = "wasm32"))]
pending_save_failure: None,
#[cfg(not(target_arch = "wasm32"))]
pending_external_change: None,
save_dialog_format: "DWG 2018".to_string(),
save_dialog_filename: "drawing.dwg".to_string(),
save_dialog_for_unsaved: false,
+1
View File
@@ -272,6 +272,7 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
crate::app::SavePurpose::Manual,
continuation,
false,
true,
);
return Task::batch([close, save]);
}
+541 -3
View File
@@ -43,6 +43,17 @@ where
}
}
#[cfg(not(target_arch = "wasm32"))]
fn native_paths_match(left: &std::path::Path, right: &std::path::Path) -> bool {
match (
std::fs::canonicalize(left),
std::fs::canonicalize(right),
) {
(Ok(left), Ok(right)) => left == right,
_ => left == right,
}
}
impl OpenCADStudio {
/// Before a save, give every cached truck solid that still has no ACIS
/// geometry (EXTRUDE/REVOLVE/SWEEP/LOFT/boolean results) an exact modeler
@@ -390,7 +401,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
self.opening = Some(crate::app::OpenProgress {
name: "Opening…".into(),
size_bytes: 0,
state: state.clone(),
state: state.clone(),
started: Instant::now(),
});
Task::perform(crate::io::pick_and_load_web(state), Message::FileOpened)
@@ -426,6 +437,233 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
}
}
#[cfg(not(target_arch = "wasm32"))]
fn install_native_edit_guard(
&mut self,
i: usize,
path: &std::path::Path,
loaded_fingerprint: Option<crate::io::edit_lock::FileFingerprint>,
) {
let current_fingerprint =
crate::io::edit_lock::FileFingerprint::capture(path).ok();
if loaded_fingerprint
.as_ref()
.zip(current_fingerprint.as_ref())
.is_some_and(|(loaded, current)| loaded != current)
{
self.command_line.push_error_once(
"Drawing changed on disk while it was opening; Save will require conflict resolution.",
);
}
self.tabs[i].disk_fingerprint =
loaded_fingerprint.or(current_fingerprint);
match crate::io::edit_lock::EditLease::acquire(path) {
Ok(lease) => {
if let Some(warning) = lease.platform_warning() {
self.command_line.push_info(&format!(
"Edit lease active; {warning}. External-change checks remain active."
));
}
self.tabs[i].edit_lease = Some(lease);
self.tabs[i].edit_lock_conflict = false;
}
Err(crate::io::edit_lock::EditLeaseError::Locked(error)) => {
self.tabs[i].edit_lease = None;
self.tabs[i].edit_lock_conflict = true;
self.command_line.push_error_once(&format!(
"Opened read-only against other editors: {error}"
));
}
Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => {
self.tabs[i].edit_lease = None;
self.tabs[i].edit_lock_conflict = false;
self.command_line.push_info(&format!(
"{error}. External-change checks remain active."
));
}
}
}
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn refresh_native_edit_guard_after_save(
&mut self,
i: usize,
path: &std::path::Path,
path_changed: bool,
destination_lease: Option<crate::io::edit_lock::EditLease>,
) {
if path_changed {
self.tabs[i].edit_lease = None;
self.tabs[i].edit_lease = destination_lease;
self.tabs[i].edit_lock_conflict = false;
if self.tabs[i].edit_lease.is_none() {
self.install_native_edit_guard(i, path, None);
return;
}
}
let refresh = self.tabs[i].edit_lease.as_mut().map(|lease| {
lease
.refresh_drawing_lock(path)
.map(|_| lease.platform_warning().map(str::to_owned))
});
match refresh {
Some(Ok(warning)) => {
self.tabs[i].edit_lock_conflict = false;
if let Some(warning) = warning {
self.command_line.push_info(&format!(
"{warning}. External-change checks remain active."
));
}
}
Some(Err(crate::io::edit_lock::EditLeaseError::Locked(error))) => {
self.tabs[i].edit_lock_conflict = true;
self.command_line.push_error_once(&format!(
"Saved, but the refreshed drawing is locked by another editor: {error}"
));
}
Some(Err(crate::io::edit_lock::EditLeaseError::Unavailable(error))) => {
self.tabs[i].edit_lock_conflict = false;
self.command_line.push_info(&format!(
"{error}. External-change checks remain active."
));
}
None => self.install_native_edit_guard(i, path, None),
}
self.tabs[i].disk_fingerprint =
crate::io::edit_lock::FileFingerprint::capture(path).ok();
}
#[cfg(not(target_arch = "wasm32"))]
pub(in crate::app) fn save_tab_synchronously_protected(
&mut self,
i: usize,
path: std::path::PathBuf,
set_current_path: bool,
) -> Result<(), crate::io::SaveFailure> {
let path_changed = self.tabs[i]
.current_path
.as_deref()
.is_none_or(|current| !native_paths_match(current, &path));
if !path_changed && self.tabs[i].edit_lock_conflict {
return Err(crate::io::SaveFailure::file_in_use(
"drawing edit lock is held by another editor",
));
}
let destination_lease = if path_changed {
match crate::io::edit_lock::EditLease::acquire(&path) {
Ok(lease) => Some(lease),
Err(crate::io::edit_lock::EditLeaseError::Locked(error)) => {
return Err(crate::io::SaveFailure::file_in_use(error));
}
Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => {
self.command_line.push_info(&format!(
"{error}. External-change checks remain active."
));
None
}
}
} else {
None
};
let expected_fingerprint = if path_changed {
match crate::io::edit_lock::FileFingerprint::capture(&path) {
Ok(fingerprint) => Some(fingerprint),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => None,
Err(error) => {
return Err(crate::io::SaveFailure::other(format!(
"could not verify {} before saving: {error}",
path.display()
)));
}
}
} else {
self.tabs[i].disk_fingerprint.clone().or_else(|| {
crate::io::edit_lock::FileFingerprint::capture(&path).ok()
})
};
self.prepare_native_save(i);
let version = self.tabs[i].scene.document.version;
let snapshot = self.tabs[i].scene.document.clone();
crate::io::save_owned_as_version_atomic(
snapshot,
&path,
version,
self.backup_on_save,
expected_fingerprint,
)?;
if set_current_path {
self.tabs[i].current_path = Some(path.clone());
}
self.refresh_native_edit_guard_after_save(
i,
&path,
path_changed,
destination_lease,
);
self.tabs[i].dirty = false;
let _ = std::fs::remove_file(path.with_extension("sv$"));
Ok(())
}
#[cfg(not(target_arch = "wasm32"))]
fn retry_native_edit_guard(
&mut self,
i: usize,
path: &std::path::Path,
) -> Result<(), String> {
if let Some(lease) = self.tabs[i].edit_lease.as_mut() {
match lease.refresh_drawing_lock(path) {
Ok(()) => {
let warning = lease.platform_warning().map(str::to_owned);
self.tabs[i].edit_lock_conflict = false;
if let Some(warning) = warning {
self.command_line.push_info(&format!(
"{warning}. External-change checks remain active."
));
}
return Ok(());
}
Err(crate::io::edit_lock::EditLeaseError::Locked(error)) => {
return Err(error);
}
Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => {
self.tabs[i].edit_lock_conflict = false;
self.command_line.push_info(&format!(
"{error}. External-change checks remain active."
));
return Ok(());
}
}
}
match crate::io::edit_lock::EditLease::acquire(path) {
Ok(lease) => {
let warning = lease.platform_warning().map(str::to_owned);
self.tabs[i].edit_lease = Some(lease);
self.tabs[i].edit_lock_conflict = false;
if let Some(warning) = warning {
self.command_line.push_info(&format!(
"{warning}. External-change checks remain active."
));
}
Ok(())
}
Err(crate::io::edit_lock::EditLeaseError::Locked(error)) => Err(error),
Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => {
self.tabs[i].edit_lock_conflict = false;
self.command_line.push_info(&format!(
"{error}. External-change checks remain active."
));
Ok(())
}
}
}
pub(super) fn on_file_opened(&mut self, name: String, path: std::path::PathBuf, doc: acadrust::CadDocument,
mut caches: crate::scene::DerivedCaches,
) -> Task<Message> {
@@ -490,7 +728,14 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
idx
};
#[cfg(not(target_arch = "wasm32"))]
let opened_fingerprint = self
.opening
.as_ref()
.and_then(|opening| opening.fingerprint.clone());
self.tabs[i].current_path = Some(path.clone());
#[cfg(not(target_arch = "wasm32"))]
self.install_native_edit_guard(i, &path, opened_fingerprint);
self.tabs[i].scene.material_base_dir =
path.parent().map(std::path::Path::to_path_buf);
self.tabs[i].scene.document = doc;
@@ -749,6 +994,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
purpose: crate::app::SavePurpose,
continuation: crate::app::SaveContinuation,
set_current_path: bool,
check_external_change: bool,
) -> Task<Message> {
let tab_id = self.tabs[i].id;
if self.active_save_jobs.contains_key(&tab_id) {
@@ -758,6 +1004,67 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
}
return Task::none();
}
if purpose != crate::app::SavePurpose::Autosave
&& !set_current_path
&& self.tabs[i].edit_lock_conflict
{
let error = "Drawing edit lock is held by another editor.".to_string();
self.command_line.push_error_once(&format!(
"Unable to save \"{}\": {error}",
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string())
));
self.pending_save_failure = Some(crate::app::PendingSaveFailure {
tab_id,
path,
version,
purpose,
continuation,
set_current_path,
error,
});
self.restore_failed_save_continuation(continuation, i);
self.active_modal = Some(crate::app::ModalKind::FileInUse);
return Task::none();
}
let destination_is_current = self.tabs[i]
.current_path
.as_deref()
.is_some_and(|current| native_paths_match(current, &path));
if set_current_path && !destination_is_current {
match crate::io::edit_lock::EditLease::acquire(&path) {
Ok(lease) => {
self.pending_save_leases.insert(tab_id, lease);
}
Err(crate::io::edit_lock::EditLeaseError::Locked(error)) => {
self.command_line.push_error_once(&format!(
"Unable to save \"{}\": {error}",
path.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| path.display().to_string())
));
self.pending_save_failure = Some(crate::app::PendingSaveFailure {
tab_id,
path,
version,
purpose,
continuation,
set_current_path,
error,
});
self.restore_failed_save_continuation(continuation, i);
self.active_modal = Some(crate::app::ModalKind::FileInUse);
return Task::none();
}
Err(crate::io::edit_lock::EditLeaseError::Unavailable(error)) => {
self.command_line.push_info(&format!(
"{error}. External-change checks remain active."
));
}
}
}
let epoch = self.tabs[i].scene.geometry_epoch;
let revision = self.tabs[i].edit_revision;
@@ -803,6 +1110,16 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
self.active_save_jobs.insert(tab_id, job_id);
let previous_autosave = set_current_path.then(|| self.autosave_target(i));
let backup = purpose != crate::app::SavePurpose::Autosave && self.backup_on_save;
let expected_fingerprint =
if check_external_change && purpose != crate::app::SavePurpose::Autosave {
if set_current_path {
crate::io::edit_lock::FileFingerprint::capture(&path).ok()
} else {
self.tabs[i].disk_fingerprint.clone()
}
} else {
None
};
let worker_path = path.clone();
Task::perform(
@@ -832,11 +1149,17 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
&worker_path,
version,
backup,
expected_fingerprint,
);
(result, refreshed_preview)
})
.join()
.unwrap_or_else(|_| (Err("save worker panicked".to_string()), None));
.unwrap_or_else(|_| {
(
Err(crate::io::SaveFailure::other("save worker panicked")),
None,
)
});
crate::app::SaveOutcome {
job_id,
tab_id,
@@ -844,6 +1167,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
revision,
camera_generation,
path,
version,
previous_autosave,
set_current_path,
purpose,
@@ -867,6 +1191,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
if latest {
self.active_save_jobs.remove(&outcome.tab_id);
}
let destination_lease = self.pending_save_leases.remove(&outcome.tab_id);
let Some(i) = self.tabs.iter().position(|tab| tab.id == outcome.tab_id) else {
if outcome.purpose == crate::app::SavePurpose::Autosave {
@@ -881,7 +1206,60 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
return Task::none();
}
if let Err(error) = outcome.result {
if let Err(error) = &outcome.result {
if error.externally_modified
&& outcome.purpose != crate::app::SavePurpose::Autosave
{
let file_name = outcome
.path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| outcome.path.display().to_string());
self.command_line.push_error_once(&format!(
"Save stopped: \"{file_name}\" changed outside Open CAD Studio."
));
self.pending_external_change = Some(crate::app::PendingExternalChange {
tab_id: outcome.tab_id,
path: outcome.path.clone(),
version: outcome.version,
purpose: outcome.purpose,
continuation: outcome.continuation,
set_current_path: outcome.set_current_path,
});
self.restore_failed_save_continuation(outcome.continuation, i);
self.active_modal = Some(crate::app::ModalKind::ExternalChange);
return Task::none();
}
if error.file_in_use && outcome.purpose != crate::app::SavePurpose::Autosave {
let file_name = outcome
.path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| outcome.path.display().to_string());
self.command_line.push_error_once(&format!(
"Unable to save \"{file_name}\": file is in use by another application."
));
self.pending_save_failure = Some(crate::app::PendingSaveFailure {
tab_id: outcome.tab_id,
path: outcome.path.clone(),
version: outcome.version,
purpose: outcome.purpose,
continuation: outcome.continuation,
set_current_path: outcome.set_current_path,
error: error.to_string(),
});
match outcome.continuation {
crate::app::SaveContinuation::CloseTab => {
self.pending_close = Some(crate::app::PendingClose::Tab(i));
}
crate::app::SaveContinuation::Quit => {
self.pending_close = Some(crate::app::PendingClose::Quit);
}
crate::app::SaveContinuation::None => {}
}
self.active_modal = Some(crate::app::ModalKind::FileInUse);
return Task::none();
}
self.command_line
.push_error(&format!("Save failed: {error}"));
return match outcome.continuation {
@@ -912,6 +1290,13 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
self.command_line.push_output("Autosaved 1 drawing");
}
crate::app::SavePurpose::Manual | crate::app::SavePurpose::SaveAs => {
let path_changed = outcome.set_current_path
&& self.tabs[i]
.current_path
.as_deref()
.is_none_or(|current| {
!native_paths_match(current, &outcome.path)
});
self.command_line
.push_output(&format!("Saved: {}", outcome.path.display()));
self.recent_thumbs.remove(&outcome.path);
@@ -924,6 +1309,12 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
self.tabs[i].current_path = Some(outcome.path.clone());
tasks.push(self.push_recent(outcome.path.clone()));
}
self.refresh_native_edit_guard_after_save(
i,
&outcome.path,
path_changed,
destination_lease,
);
if snapshot_is_current {
self.tabs[i].dirty = false;
let _ = std::fs::remove_file(outcome.path.with_extension("sv$"));
@@ -961,6 +1352,150 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
Task::batch(tasks)
}
#[cfg(not(target_arch = "wasm32"))]
fn restore_failed_save_continuation(
&mut self,
continuation: crate::app::SaveContinuation,
tab_idx: usize,
) {
self.pending_close = match continuation {
crate::app::SaveContinuation::None => None,
crate::app::SaveContinuation::CloseTab => {
Some(crate::app::PendingClose::Tab(tab_idx))
}
crate::app::SaveContinuation::Quit => Some(crate::app::PendingClose::Quit),
};
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_save_file_in_use_retry(&mut self) -> Task<Message> {
let Some(mut failure) = self.pending_save_failure.take() else {
self.close_active_modal();
return Task::none();
};
let Some(i) = self.tabs.iter().position(|tab| tab.id == failure.tab_id) else {
self.close_active_modal();
return Task::none();
};
if self.tabs[i].edit_lock_conflict {
if let Err(error) = self.retry_native_edit_guard(i, &failure.path) {
let continuation = failure.continuation;
failure.error = error.clone();
self.pending_save_failure = Some(failure);
self.restore_failed_save_continuation(continuation, i);
self.command_line
.push_error_once(&format!("Unable to acquire edit lock: {error}"));
self.active_modal = Some(crate::app::ModalKind::FileInUse);
return Task::none();
}
}
self.close_active_modal();
self.restore_failed_save_continuation(failure.continuation, i);
self.active_tab = i;
self.prepare_native_save(i);
self.queue_native_save(
i,
failure.path,
failure.version,
failure.purpose,
failure.continuation,
failure.set_current_path,
true,
)
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_save_file_in_use_save_as(&mut self) -> Task<Message> {
let Some(failure) = self.pending_save_failure.take() else {
self.close_active_modal();
return Task::none();
};
let Some(i) = self.tabs.iter().position(|tab| tab.id == failure.tab_id) else {
self.close_active_modal();
return Task::none();
};
self.close_active_modal();
self.restore_failed_save_continuation(failure.continuation, i);
self.active_tab = i;
self.save_dialog_for_unsaved =
failure.continuation != crate::app::SaveContinuation::None;
self.open_save_dialog_window(i)
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_external_change_overwrite(&mut self) -> Task<Message> {
let Some(conflict) = self.pending_external_change.take() else {
self.close_active_modal();
return Task::none();
};
let Some(i) = self.tabs.iter().position(|tab| tab.id == conflict.tab_id) else {
self.close_active_modal();
return Task::none();
};
self.close_active_modal();
self.restore_failed_save_continuation(conflict.continuation, i);
self.active_tab = i;
self.prepare_native_save(i);
self.queue_native_save(
i,
conflict.path,
conflict.version,
conflict.purpose,
conflict.continuation,
conflict.set_current_path,
false,
)
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_external_change_save_as(&mut self) -> Task<Message> {
let Some(conflict) = self.pending_external_change.take() else {
self.close_active_modal();
return Task::none();
};
let Some(i) = self.tabs.iter().position(|tab| tab.id == conflict.tab_id) else {
self.close_active_modal();
return Task::none();
};
self.close_active_modal();
self.restore_failed_save_continuation(conflict.continuation, i);
self.active_tab = i;
self.save_dialog_for_unsaved =
conflict.continuation != crate::app::SaveContinuation::None;
self.open_save_dialog_window(i)
}
#[cfg(not(target_arch = "wasm32"))]
pub(super) fn on_external_change_reload(&mut self) -> Task<Message> {
let Some(conflict) = self.pending_external_change.take() else {
self.close_active_modal();
return Task::none();
};
let Some(i) = self.tabs.iter().position(|tab| tab.id == conflict.tab_id) else {
self.close_active_modal();
return Task::none();
};
let metadata = match std::fs::metadata(&conflict.path) {
Ok(metadata) => metadata,
Err(error) => {
self.close_active_modal();
self.command_line
.push_error(&format!("Reload failed: {error}"));
return Task::none();
}
};
self.close_active_modal();
self.pending_close = None;
self.tab_counter += 1;
self.tabs[i] = crate::app::document::DocumentTab::new_drawing(self.tab_counter);
self.active_tab = i;
self.apply_bg_default(i);
self.update(Message::OpenPathPicked(Some((
conflict.path,
metadata.len(),
))))
}
pub(super) fn on_save_file(&mut self) -> Task<Message> {
if self.read_only {
self.command_line
@@ -989,6 +1524,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
crate::app::SavePurpose::Manual,
crate::app::SaveContinuation::None,
false,
true,
);
}
self.save_dialog_for_unsaved = false;
@@ -1151,6 +1687,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
crate::app::SavePurpose::SaveAs,
continuation,
true,
true,
)
}
@@ -1219,6 +1756,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
crate::app::SavePurpose::Autosave,
crate::app::SaveContinuation::None,
false,
false,
));
}
Task::batch(tasks)
+40
View File
@@ -80,6 +80,16 @@ impl OpenCADStudio {
if self.active_modal == Some(ScaleManager) {
self.scale_stage_discard();
}
#[cfg(not(target_arch = "wasm32"))]
if self.active_modal == Some(FileInUse) {
self.pending_save_failure = None;
self.pending_close = None;
}
#[cfg(not(target_arch = "wasm32"))]
if self.active_modal == Some(ExternalChange) {
self.pending_external_change = None;
self.pending_close = None;
}
match self.active_modal {
// Dismissing these via ✕ is the cancel/decline path.
Some(Unsaved) => self.pending_close = None,
@@ -457,6 +467,9 @@ impl OpenCADStudio {
size_bytes,
state: progress.clone(),
started: Instant::now(),
#[cfg(not(target_arch = "wasm32"))]
fingerprint:
crate::io::edit_lock::FileFingerprint::capture(&path).ok(),
});
let size_label = format_size(size_bytes);
self.command_line
@@ -4067,6 +4080,33 @@ impl OpenCADStudio {
#[cfg(not(target_arch = "wasm32"))]
Message::SaveFinished(outcome) => self.on_save_finished(outcome),
#[cfg(not(target_arch = "wasm32"))]
Message::SaveFileInUseRetry => self.on_save_file_in_use_retry(),
#[cfg(not(target_arch = "wasm32"))]
Message::SaveFileInUseSaveAs => self.on_save_file_in_use_save_as(),
#[cfg(not(target_arch = "wasm32"))]
Message::SaveFileInUseCancel => {
self.close_active_modal();
Task::none()
}
#[cfg(not(target_arch = "wasm32"))]
Message::ExternalChangeReload => self.on_external_change_reload(),
#[cfg(not(target_arch = "wasm32"))]
Message::ExternalChangeSaveAs => self.on_external_change_save_as(),
#[cfg(not(target_arch = "wasm32"))]
Message::ExternalChangeOverwrite => self.on_external_change_overwrite(),
#[cfg(not(target_arch = "wasm32"))]
Message::ExternalChangeCancel => {
self.close_active_modal();
Task::none()
}
// ── Page Setup ────────────────────────────────────────────────
Message::UpdateCheckResult(latest) => {
let Some(info) = latest else {
+4
View File
@@ -1722,6 +1722,10 @@ impl OpenCADStudio {
AssocPrompt => (440, 210),
Unsaved => (420, 160),
AecDropWarning => (480, 230),
#[cfg(not(target_arch = "wasm32"))]
FileInUse => (560, 250),
#[cfg(not(target_arch = "wasm32"))]
ExternalChange => (620, 250),
#[cfg(target_arch = "wasm32")]
SaveDialog => (420, 200),
#[cfg(not(target_arch = "wasm32"))]
+299 -1
View File
@@ -26,6 +26,10 @@ impl OpenCADStudio {
Some(K::DimStyle) => "Dimension Style Manager",
Some(K::AssocPrompt) => "Default Application",
Some(K::AecDropWarning) => "Save Warning",
#[cfg(not(target_arch = "wasm32"))]
Some(K::FileInUse) => "Unable to Save Drawing",
#[cfg(not(target_arch = "wasm32"))]
Some(K::ExternalChange) => "Drawing Changed on Disk",
Some(K::LayerDeleteWarning) => "Delete Layer",
Some(K::Unsaved) => "Unsaved Changes",
Some(K::PointStyle) => "Point Style",
@@ -583,6 +587,29 @@ impl OpenCADStudio {
230,
)
}
#[cfg(not(target_arch = "wasm32"))]
super::super::ModalKind::FileInUse => {
let (path, error) = self
.pending_save_failure
.as_ref()
.map(|failure| {
(
failure.path.display().to_string(),
failure.error.clone(),
)
})
.unwrap_or_default();
sized(file_in_use_dialog_window(&path, &error), 560, 250)
}
#[cfg(not(target_arch = "wasm32"))]
super::super::ModalKind::ExternalChange => {
let path = self
.pending_external_change
.as_ref()
.map(|conflict| conflict.path.display().to_string())
.unwrap_or_default();
sized(external_change_dialog_window(&path), 620, 250)
}
super::super::ModalKind::LayerDeleteWarning => {
let (names, count) = self
.layer_delete_pending
@@ -922,6 +949,278 @@ fn unsaved_changes_dialog_window(name: &str) -> Element<'static, Message> {
.into()
}
#[cfg(not(target_arch = "wasm32"))]
fn file_in_use_dialog_window(path: &str, error: &str) -> Element<'static, Message> {
const BG: Color = Color {
r: 0.18,
g: 0.18,
b: 0.20,
a: 1.0,
};
const BORDER_COL: Color = Color {
r: 0.38,
g: 0.38,
b: 0.42,
a: 1.0,
};
const TEXT_COL: Color = Color {
r: 0.90,
g: 0.90,
b: 0.90,
a: 1.0,
};
const DIM_COL: Color = Color {
r: 0.62,
g: 0.62,
b: 0.66,
a: 1.0,
};
const BTN_PRIMARY: Color = Color {
r: 0.20,
g: 0.46,
b: 0.80,
a: 1.0,
};
const BTN_PRIMARY_HOVER: Color = Color {
r: 0.26,
g: 0.55,
b: 0.92,
a: 1.0,
};
const BTN_SECONDARY: Color = Color {
r: 0.28,
g: 0.28,
b: 0.30,
a: 1.0,
};
const BTN_SECONDARY_HOVER: Color = Color {
r: 0.36,
g: 0.36,
b: 0.40,
a: 1.0,
};
let file_name = std::path::Path::new(path)
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "Drawing".to_string());
let heading = format!("\"{file_name}\" could not be saved.");
let path_line = format!("Path: {path}");
let details = format!("Details: {error}");
let btn = |label: &'static str, msg: Message, base: Color, hover: Color| {
button(text(label).size(13).color(TEXT_COL))
.on_press(msg)
.style(move |_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered | button::Status::Pressed => hover,
_ => base,
})),
text_color: TEXT_COL,
border: Border {
color: BORDER_COL,
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
})
.padding([6, 18])
};
container(
column![
text(heading).size(14).color(TEXT_COL),
Space::new().height(8),
text(
"The file is open or being used by another application. \
Close it there and retry, or save this drawing under a different name."
)
.size(13)
.color(TEXT_COL)
.width(Fill),
Space::new().height(12),
text(path_line).size(11).color(DIM_COL).width(Fill),
Space::new().height(4),
text(details).size(11).color(DIM_COL).width(Fill),
Space::new().height(18),
row![
btn(
"Retry",
Message::SaveFileInUseRetry,
BTN_PRIMARY,
BTN_PRIMARY_HOVER
),
Space::new().width(8),
btn(
"Save As",
Message::SaveFileInUseSaveAs,
BTN_SECONDARY,
BTN_SECONDARY_HOVER
),
Space::new().width(8),
btn(
"Cancel",
Message::SaveFileInUseCancel,
BTN_SECONDARY,
BTN_SECONDARY_HOVER
),
],
]
.spacing(0),
)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(BG)),
..Default::default()
})
.padding([18, 20])
.width(Fill)
.height(Fill)
.into()
}
#[cfg(not(target_arch = "wasm32"))]
fn external_change_dialog_window(path: &str) -> Element<'static, Message> {
const BG: Color = Color {
r: 0.18,
g: 0.18,
b: 0.20,
a: 1.0,
};
const BORDER_COL: Color = Color {
r: 0.38,
g: 0.38,
b: 0.42,
a: 1.0,
};
const TEXT_COL: Color = Color {
r: 0.90,
g: 0.90,
b: 0.90,
a: 1.0,
};
const DIM_COL: Color = Color {
r: 0.62,
g: 0.62,
b: 0.66,
a: 1.0,
};
const BTN_PRIMARY: Color = Color {
r: 0.20,
g: 0.46,
b: 0.80,
a: 1.0,
};
const BTN_PRIMARY_HOVER: Color = Color {
r: 0.26,
g: 0.55,
b: 0.92,
a: 1.0,
};
const BTN_SECONDARY: Color = Color {
r: 0.28,
g: 0.28,
b: 0.30,
a: 1.0,
};
const BTN_SECONDARY_HOVER: Color = Color {
r: 0.36,
g: 0.36,
b: 0.40,
a: 1.0,
};
const BTN_DANGER: Color = Color {
r: 0.62,
g: 0.31,
b: 0.14,
a: 1.0,
};
const BTN_DANGER_HOVER: Color = Color {
r: 0.78,
g: 0.39,
b: 0.17,
a: 1.0,
};
let file_name = std::path::Path::new(path)
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| "Drawing".to_string());
let heading = format!("\"{file_name}\" was changed by another application.");
let path_line = format!("Path: {path}");
let btn = |label: &'static str, msg: Message, base: Color, hover: Color| {
button(text(label).size(13).color(TEXT_COL))
.on_press(msg)
.style(move |_: &Theme, status| button::Style {
background: Some(Background::Color(match status {
button::Status::Hovered | button::Status::Pressed => hover,
_ => base,
})),
text_color: TEXT_COL,
border: Border {
color: BORDER_COL,
width: 1.0,
radius: 4.0.into(),
},
..Default::default()
})
.padding([6, 14])
};
container(
column![
text(heading).size(14).color(TEXT_COL),
Space::new().height(8),
text(
"Saving now could destroy those external changes. Reload the disk copy, \
save your local work elsewhere, or explicitly overwrite it."
)
.size(13)
.color(TEXT_COL)
.width(Fill),
Space::new().height(12),
text(path_line).size(11).color(DIM_COL).width(Fill),
Space::new().height(18),
row![
btn(
"Reload from Disk",
Message::ExternalChangeReload,
BTN_PRIMARY,
BTN_PRIMARY_HOVER
),
Space::new().width(8),
btn(
"Save As",
Message::ExternalChangeSaveAs,
BTN_SECONDARY,
BTN_SECONDARY_HOVER
),
Space::new().width(8),
btn(
"Overwrite",
Message::ExternalChangeOverwrite,
BTN_DANGER,
BTN_DANGER_HOVER
),
Space::new().width(8),
btn(
"Cancel",
Message::ExternalChangeCancel,
BTN_SECONDARY,
BTN_SECONDARY_HOVER
),
],
]
.spacing(0),
)
.style(|_: &Theme| container::Style {
background: Some(Background::Color(BG)),
..Default::default()
})
.padding([18, 20])
.width(Fill)
.height(Fill)
.into()
}
/// Warning shown before a lossy Save-As: the drawing carries unsupported
/// (AEC / application) objects that survive only as verbatim source-version
/// bytes, so saving to a different version or to DXF would drop them. Offers to
@@ -1148,4 +1447,3 @@ fn default_assoc_dialog_window() -> Element<'static, Message> {
.padding([24, 28])
.into()
}