1use crate::automation::hit_keys::HitKeyDoc;
17use std::collections::HashMap;
18
19use eframe::egui;
20
21use crate::document::{Document, Documents};
22use crate::store::{ModelStore, RECOVERY_KEY};
23
24pub const AUTOSAVE_DEBOUNCE: f64 = 2.0;
26
27const SCHEMA: u64 = 1;
29
30#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
32pub struct RecoveryEntry {
33 #[serde(default)]
35 pub name: Option<String>,
36 pub title: String,
38 #[serde(default)]
40 pub saved_at: f64,
41 pub model: String,
43}
44
45#[derive(serde::Serialize, serde::Deserialize)]
46struct Blob {
47 schema: u64,
48 documents: Vec<RecoveryEntry>,
49}
50
51#[derive(Default)]
53pub struct Autosave {
54 captured: Vec<(u64, u64, bool)>,
57 armed_at: Option<f64>,
59 written: Option<Vec<(Option<String>, String)>>,
64 errors: Vec<String>,
66}
67
68impl Autosave {
69 pub fn new() -> Self {
70 Self::default()
71 }
72
73 pub fn tick(&mut self, docs: &Documents, store: &dyn ModelStore, now: f64) -> Option<f64> {
79 let snapshot: Vec<(u64, u64, bool)> = docs
80 .iter()
81 .map(|doc| (doc.id(), doc.engine.applied_generation(), doc.dirty_marker()))
82 .collect();
83 if snapshot != self.captured {
84 let dirty_before: Vec<u64> = dirty_ids(&self.captured);
85 let dirty_now: Vec<u64> = dirty_ids(&snapshot);
86 let shrank = dirty_before.iter().any(|id| !dirty_now.contains(id));
87 self.captured = snapshot;
88 if shrank {
89 self.write(docs, store);
94 self.armed_at = None;
95 } else if !dirty_now.is_empty() {
96 self.armed_at = Some(now);
97 }
98 }
99 if let Some(armed) = self.armed_at {
100 let remaining = AUTOSAVE_DEBOUNCE - (now - armed);
101 if remaining > 0.0 {
102 return Some(remaining);
103 }
104 self.write(docs, store);
105 self.armed_at = None;
106 }
107 None
108 }
109
110 pub fn note_cleared(&mut self) {
113 self.written = Some(Vec::new());
114 }
115
116 pub fn take_errors(&mut self) -> Vec<String> {
118 std::mem::take(&mut self.errors)
119 }
120
121 fn write(&mut self, docs: &Documents, store: &dyn ModelStore) {
124 let entries: Vec<RecoveryEntry> = docs
125 .iter()
126 .filter(|doc| doc.dirty_marker())
127 .map(|doc| RecoveryEntry {
128 name: doc.name().map(str::to_string),
129 title: doc.title(),
130 saved_at: wall_clock(),
131 model: doc.engine.history_request_json(),
132 })
133 .collect();
134 let key: Vec<(Option<String>, String)> = entries
135 .iter()
136 .map(|entry| (entry.name.clone(), entry.model.clone()))
137 .collect();
138 if self.written.as_ref() == Some(&key) {
139 return;
140 }
141 let result = if entries.is_empty() {
142 store.remove(RECOVERY_KEY)
143 } else {
144 store.write(RECOVERY_KEY, &encode(&entries))
145 };
146 match result {
147 Ok(()) => self.written = Some(key),
148 Err(error) => self.errors.push(format!("autosave failed: {error}")),
149 }
150 }
151}
152
153fn dirty_ids(snapshot: &[(u64, u64, bool)]) -> Vec<u64> {
154 snapshot
155 .iter()
156 .filter(|(_, _, dirty)| *dirty)
157 .map(|(id, _, _)| *id)
158 .collect()
159}
160
161fn encode(entries: &[RecoveryEntry]) -> String {
162 serde_json::to_string(&Blob {
163 schema: SCHEMA,
164 documents: entries.to_vec(),
165 })
166 .expect("serialize recovery blob")
167}
168
169pub fn read_entries(store: &dyn ModelStore) -> Vec<RecoveryEntry> {
175 let Some(text) = store.read(RECOVERY_KEY) else {
176 return Vec::new();
177 };
178 let Ok(blob) = serde_json::from_str::<Blob>(&text) else {
179 return Vec::new();
180 };
181 if blob.schema != SCHEMA {
182 return Vec::new();
183 }
184 blob.documents
185 .into_iter()
186 .filter(|entry| !entry.model.is_empty())
187 .filter(|entry| !already_saved(store, entry))
188 .collect()
189}
190
191fn already_saved(store: &dyn ModelStore, entry: &RecoveryEntry) -> bool {
192 let Some(name) = &entry.name else {
193 return false;
194 };
195 let Some(saved) = store.read(name) else {
196 return false;
197 };
198 match (
199 serde_json::from_str::<serde_json::Value>(&saved),
200 serde_json::from_str::<serde_json::Value>(&entry.model),
201 ) {
202 (Ok(saved), Ok(model)) => saved == model,
203 _ => false,
204 }
205}
206
207pub fn restore_entries(entries: &[RecoveryEntry], docs: &mut Documents) -> (usize, Vec<String>) {
214 let pristine_seed =
215 docs.len() == 1 && docs.active().name().is_none() && !docs.active().is_dirty();
216 let mut restored = 0usize;
217 let mut failures = Vec::new();
218 for entry in entries {
219 let mut engine = docs.spawn_engine();
220 if let Err(error) = engine.load_model_and_fit(&entry.model) {
221 failures.push(format!("could not restore \"{}\": {error}", entry.title));
222 continue;
223 }
224 docs.open_document(Document::recovered(engine, entry.name.clone()));
225 restored += 1;
226 }
227 if restored > 0 && pristine_seed {
228 docs.close(0);
229 }
230 (restored, failures)
231}
232
233pub fn wall_clock() -> f64 {
236 #[cfg(target_arch = "wasm32")]
237 {
238 js_sys::Date::now() / 1000.0
239 }
240 #[cfg(not(target_arch = "wasm32"))]
241 {
242 std::time::SystemTime::now()
243 .duration_since(std::time::UNIX_EPOCH)
244 .map(|d| d.as_secs_f64())
245 .unwrap_or(0.0)
246 }
247}
248
249fn age_label(saved_at: f64, now: f64) -> String {
252 if saved_at <= 0.0 {
253 return String::new();
254 }
255 let seconds = (now - saved_at).max(0.0);
256 if seconds < 60.0 {
257 "just now".to_string()
258 } else if seconds < 3600.0 {
259 format!("{} min ago", (seconds / 60.0).floor() as u64)
260 } else if seconds < 86_400.0 {
261 format!("{} h ago", (seconds / 3600.0).floor() as u64)
262 } else {
263 format!("{} d ago", (seconds / 86_400.0).floor() as u64)
264 }
265}
266
267#[derive(Clone, Copy, Debug, PartialEq, Eq)]
269pub enum Resolution {
270 Restored(usize),
272 Discarded,
274}
275
276#[derive(Default)]
279pub struct RecoveryPanel {
280 entries: Vec<RecoveryEntry>,
281 hits: HashMap<String, egui::Rect>,
282}
283
284impl RecoveryPanel {
285 pub fn new() -> Self {
286 Self::default()
287 }
288
289 pub fn arm(&mut self, entries: Vec<RecoveryEntry>) {
291 self.entries = entries;
292 }
293
294 pub fn is_open(&self) -> bool {
295 !self.entries.is_empty()
296 }
297
298 pub fn clear_hits(&mut self) {
302 self.hits.clear();
303 }
304
305 pub fn show(
310 &mut self,
311 ctx: &egui::Context,
312 docs: &mut Documents,
313 store: &dyn ModelStore,
314 ) -> Option<Resolution> {
315 self.hits.clear();
316 if self.entries.is_empty() {
317 return None;
318 }
319 let now = wall_clock();
320 let count = self.entries.len();
321 let mut restore = false;
322 let mut discard = false;
323 egui::Modal::new(egui::Id::new("brep-recovery")).show(ctx, |ui| {
324 ui.set_width(440.0);
325 ui.heading("Recover unsaved work?");
326 ui.add_space(4.0);
327 ui.label(format!(
328 "The last session ended with {count} document{} unsaved. \
329 Autosaved copies are still here.",
330 if count == 1 { "" } else { "s" }
331 ));
332 ui.add_space(6.0);
333 for entry in &self.entries {
334 let age = age_label(entry.saved_at, now);
335 let line = if age.is_empty() {
336 format!("• {}", entry.title)
337 } else {
338 format!("• {} ({age})", entry.title)
339 };
340 ui.label(line);
341 }
342 ui.add_space(8.0);
343 ui.horizontal(|ui| {
344 let r = ui.button("Restore");
345 self.hits.insert("recovery:restore".into(), r.rect);
346 if r.clicked() {
347 restore = true;
348 }
349 let d = ui.button("Discard");
350 self.hits.insert("recovery:discard".into(), d.rect);
351 if d.clicked() {
352 discard = true;
353 }
354 });
355 ui.add_space(2.0);
356 ui.weak("Restored documents open as new tabs with unsaved changes.");
357 });
358 if restore {
361 let entries = std::mem::take(&mut self.entries);
362 let (restored, failures) = restore_entries(&entries, docs);
363 for failure in failures {
364 docs.engine_mut().push_notice(failure);
365 }
366 let _ = store.remove(RECOVERY_KEY);
367 Some(Resolution::Restored(restored))
368 } else if discard {
369 self.entries.clear();
370 let _ = store.remove(RECOVERY_KEY);
371 Some(Resolution::Discarded)
372 } else {
373 None
374 }
375 }
376
377 pub fn state_json(&self) -> String {
379 let entries: Vec<serde_json::Value> = self
380 .entries
381 .iter()
382 .map(|entry| {
383 serde_json::json!({
384 "title": entry.title,
385 "name": entry.name,
386 "savedAt": entry.saved_at,
387 })
388 })
389 .collect();
390 serde_json::json!({ "open": self.is_open(), "entries": entries }).to_string()
391 }
392
393 pub fn hits_json(&self) -> String {
396 crate::automation::hit_rects::hits_json(&self.hits)
397 }
398}
399
400pub static HIT_KEYS: &[HitKeyDoc] = &[
404 HitKeyDoc { panel: "recovery", prefix: "recovery:restore", meaning: "restore the recovered documents", command: None },
405 HitKeyDoc { panel: "recovery", prefix: "recovery:discard", meaning: "discard the recovery blob", command: None },
406];