Skip to main content

brep_app/
recovery.rs

1//! Autosave unsaved documents under [`RECOVERY_KEY`] for recovery at next boot.
2//!
3//! Edits debounce writes by [`AUTOSAVE_DEBOUNCE`]. Saves, discards, and undo back
4//! to a clean state remove entries immediately, preventing recovery of work the
5//! user already saved or discarded during the debounce window.
6//!
7//! Recovery requires an explicit choice: automatically loading a failing model
8//! could break every startup. The prompt remains until restore or discard so
9//! edits to the initial seed document cannot overwrite the recovery offer.
10//! Restored documents remain dirty and are autosaved again.
11//!
12//! Change detection uses document ID, applied-run generation, and dirty marker;
13//! metadata-only edits follow [`Document::refresh_dirty_marker`] timing.
14//! Documents are serialized only when a write is due.
15
16use 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
24/// Seconds a dirty set must hold still before its documents are written.
25pub const AUTOSAVE_DEBOUNCE: f64 = 2.0;
26
27/// The blob's schema number; a blob from a different one is ignored.
28const SCHEMA: u64 = 1;
29
30/// One autosaved document as it sits in the blob.
31#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
32pub struct RecoveryEntry {
33    /// The store identity the document had (`None` = untitled).
34    #[serde(default)]
35    pub name: Option<String>,
36    /// The tab title at the time of the write.
37    pub title: String,
38    /// Wall-clock Unix seconds of the write (0 when unknown).
39    #[serde(default)]
40    pub saved_at: f64,
41    /// The model request JSON — the same `.BREP.json` text a Save writes.
42    pub model: String,
43}
44
45#[derive(serde::Serialize, serde::Deserialize)]
46struct Blob {
47    schema: u64,
48    documents: Vec<RecoveryEntry>,
49}
50
51/// The per-frame autosave driver the shell owns.
52#[derive(Default)]
53pub struct Autosave {
54    /// `(document id, applied generation, dirty marker)` per open tab, as of the
55    /// last tick — the change detector.
56    captured: Vec<(u64, u64, bool)>,
57    /// The egui clock time the current debounce window was (re)armed at.
58    armed_at: Option<f64>,
59    /// What the blob holds — `(name, model)` per entry — as far as this session
60    /// knows: `None` at boot (the prompt owns the stored blob until it resolves),
61    /// `Some(empty)` once it is known to be absent. Compared before every write
62    /// so an unchanged dirty set never rewrites megabytes.
63    written: Option<Vec<(Option<String>, String)>>,
64    /// Store failures raised by a write, drained by the shell into the toasts.
65    errors: Vec<String>,
66}
67
68impl Autosave {
69    pub fn new() -> Self {
70        Self::default()
71    }
72
73    /// Advance the autosave by one frame. `now` is the egui clock
74    /// (`ctx.input().time`, seconds; never `Instant`, which traps on wasm).
75    /// Returns the seconds until an armed write comes due, so the shell can ask
76    /// for a repaint then — the frame loop is otherwise idle and the deadline
77    /// would never be observed.
78    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                // A save, a discarded close, an undo to clean: whatever left the
90                // set must stop being offered NOW. The write captures every
91                // still-dirty document's current state too, so nothing armed
92                // is left over.
93                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    /// The prompt resolved (restore or discard removed the blob): the store is
111    /// known empty, so the next dirty document is a fresh write.
112    pub fn note_cleared(&mut self) {
113        self.written = Some(Vec::new());
114    }
115
116    /// Store failures since the last drain, oldest first.
117    pub fn take_errors(&mut self) -> Vec<String> {
118        std::mem::take(&mut self.errors)
119    }
120
121    /// Write the dirty documents (or remove the blob when there are none),
122    /// unless the store already holds exactly that.
123    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
169/// The autosaved documents worth offering: the blob's entries minus any whose
170/// named store copy already holds the same model (a Save that landed before
171/// the shrink write did — content is compared as parsed JSON so formatting
172/// differences never manufacture a prompt). Empty when there is no blob, or it
173/// is from another schema.
174pub 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
207/// Open every entry as a tab of its own (dirty, under the name it had). An
208/// entry the engine cannot load is reported, not restored. When the session is
209/// still the pristine boot seed — one clean, untitled tab — that tab is
210/// replaced rather than kept beside the restored work, for the same reason the
211/// `?loadModel=` boot path replaces it: the user asked for THEIR document, and
212/// a demo cube next to it is noise. Returns the restored count and the failures.
213pub 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
233/// Wall-clock Unix seconds. `SystemTime` traps on wasm32; the browser's clock
234/// is `Date.now()`.
235pub 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
249/// "just now" / "3 min ago" / "2 h ago" / "4 d ago" for a `saved_at` stamp,
250/// or empty when the stamp is unknown.
251fn 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/// How the prompt resolved this frame.
268#[derive(Clone, Copy, Debug, PartialEq, Eq)]
269pub enum Resolution {
270    /// `Restore`: this many tabs were opened (the failures were toasted).
271    Restored(usize),
272    /// `Discard`: the blob is gone.
273    Discarded,
274}
275
276/// The boot-time **Recover unsaved work?** modal. Armed with the entries
277/// [`read_entries`] found; open until one of its two buttons resolves it.
278#[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    /// Offer `entries` (nothing is shown for an empty list).
290    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    /// Forget the last frame's button rects (the shell calls this when the
299    /// prompt is closed, so a verifier never reads a rect of a modal that is
300    /// no longer there).
301    pub fn clear_hits(&mut self) {
302        self.hits.clear();
303    }
304
305    /// Draw the modal while entries are pending. Returns the resolution on the
306    /// frame a button lands; `docs` gains the restored tabs and `store` loses
307    /// the blob on either button. Failures to load an entry are queued on the
308    /// active engine as notices.
309    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        // Esc / click-outside deliberately do NOT dismiss (see the module doc):
359        // the offer survives until a button decides.
360        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    /// `{open, entries:[{title, name, savedAt}]}` — the `__brepRecovery` global.
378    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    /// The modal's button rects (`recovery:restore` / `recovery:discard`) as
394    /// `{key: [x, y, w, h]}`, empty while closed.
395    pub fn hits_json(&self) -> String {
396        crate::automation::hit_rects::hits_json(&self.hits)
397    }
398}
399
400// BREP private tests: d1051b7395f5fee5
401
402/// The hit keys this panel publishes (see `automation::hit_keys`).
403pub 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];