use std::collections::HashMap;
use eframe::egui;
use crate::document::{Document, Documents};
use crate::store::{ModelStore, RECOVERY_KEY};
pub const AUTOSAVE_DEBOUNCE: f64 = 2.0;
const SCHEMA: u64 = 1;
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct RecoveryEntry {
#[serde(default)]
pub name: Option<String>,
pub title: String,
#[serde(default)]
pub saved_at: f64,
pub model: String,
}
#[derive(serde::Serialize, serde::Deserialize)]
struct Blob {
schema: u64,
documents: Vec<RecoveryEntry>,
}
#[derive(Default)]
pub struct Autosave {
captured: Vec<(u64, u64, bool)>,
armed_at: Option<f64>,
written: Option<Vec<(Option<String>, String)>>,
errors: Vec<String>,
}
impl Autosave {
pub fn new() -> Self {
Self::default()
}
pub fn tick(&mut self, docs: &Documents, store: &dyn ModelStore, now: f64) -> Option<f64> {
let snapshot: Vec<(u64, u64, bool)> = docs
.iter()
.map(|doc| (doc.id(), doc.engine.applied_generation(), doc.dirty_marker()))
.collect();
if snapshot != self.captured {
let dirty_before: Vec<u64> = dirty_ids(&self.captured);
let dirty_now: Vec<u64> = dirty_ids(&snapshot);
let shrank = dirty_before.iter().any(|id| !dirty_now.contains(id));
self.captured = snapshot;
if shrank {
self.write(docs, store);
self.armed_at = None;
} else if !dirty_now.is_empty() {
self.armed_at = Some(now);
}
}
if let Some(armed) = self.armed_at {
let remaining = AUTOSAVE_DEBOUNCE - (now - armed);
if remaining > 0.0 {
return Some(remaining);
}
self.write(docs, store);
self.armed_at = None;
}
None
}
pub fn note_cleared(&mut self) {
self.written = Some(Vec::new());
}
pub fn take_errors(&mut self) -> Vec<String> {
std::mem::take(&mut self.errors)
}
fn write(&mut self, docs: &Documents, store: &dyn ModelStore) {
let entries: Vec<RecoveryEntry> = docs
.iter()
.filter(|doc| doc.dirty_marker())
.map(|doc| RecoveryEntry {
name: doc.name().map(str::to_string),
title: doc.title(),
saved_at: wall_clock(),
model: doc.engine.history_request_json(),
})
.collect();
let key: Vec<(Option<String>, String)> = entries
.iter()
.map(|entry| (entry.name.clone(), entry.model.clone()))
.collect();
if self.written.as_ref() == Some(&key) {
return;
}
let result = if entries.is_empty() {
store.remove(RECOVERY_KEY)
} else {
store.write(RECOVERY_KEY, &encode(&entries))
};
match result {
Ok(()) => self.written = Some(key),
Err(error) => self.errors.push(format!("autosave failed: {error}")),
}
}
}
fn dirty_ids(snapshot: &[(u64, u64, bool)]) -> Vec<u64> {
snapshot
.iter()
.filter(|(_, _, dirty)| *dirty)
.map(|(id, _, _)| *id)
.collect()
}
fn encode(entries: &[RecoveryEntry]) -> String {
serde_json::to_string(&Blob {
schema: SCHEMA,
documents: entries.to_vec(),
})
.expect("serialize recovery blob")
}
pub fn read_entries(store: &dyn ModelStore) -> Vec<RecoveryEntry> {
let Some(text) = store.read(RECOVERY_KEY) else {
return Vec::new();
};
let Ok(blob) = serde_json::from_str::<Blob>(&text) else {
return Vec::new();
};
if blob.schema != SCHEMA {
return Vec::new();
}
blob.documents
.into_iter()
.filter(|entry| !entry.model.is_empty())
.filter(|entry| !already_saved(store, entry))
.collect()
}
fn already_saved(store: &dyn ModelStore, entry: &RecoveryEntry) -> bool {
let Some(name) = &entry.name else {
return false;
};
let Some(saved) = store.read(name) else {
return false;
};
match (
serde_json::from_str::<serde_json::Value>(&saved),
serde_json::from_str::<serde_json::Value>(&entry.model),
) {
(Ok(saved), Ok(model)) => saved == model,
_ => false,
}
}
pub fn restore_entries(entries: &[RecoveryEntry], docs: &mut Documents) -> (usize, Vec<String>) {
let pristine_seed =
docs.len() == 1 && docs.active().name().is_none() && !docs.active().is_dirty();
let mut restored = 0usize;
let mut failures = Vec::new();
for entry in entries {
let mut engine = docs.spawn_engine();
if let Err(error) = engine.load_model_and_fit(&entry.model) {
failures.push(format!("could not restore \"{}\": {error}", entry.title));
continue;
}
docs.open_document(Document::recovered(engine, entry.name.clone()));
restored += 1;
}
if restored > 0 && pristine_seed {
docs.close(0);
}
(restored, failures)
}
pub fn wall_clock() -> f64 {
#[cfg(target_arch = "wasm32")]
{
js_sys::Date::now() / 1000.0
}
#[cfg(not(target_arch = "wasm32"))]
{
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs_f64())
.unwrap_or(0.0)
}
}
fn age_label(saved_at: f64, now: f64) -> String {
if saved_at <= 0.0 {
return String::new();
}
let seconds = (now - saved_at).max(0.0);
if seconds < 60.0 {
"just now".to_string()
} else if seconds < 3600.0 {
format!("{} min ago", (seconds / 60.0).floor() as u64)
} else if seconds < 86_400.0 {
format!("{} h ago", (seconds / 3600.0).floor() as u64)
} else {
format!("{} d ago", (seconds / 86_400.0).floor() as u64)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Resolution {
Restored(usize),
Discarded,
}
#[derive(Default)]
pub struct RecoveryPanel {
entries: Vec<RecoveryEntry>,
hits: HashMap<String, egui::Rect>,
}
impl RecoveryPanel {
pub fn new() -> Self {
Self::default()
}
pub fn arm(&mut self, entries: Vec<RecoveryEntry>) {
self.entries = entries;
}
pub fn is_open(&self) -> bool {
!self.entries.is_empty()
}
pub fn clear_hits(&mut self) {
self.hits.clear();
}
pub fn show(
&mut self,
ctx: &egui::Context,
docs: &mut Documents,
store: &dyn ModelStore,
) -> Option<Resolution> {
self.hits.clear();
if self.entries.is_empty() {
return None;
}
let now = wall_clock();
let count = self.entries.len();
let mut restore = false;
let mut discard = false;
egui::Modal::new(egui::Id::new("brep-recovery")).show(ctx, |ui| {
ui.set_width(440.0);
ui.heading("Recover unsaved work?");
ui.add_space(4.0);
ui.label(format!(
"The last session ended with {count} document{} unsaved. \
Autosaved copies are still here.",
if count == 1 { "" } else { "s" }
));
ui.add_space(6.0);
for entry in &self.entries {
let age = age_label(entry.saved_at, now);
let line = if age.is_empty() {
format!("• {}", entry.title)
} else {
format!("• {} ({age})", entry.title)
};
ui.label(line);
}
ui.add_space(8.0);
ui.horizontal(|ui| {
let r = ui.button("Restore");
self.hits.insert("recovery:restore".into(), r.rect);
if r.clicked() {
restore = true;
}
let d = ui.button("Discard");
self.hits.insert("recovery:discard".into(), d.rect);
if d.clicked() {
discard = true;
}
});
ui.add_space(2.0);
ui.weak("Restored documents open as new tabs with unsaved changes.");
});
if restore {
let entries = std::mem::take(&mut self.entries);
let (restored, failures) = restore_entries(&entries, docs);
for failure in failures {
docs.engine_mut().push_notice(failure);
}
let _ = store.remove(RECOVERY_KEY);
Some(Resolution::Restored(restored))
} else if discard {
self.entries.clear();
let _ = store.remove(RECOVERY_KEY);
Some(Resolution::Discarded)
} else {
None
}
}
pub fn state_json(&self) -> String {
let entries: Vec<serde_json::Value> = self
.entries
.iter()
.map(|entry| {
serde_json::json!({
"title": entry.title,
"name": entry.name,
"savedAt": entry.saved_at,
})
})
.collect();
serde_json::json!({ "open": self.is_open(), "entries": entries }).to_string()
}
pub fn hits_json(&self) -> String {
let map: serde_json::Map<String, serde_json::Value> = self
.hits
.iter()
.map(|(key, rect)| {
(
key.clone(),
serde_json::json!([rect.min.x, rect.min.y, rect.width(), rect.height()]),
)
})
.collect();
serde_json::Value::Object(map).to_string()
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::*;
use crate::store::MemModelStore;
use brep_render::engine_state::EngineState;
const BOX_SEED: &str = r#"{"expressions":"","configurator":{},"features":[
{"type":"P.CU","inputParams":{"id":"Box","sizeX":8.0,"sizeY":8.0,"sizeZ":8.0,
"transform":{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]},
"boolean":{"targets":[],"operation":"NONE"}},"persistentData":{}}
]}"#;
fn documents() -> Documents {
Documents::new(Box::new(EngineState::new))
}
fn named(docs: &mut Documents, name: &str) -> usize {
let mut engine = docs.spawn_engine();
engine.set_history_json(BOX_SEED).unwrap();
let mut doc = Document::new(engine);
doc.set_name(Some(name.to_string()));
docs.open_document(doc)
}
fn edit_active(docs: &mut Documents, size: f64) {
docs.engine_mut()
.update_feature_params(
"Box",
&format!(
r#"{{"id":"Box","sizeX":{size},"sizeY":8.0,"sizeZ":8.0,"transform":{{"position":[0,0,0],"rotationEuler":[0,0,0],"scale":[1,1,1]}},"boolean":{{"targets":[],"operation":"NONE"}}}}"#
),
)
.unwrap();
docs.refresh_dirty_markers();
}
#[test]
fn an_edit_is_written_after_the_debounce_not_before() {
let store = MemModelStore::new();
let mut docs = documents();
named(&mut docs, "part-a");
docs.refresh_dirty_markers();
let mut autosave = Autosave::new();
assert_eq!(autosave.tick(&docs, &store, 0.0), None, "nothing dirty, nothing armed");
assert!(store.read(RECOVERY_KEY).is_none());
edit_active(&mut docs, 19.0);
let due = autosave.tick(&docs, &store, 1.0).expect("armed");
assert!((due - AUTOSAVE_DEBOUNCE).abs() < 1e-9);
assert!(store.read(RECOVERY_KEY).is_none(), "not yet: the burst may continue");
edit_active(&mut docs, 21.0);
let due = autosave.tick(&docs, &store, 2.5).expect("re-armed");
assert!((due - AUTOSAVE_DEBOUNCE).abs() < 1e-9);
assert_eq!(autosave.tick(&docs, &store, 4.0), Some(0.5));
assert!(store.read(RECOVERY_KEY).is_none());
assert_eq!(autosave.tick(&docs, &store, 4.5), None, "written, nothing armed");
let entries = read_entries(&store);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name.as_deref(), Some("part-a"));
assert_eq!(entries[0].title, "part-a");
assert_eq!(entries[0].model, docs.engine().history_request_json());
assert!(entries[0].model.contains("\"sizeX\":21"), "the LAST edit is what was captured: {}", entries[0].model);
assert!(entries[0].saved_at > 0.0);
assert!(autosave.take_errors().is_empty());
}
#[test]
fn saving_removes_the_blob_immediately() {
let store = MemModelStore::new();
let mut docs = documents();
named(&mut docs, "part-a");
docs.refresh_dirty_markers();
let mut autosave = Autosave::new();
autosave.tick(&docs, &store, 0.0);
edit_active(&mut docs, 19.0);
autosave.tick(&docs, &store, 1.0);
autosave.tick(&docs, &store, 3.5);
assert!(store.read(RECOVERY_KEY).is_some());
let writes_before = store.writes();
autosave.tick(&docs, &store, 5.0);
autosave.tick(&docs, &store, 9.0);
assert_eq!(store.writes(), writes_before);
docs.active_mut().mark_clean();
docs.refresh_dirty_markers();
assert_eq!(autosave.tick(&docs, &store, 9.1), None);
assert!(store.read(RECOVERY_KEY).is_none(), "removed on the frame the save landed");
assert!(read_entries(&store).is_empty());
}
#[test]
fn a_closed_tab_leaves_the_blob_and_a_dirty_sibling_stays() {
let store = MemModelStore::new();
let mut docs = documents();
named(&mut docs, "part-a");
named(&mut docs, "part-b");
docs.refresh_dirty_markers();
let mut autosave = Autosave::new();
autosave.tick(&docs, &store, 0.0);
docs.activate(1);
edit_active(&mut docs, 19.0);
docs.activate(2);
edit_active(&mut docs, 23.0);
autosave.tick(&docs, &store, 1.0);
autosave.tick(&docs, &store, 3.5);
assert_eq!(read_entries(&store).len(), 2);
docs.close(2);
docs.refresh_dirty_markers();
autosave.tick(&docs, &store, 3.6);
let entries = read_entries(&store);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name.as_deref(), Some("part-a"));
}
#[test]
fn restore_reopens_dirty_documents_and_replaces_the_pristine_seed() {
let store = MemModelStore::new();
let mut source = documents();
named(&mut source, "part-a");
edit_active(&mut source, 19.0);
let mut engine = source.spawn_engine();
engine.set_history_json(BOX_SEED).unwrap();
source.open_document(Document::new(engine));
edit_active(&mut source, 5.0);
let mut autosave = Autosave::new();
autosave.tick(&source, &store, 0.0);
autosave.tick(&source, &store, 3.0);
let entries = read_entries(&store);
assert_eq!(entries.len(), 2);
let mut docs = documents();
assert_eq!(docs.len(), 1, "the boot seed");
let (restored, failures) = restore_entries(&entries, &mut docs);
assert_eq!(restored, 2);
assert!(failures.is_empty());
assert_eq!(docs.len(), 2, "the pristine seed was replaced");
assert_eq!(docs.get(0).unwrap().title(), "part-a");
assert_eq!(docs.get(0).unwrap().name(), Some("part-a"));
assert_eq!(docs.get(1).unwrap().title(), "untitled");
assert!(docs.get(1).unwrap().name().is_none());
docs.refresh_dirty_markers();
assert!(docs.iter().all(Document::is_dirty), "recovered work is unsaved work");
assert!(docs.iter().all(Document::dirty_marker));
assert_eq!(docs.active().engine.scene.solids().len(), 1, "geometry is resident");
assert!(docs.get(0).unwrap().engine.history_request_json().contains("\"sizeX\":19"));
docs.get_mut(0).unwrap().mark_clean();
assert!(!docs.get(0).unwrap().is_dirty());
}
#[test]
fn restore_keeps_a_dirty_seed_and_reports_a_broken_entry() {
let mut docs = documents();
docs.engine_mut().set_history_json(BOX_SEED).unwrap();
docs.active_mut().mark_clean();
edit_active(&mut docs, 11.0);
let entries = vec![
RecoveryEntry {
name: None,
title: "untitled".into(),
saved_at: 0.0,
model: BOX_SEED.into(),
},
RecoveryEntry {
name: Some("broken".into()),
title: "broken".into(),
saved_at: 0.0,
model: "not json".into(),
},
];
let (restored, failures) = restore_entries(&entries, &mut docs);
assert_eq!(restored, 1);
assert_eq!(failures.len(), 1);
assert!(failures[0].contains("broken"));
assert_eq!(docs.len(), 2, "the dirty seed stayed");
}
#[test]
fn a_blob_matching_the_saved_copy_is_not_offered() {
let store = MemModelStore::new();
assert!(read_entries(&store).is_empty());
let model = r#"{"expressions":"","configurator":{},"features":[]}"#;
let pretty = "{\n \"features\": [],\n \"configurator\": {},\n \"expressions\": \"\"\n}";
store.put("part-a", pretty);
store.put(
RECOVERY_KEY,
&encode(&[
RecoveryEntry {
name: Some("part-a".into()),
title: "part-a".into(),
saved_at: 1.0,
model: model.into(),
},
RecoveryEntry {
name: Some("part-b".into()),
title: "part-b".into(),
saved_at: 1.0,
model: model.into(),
},
]),
);
let entries = read_entries(&store);
assert_eq!(entries.len(), 1, "part-a is already saved; part-b has no copy");
assert_eq!(entries[0].name.as_deref(), Some("part-b"));
store.put(RECOVERY_KEY, r#"{"schema":99,"documents":[{"title":"x","model":"{}"}]}"#);
assert!(read_entries(&store).is_empty());
store.put(RECOVERY_KEY, "not json");
assert!(read_entries(&store).is_empty());
}
#[test]
fn restored_documents_are_autosaved_again() {
let store = MemModelStore::new();
store.put(
RECOVERY_KEY,
&encode(&[RecoveryEntry {
name: Some("part-a".into()),
title: "part-a".into(),
saved_at: 1.0,
model: BOX_SEED.into(),
}]),
);
let mut docs = documents();
let entries = read_entries(&store);
restore_entries(&entries, &mut docs);
store.remove(RECOVERY_KEY).unwrap();
let mut autosave = Autosave::new();
autosave.note_cleared();
docs.refresh_dirty_markers();
assert!(autosave.tick(&docs, &store, 0.0).is_some(), "a dirty tab arms a write");
assert!(store.read(RECOVERY_KEY).is_none());
assert_eq!(autosave.tick(&docs, &store, 2.5), None);
assert_eq!(read_entries(&store).len(), 1);
}
#[test]
fn age_labels_are_coarse() {
assert_eq!(age_label(0.0, 100.0), "");
assert_eq!(age_label(90.0, 100.0), "just now");
assert_eq!(age_label(100.0, 100.0 + 5.0 * 60.0), "5 min ago");
assert_eq!(age_label(100.0, 100.0 + 3.0 * 3600.0), "3 h ago");
assert_eq!(age_label(100.0, 100.0 + 2.0 * 86_400.0), "2 d ago");
}
}