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 flush(&mut self, docs: &Documents, store: &dyn ModelStore) {
119 self.write(docs, store);
120 self.armed_at = None;
121 }
122
123 pub fn note_cleared(&mut self) {
126 self.written = Some(Vec::new());
127 }
128
129 pub fn take_errors(&mut self) -> Vec<String> {
131 std::mem::take(&mut self.errors)
132 }
133
134 fn write(&mut self, docs: &Documents, store: &dyn ModelStore) {
137 let entries: Vec<RecoveryEntry> = docs
138 .iter()
139 .filter(|doc| doc.dirty_marker())
140 .map(|doc| RecoveryEntry {
141 name: doc.name().map(str::to_string),
142 title: doc.title(),
143 saved_at: wall_clock(),
144 model: doc.engine.history_request_json(),
145 })
146 .collect();
147 let key: Vec<(Option<String>, String)> = entries
148 .iter()
149 .map(|entry| (entry.name.clone(), entry.model.clone()))
150 .collect();
151 if self.written.as_ref() == Some(&key) {
152 return;
153 }
154 let result = if entries.is_empty() {
155 store.remove(RECOVERY_KEY)
156 } else {
157 store.write(RECOVERY_KEY, &encode(&entries))
158 };
159 match result {
160 Ok(()) => self.written = Some(key),
161 Err(error) => self.errors.push(format!("autosave failed: {error}")),
162 }
163 }
164}
165
166fn dirty_ids(snapshot: &[(u64, (u64, u64), bool)]) -> Vec<u64> {
167 snapshot
168 .iter()
169 .filter(|(_, _, dirty)| *dirty)
170 .map(|(id, _, _)| *id)
171 .collect()
172}
173
174fn encode(entries: &[RecoveryEntry]) -> String {
175 serde_json::to_string(&Blob {
176 schema: SCHEMA,
177 documents: entries.to_vec(),
178 })
179 .expect("serialize recovery blob")
180}
181
182pub fn read_entries(store: &dyn ModelStore) -> Vec<RecoveryEntry> {
188 let Some(text) = store.read(RECOVERY_KEY) else {
189 return Vec::new();
190 };
191 let Ok(blob) = serde_json::from_str::<Blob>(&text) else {
192 return Vec::new();
193 };
194 if blob.schema != SCHEMA {
195 return Vec::new();
196 }
197 blob.documents
198 .into_iter()
199 .filter(|entry| !entry.model.is_empty())
200 .filter(|entry| !already_saved(store, entry))
201 .collect()
202}
203
204fn already_saved(store: &dyn ModelStore, entry: &RecoveryEntry) -> bool {
205 let Some(name) = &entry.name else {
206 return false;
207 };
208 let Some(saved) = store.read(name) else {
209 return false;
210 };
211 match (
212 serde_json::from_str::<serde_json::Value>(&saved),
213 serde_json::from_str::<serde_json::Value>(&entry.model),
214 ) {
215 (Ok(saved), Ok(model)) => saved == model,
216 _ => false,
217 }
218}
219
220pub fn restore_entries(entries: &[RecoveryEntry], docs: &mut Documents) -> (usize, Vec<String>) {
227 let pristine_seed =
228 docs.len() == 1 && docs.active().name().is_none() && !docs.active().is_dirty();
229 let mut restored = 0usize;
230 let mut failures = Vec::new();
231 for entry in entries {
232 let mut engine = docs.spawn_engine();
233 if let Err(error) = engine.load_model_and_fit(&entry.model) {
234 failures.push(format!("could not restore \"{}\": {error}", entry.title));
235 continue;
236 }
237 docs.open_document(Document::recovered(engine, entry.name.clone()));
238 restored += 1;
239 }
240 if restored > 0 && pristine_seed {
241 docs.close(0);
242 docs.renumber_untitled();
243 }
244 (restored, failures)
245}
246
247pub fn wall_clock() -> f64 {
250 #[cfg(target_arch = "wasm32")]
251 {
252 js_sys::Date::now() / 1000.0
253 }
254 #[cfg(not(target_arch = "wasm32"))]
255 {
256 std::time::SystemTime::now()
257 .duration_since(std::time::UNIX_EPOCH)
258 .map(|d| d.as_secs_f64())
259 .unwrap_or(0.0)
260 }
261}
262
263fn age_label(saved_at: f64, now: f64) -> String {
266 if saved_at <= 0.0 {
267 return String::new();
268 }
269 let seconds = (now - saved_at).max(0.0);
270 if seconds < 60.0 {
271 "just now".to_string()
272 } else if seconds < 3600.0 {
273 format!("{} min ago", (seconds / 60.0).floor() as u64)
274 } else if seconds < 86_400.0 {
275 format!("{} h ago", (seconds / 3600.0).floor() as u64)
276 } else {
277 format!("{} d ago", (seconds / 86_400.0).floor() as u64)
278 }
279}
280
281#[derive(Clone, Copy, Debug, PartialEq, Eq)]
283pub enum Resolution {
284 Restored(usize),
286 Discarded,
288}
289
290#[derive(Default)]
293pub struct RecoveryPanel {
294 entries: Vec<RecoveryEntry>,
295 hits: HashMap<String, egui::Rect>,
296}
297
298impl RecoveryPanel {
299 pub fn new() -> Self {
300 Self::default()
301 }
302
303 pub fn arm(&mut self, entries: Vec<RecoveryEntry>) {
305 self.entries = entries;
306 }
307
308 pub fn arm_from_store(&mut self, store: &dyn ModelStore, disabled: bool) {
311 self.arm(if disabled { Vec::new() } else { read_entries(store) });
312 }
313
314 pub fn is_open(&self) -> bool {
315 !self.entries.is_empty()
316 }
317
318 pub fn clear_hits(&mut self) {
322 self.hits.clear();
323 }
324
325 pub fn show(
330 &mut self,
331 ctx: &egui::Context,
332 docs: &mut Documents,
333 store: &dyn ModelStore,
334 ) -> Option<Resolution> {
335 self.hits.clear();
336 if self.entries.is_empty() {
337 return None;
338 }
339 let now = wall_clock();
340 let count = self.entries.len();
341 let mut restore = false;
342 let mut discard = false;
343 egui::Modal::new(egui::Id::new("brep-recovery")).show(ctx, |ui| {
344 ui.set_width(440.0);
345 ui.heading("Recover unsaved work?");
346 ui.add_space(4.0);
347 ui.label(format!(
348 "The last session ended with {count} document{} unsaved. \
349 Autosaved copies are still here.",
350 if count == 1 { "" } else { "s" }
351 ));
352 ui.add_space(6.0);
353 let list_height = (ctx.content_rect().height() * 0.5).max(120.0);
355 egui::ScrollArea::vertical()
356 .id_salt("brep-recovery-entries")
357 .max_height(list_height)
358 .show(ui, |ui| {
359 for entry in &self.entries {
360 let age = age_label(entry.saved_at, now);
361 let line = if age.is_empty() {
362 format!("• {}", entry.title)
363 } else {
364 format!("• {} ({age})", entry.title)
365 };
366 ui.label(line);
367 }
368 });
369 ui.add_space(8.0);
370 ui.horizontal(|ui| {
371 let r = ui.button("Restore");
372 self.hits.insert("recovery:restore".into(), r.rect);
373 if r.clicked() {
374 restore = true;
375 }
376 let d = ui.button("Discard");
377 self.hits.insert("recovery:discard".into(), d.rect);
378 if d.clicked() {
379 discard = true;
380 }
381 });
382 ui.add_space(2.0);
383 ui.weak("Restored documents open as new tabs with unsaved changes.");
384 });
385 if restore {
388 let entries = std::mem::take(&mut self.entries);
389 let (restored, failures) = restore_entries(&entries, docs);
390 for failure in failures {
391 docs.engine_mut().push_notice(failure);
392 }
393 let _ = store.remove(RECOVERY_KEY);
394 Some(Resolution::Restored(restored))
395 } else if discard {
396 self.entries.clear();
397 let _ = store.remove(RECOVERY_KEY);
398 Some(Resolution::Discarded)
399 } else {
400 None
401 }
402 }
403
404 pub fn state_json(&self) -> String {
406 let entries: Vec<serde_json::Value> = self
407 .entries
408 .iter()
409 .map(|entry| {
410 serde_json::json!({
411 "title": entry.title,
412 "name": entry.name,
413 "savedAt": entry.saved_at,
414 })
415 })
416 .collect();
417 serde_json::json!({ "open": self.is_open(), "entries": entries }).to_string()
418 }
419
420 pub fn hits_json(&self) -> String {
423 crate::automation::hit_rects::hits_json(&self.hits)
424 }
425}
426
427
428pub static HIT_KEYS: &[HitKeyDoc] = &[
430 HitKeyDoc { panel: "recovery", prefix: "recovery:restore", meaning: "restore the recovered documents", command: None },
431 HitKeyDoc { panel: "recovery", prefix: "recovery:discard", meaning: "discard the recovery blob", command: None },
432];