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    /// The prompt resolved (restore or discard removed the blob): the store is
117    /// known empty, so the next dirty document is a fresh write.
118    pub fn note_cleared(&mut self) {
119        self.written = Some(Vec::new());
120    }
121
122    /// Store failures since the last drain, oldest first.
123    pub fn take_errors(&mut self) -> Vec<String> {
124        std::mem::take(&mut self.errors)
125    }
126
127    /// Write the dirty documents (or remove the blob when there are none),
128    /// unless the store already holds exactly that.
129    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
175/// The autosaved documents worth offering: the blob's entries minus any whose
176/// named store copy already holds the same model (a Save that landed before
177/// the shrink write did — content is compared as parsed JSON so formatting
178/// differences never manufacture a prompt). Empty when there is no blob, or it
179/// is from another schema.
180pub 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
213/// Open every entry as a tab of its own (dirty, under the name it had). An
214/// entry the engine cannot load is reported, not restored. When the session is
215/// still the pristine boot seed — one clean, untitled tab — that tab is
216/// replaced rather than kept beside the restored work, for the same reason the
217/// `?loadModel=` boot path replaces it: the user asked for THEIR document, and
218/// a demo cube next to it is noise. Returns the restored count and the failures.
219pub 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        docs.renumber_untitled();
236    }
237    (restored, failures)
238}
239
240/// Wall-clock Unix seconds. `SystemTime` traps on wasm32; the browser's clock
241/// is `Date.now()`.
242pub fn wall_clock() -> f64 {
243    #[cfg(target_arch = "wasm32")]
244    {
245        js_sys::Date::now() / 1000.0
246    }
247    #[cfg(not(target_arch = "wasm32"))]
248    {
249        std::time::SystemTime::now()
250            .duration_since(std::time::UNIX_EPOCH)
251            .map(|d| d.as_secs_f64())
252            .unwrap_or(0.0)
253    }
254}
255
256/// "just now" / "3 min ago" / "2 h ago" / "4 d ago" for a `saved_at` stamp,
257/// or empty when the stamp is unknown.
258fn age_label(saved_at: f64, now: f64) -> String {
259    if saved_at <= 0.0 {
260        return String::new();
261    }
262    let seconds = (now - saved_at).max(0.0);
263    if seconds < 60.0 {
264        "just now".to_string()
265    } else if seconds < 3600.0 {
266        format!("{} min ago", (seconds / 60.0).floor() as u64)
267    } else if seconds < 86_400.0 {
268        format!("{} h ago", (seconds / 3600.0).floor() as u64)
269    } else {
270        format!("{} d ago", (seconds / 86_400.0).floor() as u64)
271    }
272}
273
274/// How the prompt resolved this frame.
275#[derive(Clone, Copy, Debug, PartialEq, Eq)]
276pub enum Resolution {
277    /// `Restore`: this many tabs were opened (the failures were toasted).
278    Restored(usize),
279    /// `Discard`: the blob is gone.
280    Discarded,
281}
282
283/// The boot-time **Recover unsaved work?** modal. Armed with the entries
284/// [`read_entries`] found; open until one of its two buttons resolves it.
285#[derive(Default)]
286pub struct RecoveryPanel {
287    entries: Vec<RecoveryEntry>,
288    hits: HashMap<String, egui::Rect>,
289}
290
291impl RecoveryPanel {
292    pub fn new() -> Self {
293        Self::default()
294    }
295
296    /// Offer `entries` (nothing is shown for an empty list).
297    pub fn arm(&mut self, entries: Vec<RecoveryEntry>) {
298        self.entries = entries;
299    }
300
301    /// Respect the saved startup preference. Skipping the offer leaves the
302    /// stored copies alone; normal autosaves may replace them with new work.
303    pub fn arm_from_store(&mut self, store: &dyn ModelStore, disabled: bool) {
304        self.arm(if disabled { Vec::new() } else { read_entries(store) });
305    }
306
307    pub fn is_open(&self) -> bool {
308        !self.entries.is_empty()
309    }
310
311    /// Forget the last frame's button rects (the shell calls this when the
312    /// prompt is closed, so a verifier never reads a rect of a modal that is
313    /// no longer there).
314    pub fn clear_hits(&mut self) {
315        self.hits.clear();
316    }
317
318    /// Draw the modal while entries are pending. Returns the resolution on the
319    /// frame a button lands; `docs` gains the restored tabs and `store` loses
320    /// the blob on either button. Failures to load an entry are queued on the
321    /// active engine as notices.
322    pub fn show(
323        &mut self,
324        ctx: &egui::Context,
325        docs: &mut Documents,
326        store: &dyn ModelStore,
327    ) -> Option<Resolution> {
328        self.hits.clear();
329        if self.entries.is_empty() {
330            return None;
331        }
332        let now = wall_clock();
333        let count = self.entries.len();
334        let mut restore = false;
335        let mut discard = false;
336        egui::Modal::new(egui::Id::new("brep-recovery")).show(ctx, |ui| {
337            ui.set_width(440.0);
338            ui.heading("Recover unsaved work?");
339            ui.add_space(4.0);
340            ui.label(format!(
341                "The last session ended with {count} document{} unsaved. \
342                 Autosaved copies are still here.",
343                if count == 1 { "" } else { "s" }
344            ));
345            ui.add_space(6.0);
346            // Bounded, so a long list never pushes Restore / Discard off-screen.
347            let list_height = (ctx.content_rect().height() * 0.5).max(120.0);
348            egui::ScrollArea::vertical()
349                .id_salt("brep-recovery-entries")
350                .max_height(list_height)
351                .show(ui, |ui| {
352                    for entry in &self.entries {
353                        let age = age_label(entry.saved_at, now);
354                        let line = if age.is_empty() {
355                            format!("•  {}", entry.title)
356                        } else {
357                            format!("•  {}  ({age})", entry.title)
358                        };
359                        ui.label(line);
360                    }
361                });
362            ui.add_space(8.0);
363            ui.horizontal(|ui| {
364                let r = ui.button("Restore");
365                self.hits.insert("recovery:restore".into(), r.rect);
366                if r.clicked() {
367                    restore = true;
368                }
369                let d = ui.button("Discard");
370                self.hits.insert("recovery:discard".into(), d.rect);
371                if d.clicked() {
372                    discard = true;
373                }
374            });
375            ui.add_space(2.0);
376            ui.weak("Restored documents open as new tabs with unsaved changes.");
377        });
378        // Esc / click-outside deliberately do NOT dismiss (see the module doc):
379        // the offer survives until a button decides.
380        if restore {
381            let entries = std::mem::take(&mut self.entries);
382            let (restored, failures) = restore_entries(&entries, docs);
383            for failure in failures {
384                docs.engine_mut().push_notice(failure);
385            }
386            let _ = store.remove(RECOVERY_KEY);
387            Some(Resolution::Restored(restored))
388        } else if discard {
389            self.entries.clear();
390            let _ = store.remove(RECOVERY_KEY);
391            Some(Resolution::Discarded)
392        } else {
393            None
394        }
395    }
396
397    /// `{open, entries:[{title, name, savedAt}]}` — the `__brepRecovery` global.
398    pub fn state_json(&self) -> String {
399        let entries: Vec<serde_json::Value> = self
400            .entries
401            .iter()
402            .map(|entry| {
403                serde_json::json!({
404                    "title": entry.title,
405                    "name": entry.name,
406                    "savedAt": entry.saved_at,
407                })
408            })
409            .collect();
410        serde_json::json!({ "open": self.is_open(), "entries": entries }).to_string()
411    }
412
413    /// The modal's button rects (`recovery:restore` / `recovery:discard`) as
414    /// `{key: [x, y, w, h]}`, empty while closed.
415    pub fn hits_json(&self) -> String {
416        crate::automation::hit_rects::hits_json(&self.hits)
417    }
418}
419
420
421/// The hit keys this panel publishes (see `automation::hit_keys`).
422pub static HIT_KEYS: &[HitKeyDoc] = &[
423    HitKeyDoc { panel: "recovery", prefix: "recovery:restore", meaning: "restore the recovered documents", command: None },
424    HitKeyDoc { panel: "recovery", prefix: "recovery:discard", meaning: "discard the recovery blob", command: None },
425];