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, [`Document::edit_key`] (the applied-run
13//! generation and the history revision, so a block edit that runs no history
14//! still re-arms the write), and dirty marker; metadata-only edits follow
15//! [`Document::refresh_dirty_marker`] timing. Documents are serialized only when
16//! a write is due.
17
18use 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
26/// Seconds a dirty set must hold still before its documents are written.
27pub const AUTOSAVE_DEBOUNCE: f64 = 2.0;
28
29/// The blob's schema number; a blob from a different one is ignored.
30const SCHEMA: u64 = 1;
31
32/// One autosaved document as it sits in the blob.
33#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
34pub struct RecoveryEntry {
35    /// The store identity the document had (`None` = untitled) — a copy of
36    /// `Document::name`, one of the identity sites enumerated in
37    /// `crate::store` (a path on the file stores, `part/…/rev/…` on a PLM
38    /// store). The blob itself is [`KeySpace::Local`](crate::store::KeySpace):
39    /// written on this machine first, mirrored to the server by a PLM session.
40    #[serde(default)]
41    pub name: Option<String>,
42    /// The tab title at the time of the write.
43    pub title: String,
44    /// Wall-clock Unix seconds of the write (0 when unknown).
45    #[serde(default)]
46    pub saved_at: f64,
47    /// The model request JSON — the same `.nbrep` text a Save writes.
48    pub model: String,
49}
50
51#[derive(serde::Serialize, serde::Deserialize)]
52struct Blob {
53    schema: u64,
54    documents: Vec<RecoveryEntry>,
55}
56
57/// The per-frame autosave driver the shell owns.
58#[derive(Default)]
59pub struct Autosave {
60    /// `(document id, edit key, dirty marker)` per open tab, as of the last
61    /// tick — the change detector.
62    captured: Vec<(u64, (u64, u64), bool)>,
63    /// The egui clock time the current debounce window was (re)armed at.
64    armed_at: Option<f64>,
65    /// What the blob holds — `(name, model)` per entry — as far as this session
66    /// knows: `None` at boot (the prompt owns the stored blob until it resolves),
67    /// `Some(empty)` once it is known to be absent. Compared before every write
68    /// so an unchanged dirty set never rewrites megabytes.
69    written: Option<Vec<(Option<String>, String)>>,
70    /// Store failures raised by a write, drained by the shell into the toasts.
71    errors: Vec<String>,
72}
73
74impl Autosave {
75    pub fn new() -> Self {
76        Self::default()
77    }
78
79    /// Advance the autosave by one frame. `now` is the egui clock
80    /// (`ctx.input().time`, seconds; never `Instant`, which traps on wasm).
81    /// Returns the seconds until an armed write comes due, so the shell can ask
82    /// for a repaint then — the frame loop is otherwise idle and the deadline
83    /// would never be observed.
84    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                // A save, a discarded close, an undo to clean: whatever left the
96                // set must stop being offered NOW. The write captures every
97                // still-dirty document's current state too, so nothing armed
98                // is left over.
99                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    /// Write what is unsaved now, not after the debounce: the app is closing,
117    /// and the next start offers it back.
118    pub fn flush(&mut self, docs: &Documents, store: &dyn ModelStore) {
119        self.write(docs, store);
120        self.armed_at = None;
121    }
122
123    /// The prompt resolved (restore or discard removed the blob): the store is
124    /// known empty, so the next dirty document is a fresh write.
125    pub fn note_cleared(&mut self) {
126        self.written = Some(Vec::new());
127    }
128
129    /// Store failures since the last drain, oldest first.
130    pub fn take_errors(&mut self) -> Vec<String> {
131        std::mem::take(&mut self.errors)
132    }
133
134    /// Write the dirty documents (or remove the blob when there are none),
135    /// unless the store already holds exactly that.
136    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
182/// The autosaved documents worth offering: the blob's entries minus any whose
183/// named store copy already holds the same model (a Save that landed before
184/// the shrink write did — content is compared as parsed JSON so formatting
185/// differences never manufacture a prompt). Empty when there is no blob, or it
186/// is from another schema.
187pub 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
220/// Open every entry as a tab of its own (dirty, under the name it had). An
221/// entry the engine cannot load is reported, not restored. When the session is
222/// still the pristine boot seed — one clean, untitled tab — that tab is
223/// replaced rather than kept beside the restored work, for the same reason the
224/// `?loadModel=` boot path replaces it: the user asked for THEIR document, and
225/// a demo cube next to it is noise. Returns the restored count and the failures.
226pub 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
247/// Wall-clock Unix seconds. `SystemTime` traps on wasm32; the browser's clock
248/// is `Date.now()`.
249pub 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
263/// "just now" / "3 min ago" / "2 h ago" / "4 d ago" for a `saved_at` stamp,
264/// or empty when the stamp is unknown.
265fn 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/// How the prompt resolved this frame.
282#[derive(Clone, Copy, Debug, PartialEq, Eq)]
283pub enum Resolution {
284    /// `Restore`: this many tabs were opened (the failures were toasted).
285    Restored(usize),
286    /// `Discard`: the blob is gone.
287    Discarded,
288}
289
290/// The boot-time **Recover unsaved work?** modal. Armed with the entries
291/// [`read_entries`] found; open until one of its two buttons resolves it.
292#[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    /// Offer `entries` (nothing is shown for an empty list).
304    pub fn arm(&mut self, entries: Vec<RecoveryEntry>) {
305        self.entries = entries;
306    }
307
308    /// Respect the saved startup preference. Skipping the offer leaves the
309    /// stored copies alone; normal autosaves may replace them with new work.
310    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    /// Forget the last frame's button rects (the shell calls this when the
319    /// prompt is closed, so a verifier never reads a rect of a modal that is
320    /// no longer there).
321    pub fn clear_hits(&mut self) {
322        self.hits.clear();
323    }
324
325    /// Draw the modal while entries are pending. Returns the resolution on the
326    /// frame a button lands; `docs` gains the restored tabs and `store` loses
327    /// the blob on either button. Failures to load an entry are queued on the
328    /// active engine as notices.
329    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            // Bounded, so a long list never pushes Restore / Discard off-screen.
354            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        // Esc / click-outside deliberately do NOT dismiss (see the module doc):
386        // the offer survives until a button decides.
387        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    /// `{open, entries:[{title, name, savedAt}]}` — the `__brepRecovery` global.
405    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    /// The modal's button rects (`recovery:restore` / `recovery:discard`) as
421    /// `{key: [x, y, w, h]}`, empty while closed.
422    pub fn hits_json(&self) -> String {
423        crate::automation::hit_rects::hits_json(&self.hits)
424    }
425}
426
427
428/// The hit keys this panel publishes (see `automation::hit_keys`).
429pub 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];