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");