use crate::automation::hit_keys::HitKeyDoc;
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 {
crate::automation::hit_rects::hits_json(&self.hits)
}
}
pub static HIT_KEYS: &[HitKeyDoc] = &[
HitKeyDoc { panel: "recovery", prefix: "recovery:restore", meaning: "restore the recovered documents", command: None },
HitKeyDoc { panel: "recovery", prefix: "recovery:discard", meaning: "discard the recovery blob", command: None },
];