Never lose work: atomic saves (temporary file, flush, rename) keeping the replaced version as <file>.bak; autosave of unsaved changes into a recovery folder (every 5 min, preference autosave_minutes, and after commands over 1.5 s; skipped when unchanged; entries locked while their app runs); Recover unsaved design? at launch; doc.recovery_list / doc.recover / doc.recovery_discard; kill-mid-save test
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AS5Tn3xieewUSg3uxrMVdL
This commit is contained in:
1 parent
01b349842c
commit
f40356a74f
11 files changed
+679
-1
No files matched your search
@@ -33,6 +33,7 @@ pub mod params_dialog;
|
||||
#[cfg(test)]
|
||||
mod preselect_tests;
|
||||
pub mod preview;
|
||||
pub mod recovery_ui;
|
||||
pub mod ref_images;
|
||||
pub mod scenario;
|
||||
pub mod selection;
|
||||
@@ -163,6 +164,14 @@ pub struct SolveApp {
|
||||
pub esc_handled: bool,
|
||||
pub status: Option<(String, f64, bool)>,
|
||||
pub quit_requested: bool,
|
||||
/// Autosave into the recovery folder (the desktop app turns it on).
|
||||
pub autosave: Option<solvecraft_engine::recovery::Autosaver>,
|
||||
/// Designs left by crashed apps, offered for recovery.
|
||||
pub recoverable: Vec<solvecraft_engine::recovery::Entry>,
|
||||
/// The last command was a big operation: autosave after it.
|
||||
pub big_operation: bool,
|
||||
/// Autosave interval (minutes; 0 = only after big operations). A preference.
|
||||
pub autosave_minutes: f64,
|
||||
/// The application bar is the window's title bar (Windows, Linux: no OS decorations).
|
||||
pub custom_titlebar: bool,
|
||||
/// macOS: the traffic lights sit over the application bar's left end.
|
||||
@@ -206,6 +215,10 @@ impl SolveApp {
|
||||
esc_handled: false,
|
||||
status: None,
|
||||
quit_requested: false,
|
||||
autosave: None,
|
||||
recoverable: Vec::new(),
|
||||
big_operation: false,
|
||||
autosave_minutes: 5.0,
|
||||
custom_titlebar: false,
|
||||
integrated_titlebar: false,
|
||||
frame_ms: 0.0,
|
||||
@@ -241,6 +254,7 @@ impl SolveApp {
|
||||
"recent": self.home.recent,
|
||||
"shortcut_box": self.sbox.prefs(),
|
||||
"shortcuts": self.keymap.prefs(),
|
||||
"autosave_minutes": self.autosave_minutes,
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
@@ -264,6 +278,9 @@ impl SolveApp {
|
||||
if let Some(a) = flag("auto_project") {
|
||||
self.session.auto_project = a;
|
||||
}
|
||||
if let Some(m) = v.get("autosave_minutes").and_then(Value::as_f64).filter(|m| m.is_finite()) {
|
||||
self.autosave_minutes = m.clamp(0.0, 600.0);
|
||||
}
|
||||
if let Some(s) = v.get("visual_style").and_then(Value::as_u64) {
|
||||
self.ui.visual_style = u8::try_from(s.min(2)).unwrap_or(0);
|
||||
}
|
||||
@@ -296,7 +313,9 @@ impl SolveApp {
|
||||
|
||||
/// Run a command programmatically (JSON parameters, no dialogs).
|
||||
pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> {
|
||||
let t0 = now_ms();
|
||||
let r = self.session.execute(id, ¶ms).map_err(|e| e.to_string());
|
||||
self.big_operation |= now_ms() - t0 >= recovery_ui::BIG_OPERATION_MS;
|
||||
match &r {
|
||||
Ok(_) => {
|
||||
self.session.echo(format!("{id}: done"));
|
||||
@@ -604,6 +623,7 @@ impl SolveApp {
|
||||
self.collect_screenshots(ctx);
|
||||
self.issue_screenshots(ctx);
|
||||
toolbar::shortcuts(self, ctx);
|
||||
recovery_ui::tick(self);
|
||||
for f in ctx.input(|i| i.raw.dropped_files.clone()) {
|
||||
let p = f.path().to_string_lossy().to_string();
|
||||
if !p.is_empty() {
|
||||
@@ -663,6 +683,7 @@ impl SolveApp {
|
||||
dialogs::show(self, ui.ctx());
|
||||
sketch_palette::show(self, ui.ctx());
|
||||
params_dialog::show(self, ui.ctx());
|
||||
recovery_ui::show(self, ui.ctx());
|
||||
context_menu::show(self, ui.ctx());
|
||||
delete::show(self, ui.ctx());
|
||||
if self.custom_titlebar {
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
//! Autosave ticks and the "Recover unsaved design?" window shown at launch.
|
||||
|
||||
use egui::RichText;
|
||||
use serde_json::json;
|
||||
use solvecraft_engine::recovery::{self, Autosaver, Entry};
|
||||
|
||||
use crate::SolveApp;
|
||||
|
||||
/// A command that ran at least this long counts as a big operation: autosave right after it.
|
||||
pub const BIG_OPERATION_MS: f64 = 1500.0;
|
||||
|
||||
/// Autosave (when due, or after a big operation).
|
||||
pub fn tick(app: &mut SolveApp) {
|
||||
let big = std::mem::take(&mut app.big_operation);
|
||||
let Some(a) = app.autosave.as_mut() else { return };
|
||||
if let Err(e) = a.tick(&app.session, big) {
|
||||
let msg = format!("autosave failed: {e}");
|
||||
if app.status.as_ref().is_none_or(|(s, _, _)| *s != msg) {
|
||||
app.set_status(msg, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start autosaving into `dir` and queue the designs that crashed apps left there.
|
||||
pub fn start(app: &mut SolveApp, dir: &std::path::Path, interval: std::time::Duration) {
|
||||
match Autosaver::new(dir, interval) {
|
||||
Ok(a) => {
|
||||
app.recoverable = recovery::orphans(dir);
|
||||
app.autosave = Some(a);
|
||||
}
|
||||
Err(e) => app.set_status(format!("autosave is off: {e}"), true),
|
||||
}
|
||||
}
|
||||
|
||||
/// The app is closing: keep unsaved work for the next launch, drop everything else.
|
||||
pub fn close(app: &mut SolveApp) {
|
||||
if let Some(a) = app.autosave.take() {
|
||||
a.close(&app.session);
|
||||
}
|
||||
}
|
||||
|
||||
fn ago(secs: u64) -> String {
|
||||
let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(secs);
|
||||
let d = now.saturating_sub(secs);
|
||||
match d {
|
||||
0..=59 => "just now".into(),
|
||||
60..=3599 => format!("{} min ago", d / 60),
|
||||
3600..=86_399 => format!("{} h ago", d / 3600),
|
||||
_ => format!("{} days ago", d / 86_400),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn show(app: &mut SolveApp, ctx: &egui::Context) {
|
||||
if app.recoverable.is_empty() {
|
||||
return;
|
||||
}
|
||||
let dir = app.autosave.as_ref().map(|a| a.dir.clone()).or_else(recovery::default_dir);
|
||||
let mut act: Option<(Entry, bool)> = None;
|
||||
let mut later = false;
|
||||
egui::Window::new(RichText::new("Recover unsaved design?").strong().size(13.0))
|
||||
.collapsible(false)
|
||||
.resizable(false)
|
||||
.min_width(420.0)
|
||||
.anchor(egui::Align2::CENTER_CENTER, egui::vec2(0.0, 0.0))
|
||||
.show(ctx, |ui| {
|
||||
ui.label("SolveCraft closed before these designs were saved. Their last autosave can be opened.");
|
||||
ui.add_space(6.0);
|
||||
for e in app.recoverable.iter().take(20) {
|
||||
ui.label(RichText::new(&e.name).strong());
|
||||
let file = e.path.clone().unwrap_or_else(|| "never saved".into());
|
||||
ui.label(RichText::new(format!("{file} · {} features · autosaved {}", e.features, ago(e.saved_at))).small().weak());
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Recover").clicked() {
|
||||
act = Some((e.clone(), true));
|
||||
}
|
||||
if ui.button("Discard").clicked() {
|
||||
act = Some((e.clone(), false));
|
||||
}
|
||||
});
|
||||
ui.separator();
|
||||
}
|
||||
ui.horizontal(|ui| {
|
||||
if ui.button("Later").on_hover_text("Keep them; ask again next launch").clicked() {
|
||||
later = true;
|
||||
}
|
||||
});
|
||||
});
|
||||
let dir_s = dir.map(|d| d.to_string_lossy().to_string());
|
||||
if let Some((e, recover)) = act {
|
||||
app.recoverable.retain(|x| x.id != e.id);
|
||||
if recover {
|
||||
if app.session.is_dirty() {
|
||||
app.set_status("save or discard the open design's changes before recovering another", true);
|
||||
app.recoverable.insert(0, e);
|
||||
return;
|
||||
}
|
||||
if app.run("doc.recover", json!({"id": e.id, "dir": dir_s})).is_ok() {
|
||||
// Ours now: autosave it under this app's entry before the old entry goes.
|
||||
let kept = app.autosave.as_mut().map(|a| a.save(&app.session));
|
||||
if let Some(Ok(_)) = kept {
|
||||
let _ = app.run("doc.recovery_discard", json!({"id": e.id, "dir": dir_s}));
|
||||
}
|
||||
app.set_status(format!("recovered {}: save it to keep it", e.name), false);
|
||||
}
|
||||
} else {
|
||||
let _ = app.run("doc.recovery_discard", json!({"id": e.id, "dir": dir_s}));
|
||||
}
|
||||
}
|
||||
if later {
|
||||
app.recoverable.clear();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user