From c26b1426306df334141644f7b20f054323e050ba Mon Sep 17 00:00:00 2001 From: jyviko <2501648+jyviko@users.noreply.github.com> Date: Wed, 14 Oct 2026 22:07:44 -0400 Subject: [PATCH] Recover: each entry's age, and Discard all MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit file.recovery.list gives each entry's age; File › Recover shows name, age and counts, newest first, with Discard all. docs/formats/fieldcraft.md states the folder and pruning rules. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo --- crates/engine/src/autosave.rs | 1 + crates/engine/src/cmd/file.rs | 13 ++++++--- crates/ui-egui/src/files.rs | 51 ++++++++++++++++++++++++++++++----- docs/formats/fieldcraft.md | 19 +++++++++---- 4 files changed, 69 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/autosave.rs b/crates/engine/src/autosave.rs index d4f7a32..f385169 100644 --- a/crates/engine/src/autosave.rs +++ b/crates/engine/src/autosave.rs @@ -84,6 +84,7 @@ mod tests { let listed = s.execute("file.recovery.list", &json!({"dir": dir})).unwrap(); assert_eq!(listed[0]["id"], id); assert_eq!(listed[0]["nodes"], 1); + assert!(listed[0]["age"].as_u64().is_some_and(|a| a < 60), "{listed}"); let mut next = Session::default(); let r = next.execute("file.recover", &json!({"id": id, "dir": dir})).unwrap(); assert_eq!(r["nodes"], 1); diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 7453c77..456bbc1 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -52,7 +52,7 @@ pub const SAVE_AS: CommandSpec = CommandSpec { pub const RECOVERY_LIST: CommandSpec = CommandSpec { id: "file.recovery.list", label: "Recoverable Models", - params: r#"{"dir"?: "…"} — models autosaved by apps that crashed or closed with unsaved changes, newest first (default folder: FIELDCRAFT_RECOVERY_DIR or the per-user data folder)"#, + params: r#"{"dir"?: "…"} — models autosaved by apps that crashed or closed with unsaved changes, newest first, each with its id, path, saved_at, age (seconds) and node and element counts (default folder: FIELDCRAFT_RECOVERY_DIR, a temporary folder in runs driven by automation, else the per-user data folder)"#, form: Some(super::forms::RECOVERY_LIST), kind: CommandKind::Session, requires: &[], @@ -307,8 +307,15 @@ fn recovery_dir(s: &Session, p: &Value, access: Access) -> Result { } fn recovery_list(s: &mut Session, p: &Value) -> Result { - let entries = fieldcraft_io::recovery::orphans(&recovery_dir(s, p, Access::Read)?); - serde_json::to_value(entries).map_err(|e| EngineError::Params(e.to_string())) + let mut listed = Vec::new(); + for e in fieldcraft_io::recovery::orphans(&recovery_dir(s, p, Access::Read)?) { + let mut v = serde_json::to_value(&e).map_err(|e| EngineError::Params(e.to_string()))?; + if let Some(o) = v.as_object_mut() { + o.insert("age".into(), json!(e.age())); + } + listed.push(v); + } + Ok(Value::Array(listed)) } fn recover(s: &mut Session, p: &Value) -> Result { diff --git a/crates/ui-egui/src/files.rs b/crates/ui-egui/src/files.rs index 1852192..30d0295 100644 --- a/crates/ui-egui/src/files.rs +++ b/crates/ui-egui/src/files.rs @@ -751,6 +751,17 @@ pub fn file_name(path: &str) -> String { Path::new(path).file_name().and_then(|n| n.to_str()).map_or_else(|| path.to_string(), str::to_string) } +/// How long ago, from an age in seconds: "just now", "5 min ago", "3 h ago", "2 days ago". +pub fn ago(seconds: u64) -> String { + match seconds { + 0..60 => "just now".into(), + 60..3600 => format!("{} min ago", seconds / 60), + 3600..86_400 => format!("{} h ago", seconds / 3600), + 86_400..172_800 => "1 day ago".into(), + _ => format!("{} days ago", seconds / 86_400), + } +} + /// The prompts (unsaved changes, recover), the loading overlay and the drop hint. pub fn show(app: &mut ModelApp, ctx: &egui::Context) { let t = crate::theme::Tokens::get(app.ui.dark); @@ -842,17 +853,21 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) { } Some(Prompt::Recover) => { let entries = app.recover_list(ctx); - let (mut recover, mut discard, mut close) = (None, None, false); + let (mut recover, mut discard, mut discard_all, mut close) = (None, None, false, false); crate::frame::window(ctx, "Recover", crate::frame::Width::Wide).id(egui::Id::new("fc_recover")).show(ctx, |ui| { if entries.is_empty() { ui.label(egui::RichText::new("No autosaved models to recover.").color(t.text_dim)); } - egui::Grid::new("fc_recover_grid").num_columns(3).spacing(egui::vec2(10.0, 4.0)).show(ui, |ui| { + egui::Grid::new("fc_recover_grid").num_columns(4).spacing(egui::vec2(10.0, 4.0)).show(ui, |ui| { for e in &entries { let id = e.get("id").and_then(Value::as_str).unwrap_or_default().to_string(); - let name = e.get("path").and_then(Value::as_str).map_or_else(|| "Untitled".to_string(), file_name); + let path = e.get("path").and_then(Value::as_str); let n = |k: &str| e.get(k).and_then(Value::as_u64).unwrap_or(0); - ui.label(name); + let name = ui.label(path.map_or_else(|| "Untitled".to_string(), file_name)); + if let Some(path) = path { + name.on_hover_text(path); + } + ui.label(egui::RichText::new(ago(n("age"))).color(t.text_dim)); ui.label(egui::RichText::new(format!("{} nodes, {} elements", n("nodes"), n("elements"))).color(t.text_dim)); ui.horizontal(|ui| { if ui.button("Recover").clicked() { @@ -866,12 +881,18 @@ pub fn show(app: &mut ModelApp, ctx: &egui::Context) { } }); ui.add_space(6.0); - close = ui.button("Close").clicked(); + ui.horizontal(|ui| { + close = ui.button("Close").clicked(); + if entries.len() > 1 { + discard_all = ui.button("Discard all").clicked(); + } + }); }); if let Some(id) = recover { app.recover(&id); - } else if let Some(id) = discard { - let _ = app.run("file.recovery.discard", json!({"id": id})); + } else if discard.is_some() || discard_all { + let params = discard.map_or_else(|| json!({"all": true}), |id| json!({"id": id})); + let _ = app.run("file.recovery.discard", params); ctx.data_mut(|d| d.remove::>(egui::Id::new("fc_recover_entries"))); } else if close || ctx.input(|i| i.key_pressed(egui::Key::Escape)) { app.prompt = None; @@ -1194,6 +1215,22 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn recovered_entries_say_how_long_ago() { + let cases = [ + (0, "just now"), + (59, "just now"), + (60, "1 min ago"), + (3599, "59 min ago"), + (7200, "2 h ago"), + (90_000, "1 day ago"), + (13 * 86_400, "13 days ago"), + ]; + for (s, want) in cases { + assert_eq!(ago(s), want, "{s}"); + } + } + #[test] fn the_recent_list_is_kept_newest_first_and_survives_bad_files() { let dir = temp("recent"); diff --git a/docs/formats/fieldcraft.md b/docs/formats/fieldcraft.md index bb6613a..2ec9662 100644 --- a/docs/formats/fieldcraft.md +++ b/docs/formats/fieldcraft.md @@ -87,8 +87,17 @@ refused. ## Autosave and recovery The desktop app autosaves every 30 seconds while there are unsaved changes, into the recovery -folder (`FIELDCRAFT_RECOVERY_DIR`, else `FieldCraft/recovery` in the per-user data folder): -`.fieldcraft`, `.json` (the model's own path, time, counts) and `.lock`, held while -the app runs. Saving (or undoing back to the saved state) drops the autosave. After a crash, the -next launch reports the entries; `file.recovery.list` lists them, `file.recover` opens one as -unsaved work under its original path, and `file.recovery.discard` deletes them. +folder: `FIELDCRAFT_RECOVERY_DIR` when set; else, in runs driven by automation (`--control`, +`FIELDCRAFT_CONTROL_PORT`) and on CI (`CI` set), a temporary folder +(`fieldcraft-automation/recovery` in the system's temporary folder, beside that run's recent files +and preferences), so they never add to the user's recoveries; else `FieldCraft/recovery` in the +per-user data folder. An entry is `.fieldcraft`, `.json` (the model's own path, time, +counts) and `.lock`, held while the app runs. Saving (or undoing back to the saved state) +drops the autosave. After a crash, the next launch reports the entries; `file.recovery.list` +lists them newest first, `file.recover` opens one as unsaved work under its original path, and +`file.recovery.discard` deletes one or all of them (File › Recover… shows each with its name, +age and counts, with Recover, Discard and Discard all). + +Entries prune themselves: each launch deletes left-behind entries older than 14 days, and those +whose model's own file was saved after the autosave (the file's modification time is later than +the entry's). Entries of running apps are never touched.