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:
bherbruckandClaude Opus 5.5 committed 2026-10-08 11:37:33 +00:00
1 parent 01b349842c
commit f40356a74f
11 files changed
+679 -1

No files matched your search

+21
View File
@@ -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, &params).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 {
+112
View File
@@ -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();
}
}