1use crate::automation::hit_keys::HitKeyDoc;
19use std::collections::HashMap;
20
21use eframe::egui;
22
23use crate::document::{Document, Documents};
24use crate::store::{ModelStore, RECOVERY_KEY};
25
26pub const AUTOSAVE_DEBOUNCE: f64 = 2.0;
28
29const SCHEMA: u64 = 1;
31
32#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
34pub struct RecoveryEntry {
35 #[serde(default)]
41 pub name: Option<String>,
42 pub title: String,
44 #[serde(default)]
46 pub saved_at: f64,
47 pub model: String,
49}
50
51#[derive(serde::Serialize, serde::Deserialize)]
52struct Blob {
53 schema: u64,
54 documents: Vec<RecoveryEntry>,
55}
56
57#[derive(Default)]
59pub struct Autosave {
60 captured: Vec<(u64, (u64, u64), bool)>,
63 armed_at: Option<f64>,
65 written: Option<Vec<(Option<String>, String)>>,
70 errors: Vec<String>,
72}
73
74impl Autosave {
75 pub fn new() -> Self {
76 Self::default()
77 }
78
79 pub fn tick(&mut self, docs: &Documents, store: &dyn ModelStore, now: f64) -> Option<f64> {
85 let snapshot: Vec<(u64, (u64, u64), bool)> = docs
86 .iter()
87 .map(|doc| (doc.id(), doc.edit_key(), doc.dirty_marker()))
88 .collect();
89 if snapshot != self.captured {
90 let dirty_before: Vec<u64> = dirty_ids(&self.captured);
91 let dirty_now: Vec<u64> = dirty_ids(&snapshot);
92 let shrank = dirty_before.iter().any(|id| !dirty_now.contains(id));
93 self.captured = snapshot;
94 if shrank {
95 self.write(docs, store);
100 self.armed_at = None;
101 } else if !dirty_now.is_empty() {
102 self.armed_at = Some(now);
103 }
104 }
105 if let Some(armed) = self.armed_at {
106 let remaining = AUTOSAVE_DEBOUNCE - (now - armed);
107 if remaining > 0.0 {
108 return Some(remaining);
109 }
110 self.write(docs, store);
111 self.armed_at = None;
112 }
113 None
114 }
115
116 pub fn note_cleared(&mut self) {
119 self.written = Some(Vec::new());
120 }
121
122 pub fn take_errors(&mut self) -> Vec<String> {
124 std::mem::take(&mut self.errors)
125 }
126
127 fn write(&mut self, docs: &Documents, store: &dyn ModelStore) {
130 let entries: Vec<RecoveryEntry> = docs
131 .iter()
132 .filter(|doc| doc.dirty_marker())
133 .map(|doc| RecoveryEntry {
134 name: doc.name().map(str::to_string),
135 title: doc.title(),
136 saved_at: wall_clock(),
137 model: doc.engine.history_request_json(),
138 })
139 .collect();
140 let key: Vec<(Option<String>, String)> = entries
141 .iter()
142 .map(|entry| (entry.name.clone(), entry.model.clone()))
143 .collect();
144 if self.written.as_ref() == Some(&key) {
145 return;
146 }
147 let result = if entries.is_empty() {
148 store.remove(RECOVERY_KEY)
149 } else {
150 store.write(RECOVERY_KEY, &encode(&entries))
151 };
152 match result {
153 Ok(()) => self.written = Some(key),
154 Err(error) => self.errors.push(format!("autosave failed: {error}")),
155 }
156 }
157}
158
159fn dirty_ids(snapshot: &[(u64, (u64, u64), bool)]) -> Vec<u64> {
160 snapshot
161 .iter()
162 .filter(|(_, _, dirty)| *dirty)
163 .map(|(id, _, _)| *id)
164 .collect()
165}
166
167fn encode(entries: &[RecoveryEntry]) -> String {
168 serde_json::to_string(&Blob {
169 schema: SCHEMA,
170 documents: entries.to_vec(),
171 })
172 .expect("serialize recovery blob")
173}
174
175pub fn read_entries(store: &dyn ModelStore) -> Vec<RecoveryEntry> {
181 let Some(text) = store.read(RECOVERY_KEY) else {
182 return Vec::new();
183 };
184 let Ok(blob) = serde_json::from_str::<Blob>(&text) else {
185 return Vec::new();
186 };
187 if blob.schema != SCHEMA {
188 return Vec::new();
189 }
190 blob.documents
191 .into_iter()
192 .filter(|entry| !entry.model.is_empty())
193 .filter(|entry| !already_saved(store, entry))
194 .collect()
195}
196
197fn already_saved(store: &dyn ModelStore, entry: &RecoveryEntry) -> bool {
198 let Some(name) = &entry.name else {
199 return false;
200 };
201 let Some(saved) = store.read(name) else {
202 return false;
203 };
204 match (
205 serde_json::from_str::<serde_json::Value>(&saved),
206 serde_json::from_str::<serde_json::Value>(&entry.model),
207 ) {
208 (Ok(saved), Ok(model)) => saved == model,
209 _ => false,
210 }
211}
212
213pub fn restore_entries(entries: &[RecoveryEntry], docs: &mut Documents) -> (usize, Vec<String>) {
220 let pristine_seed =
221 docs.len() == 1 && docs.active().name().is_none() && !docs.active().is_dirty();
222 let mut restored = 0usize;
223 let mut failures = Vec::new();
224 for entry in entries {
225 let mut engine = docs.spawn_engine();
226 if let Err(error) = engine.load_model_and_fit(&entry.model) {
227 failures.push(format!("could not restore \"{}\": {error}", entry.title));
228 continue;
229 }
230 docs.open_document(Document::recovered(engine, entry.name.clone()));
231 restored += 1;
232 }
233 if restored > 0 && pristine_seed {
234 docs.close(0);
235 docs.renumber_untitled();
236 }
237 (restored, failures)
238}
239
240pub fn wall_clock() -> f64 {
243 #[cfg(target_arch = "wasm32")]
244 {
245 js_sys::Date::now() / 1000.0
246 }
247 #[cfg(not(target_arch = "wasm32"))]
248 {
249 std::time::SystemTime::now()
250 .duration_since(std::time::UNIX_EPOCH)
251 .map(|d| d.as_secs_f64())
252 .unwrap_or(0.0)
253 }
254}
255
256fn age_label(saved_at: f64, now: f64) -> String {
259 if saved_at <= 0.0 {
260 return String::new();
261 }
262 let seconds = (now - saved_at).max(0.0);
263 if seconds < 60.0 {
264 "just now".to_string()
265 } else if seconds < 3600.0 {
266 format!("{} min ago", (seconds / 60.0).floor() as u64)
267 } else if seconds < 86_400.0 {
268 format!("{} h ago", (seconds / 3600.0).floor() as u64)
269 } else {
270 format!("{} d ago", (seconds / 86_400.0).floor() as u64)
271 }
272}
273
274#[derive(Clone, Copy, Debug, PartialEq, Eq)]
276pub enum Resolution {
277 Restored(usize),
279 Discarded,
281}
282
283#[derive(Default)]
286pub struct RecoveryPanel {
287 entries: Vec<RecoveryEntry>,
288 hits: HashMap<String, egui::Rect>,
289}
290
291impl RecoveryPanel {
292 pub fn new() -> Self {
293 Self::default()
294 }
295
296 pub fn arm(&mut self, entries: Vec<RecoveryEntry>) {
298 self.entries = entries;
299 }
300
301 pub fn arm_from_store(&mut self, store: &dyn ModelStore, disabled: bool) {
304 self.arm(if disabled { Vec::new() } else { read_entries(store) });
305 }
306
307 pub fn is_open(&self) -> bool {
308 !self.entries.is_empty()
309 }
310
311 pub fn clear_hits(&mut self) {
315 self.hits.clear();
316 }
317
318 pub fn show(
323 &mut self,
324 ctx: &egui::Context,
325 docs: &mut Documents,
326 store: &dyn ModelStore,
327 ) -> Option<Resolution> {
328 self.hits.clear();
329 if self.entries.is_empty() {
330 return None;
331 }
332 let now = wall_clock();
333 let count = self.entries.len();
334 let mut restore = false;
335 let mut discard = false;
336 egui::Modal::new(egui::Id::new("brep-recovery")).show(ctx, |ui| {
337 ui.set_width(440.0);
338 ui.heading("Recover unsaved work?");
339 ui.add_space(4.0);
340 ui.label(format!(
341 "The last session ended with {count} document{} unsaved. \
342 Autosaved copies are still here.",
343 if count == 1 { "" } else { "s" }
344 ));
345 ui.add_space(6.0);
346 let list_height = (ctx.content_rect().height() * 0.5).max(120.0);
348 egui::ScrollArea::vertical()
349 .id_salt("brep-recovery-entries")
350 .max_height(list_height)
351 .show(ui, |ui| {
352 for entry in &self.entries {
353 let age = age_label(entry.saved_at, now);
354 let line = if age.is_empty() {
355 format!("• {}", entry.title)
356 } else {
357 format!("• {} ({age})", entry.title)
358 };
359 ui.label(line);
360 }
361 });
362 ui.add_space(8.0);
363 ui.horizontal(|ui| {
364 let r = ui.button("Restore");
365 self.hits.insert("recovery:restore".into(), r.rect);
366 if r.clicked() {
367 restore = true;
368 }
369 let d = ui.button("Discard");
370 self.hits.insert("recovery:discard".into(), d.rect);
371 if d.clicked() {
372 discard = true;
373 }
374 });
375 ui.add_space(2.0);
376 ui.weak("Restored documents open as new tabs with unsaved changes.");
377 });
378 if restore {
381 let entries = std::mem::take(&mut self.entries);
382 let (restored, failures) = restore_entries(&entries, docs);
383 for failure in failures {
384 docs.engine_mut().push_notice(failure);
385 }
386 let _ = store.remove(RECOVERY_KEY);
387 Some(Resolution::Restored(restored))
388 } else if discard {
389 self.entries.clear();
390 let _ = store.remove(RECOVERY_KEY);
391 Some(Resolution::Discarded)
392 } else {
393 None
394 }
395 }
396
397 pub fn state_json(&self) -> String {
399 let entries: Vec<serde_json::Value> = self
400 .entries
401 .iter()
402 .map(|entry| {
403 serde_json::json!({
404 "title": entry.title,
405 "name": entry.name,
406 "savedAt": entry.saved_at,
407 })
408 })
409 .collect();
410 serde_json::json!({ "open": self.is_open(), "entries": entries }).to_string()
411 }
412
413 pub fn hits_json(&self) -> String {
416 crate::automation::hit_rects::hits_json(&self.hits)
417 }
418}
419
420
421pub static HIT_KEYS: &[HitKeyDoc] = &[
423 HitKeyDoc { panel: "recovery", prefix: "recovery:restore", meaning: "restore the recovered documents", command: None },
424 HitKeyDoc { panel: "recovery", prefix: "recovery:discard", meaning: "discard the recovery blob", command: None },
425];