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 }
236 (restored, failures)
237}
238
239pub fn wall_clock() -> f64 {
242 #[cfg(target_arch = "wasm32")]
243 {
244 js_sys::Date::now() / 1000.0
245 }
246 #[cfg(not(target_arch = "wasm32"))]
247 {
248 std::time::SystemTime::now()
249 .duration_since(std::time::UNIX_EPOCH)
250 .map(|d| d.as_secs_f64())
251 .unwrap_or(0.0)
252 }
253}
254
255fn age_label(saved_at: f64, now: f64) -> String {
258 if saved_at <= 0.0 {
259 return String::new();
260 }
261 let seconds = (now - saved_at).max(0.0);
262 if seconds < 60.0 {
263 "just now".to_string()
264 } else if seconds < 3600.0 {
265 format!("{} min ago", (seconds / 60.0).floor() as u64)
266 } else if seconds < 86_400.0 {
267 format!("{} h ago", (seconds / 3600.0).floor() as u64)
268 } else {
269 format!("{} d ago", (seconds / 86_400.0).floor() as u64)
270 }
271}
272
273#[derive(Clone, Copy, Debug, PartialEq, Eq)]
275pub enum Resolution {
276 Restored(usize),
278 Discarded,
280}
281
282#[derive(Default)]
285pub struct RecoveryPanel {
286 entries: Vec<RecoveryEntry>,
287 hits: HashMap<String, egui::Rect>,
288}
289
290impl RecoveryPanel {
291 pub fn new() -> Self {
292 Self::default()
293 }
294
295 pub fn arm(&mut self, entries: Vec<RecoveryEntry>) {
297 self.entries = entries;
298 }
299
300 pub fn is_open(&self) -> bool {
301 !self.entries.is_empty()
302 }
303
304 pub fn clear_hits(&mut self) {
308 self.hits.clear();
309 }
310
311 pub fn show(
316 &mut self,
317 ctx: &egui::Context,
318 docs: &mut Documents,
319 store: &dyn ModelStore,
320 ) -> Option<Resolution> {
321 self.hits.clear();
322 if self.entries.is_empty() {
323 return None;
324 }
325 let now = wall_clock();
326 let count = self.entries.len();
327 let mut restore = false;
328 let mut discard = false;
329 egui::Modal::new(egui::Id::new("brep-recovery")).show(ctx, |ui| {
330 ui.set_width(440.0);
331 ui.heading("Recover unsaved work?");
332 ui.add_space(4.0);
333 ui.label(format!(
334 "The last session ended with {count} document{} unsaved. \
335 Autosaved copies are still here.",
336 if count == 1 { "" } else { "s" }
337 ));
338 ui.add_space(6.0);
339 let list_height = (ctx.content_rect().height() * 0.5).max(120.0);
341 egui::ScrollArea::vertical()
342 .id_salt("brep-recovery-entries")
343 .max_height(list_height)
344 .show(ui, |ui| {
345 for entry in &self.entries {
346 let age = age_label(entry.saved_at, now);
347 let line = if age.is_empty() {
348 format!("• {}", entry.title)
349 } else {
350 format!("• {} ({age})", entry.title)
351 };
352 ui.label(line);
353 }
354 });
355 ui.add_space(8.0);
356 ui.horizontal(|ui| {
357 let r = ui.button("Restore");
358 self.hits.insert("recovery:restore".into(), r.rect);
359 if r.clicked() {
360 restore = true;
361 }
362 let d = ui.button("Discard");
363 self.hits.insert("recovery:discard".into(), d.rect);
364 if d.clicked() {
365 discard = true;
366 }
367 });
368 ui.add_space(2.0);
369 ui.weak("Restored documents open as new tabs with unsaved changes.");
370 });
371 if restore {
374 let entries = std::mem::take(&mut self.entries);
375 let (restored, failures) = restore_entries(&entries, docs);
376 for failure in failures {
377 docs.engine_mut().push_notice(failure);
378 }
379 let _ = store.remove(RECOVERY_KEY);
380 Some(Resolution::Restored(restored))
381 } else if discard {
382 self.entries.clear();
383 let _ = store.remove(RECOVERY_KEY);
384 Some(Resolution::Discarded)
385 } else {
386 None
387 }
388 }
389
390 pub fn state_json(&self) -> String {
392 let entries: Vec<serde_json::Value> = self
393 .entries
394 .iter()
395 .map(|entry| {
396 serde_json::json!({
397 "title": entry.title,
398 "name": entry.name,
399 "savedAt": entry.saved_at,
400 })
401 })
402 .collect();
403 serde_json::json!({ "open": self.is_open(), "entries": entries }).to_string()
404 }
405
406 pub fn hits_json(&self) -> String {
409 crate::automation::hit_rects::hits_json(&self.hits)
410 }
411}
412
413
414pub static HIT_KEYS: &[HitKeyDoc] = &[
416 HitKeyDoc { panel: "recovery", prefix: "recovery:restore", meaning: "restore the recovered documents", command: None },
417 HitKeyDoc { panel: "recovery", prefix: "recovery:discard", meaning: "discard the recovery blob", command: None },
418];