From 02d2764e450036ca2be40d27900c74a9f9d0a805 Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Mon, 29 Jun 2026 13:17:40 +0300 Subject: [PATCH] feat(io): autosave to .sv$ recovery files (SAVETIME command) (#205) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A periodic subscription (every SAVETIME minutes — default 10, 0 disables) writes a .sv$ recovery copy of each dirty, already-saved drawing at its own DWG version. Non-destructive: it never touches the original file or the dirty flag, and skips unsaved (path-less) tabs. The SAVETIME command sets/queries the interval and persists it. Recovery files (.sv$ and .bak) open through the normal Open dialog — the loader detects DWG vs DXF from the leading magic bytes, and a "Backup / Autosave" filter lists them. Co-Authored-By: Claude Opus 4.8 --- src/app/commands/styleprops.rs | 21 +++++++++++++++++++ src/app/mod.rs | 6 ++++++ src/app/settings.rs | 12 ++++++++++- src/app/update/file.rs | 37 ++++++++++++++++++++++++++++++++++ src/app/update/mod.rs | 1 + src/app/view/mod.rs | 12 +++++++++++ src/io/mod.rs | 10 ++++----- 7 files changed, 93 insertions(+), 6 deletions(-) diff --git a/src/app/commands/styleprops.rs b/src/app/commands/styleprops.rs index 23da24ce3..1b83b0898 100644 --- a/src/app/commands/styleprops.rs +++ b/src/app/commands/styleprops.rs @@ -1501,6 +1501,27 @@ impl OpenCADStudio { _ => self.command_line.push_error("Requires 0 or 1"), } } + "SAVETIME" => { + self.command_line + .push_output(&format!("SAVETIME = {}", self.savetime_min)); + } + cmd if cmd.starts_with("SAVETIME ") => { + match cmd.trim_start_matches("SAVETIME").trim().parse::() { + Ok(v) if v >= 0 => { + self.savetime_min = v; + self.persist_settings_if_changed(); + let msg = if v == 0 { + "SAVETIME set to 0 (autosave off)".to_string() + } else { + format!("SAVETIME set to {v} minute(s)") + }; + self.command_line.push_output(&msg); + } + _ => self + .command_line + .push_error("Requires a non-negative number of minutes (0 = off)"), + } + } "PDSIZE" => { use crate::command::ValuePromptCommand; let c = ValuePromptCommand::new( diff --git a/src/app/mod.rs b/src/app/mod.rs index 35264aa75..645a64104 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -253,6 +253,9 @@ pub(super) struct OpenCADStudio { /// When true (default), the app registers itself as a .dwg/.dxf/.bak file /// handler on each launch. Toggle with the FILEASSOC command. pub file_assoc_enabled: bool, + /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command); + /// 0 disables autosave. + pub savetime_min: i32, /// Persisted default viewport background, restored from settings and applied /// to every drawing tab (new and opened) so a chosen background survives /// restarts (#188). `None` = the built-in dark-grey / off-white defaults. @@ -1120,6 +1123,8 @@ pub enum Message { OverwriteConfirm, /// Go back to the Save dialog from the overwrite warning. OverwriteCancel, + /// Periodic autosave tick — write `.sv$` recovery files for dirty tabs. + AutoSave, /// Save-as path picked for the unsaved-changes → save → close flow. UnsavedPickedSavePath(Option), // ───────────────────────────────────────────────────────────────────── @@ -1870,6 +1875,7 @@ impl OpenCADStudio { texteditmode: false, backup_on_save: true, file_assoc_enabled: true, + savetime_min: 10, default_bg_color: None, default_paper_bg_color: None, awaiting_vports: false, diff --git a/src/app/settings.rs b/src/app/settings.rs index 9dc68a0a0..42d43bda1 100644 --- a/src/app/settings.rs +++ b/src/app/settings.rs @@ -109,6 +109,9 @@ pub struct UserSettings { /// When true (default), the app (re)registers itself as a .dwg/.dxf/.bak /// handler on every launch. Toggle with the FILEASSOC command. pub file_assoc_enabled: bool, + /// Minutes between autosaves to a `.sv$` recovery file (SAVETIME command). + /// 0 disables autosave. + pub savetime_min: i32, /// Persisted viewport background colours (0–255 RGB); `None` = app default /// (dark grey model / off-white paper). Applied to every drawing tab on /// launch and to tabs opened later, so a chosen background survives restarts @@ -141,6 +144,7 @@ impl Default for UserSettings { texteditmode: false, backup_on_save: true, file_assoc_enabled: true, + savetime_min: 10, bg_color: None, paper_bg_color: None, } @@ -187,6 +191,11 @@ impl UserSettings { } "backup_on_save" => s.backup_on_save = val == "1", "file_assoc_enabled" => s.file_assoc_enabled = val == "1", + "savetime_min" => { + if let Ok(v) = val.parse::() { + s.savetime_min = v.max(0); + } + } "disabled_plugins" => { s.disabled_plugins = val .split(',') @@ -228,7 +237,7 @@ impl UserSettings { .collect::>() .join(","); let body = format!( - "dyn={}\northo={}\npolar={}\npolar_increment_deg={}\nosnap={}\notrack={}\ndefault_assoc_prompted={}\nsnap_modes={}\ndisabled_plugins={}\nplugin_repos={}\ntexteditmode={}\nbackup_on_save={}\nfile_assoc_enabled={}\nbg_color={}\npaper_bg_color={}\n", + "dyn={}\northo={}\npolar={}\npolar_increment_deg={}\nosnap={}\notrack={}\ndefault_assoc_prompted={}\nsnap_modes={}\ndisabled_plugins={}\nplugin_repos={}\ntexteditmode={}\nbackup_on_save={}\nfile_assoc_enabled={}\nsavetime_min={}\nbg_color={}\npaper_bg_color={}\n", b(self.dyn_input), b(self.ortho), b(self.polar), @@ -242,6 +251,7 @@ impl UserSettings { self.texteditmode, b(self.backup_on_save), b(self.file_assoc_enabled), + self.savetime_min, rgb_to_str(self.bg_color), rgb_to_str(self.paper_bg_color), ); diff --git a/src/app/update/file.rs b/src/app/update/file.rs index 1f1189c35..bf56d6662 100644 --- a/src/app/update/file.rs +++ b/src/app/update/file.rs @@ -42,6 +42,7 @@ impl OpenCADStudio { texteditmode: self.texteditmode, backup_on_save: self.backup_on_save, file_assoc_enabled: self.file_assoc_enabled, + savetime_min: self.savetime_min, bg_color: self.default_bg_color.map(f4_to_u3), paper_bg_color: self.default_paper_bg_color.map(f4_to_u3), } @@ -62,6 +63,7 @@ impl OpenCADStudio { self.texteditmode = s.texteditmode; self.backup_on_save = s.backup_on_save; self.file_assoc_enabled = s.file_assoc_enabled; + self.savetime_min = s.savetime_min; self.default_bg_color = s.bg_color.map(u3_to_f4); self.default_paper_bg_color = s.paper_bg_color.map(u3_to_f4); // Push the restored background onto every drawing tab that exists now @@ -675,6 +677,41 @@ pub(super) fn on_open_file(&mut self) -> Task { self.on_save_dialog_confirm() } + /// Periodic autosave (SAVETIME): write a `.sv$` recovery copy for every + /// dirty tab that has a path, at the document's own DWG version. Best-effort + /// and non-destructive — it never touches the original file or the dirty + /// flag, and skips unsaved (path-less) tabs. + #[cfg(not(target_arch = "wasm32"))] + pub(super) fn on_autosave(&mut self) -> Task { + let mut n = 0; + for i in 0..self.tabs.len() { + if !self.tabs[i].dirty { + continue; + } + let Some(path) = self.tabs[i].current_path.clone() else { + continue; + }; + let version = self.tabs[i].scene.document.version; + if let Ok(bytes) = + crate::io::save_to_bytes(&self.tabs[i].scene.document, "dwg", version) + { + if std::fs::write(path.with_extension("sv$"), bytes).is_ok() { + n += 1; + } + } + } + if n > 0 { + self.command_line + .push_output(&format!("Autosaved {n} drawing(s) to .sv$")); + } + Task::none() + } + + #[cfg(target_arch = "wasm32")] + pub(super) fn on_autosave(&mut self) -> Task { + Task::none() + } + pub(super) fn on_page_setup_commit(&mut self) -> Task { let i = self.active_tab; let layout_name = self.tabs[i].scene.current_layout.clone(); diff --git a/src/app/update/mod.rs b/src/app/update/mod.rs index a8b2554e3..69088eb19 100644 --- a/src/app/update/mod.rs +++ b/src/app/update/mod.rs @@ -2540,6 +2540,7 @@ impl OpenCADStudio { self.active_modal = Some(crate::app::ModalKind::SaveDialog); Task::none() } + Message::AutoSave => self.on_autosave(), Message::UnsavedPickedSavePath(Some(path)) => { self.on_unsaved_picked_save_path_some(path) diff --git a/src/app/view/mod.rs b/src/app/view/mod.rs index 642db3e54..c4e6e9b5f 100644 --- a/src/app/view/mod.rs +++ b/src/app/view/mod.rs @@ -1423,6 +1423,17 @@ impl OpenCADStudio { iced::time::every(std::time::Duration::from_millis(300)).map(|_| Message::PollWebFonts); #[cfg(not(target_arch = "wasm32"))] let web_fonts = Subscription::none(); + // Periodic autosave to a `.sv$` recovery file (native only). SAVETIME is + // the interval in minutes; 0 disables it. + #[cfg(not(target_arch = "wasm32"))] + let autosave = if self.savetime_min > 0 { + iced::time::every(std::time::Duration::from_secs(self.savetime_min as u64 * 60)) + .map(|_| Message::AutoSave) + } else { + Subscription::none() + }; + #[cfg(target_arch = "wasm32")] + let autosave = Subscription::none(); iced::Subscription::batch([ frames, history_tick, @@ -1430,6 +1441,7 @@ impl OpenCADStudio { hover_dwell, caret_blink, web_fonts, + autosave, event::listen_with(|ev, status, win_id| { use iced::event::Status; match ev { diff --git a/src/io/mod.rs b/src/io/mod.rs index f540c9538..703ca6743 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -38,10 +38,10 @@ const PHASE_FINALIZING: u8 = 3; pub async fn pick_open_path() -> Option<(PathBuf, u64)> { let handle = rfd::AsyncFileDialog::new() .set_title("Open CAD file") - .add_filter("CAD Files", &["dwg", "dxf", "bak", "DWG", "DXF", "BAK"]) + .add_filter("CAD Files", &["dwg", "dxf", "bak", "sv$", "DWG", "DXF", "BAK"]) .add_filter("DWG Files", &["dwg", "DWG"]) .add_filter("DXF Files", &["dxf", "DXF"]) - .add_filter("Backup Files", &["bak", "BAK"]) + .add_filter("Backup / Autosave", &["bak", "sv$", "BAK"]) .add_filter("All Files", &["*"]) .pick_file() .await?; @@ -169,9 +169,9 @@ pub fn load_file(path: &Path) -> Result { .map(|e| e.to_string_lossy().to_lowercase()) .unwrap_or_default(); - // A `.bak` backup holds a verbatim DWG or DXF copy — detect the real format - // from the file's leading bytes rather than the `.bak` extension. - let effective = if ext == "bak" { + // A `.bak` backup or `.sv$` autosave holds a verbatim DWG/DXF copy — detect + // the real format from the file's leading bytes, not the extension. + let effective = if ext == "bak" || ext == "sv$" { sniff_dwg_or_dxf(path) } else { ext.clone()