forked from 1b-agent/fieldcraft-upstream
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 <[email protected]> Claude-Session: https://claude.ai/code/session_01UhhZMBTviDQxZvwnyP89fo
This commit is contained in:
1 parent
6bb3b04e60
commit
c26b142630
4 files changed
+69
-15
No files matched your search
@@ -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);
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user