forked from 1b-agent/opencadstudio-upstream
feat(io): add user-approved recovery
This commit is contained in:
1 parent
25515fa33c
commit
9e90e50662
25 files changed
+2723
-198
No files matched your search
+14
-3
@@ -898,11 +898,22 @@ mod tests {
|
||||
);
|
||||
assert_eq!(app.pending_opens.front(), Some(&b));
|
||||
|
||||
// A drawing that fails to parse must still release the queue behind it.
|
||||
let _ = app.update(Message::FileOpened(Err("boom".into())));
|
||||
// A failed drawing pauses the queue while its recovery report is shown.
|
||||
let open_id = app.opening.as_ref().map(|opening| opening.id).unwrap();
|
||||
let _ = app.update(Message::FileOpened(open_id, Err("boom".into())));
|
||||
assert!(
|
||||
app.active_modal == Some(crate::app::ModalKind::Recovery),
|
||||
"failed open should show its recovery report"
|
||||
);
|
||||
assert_eq!(
|
||||
app.pending_opens.len(),
|
||||
1,
|
||||
"queued drawing should wait until the report is acknowledged"
|
||||
);
|
||||
let _ = app.update(Message::RecoveryClose);
|
||||
assert!(
|
||||
app.pending_opens.is_empty(),
|
||||
"a failed open must drain the queue, not strand it"
|
||||
"closing the report must release the queued drawing"
|
||||
);
|
||||
|
||||
let _ = std::fs::remove_file(&a);
|
||||
|
||||
@@ -550,12 +550,24 @@ impl OpenCADStudio {
|
||||
self.command_line
|
||||
.push_output(crate::tf!("XREF Reloaded \"{}\"", info.name).as_ref());
|
||||
}
|
||||
crate::io::xref::XrefStatus::Recovered => {
|
||||
self.command_line.push_error(crate::tf!(
|
||||
"XREF Reloaded with repairs: \"{}\"",
|
||||
info.name
|
||||
).as_ref());
|
||||
}
|
||||
crate::io::xref::XrefStatus::NotFound => {
|
||||
self.command_line.push_error(crate::tf!(
|
||||
"XREF Not found: \"{}\" ({})",
|
||||
info.name, info.path
|
||||
).as_ref());
|
||||
}
|
||||
crate::io::xref::XrefStatus::Failed => {
|
||||
self.command_line.push_error(crate::tf!(
|
||||
"XREF Reload failed: \"{}\" ({})",
|
||||
info.name, info.path
|
||||
).as_ref());
|
||||
}
|
||||
crate::io::xref::XrefStatus::Unloaded => {
|
||||
self.command_line.push_info(crate::tf!(
|
||||
"XREF Unloaded (skipped): \"{}\"",
|
||||
|
||||
@@ -121,6 +121,9 @@ pub(super) struct DocumentTab {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(super) disk_fingerprint: Option<crate::io::edit_lock::FileFingerprint>,
|
||||
pub(super) dirty: bool,
|
||||
/// Direct Save is redirected to Save As after open-time repairs so the
|
||||
/// source drawing cannot be overwritten accidentally.
|
||||
pub(super) recovery_save_as_required: bool,
|
||||
/// Monotonic committed-edit/undo/redo revision. Background save completion
|
||||
/// uses it with scene epochs so an older snapshot never clears newer work.
|
||||
pub(super) edit_revision: u64,
|
||||
@@ -434,6 +437,7 @@ impl DocumentTab {
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
disk_fingerprint: None,
|
||||
dirty: false,
|
||||
recovery_save_as_required: false,
|
||||
edit_revision: 0,
|
||||
prev_selection: Vec::new(),
|
||||
tab_title: format!("Drawing{}", n),
|
||||
|
||||
+26
-1
@@ -206,10 +206,16 @@ pub const OPEN_PHASE_FINALIZING: u8 = 4;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct OpenProgress {
|
||||
pub id: u64,
|
||||
pub name: String,
|
||||
pub source_path: Option<std::path::PathBuf>,
|
||||
pub size_bytes: u64,
|
||||
pub state: Arc<crate::io::OpenProgressState>,
|
||||
pub started: Instant,
|
||||
pub recovery_error: Option<String>,
|
||||
pub recovery_read_stats: Option<acadrust::ReadStats>,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
pub recovery_bytes: Option<std::sync::Arc<[u8]>>,
|
||||
/// 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"))]
|
||||
@@ -816,6 +822,9 @@ pub(super) struct OpenCADStudio {
|
||||
/// `Some` while a CAD file is loading — drives the modal overlay.
|
||||
/// Cleared when the load finishes, errors, or the user cancels.
|
||||
pub(super) opening: Option<OpenProgress>,
|
||||
open_job_serial: u64,
|
||||
/// Last repair or failed-open report shown in the recovery modal.
|
||||
recovery_report: Option<crate::io::recovery::RecoveryReport>,
|
||||
/// Drawings handed to us by other launches while `opening` was busy.
|
||||
/// `opening` is a single slot that a second `OpenPathPicked` would
|
||||
/// overwrite, and `on_file_opened` drops any result arriving once it is
|
||||
@@ -1409,6 +1418,8 @@ pub enum ModalKind {
|
||||
DimStyle,
|
||||
Unsaved,
|
||||
SaveDialog,
|
||||
Recovery,
|
||||
RecoveryPrompt,
|
||||
Options,
|
||||
FindReplace,
|
||||
AecDropWarning,
|
||||
@@ -1603,7 +1614,19 @@ pub enum Message {
|
||||
/// User clicked Cancel on the loading overlay. The parser thread keeps
|
||||
/// running but its result is discarded.
|
||||
OpenCancel,
|
||||
FileOpened(Result<(String, PathBuf, CadDocument, crate::scene::DerivedCaches), String>),
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
WebFileOpened(u64, crate::io::WebOpenOutcome),
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
WebFileCached(u64, crate::io::WebOpenOutcome, Result<(), String>),
|
||||
FileOpened(u64, Result<
|
||||
(String, PathBuf, CadDocument, crate::scene::DerivedCaches),
|
||||
crate::io::OpenLoadError,
|
||||
>),
|
||||
RecoveryClose,
|
||||
RecoveryAttempt,
|
||||
RecoveryDecline,
|
||||
RecoverySaveAs,
|
||||
RecoveryShowLog,
|
||||
/// Web: an asynchronous OPFS copy written after Save is ready for recents.
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
WebRecentStored(Result<PathBuf, String>),
|
||||
@@ -2867,6 +2890,8 @@ impl OpenCADStudio {
|
||||
plot_dialog: crate::ui::window::plot::PlotDialogState::default(),
|
||||
plot_prev: None,
|
||||
opening: None,
|
||||
open_job_serial: 0,
|
||||
recovery_report: None,
|
||||
pending_opens: std::collections::VecDeque::new(),
|
||||
active_interaction_index: None,
|
||||
queued_interaction_indices: std::collections::VecDeque::new(),
|
||||
|
||||
+25
-19
@@ -29,12 +29,8 @@ impl OpenCADStudio {
|
||||
// re-targeting it. A new/unsaved drawing has no source format, so it
|
||||
// uses the application-wide default chosen in Options (#529).
|
||||
self.save_dialog_format = if let Some(path) = &self.tabs[tab_idx].current_path {
|
||||
let is_dxf = path
|
||||
.extension()
|
||||
.and_then(|e| e.to_str())
|
||||
.map(|e| e.eq_ignore_ascii_case("dxf"))
|
||||
.unwrap_or(false);
|
||||
let document = &self.tabs[tab_idx].scene.document;
|
||||
let is_dxf = crate::io::source_is_dxf(Some(path), document);
|
||||
let version = if is_dxf {
|
||||
document.version
|
||||
} else {
|
||||
@@ -55,6 +51,14 @@ impl OpenCADStudio {
|
||||
let (ext, _) = crate::io::parse_save_format(&self.save_dialog_format);
|
||||
self.save_dialog_filename = format!("{}.{ext}", self.tabs[tab_idx].tab_display_name());
|
||||
}
|
||||
if self.tabs[tab_idx].recovery_save_as_required {
|
||||
let (ext, _) = crate::io::parse_save_format(&self.save_dialog_format);
|
||||
let stem = std::path::Path::new(&self.save_dialog_filename)
|
||||
.file_stem()
|
||||
.map(|value| value.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| self.tabs[tab_idx].tab_display_name());
|
||||
self.save_dialog_filename = format!("{stem}_recovered.{ext}");
|
||||
}
|
||||
self.aec_drop_acknowledged = false;
|
||||
self.active_modal = Some(crate::app::ModalKind::SaveDialog);
|
||||
Task::none()
|
||||
@@ -269,20 +273,22 @@ pub(super) fn on_ribbon_tool_click(&mut self, tool_id: String, event: ModuleEven
|
||||
return Task::none();
|
||||
}
|
||||
|
||||
if let Some(path) = self.tabs[idx].current_path.clone() {
|
||||
let version = self.tabs[idx].scene.document.version;
|
||||
self.prepare_native_save(idx);
|
||||
let close = self.close_unsaved_dialog_window();
|
||||
let save = self.queue_native_save(
|
||||
idx,
|
||||
path,
|
||||
version,
|
||||
crate::app::SavePurpose::Manual,
|
||||
continuation,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
return Task::batch([close, save]);
|
||||
if !self.tabs[idx].recovery_save_as_required {
|
||||
if let Some(path) = self.tabs[idx].current_path.clone() {
|
||||
let version = self.tabs[idx].scene.document.version;
|
||||
self.prepare_native_save(idx);
|
||||
let close = self.close_unsaved_dialog_window();
|
||||
let save = self.queue_native_save(
|
||||
idx,
|
||||
path,
|
||||
version,
|
||||
crate::app::SavePurpose::Manual,
|
||||
continuation,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
return Task::batch([close, save]);
|
||||
}
|
||||
}
|
||||
|
||||
self.active_tab = idx;
|
||||
|
||||
+176
-39
@@ -594,16 +594,29 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
let state = std::sync::Arc::new(crate::io::OpenProgressState::new(
|
||||
crate::app::OPEN_PHASE_READING,
|
||||
));
|
||||
let open_id = self.next_open_id();
|
||||
self.opening = Some(crate::app::OpenProgress {
|
||||
id: open_id,
|
||||
name: "Opening…".into(),
|
||||
source_path: None,
|
||||
size_bytes: 0,
|
||||
state: state.clone(),
|
||||
started: Instant::now(),
|
||||
recovery_error: None,
|
||||
recovery_read_stats: None,
|
||||
recovery_bytes: None,
|
||||
});
|
||||
Task::perform(crate::io::pick_and_load_web(state), Message::FileOpened)
|
||||
Task::perform(crate::io::pick_and_load_web(state), move |outcome| {
|
||||
Message::WebFileOpened(open_id, outcome)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::app) fn next_open_id(&mut self) -> u64 {
|
||||
self.open_job_serial = self.open_job_serial.wrapping_add(1).max(1);
|
||||
self.open_job_serial
|
||||
}
|
||||
|
||||
/// Index of a tab already showing `path`, or `None`.
|
||||
///
|
||||
/// Compares resolved paths, so the same drawing reached through a symlink,
|
||||
@@ -751,6 +764,17 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
path: std::path::PathBuf,
|
||||
set_current_path: bool,
|
||||
) -> Result<(), crate::io::SaveFailure> {
|
||||
let previous_autosave = self.autosave_target(i);
|
||||
if self.tabs[i].recovery_save_as_required
|
||||
&& self.tabs[i]
|
||||
.current_path
|
||||
.as_deref()
|
||||
.is_some_and(|source| native_paths_match(source, &path))
|
||||
{
|
||||
return Err(crate::io::SaveFailure::other(
|
||||
"repaired drawing must be saved to a new file",
|
||||
));
|
||||
}
|
||||
let path_changed = self.tabs[i]
|
||||
.current_path
|
||||
.as_deref()
|
||||
@@ -816,7 +840,10 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
destination_lease,
|
||||
);
|
||||
self.tabs[i].dirty = false;
|
||||
let _ = std::fs::remove_file(path.with_extension("sv$"));
|
||||
if set_current_path {
|
||||
self.tabs[i].recovery_save_as_required = false;
|
||||
}
|
||||
let _ = std::fs::remove_file(previous_autosave);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -883,8 +910,36 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
return Task::none();
|
||||
}
|
||||
let open_started = self.opening.as_ref().map(|p| p.started);
|
||||
let size_bytes = self
|
||||
.opening
|
||||
.as_ref()
|
||||
.map(|progress| progress.size_bytes)
|
||||
.unwrap_or(0);
|
||||
let timings = caches.timings;
|
||||
let entity_count = doc.entities().count();
|
||||
let parser_errors_recovered = caches.read_stats.as_ref().is_some_and(|stats| {
|
||||
stats.recovered()
|
||||
|| stats.skipped_source_records > 0
|
||||
|| !stats.stream_completed
|
||||
}) || doc.notifications.iter().any(|item| {
|
||||
item.notification_type == acadrust::notification::NotificationType::Error
|
||||
});
|
||||
let reference_recovered = caches
|
||||
.xrefs
|
||||
.iter()
|
||||
.any(|item| item.status == crate::io::xref::XrefStatus::Recovered);
|
||||
let reference_failed = caches
|
||||
.xrefs
|
||||
.iter()
|
||||
.any(|item| item.status == crate::io::xref::XrefStatus::Failed);
|
||||
let document_repaired = parser_errors_recovered
|
||||
|| reference_recovered
|
||||
|| caches.corrupt_dropped > 0
|
||||
|| caches.xref_dropped > 0;
|
||||
let recovery_needed = document_repaired || reference_failed;
|
||||
let total_ms = open_started
|
||||
.map(|started| started.elapsed().as_millis() as u32)
|
||||
.unwrap_or(0);
|
||||
self.command_line
|
||||
.push_output(crate::tf!("Opened \"{name}\" — {entity_count} entities").as_ref());
|
||||
if caches.corrupt_dropped > 0 {
|
||||
@@ -905,19 +960,34 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
self.command_line
|
||||
.push_output(crate::tf!("XREF Loaded \"{}\"", info.name).as_ref());
|
||||
}
|
||||
crate::io::xref::XrefStatus::Recovered => {
|
||||
self.command_line.push_error(crate::tf!(
|
||||
"XREF Recovered with warnings: \"{}\"",
|
||||
info.name
|
||||
).as_ref());
|
||||
}
|
||||
crate::io::xref::XrefStatus::NotFound => {
|
||||
self.command_line.push_error(crate::tf!(
|
||||
"XREF Not found: \"{}\" ({})",
|
||||
info.name, info.path
|
||||
).as_ref());
|
||||
}
|
||||
crate::io::xref::XrefStatus::Failed => {
|
||||
self.command_line.push_error(crate::tf!(
|
||||
"XREF Recovery failed: \"{}\" ({})",
|
||||
info.name, info.path
|
||||
).as_ref());
|
||||
}
|
||||
crate::io::xref::XrefStatus::Unloaded => {
|
||||
self.command_line
|
||||
.push_info(crate::tf!("XREF Unloaded (skipped): \"{}\"", info.name).as_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let thumbs_task = self.push_recent(path.clone());
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let thumbs_task = Task::none();
|
||||
|
||||
let current_is_empty = {
|
||||
let t = &self.tabs[self.active_tab];
|
||||
@@ -938,6 +1008,27 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
idx
|
||||
};
|
||||
|
||||
let mut recovery_report = recovery_needed.then(|| {
|
||||
crate::io::recovery::RecoveryReport::recovered(
|
||||
self.tabs[i].id,
|
||||
&path,
|
||||
size_bytes,
|
||||
caches.source_sha256.clone(),
|
||||
caches.read_stats.clone(),
|
||||
entity_count.saturating_add(caches.corrupt_dropped),
|
||||
caches.corrupt_dropped,
|
||||
caches.xref_dropped,
|
||||
&caches.xrefs,
|
||||
&doc.notifications,
|
||||
document_repaired,
|
||||
timings,
|
||||
total_ms,
|
||||
)
|
||||
});
|
||||
if let Some(report) = recovery_report.as_mut() {
|
||||
report.persist();
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
let opened_fingerprint = self
|
||||
.opening
|
||||
@@ -982,9 +1073,6 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
// `total` is wall time from the Open click to here (post-xref,
|
||||
// pre-first-frame); the phase figures are the background-thread
|
||||
// parse/purge/cache spans plus the UI-thread xref resolve.
|
||||
let total_ms = open_started
|
||||
.map(|s| s.elapsed().as_millis() as u32)
|
||||
.unwrap_or(0);
|
||||
self.command_line.push_info(crate::tf!(
|
||||
" parse {}ms · purge {}ms · caches {}ms · xref {}ms · total {}ms",
|
||||
timings.parse_ms, timings.purge_ms, timings.caches_ms, timings.xref_ms, total_ms
|
||||
@@ -1074,7 +1162,8 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
self.adopt_view_display(i);
|
||||
self.sync_render_mode_to_active_tile(i);
|
||||
self.tabs[i].last_synced_camera_gen = self.tabs[i].scene.camera_generation;
|
||||
self.tabs[i].dirty = false;
|
||||
self.tabs[i].dirty = document_repaired;
|
||||
self.tabs[i].recovery_save_as_required = document_repaired;
|
||||
self.tabs[i].history = crate::app::document::HistoryState::default();
|
||||
self.refresh_properties();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
@@ -1092,7 +1181,13 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
.set(crate::app::OPEN_PHASE_FINALIZING, 10000, 1, 1);
|
||||
}
|
||||
self.opening.take();
|
||||
let pending_open_task = self.drain_pending_open();
|
||||
let pending_open_task = if let Some(report) = recovery_report {
|
||||
self.recovery_report = Some(report);
|
||||
self.active_modal = Some(crate::app::ModalKind::Recovery);
|
||||
Task::none()
|
||||
} else {
|
||||
self.drain_pending_open()
|
||||
};
|
||||
Task::batch([thumbs_task, pending_open_task, interaction_task])
|
||||
}
|
||||
|
||||
@@ -1216,6 +1311,23 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
}
|
||||
return Task::none();
|
||||
}
|
||||
let destination_is_current = self.tabs[i]
|
||||
.current_path
|
||||
.as_deref()
|
||||
.is_some_and(|current| native_paths_match(current, &path));
|
||||
if purpose != crate::app::SavePurpose::Autosave
|
||||
&& self.tabs[i].recovery_save_as_required
|
||||
&& destination_is_current
|
||||
{
|
||||
self.command_line.push_error_once(
|
||||
crate::tr!("recovery-save-new-file-required").as_ref(),
|
||||
);
|
||||
self.restore_failed_save_continuation(continuation, i);
|
||||
self.active_tab = i;
|
||||
self.save_dialog_for_unsaved =
|
||||
continuation != crate::app::SaveContinuation::None;
|
||||
return self.open_save_dialog_window(i);
|
||||
}
|
||||
if purpose != crate::app::SavePurpose::Autosave
|
||||
&& !set_current_path
|
||||
&& self.tabs[i].edit_lock_conflict
|
||||
@@ -1241,10 +1353,6 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
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) => {
|
||||
@@ -1320,7 +1428,8 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
self.save_job_serial = self.save_job_serial.wrapping_add(1);
|
||||
let job_id = self.save_job_serial;
|
||||
self.active_save_jobs.insert(tab_id, job_id);
|
||||
let previous_autosave = set_current_path.then(|| self.autosave_target(i));
|
||||
let previous_autosave =
|
||||
(purpose != crate::app::SavePurpose::Autosave).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 {
|
||||
@@ -1520,6 +1629,9 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
if outcome.set_current_path {
|
||||
self.tabs[i].current_path = Some(outcome.path.clone());
|
||||
self.tabs[i].scene.document.version = outcome.version;
|
||||
if outcome.purpose == crate::app::SavePurpose::SaveAs {
|
||||
self.tabs[i].recovery_save_as_required = false;
|
||||
}
|
||||
tasks.push(self.push_recent(outcome.path.clone()));
|
||||
}
|
||||
self.refresh_native_edit_guard_after_save(
|
||||
@@ -1530,7 +1642,6 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
);
|
||||
if snapshot_is_current {
|
||||
self.tabs[i].dirty = false;
|
||||
let _ = std::fs::remove_file(outcome.path.with_extension("sv$"));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1726,19 +1837,21 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
// Native: save straight to the known path. Web has no path
|
||||
// (downloads instead), so always go through the Save dialog.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(path) = self.tabs[i].current_path.clone() {
|
||||
// A direct Save preserves the document's current version.
|
||||
let ver = self.tabs[i].scene.document.version;
|
||||
self.prepare_native_save(i);
|
||||
return self.queue_native_save(
|
||||
i,
|
||||
path,
|
||||
ver,
|
||||
crate::app::SavePurpose::Manual,
|
||||
crate::app::SaveContinuation::None,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
if !self.tabs[i].recovery_save_as_required {
|
||||
if let Some(path) = self.tabs[i].current_path.clone() {
|
||||
// A direct Save preserves the document's current version.
|
||||
let ver = self.tabs[i].scene.document.version;
|
||||
self.prepare_native_save(i);
|
||||
return self.queue_native_save(
|
||||
i,
|
||||
path,
|
||||
ver,
|
||||
crate::app::SavePurpose::Manual,
|
||||
crate::app::SaveContinuation::None,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
self.save_dialog_for_unsaved = false;
|
||||
self.save_with_default_format(i)
|
||||
@@ -1750,7 +1863,21 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
/// save-before-close flow — the version picker is reserved for Save As.
|
||||
pub(in crate::app) fn save_with_default_format(&mut self, tab_idx: usize) -> Task<Message> {
|
||||
self.active_tab = tab_idx;
|
||||
self.save_dialog_format = self.default_save_format.clone();
|
||||
self.save_dialog_format = if self.tabs[tab_idx].recovery_save_as_required {
|
||||
let document = &self.tabs[tab_idx].scene.document;
|
||||
let is_dxf = crate::io::source_is_dxf(
|
||||
self.tabs[tab_idx].current_path.as_deref(),
|
||||
document,
|
||||
);
|
||||
let version = if is_dxf {
|
||||
document.version
|
||||
} else {
|
||||
document.dwg_source_version.unwrap_or(document.version)
|
||||
};
|
||||
crate::io::format_for_version(version, is_dxf)
|
||||
} else {
|
||||
self.default_save_format.clone()
|
||||
};
|
||||
let (ext, _) = crate::io::parse_save_format(&self.save_dialog_format);
|
||||
self.save_dialog_filename = self.tabs[tab_idx]
|
||||
.current_path
|
||||
@@ -1758,6 +1885,14 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
.and_then(|p| p.file_name())
|
||||
.map(|n| n.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| format!("{}.{ext}", self.tabs[tab_idx].tab_display_name()));
|
||||
if self.tabs[tab_idx].recovery_save_as_required {
|
||||
let path = std::path::Path::new(&self.save_dialog_filename);
|
||||
let stem = path
|
||||
.file_stem()
|
||||
.map(|value| value.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "drawing".to_string());
|
||||
self.save_dialog_filename = format!("{stem}_recovered.{ext}");
|
||||
}
|
||||
self.aec_drop_acknowledged = false;
|
||||
self.on_save_dialog_confirm()
|
||||
}
|
||||
@@ -1851,6 +1986,7 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
self.tabs[i].current_path = Some(path.clone());
|
||||
self.tabs[i].scene.document.version = version;
|
||||
self.tabs[i].dirty = false;
|
||||
self.tabs[i].recovery_save_as_required = false;
|
||||
recent_task = Task::perform(
|
||||
async move {
|
||||
crate::io::web_recent::store(
|
||||
@@ -1939,14 +2075,8 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
/// document's source type and version, then save.
|
||||
pub(super) fn on_aec_drop_same_version(&mut self) -> Task<Message> {
|
||||
let tab = &self.tabs[self.active_tab];
|
||||
let is_dxf = tab
|
||||
.current_path
|
||||
.as_ref()
|
||||
.and_then(|path| path.extension())
|
||||
.and_then(|extension| extension.to_str())
|
||||
.map(|extension| extension.eq_ignore_ascii_case("dxf"))
|
||||
.unwrap_or(false);
|
||||
let document = &tab.scene.document;
|
||||
let is_dxf = crate::io::source_is_dxf(tab.current_path.as_deref(), document);
|
||||
let src = if is_dxf {
|
||||
document.version
|
||||
} else {
|
||||
@@ -1964,20 +2094,27 @@ pub(super) fn on_open_file(&mut self) -> Task<Message> {
|
||||
self.on_save_dialog_confirm()
|
||||
}
|
||||
|
||||
/// Where the autosave recovery copy for tab `i` lives: beside a saved
|
||||
/// drawing as `<file>.sv$`, or — for an unsaved drawing with no path yet —
|
||||
/// under the system temp dir keyed by the tab's display name.
|
||||
/// Where the autosave recovery copy for tab `i` lives.
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub(in crate::app) fn autosave_target(&self, i: usize) -> std::path::PathBuf {
|
||||
match &self.tabs[i].current_path {
|
||||
Some(p) => p.with_extension("sv$"),
|
||||
Some(p) => {
|
||||
let name = p
|
||||
.file_name()
|
||||
.map(|value| value.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| "drawing".to_string());
|
||||
p.with_file_name(format!("{name}.ocs-autosave.sv$"))
|
||||
}
|
||||
None => {
|
||||
let safe: String = self.tabs[i]
|
||||
.tab_display_name()
|
||||
.chars()
|
||||
.map(|c| if c.is_alphanumeric() { c } else { '_' })
|
||||
.collect();
|
||||
std::env::temp_dir().join(format!("OpenCADStudio_{safe}.sv$"))
|
||||
std::env::temp_dir().join(format!(
|
||||
"OpenCADStudio_{safe}_{}.sv$",
|
||||
self.tabs[i].id
|
||||
))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+276
-19
@@ -164,6 +164,7 @@ impl OpenCADStudio {
|
||||
self.layer_state_edit_filter.clear();
|
||||
self.layer_state_edit_color_open = None;
|
||||
}
|
||||
Some(Recovery) => self.recovery_report = None,
|
||||
_ => {}
|
||||
}
|
||||
// The tool that opened this dialog is done with it now. Keep the
|
||||
@@ -344,15 +345,21 @@ impl OpenCADStudio {
|
||||
let state = std::sync::Arc::new(crate::io::OpenProgressState::new(
|
||||
crate::app::OPEN_PHASE_READING,
|
||||
));
|
||||
let open_id = self.next_open_id();
|
||||
self.opening = Some(crate::app::OpenProgress {
|
||||
id: open_id,
|
||||
name,
|
||||
source_path: Some(path.clone()),
|
||||
size_bytes: 0,
|
||||
state: state.clone(),
|
||||
started: Instant::now(),
|
||||
recovery_error: None,
|
||||
recovery_read_stats: None,
|
||||
recovery_bytes: None,
|
||||
});
|
||||
Task::perform(
|
||||
crate::io::open_recent_web(path, state),
|
||||
Message::FileOpened,
|
||||
move |outcome| Message::WebFileOpened(open_id, outcome),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -382,15 +389,13 @@ impl OpenCADStudio {
|
||||
]),
|
||||
None => Task::none(),
|
||||
};
|
||||
// Already open → go to that tab rather than load a second copy
|
||||
// of the same drawing. Checked before the queue: switching is
|
||||
// instant and needs no load slot.
|
||||
if let Some(idx) = self.tab_showing(&path) {
|
||||
return Task::batch([raise, self.update(Message::TabSwitch(idx))]);
|
||||
}
|
||||
if self.opening.is_some() {
|
||||
if self.opening.is_some()
|
||||
|| self.active_modal == Some(super::ModalKind::Recovery)
|
||||
{
|
||||
self.pending_opens.push_back(path);
|
||||
raise
|
||||
} else if let Some(idx) = self.tab_showing(&path) {
|
||||
Task::batch([raise, self.update(Message::TabSwitch(idx))])
|
||||
} else {
|
||||
Task::batch([raise, self.update(Message::OpenRecent(path))])
|
||||
}
|
||||
@@ -513,14 +518,15 @@ impl OpenCADStudio {
|
||||
).as_ref());
|
||||
return Task::none();
|
||||
}
|
||||
// Already open → switch to its tab; a load in progress →
|
||||
// queue behind it (multi-file drops arrive one event each).
|
||||
if let Some(idx) = self.tab_showing(&path) {
|
||||
return self.update(Message::TabSwitch(idx));
|
||||
}
|
||||
if self.opening.is_some() {
|
||||
// A load or recovery report owns the open slot; queue another
|
||||
// drop until that state is acknowledged.
|
||||
if self.opening.is_some()
|
||||
|| self.active_modal == Some(super::ModalKind::Recovery)
|
||||
{
|
||||
self.pending_opens.push_back(path);
|
||||
Task::none()
|
||||
} else if let Some(idx) = self.tab_showing(&path) {
|
||||
self.update(Message::TabSwitch(idx))
|
||||
} else {
|
||||
self.update(Message::OpenRecent(path))
|
||||
}
|
||||
@@ -552,11 +558,18 @@ impl OpenCADStudio {
|
||||
let progress = std::sync::Arc::new(crate::io::OpenProgressState::new(
|
||||
super::OPEN_PHASE_READING,
|
||||
));
|
||||
let open_id = self.next_open_id();
|
||||
self.opening = Some(super::OpenProgress {
|
||||
id: open_id,
|
||||
name: name.clone(),
|
||||
source_path: Some(path.clone()),
|
||||
size_bytes,
|
||||
state: progress.clone(),
|
||||
started: Instant::now(),
|
||||
recovery_error: None,
|
||||
recovery_read_stats: None,
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
recovery_bytes: None,
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
fingerprint:
|
||||
crate::io::edit_lock::FileFingerprint::capture(&path).ok(),
|
||||
@@ -572,7 +585,7 @@ impl OpenCADStudio {
|
||||
]);
|
||||
Task::perform(
|
||||
crate::io::open_path_with_phase(path, progress, model_bg),
|
||||
Message::FileOpened,
|
||||
move |result| Message::FileOpened(open_id, result),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -584,16 +597,105 @@ impl OpenCADStudio {
|
||||
self.drain_pending_open()
|
||||
}
|
||||
|
||||
Message::FileOpened(Ok((name, path, doc, caches))) => {
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Message::WebFileOpened(open_id, mut outcome) => {
|
||||
if self.opening.as_ref().map(|opening| opening.id) != Some(open_id) {
|
||||
return Task::none();
|
||||
}
|
||||
if let Some(opening) = self.opening.as_mut() {
|
||||
opening.name = outcome.name.clone();
|
||||
opening.source_path = Some(std::path::PathBuf::from(&outcome.name));
|
||||
if outcome.size_bytes > 0 || opening.size_bytes == 0 {
|
||||
opening.size_bytes = outcome.size_bytes;
|
||||
}
|
||||
opening.recovery_bytes = outcome.recovery_bytes.take();
|
||||
}
|
||||
if let Some(bytes) = outcome.cache_bytes.take() {
|
||||
let name = outcome.name.clone();
|
||||
return Task::perform(
|
||||
async move {
|
||||
let result =
|
||||
crate::io::web_recent::store_open(&name, bytes, open_id).await;
|
||||
(outcome, result)
|
||||
},
|
||||
move |(outcome, result)| {
|
||||
Message::WebFileCached(open_id, outcome, result)
|
||||
},
|
||||
);
|
||||
}
|
||||
let recent_task = if outcome.record_recent && outcome.result.is_ok() {
|
||||
self.push_recent(std::path::PathBuf::from(&outcome.name))
|
||||
} else {
|
||||
Task::none()
|
||||
};
|
||||
let opened_task = self.update(Message::FileOpened(open_id, outcome.result));
|
||||
Task::batch([recent_task, opened_task])
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
Message::WebFileCached(open_id, outcome, cache_result) => {
|
||||
if self.opening.as_ref().map(|opening| opening.id) != Some(open_id) {
|
||||
return Task::none();
|
||||
}
|
||||
let recent_task = match cache_result {
|
||||
Ok(()) => self.push_recent(std::path::PathBuf::from(&outcome.name)),
|
||||
Err(error) => {
|
||||
self.command_line.push_error(crate::tf!(
|
||||
"Opened drawing, but recent copy could not be stored: {error}"
|
||||
).as_ref());
|
||||
Task::none()
|
||||
}
|
||||
};
|
||||
let opened_task = self.update(Message::FileOpened(open_id, outcome.result));
|
||||
Task::batch([recent_task, opened_task])
|
||||
}
|
||||
|
||||
Message::FileOpened(open_id, Ok((name, path, doc, caches))) => {
|
||||
if self.opening.as_ref().map(|opening| opening.id) != Some(open_id) {
|
||||
return Task::none();
|
||||
}
|
||||
self.on_file_opened(name, path, doc, caches)
|
||||
}
|
||||
|
||||
Message::FileOpened(Err(e)) => {
|
||||
Message::FileOpened(open_id, Err(e)) => {
|
||||
if self.opening.as_ref().map(|opening| opening.id) != Some(open_id) {
|
||||
return Task::none();
|
||||
}
|
||||
if e.recovery_available {
|
||||
if let Some(opening) = self.opening.as_mut() {
|
||||
opening.recovery_error = Some(e.message);
|
||||
opening.recovery_read_stats = e.read_stats;
|
||||
self.active_modal = Some(super::ModalKind::RecoveryPrompt);
|
||||
return Task::none();
|
||||
}
|
||||
}
|
||||
// If the user cancelled, the overlay was already cleared and
|
||||
// we suppress the noise.
|
||||
let was_open = self.opening.take().is_some();
|
||||
if was_open && e != "Cancelled" {
|
||||
let opening = self.opening.take();
|
||||
if let Some(opening) = opening.filter(|_| e.message != "Cancelled") {
|
||||
self.command_line.push_error(crate::tf!("Open failed: {e}").as_ref());
|
||||
let total_ms = opening.started.elapsed().as_millis() as u32;
|
||||
let failure_phase = crate::io::open_phase_name(
|
||||
opening
|
||||
.state
|
||||
.phase
|
||||
.load(std::sync::atomic::Ordering::Acquire),
|
||||
)
|
||||
.to_string();
|
||||
let mut report = crate::io::recovery::RecoveryReport::failed(
|
||||
opening.source_path,
|
||||
opening.name,
|
||||
opening.size_bytes,
|
||||
e.source_sha256,
|
||||
e.read_stats,
|
||||
failure_phase,
|
||||
e.message,
|
||||
total_ms,
|
||||
);
|
||||
report.persist();
|
||||
self.recovery_report = Some(report);
|
||||
self.active_modal = Some(super::ModalKind::Recovery);
|
||||
return Task::none();
|
||||
}
|
||||
// A drawing that fails to parse must not strand the ones queued
|
||||
// behind it.
|
||||
@@ -1020,6 +1122,9 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
Message::TabSwitch(idx) => {
|
||||
if self.active_modal == Some(super::ModalKind::Recovery) {
|
||||
return Task::none();
|
||||
}
|
||||
self.layout_list_open = false;
|
||||
self.layout_rename_state = None;
|
||||
if idx < self.tabs.len() {
|
||||
@@ -4345,7 +4450,159 @@ impl OpenCADStudio {
|
||||
}
|
||||
|
||||
Message::CloseModal => {
|
||||
if self.active_modal == Some(super::ModalKind::RecoveryPrompt) {
|
||||
return self.update(Message::RecoveryDecline);
|
||||
}
|
||||
let resume_open_queue = self.active_modal == Some(super::ModalKind::Recovery);
|
||||
self.close_active_modal();
|
||||
if resume_open_queue {
|
||||
self.drain_pending_open()
|
||||
} else {
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
Message::RecoveryClose => {
|
||||
self.close_active_modal();
|
||||
self.drain_pending_open()
|
||||
}
|
||||
Message::RecoveryAttempt => {
|
||||
let open_id = self.next_open_id();
|
||||
let Some(opening) = self.opening.as_mut() else {
|
||||
self.close_active_modal();
|
||||
return Task::none();
|
||||
};
|
||||
let model_bg = self.default_bg_color.unwrap_or([
|
||||
33.0 / 255.0,
|
||||
40.0 / 255.0,
|
||||
48.0 / 255.0,
|
||||
1.0,
|
||||
]);
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
if let Some(path) = opening.source_path.clone() {
|
||||
let current_fingerprint =
|
||||
crate::io::edit_lock::FileFingerprint::capture(&path).ok();
|
||||
if current_fingerprint.as_ref() != opening.fingerprint.as_ref() {
|
||||
let progress = std::sync::Arc::new(crate::io::OpenProgressState::new(
|
||||
super::OPEN_PHASE_READING,
|
||||
));
|
||||
opening.id = open_id;
|
||||
opening.state = progress.clone();
|
||||
opening.started = Instant::now();
|
||||
opening.recovery_error = None;
|
||||
opening.recovery_read_stats = None;
|
||||
opening.fingerprint = current_fingerprint;
|
||||
opening.size_bytes = std::fs::metadata(&path)
|
||||
.map(|metadata| metadata.len())
|
||||
.unwrap_or(0);
|
||||
self.close_active_modal();
|
||||
return Task::perform(
|
||||
crate::io::open_path_with_phase(path, progress, model_bg),
|
||||
move |result| Message::FileOpened(open_id, result),
|
||||
);
|
||||
}
|
||||
}
|
||||
let Some(initial_error) = opening.recovery_error.take() else {
|
||||
self.close_active_modal();
|
||||
return Task::none();
|
||||
};
|
||||
let initial_stats = opening.recovery_read_stats.take();
|
||||
let Some(path) = opening.source_path.clone() else {
|
||||
self.close_active_modal();
|
||||
return Task::none();
|
||||
};
|
||||
let progress = std::sync::Arc::new(crate::io::OpenProgressState::new(
|
||||
super::OPEN_PHASE_READING,
|
||||
));
|
||||
opening.id = open_id;
|
||||
opening.state = progress.clone();
|
||||
opening.started = Instant::now();
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
let recovery_bytes = opening.recovery_bytes.take();
|
||||
self.close_active_modal();
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
Task::perform(
|
||||
crate::io::recover_path_with_phase(
|
||||
path,
|
||||
progress,
|
||||
model_bg,
|
||||
initial_error,
|
||||
initial_stats,
|
||||
),
|
||||
move |result| Message::FileOpened(open_id, result),
|
||||
)
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let _ = model_bg;
|
||||
let Some(bytes) = recovery_bytes else {
|
||||
self.opening = None;
|
||||
return self.drain_pending_open();
|
||||
};
|
||||
Task::perform(
|
||||
crate::io::recover_web_bytes(
|
||||
path.to_string_lossy().into_owned(),
|
||||
bytes,
|
||||
progress,
|
||||
initial_error,
|
||||
initial_stats,
|
||||
),
|
||||
move |outcome| Message::WebFileOpened(open_id, outcome),
|
||||
)
|
||||
}
|
||||
}
|
||||
Message::RecoveryDecline => {
|
||||
let declined = self.opening.take();
|
||||
self.close_active_modal();
|
||||
if let Some(opening) = declined {
|
||||
self.command_line.push_info(crate::tf!(
|
||||
"Recovery cancelled: \"{}\"",
|
||||
opening.name
|
||||
).as_ref());
|
||||
}
|
||||
self.drain_pending_open()
|
||||
}
|
||||
Message::RecoverySaveAs => {
|
||||
if !self.pending_opens.is_empty() {
|
||||
self.close_active_modal();
|
||||
return self.drain_pending_open();
|
||||
}
|
||||
let Some(tab_id) = self
|
||||
.recovery_report
|
||||
.as_ref()
|
||||
.and_then(|report| report.tab_id)
|
||||
else {
|
||||
self.close_active_modal();
|
||||
return Task::none();
|
||||
};
|
||||
let Some(i) = self.tabs.iter().position(|tab| tab.id == tab_id) else {
|
||||
self.close_active_modal();
|
||||
return Task::none();
|
||||
};
|
||||
self.close_active_modal();
|
||||
self.active_tab = i;
|
||||
self.open_save_dialog_window(i)
|
||||
}
|
||||
Message::RecoveryShowLog => {
|
||||
let Some(report) = self.recovery_report.as_ref() else {
|
||||
return Task::none();
|
||||
};
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
{
|
||||
if let Some(path) = &report.log_path {
|
||||
if let Err(error) = crate::sys::reveal_in_file_manager(path) {
|
||||
self.command_line.push_error(crate::tf!(
|
||||
"Could not show recovery log: {error}"
|
||||
).as_ref());
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
{
|
||||
let name = report.suggested_download_name();
|
||||
let body = report.log_text();
|
||||
crate::sys::download_bytes(&name, body.as_bytes());
|
||||
}
|
||||
Task::none()
|
||||
}
|
||||
Message::AttrEditorOpen(handle) => {
|
||||
|
||||
+9
-2
@@ -1565,7 +1565,11 @@ impl OpenCADStudio {
|
||||
iced::widget::Space::new().width(0).height(0).into()
|
||||
};
|
||||
|
||||
let open_progress_layer: Element<'_, Message> = if let Some(p) = &self.opening {
|
||||
let open_progress_layer: Element<'_, Message> = if let Some(p) = self
|
||||
.opening
|
||||
.as_ref()
|
||||
.filter(|progress| progress.recovery_error.is_none())
|
||||
{
|
||||
crate::ui::window::open_progress::view(p, iced::time::Instant::now())
|
||||
} else {
|
||||
iced::widget::Space::new().width(0).height(0).into()
|
||||
@@ -1641,7 +1645,10 @@ impl OpenCADStudio {
|
||||
// (currently just the open-progress indicator). Without this gate the
|
||||
// app burned 2-3% CPU continuously redrawing an unchanged view.
|
||||
// See #18.
|
||||
let needs_frames = self.opening.is_some();
|
||||
let needs_frames = self
|
||||
.opening
|
||||
.as_ref()
|
||||
.is_some_and(|progress| progress.recovery_error.is_none());
|
||||
let frames = if needs_frames {
|
||||
window::frames().map(Message::Tick)
|
||||
} else {
|
||||
|
||||
@@ -41,6 +41,8 @@ impl OpenCADStudio {
|
||||
Some(K::PointStyle) => crate::tr!("modal-point-style"),
|
||||
Some(K::AttributeEditor) => crate::tr!("modal-attribute-editor"),
|
||||
Some(K::SaveDialog) => crate::tr!("modal-save-drawing-as"),
|
||||
Some(K::Recovery) => crate::tr!("modal-recovery-report"),
|
||||
Some(K::RecoveryPrompt) => crate::tr!("modal-recovery-prompt"),
|
||||
None => String::new(),
|
||||
}
|
||||
}
|
||||
@@ -1289,6 +1291,27 @@ impl OpenCADStudio {
|
||||
)
|
||||
})
|
||||
}
|
||||
super::super::ModalKind::Recovery => {
|
||||
let report = self.recovery_report.as_ref()?;
|
||||
sized_flow(ex, 680, 460, |flow| {
|
||||
crate::ui::window::recovery::view_window(
|
||||
report,
|
||||
self.pending_opens.is_empty(),
|
||||
flow,
|
||||
)
|
||||
})
|
||||
}
|
||||
super::super::ModalKind::RecoveryPrompt => {
|
||||
let opening = self.opening.as_ref()?;
|
||||
let error = opening.recovery_error.as_deref()?;
|
||||
automatic_flow(ex, |flow| {
|
||||
crate::ui::window::recovery::view_prompt(
|
||||
&opening.name,
|
||||
error,
|
||||
flow,
|
||||
)
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user