Recover: each entry's age, and Discard all

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
jyvikoandClaude Opus 5.5 authored and Irakli Kourtis committed 2026-10-14 22:11:13 -04:00
1 parent 6bb3b04e60
commit c26b142630
4 files changed
+69 -15

No files matched your search

+1
View File
@@ -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);
+10 -3
View File
@@ -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<PathBuf> {
}
fn recovery_list(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
+44 -7
View File
@@ -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::<Vec<Value>>(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");
+14 -5
View File
@@ -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):
`<id>.fieldcraft`, `<id>.json` (the model's own path, time, counts) and `<id>.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 `<id>.fieldcraft`, `<id>.json` (the model's own path, time,
counts) and `<id>.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.