Skip to main content

brep_app/
store.rs

1//! The persistent-storage / filesystem seam — the ONE platform exception in
2//! the engine-native UI. Settings, dock layout, and model documents all use the
3//! same [`ModelStore::read`] / [`ModelStore::write`] API. Only its backend differs:
4//! desktop writes files; the browser mirrors an ASYNC, swappable
5//! [`mirror_store::StoreBackend`] — IndexedDB today, a remote server next — into
6//! memory and writes behind it, which is what keeps this whole API synchronous.
7
8/// Reserved persistent-object names used internally for application state.
9pub const SETTINGS_KEY: &str = "@settings";
10pub const FEATURE_PALETTE_DISPLAY_KEY: &str = "@feature_palette_display";
11pub const DOCK_LAYOUT_KEY: &str = "@dock_layout";
12/// The open-document SESSION (which models are in tabs and which one is active
13/// — see `crate::document`), so a reload comes back to the desk you left.
14pub const SESSION_KEY: &str = "@session";
15/// Pinned explorer locations (a JSON array of navigable location strings),
16/// persisted through the ordinary `read`/`write` CRUD like the other reserved
17/// keys so the sidebar needs no dedicated trait surface.
18pub const PINNED_KEY: &str = "@pinned";
19/// The AUTOSAVE blob: every open document that had unsaved changes the last
20/// time the shell's autosave fired (see `crate::recovery`), so a crash, a
21/// closed tab or a reload can offer that work back. Written debounced, removed
22/// the moment nothing is dirty; never listed as a document.
23pub const RECOVERY_KEY: &str = "@recovery";
24
25/// A file selected outside the model store. Keeping bytes verbatim allows the
26/// same upload channel to carry binary STL as well as text CAD formats.
27pub struct ImportedFile {
28    pub name: String,
29    pub bytes: Vec<u8>,
30}
31
32/// One entry exposed to the common in-application filesystem browser.
33#[derive(Clone, Debug, PartialEq)]
34pub struct BrowserEntry {
35    pub name: String,
36    pub identity: String,
37    pub is_dir: bool,
38    /// File size in bytes for the Size column. `None` for directories or where
39    /// the backend cannot report it.
40    pub size: Option<u64>,
41    /// Last-modified time as whole Unix seconds for the Date column. `None` where
42    /// unavailable — the browser key/value backends store values, not timestamps.
43    pub modified: Option<f64>,
44}
45
46/// A quick-access destination for the explorer's left sidebar.
47#[derive(Clone, Debug, PartialEq, Eq)]
48pub struct BrowserPlace {
49    /// Human label shown in the sidebar (e.g. `"Home"`, `"Models"`, a disk name).
50    pub label: String,
51    /// The navigable location — pass to [`ModelStore::browser_navigate`].
52    pub location: String,
53    /// Which built-in glyph the widget draws for this place.
54    pub kind: PlaceKind,
55}
56
57/// The category of a [`BrowserPlace`], so the widget owns the icon styling.
58#[derive(Clone, Copy, Debug, PartialEq, Eq)]
59pub enum PlaceKind {
60    Home,
61    Documents,
62    Downloads,
63    Models,
64    Root,
65}
66// A MODEL is a whole document
67// (the engine-owned `HistoryRequest` JSON — one `.BREP.json` recipe), and the
68// file panel needs to *enumerate*, read, and write NAMED documents, plus (in the
69// browser) hand a file to / take a file from the user's real filesystem. The same
70// trait also stores reserved application-state blobs. Platform-specific behavior
71// remains behind two `#[cfg]`-gated implementations.
72//
73// The trait is deliberately a **named-document CRUD** (`list` / `read` / `write`
74// / `remove`) plus a poll-based **file-interchange** side-channel. That shape is
75// exactly what a later **GitHub backend** needs: `list` = a repo directory
76// listing, `read` = fetch a file's contents, `write` = create/update (commit)
77// a file, `remove` = delete a file — the model name is the path within the repo.
78// An async backend (GitHub over HTTP, or the browser File System Access API /
79// OPFS whose main-thread API is async) slots in behind the SAME trait by driving
80// its request on a background task and surfacing the result through the
81// `begin_import` → `take_import` poll pattern used here for uploads, so the
82// synchronous panel code never changes. GitHub itself is a deferred follow-up;
83// this lands the seam it plugs into.
84
85/// The application's single persistent-storage abstraction. Reserved application
86/// keys and named model documents are read and written through the same methods.
87/// All methods take `&self`; mutable backend state lives behind interior mutability.
88pub trait ModelStore {
89    /// A short human label of where documents persist, for the panel header
90    /// (e.g. `"filesystem: ~/.config/brep-app/models"` or `"browser storage"`).
91    fn backend_label(&self) -> String {
92        "persistent storage".into()
93    }
94
95    /// The names of the documents currently available to **Open** (bare names,
96    /// no extension). May be empty on a backend that cannot enumerate — then the
97    /// panel falls back to the name field / import.
98    fn list(&self) -> Vec<String> {
99        Vec::new()
100    }
101
102    /// Read a stored document by name, or `None` if absent/unreadable.
103    fn read(&self, name: &str) -> Option<String>;
104
105    /// Create or overwrite the document `name` with `contents`. `Err` carries a
106    /// message the panel surfaces in its status line.
107    fn write(&self, name: &str, contents: &str) -> Result<(), String>;
108
109    /// Delete the document `name` (best-effort; `Ok` if it is already gone).
110    fn remove(&self, _name: &str) -> Result<(), String> {
111        Ok(())
112    }
113
114    /// Persistence failures that surfaced AFTER a `write` / `remove` already
115    /// returned `Ok` — the price of a WRITE-BEHIND backend (see
116    /// [`mirror_store`]). The app shell drains this once per frame into the toast
117    /// overlay, so a save that never reached storage is never silent: the
118    /// in-memory copy means nothing is lost mid-session, but the user has to know
119    /// it will not survive a reload. Empty on a backend that writes synchronously
120    /// (native files), which reports through `write`'s `Err` instead.
121    fn take_persistence_errors(&self) -> Vec<String> {
122        Vec::new()
123    }
124
125    // --- common file-browser filesystem --------------------------------------
126
127    /// Current directory shown by the embedded explorer.
128    fn browser_location(&self) -> String {
129        self.backend_label()
130    }
131
132    /// Directories and matching files at the current explorer location.
133    /// Directories are never filtered. The default implementation presents the
134    /// backend's named model collection as a flat virtual directory.
135    fn browser_entries(&self, _extensions: &[&str]) -> Vec<BrowserEntry> {
136        self.list()
137            .into_iter()
138            .map(|name| BrowserEntry {
139                identity: name.clone(),
140                name,
141                is_dir: false,
142                size: None,
143                modified: None,
144            })
145            .collect()
146    }
147
148    fn browser_enter(&self, _identity: &str) -> Result<(), String> {
149        Err("this storage backend has no directories".into())
150    }
151
152    fn browser_up(&self) -> Result<(), String> {
153        Ok(())
154    }
155
156    fn browser_home(&self) -> Result<(), String> {
157        Ok(())
158    }
159
160    fn browser_root(&self) -> Result<(), String> {
161        Ok(())
162    }
163
164    /// Navigate the explorer directly to `location` — a value previously returned
165    /// by [`Self::browser_location`], a [`BrowserPlace::location`], a breadcrumb
166    /// ancestor, or a user-typed path. `Err` if it is not a directory this backend
167    /// can browse. Powers the breadcrumb, the sidebar, back/forward, and the
168    /// path-edit field. Default: unsupported.
169    fn browser_navigate(&self, _location: &str) -> Result<(), String> {
170        Err("this storage backend cannot navigate to a path".into())
171    }
172
173    /// Quick-access places for the explorer's left sidebar (home, documents, the
174    /// models root, disks…). Default: none, and the sidebar hides itself.
175    fn browser_places(&self) -> Vec<BrowserPlace> {
176        Vec::new()
177    }
178
179    /// Create a child directory at the current browser location.
180    fn browser_create_dir(&self, _name: &str) -> Result<(), String> {
181        Err("this storage backend cannot create directories".into())
182    }
183
184    /// Write a filename at the current browser location and return its stable
185    /// identity for subsequent plain Save operations.
186    fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
187        self.write(name, contents)?;
188        Ok(name.to_string())
189    }
190
191    /// Files available to the in-app explorer for a foreign-format import.
192    /// Names retain their extension. Browser storage returns none and offers an
193    /// Upload button instead; desktop enumerates its application files directory.
194    fn list_external_files(&self, _extensions: &[&str]) -> Vec<String> {
195        Vec::new()
196    }
197
198    /// Read one entry returned by [`Self::list_external_files`].
199    fn read_external_file(&self, _name: &str) -> Option<Vec<u8>> {
200        None
201    }
202
203    // --- real-file interchange (the platform "fallback") ----------------------
204    // The browser cannot silently write to an arbitrary path; these methods let
205    // it move a document to/from the user's real filesystem. Desktop instead
206    // browses and writes its application files directory directly.
207
208    /// Whether this backend can exchange files with the user's real filesystem
209    /// (browser download+upload). The panel shows the Upload affordance only when
210    /// this is `true`.
211    fn supports_file_interchange(&self) -> bool {
212        false
213    }
214
215    /// Hand `contents` to the user as a file named after `name` (browser: a
216    /// download; native w/ dialog: a Save-As). Returns the saved document's
217    /// identity — the full path the user chose on native (so the caller can
218    /// re-save straight to it), the bare download name on the web — or `None`
219    /// when the user cancelled. No-op (`None`) by default.
220    fn export_file(&self, name: &str, contents: &str) -> Result<Option<String>, String> {
221        let _ = (name, contents);
222        Ok(None)
223    }
224
225    /// Begin importing a real file — opens the platform picker. The result is
226    /// retrieved later via [`Self::take_import`] (upload/read is async in the
227    /// browser). No-op by default.
228    fn begin_import(&self) -> Result<(), String> {
229        Ok(())
230    }
231
232    /// Poll for a completed import, consuming it.
233    /// `None` until a `begin_import` finishes. Default: never any.
234    fn take_import(&self) -> Option<ImportedFile> {
235        None
236    }
237
238    // --- format-typed interchange (CAD / mesh) --------------------------------
239    // The model lanes above trade the `.BREP.json` recipe; import/export of a
240    // foreign format (STEP text, ASCII STL) needs a DIFFERENT picker filter and
241    // must NOT mangle the file extension. These two methods add that lane while
242    // leaving the model lanes byte-for-byte. They share the SAME `take_import`
243    // pickup channel — the panel routes the result by its filename extension.
244
245    /// Begin importing a real file behind a specific picker `filter` (a human
246    /// label + dot-less extensions, e.g. `("STEP", &["step","stp"])`). The chosen
247    /// file's contents + its FULL name (extension preserved, so the panel can
248    /// route it) arrive via [`Self::take_import`]. Default: reuse [`Self::begin_import`].
249    fn begin_import_filtered(&self, _filter: (&str, &[&str])) -> Result<(), String> {
250        self.begin_import()
251    }
252
253    /// Hand `contents` to the user under EXACTLY `file_name` (extension included,
254    /// no model-extension munging) — the Save-As / download for a foreign export
255    /// format. Default no-op (interchange unsupported).
256    fn export_file_named(&self, _file_name: &str, _contents: &str) -> Result<(), String> {
257        Ok(())
258    }
259}
260
261/// Construct the platform's one persistent store for application state and models.
262pub fn default_model_store() -> Box<dyn ModelStore> {
263    #[cfg(not(target_arch = "wasm32"))]
264    {
265        Box::new(native_model::FileModelStore::new())
266    }
267    #[cfg(target_arch = "wasm32")]
268    {
269        // Already built and HYDRATED by [`hydrate_web_store`] inside the async
270        // wasm entry point, so this is a hand-off, not a construction.
271        web_model::take_boot_store()
272    }
273}
274
275/// wasm: bring up browser persistence and pull the whole key space into memory.
276/// MUST be awaited before `eframe::WebRunner::start`, because everything
277/// downstream of it — [`default_model_store`], every `read` in `BrepApp::new` and
278/// in the frame loop — is synchronous and assumes a complete mirror.
279#[cfg(target_arch = "wasm32")]
280pub async fn hydrate_web_store() {
281    web_model::hydrate().await;
282}
283
284/// wasm: wake the reactive frame loop when an async file upload completes (see
285/// [`web_model::set_repaint_ctx`]). Re-exported here so the app shell reaches it
286/// as `store::set_repaint_ctx` without knowing the platform module.
287#[cfg(target_arch = "wasm32")]
288pub use web_model::set_repaint_ctx;
289
290/// The document extension for a model recipe (`<name>.BREP.json`).
291pub const MODEL_EXT: &str = ".BREP.json";
292
293/// Test-only: a native model store rooted at an explicit directory (a temp dir
294/// in tests) so a round-trip test never touches the real config dir.
295#[cfg(all(test, not(target_arch = "wasm32")))]
296pub fn native_test_store(dir: std::path::PathBuf) -> Box<dyn ModelStore> {
297    Box::new(native_model::FileModelStore::with_dir(dir))
298}
299
300/// Test-only: an IN-MEMORY model store (no filesystem, no dialogs) shared by
301/// the update-components and assembly-panel tests. `RefCell` keeps the trait's
302/// `&self` shape; the read counter lets a staleness test prove a cached badge
303/// re-reads the store only when its generation key changes.
304#[cfg(all(test, not(target_arch = "wasm32")))]
305pub(crate) struct MemModelStore {
306    docs: std::cell::RefCell<std::collections::BTreeMap<String, String>>,
307    reads: std::cell::Cell<usize>,
308    writes: std::cell::Cell<usize>,
309}
310
311#[cfg(all(test, not(target_arch = "wasm32")))]
312impl MemModelStore {
313    pub fn new() -> Self {
314        Self {
315            docs: std::cell::RefCell::new(std::collections::BTreeMap::new()),
316            reads: std::cell::Cell::new(0),
317            writes: std::cell::Cell::new(0),
318        }
319    }
320
321    /// Seed/overwrite a document without going through `write`'s Result.
322    pub fn put(&self, name: &str, contents: &str) {
323        self.docs
324            .borrow_mut()
325            .insert(name.to_string(), contents.to_string());
326    }
327
328    /// How many `read` calls the store has served (staleness-cache probe).
329    pub fn reads(&self) -> usize {
330        self.reads.get()
331    }
332
333    /// How many `write` calls the store has served (the autosave's "an
334    /// unchanged dirty set never rewrites" probe).
335    pub fn writes(&self) -> usize {
336        self.writes.get()
337    }
338}
339
340#[cfg(all(test, not(target_arch = "wasm32")))]
341impl ModelStore for MemModelStore {
342    fn backend_label(&self) -> String {
343        "in-memory test store".into()
344    }
345    fn list(&self) -> Vec<String> {
346        self.docs.borrow().keys().cloned().collect()
347    }
348    fn read(&self, name: &str) -> Option<String> {
349        self.reads.set(self.reads.get() + 1);
350        self.docs.borrow().get(name).cloned()
351    }
352    fn write(&self, name: &str, contents: &str) -> Result<(), String> {
353        self.writes.set(self.writes.get() + 1);
354        self.put(name, contents);
355        Ok(())
356    }
357    fn remove(&self, name: &str) -> Result<(), String> {
358        self.docs.borrow_mut().remove(name);
359        Ok(())
360    }
361}
362
363/// Strip the model extension (and any directory) from a filename to get the
364/// bare display name — shared by both platform impls (and the file panel, which
365/// shows the bare name while keeping the raw identity for re-saves).
366pub(crate) fn model_display_name(file_name: &str) -> String {
367    let base = file_name
368        .rsplit(['/', '\\'])
369        .next()
370        .unwrap_or(file_name);
371    base.strip_suffix(MODEL_EXT)
372        .or_else(|| base.strip_suffix(".json"))
373        .unwrap_or(base)
374        .to_string()
375}
376
377// --- Desktop: application models/files directory -------------------------------
378#[cfg(not(target_arch = "wasm32"))]
379mod native_model {
380    use super::{
381        model_display_name, BrowserEntry, BrowserPlace, ModelStore, PlaceKind, DOCK_LAYOUT_KEY,
382        FEATURE_PALETTE_DISPLAY_KEY, MODEL_EXT, PINNED_KEY, RECOVERY_KEY, SESSION_KEY, SETTINGS_KEY,
383    };
384    use std::cell::RefCell;
385    use std::path::{Path, PathBuf};
386
387    /// Persist models as `<config>/brep-app/models/<name>.BREP.json` and reserved
388    /// application state as files under `<config>/brep-app`. Enumerable (for the
389    /// Open list) and unit-testable without a display.
390    ///
391    /// Open, Save As, import, and export are all driven by the application's
392    /// common egui explorer; no OS-native dialog is involved.
393    pub struct FileModelStore {
394        app_dir: PathBuf,
395        dir: PathBuf,
396        browser_dir: RefCell<PathBuf>,
397    }
398
399    impl FileModelStore {
400        pub fn new() -> Self {
401            let base = std::env::var_os("XDG_CONFIG_HOME")
402                .map(PathBuf::from)
403                .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
404                .unwrap_or_else(|| PathBuf::from("."));
405            let app_dir = base.join("brep-app");
406            Self {
407                dir: app_dir.join("models"),
408                browser_dir: RefCell::new(app_dir.join("models")),
409                app_dir,
410            }
411        }
412
413        /// A store rooted at an explicit directory — used by the round-trip test.
414        /// The tests stay headless and exercise the same egui explorer path.
415        #[cfg_attr(not(test), allow(dead_code))]
416        pub fn with_dir(dir: PathBuf) -> Self {
417            Self {
418                app_dir: dir.clone(),
419                browser_dir: RefCell::new(dir.clone()),
420                dir,
421            }
422        }
423
424        /// Resolve a name to a path. Explicit paths remain supported for existing
425        /// callers; a bare name maps to `<dir>/<sanitized>.BREP.json`.
426        fn resolve(&self, name: &str) -> PathBuf {
427            match name {
428                SETTINGS_KEY => return self.app_dir.join("settings.json"),
429                FEATURE_PALETTE_DISPLAY_KEY => return self.app_dir.join("feature_palette_display.json"),
430                DOCK_LAYOUT_KEY => return self.app_dir.join("dock_layout.json"),
431                SESSION_KEY => return self.app_dir.join("session.json"),
432                PINNED_KEY => return self.app_dir.join("pinned.json"),
433                RECOVERY_KEY => return self.app_dir.join("recovery.json"),
434                _ => {}
435            }
436            if name.contains('/') || name.contains('\\') {
437                return PathBuf::from(name);
438            }
439            let safe: String = name
440                .strip_suffix(MODEL_EXT)
441                .unwrap_or(name)
442                .chars()
443                .map(|c| {
444                    if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
445                        c
446                    } else {
447                        '_'
448                    }
449                })
450                .collect();
451            self.dir.join(format!("{safe}{MODEL_EXT}"))
452        }
453
454        fn home_dir() -> PathBuf {
455            std::env::var_os("HOME")
456                .map(PathBuf::from)
457                .or_else(|| std::env::current_dir().ok())
458                .unwrap_or_else(|| PathBuf::from("."))
459        }
460
461        fn matches_extension(path: &Path, extensions: &[&str]) -> bool {
462            extensions.is_empty()
463                || extensions.iter().any(|extension| {
464                    let wanted = extension.trim_start_matches('.').to_ascii_lowercase();
465                    let name = path
466                        .file_name()
467                        .unwrap_or_default()
468                        .to_string_lossy()
469                        .to_ascii_lowercase();
470                    name.ends_with(&format!(".{wanted}"))
471                })
472        }
473    }
474
475    impl ModelStore for FileModelStore {
476        fn backend_label(&self) -> String {
477            format!("filesystem: {}", self.dir.display())
478        }
479
480        fn list(&self) -> Vec<String> {
481            let mut names: Vec<String> = std::fs::read_dir(&self.dir)
482                .into_iter()
483                .flatten()
484                .flatten()
485                .filter_map(|entry| {
486                    let name = entry.file_name().to_string_lossy().into_owned();
487                    name.ends_with(MODEL_EXT).then(|| model_display_name(&name))
488                })
489                .collect();
490            names.sort();
491            names
492        }
493
494        fn read(&self, name: &str) -> Option<String> {
495            std::fs::read_to_string(self.resolve(name)).ok()
496        }
497
498        fn write(&self, name: &str, contents: &str) -> Result<(), String> {
499            let path = self.resolve(name);
500            if let Some(parent) = path.parent() {
501                std::fs::create_dir_all(parent).map_err(|e| format!("create models dir: {e}"))?;
502            }
503            std::fs::write(&path, contents).map_err(|e| format!("write {}: {e}", path.display()))
504        }
505
506        fn remove(&self, name: &str) -> Result<(), String> {
507            match std::fs::remove_file(self.resolve(name)) {
508                Ok(()) => Ok(()),
509                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
510                Err(e) => Err(format!("remove: {e}")),
511            }
512        }
513
514        fn browser_location(&self) -> String {
515            self.browser_dir.borrow().display().to_string()
516        }
517
518        fn browser_entries(&self, extensions: &[&str]) -> Vec<BrowserEntry> {
519            let mut entries: Vec<BrowserEntry> = std::fs::read_dir(&*self.browser_dir.borrow())
520                .into_iter()
521                .flatten()
522                .flatten()
523                .filter_map(|entry| {
524                    let path = entry.path();
525                    let is_dir = path.is_dir();
526                    if !(is_dir || (path.is_file() && Self::matches_extension(&path, extensions))) {
527                        return None;
528                    }
529                    let meta = entry.metadata().ok();
530                    let size = meta.as_ref().filter(|m| m.is_file()).map(|m| m.len());
531                    let modified = meta
532                        .as_ref()
533                        .and_then(|m| m.modified().ok())
534                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
535                        .map(|d| d.as_secs_f64());
536                    Some(BrowserEntry {
537                        name: entry.file_name().to_string_lossy().into_owned(),
538                        identity: path.to_string_lossy().into_owned(),
539                        is_dir,
540                        size,
541                        modified,
542                    })
543                })
544                .collect();
545            entries.sort_by(|a, b| {
546                b.is_dir
547                    .cmp(&a.is_dir)
548                    .then_with(|| a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase()))
549            });
550            entries
551        }
552
553        fn browser_enter(&self, identity: &str) -> Result<(), String> {
554            let path = PathBuf::from(identity);
555            if !path.is_dir() {
556                return Err(format!("not a directory: {}", path.display()));
557            }
558            *self.browser_dir.borrow_mut() = path;
559            Ok(())
560        }
561
562        fn browser_up(&self) -> Result<(), String> {
563            let parent = self.browser_dir.borrow().parent().map(Path::to_path_buf);
564            if let Some(parent) = parent {
565                *self.browser_dir.borrow_mut() = parent;
566            }
567            Ok(())
568        }
569
570        fn browser_home(&self) -> Result<(), String> {
571            *self.browser_dir.borrow_mut() = Self::home_dir();
572            Ok(())
573        }
574
575        fn browser_root(&self) -> Result<(), String> {
576            let current = self.browser_dir.borrow().clone();
577            let root = current
578                .ancestors()
579                .last()
580                .map(Path::to_path_buf)
581                .unwrap_or_else(|| PathBuf::from(std::path::MAIN_SEPARATOR.to_string()));
582            *self.browser_dir.borrow_mut() = root;
583            Ok(())
584        }
585
586        fn browser_navigate(&self, location: &str) -> Result<(), String> {
587            let path = PathBuf::from(location);
588            if path.is_dir() {
589                *self.browser_dir.borrow_mut() = path;
590                Ok(())
591            } else {
592                Err(format!("not a directory: {location}"))
593            }
594        }
595
596        fn browser_places(&self) -> Vec<BrowserPlace> {
597            let home = Self::home_dir();
598            let mut places = vec![BrowserPlace {
599                label: "Home".into(),
600                location: home.display().to_string(),
601                kind: PlaceKind::Home,
602            }];
603            for (label, sub, kind) in [
604                ("Documents", "Documents", PlaceKind::Documents),
605                ("Downloads", "Downloads", PlaceKind::Downloads),
606            ] {
607                let path = home.join(sub);
608                if path.is_dir() {
609                    places.push(BrowserPlace {
610                        label: label.into(),
611                        location: path.display().to_string(),
612                        kind,
613                    });
614                }
615            }
616            places.push(BrowserPlace {
617                label: "Models".into(),
618                location: self.dir.display().to_string(),
619                kind: PlaceKind::Models,
620            });
621            places.push(BrowserPlace {
622                label: "/".into(),
623                location: "/".into(),
624                kind: PlaceKind::Root,
625            });
626            places
627        }
628
629        fn browser_create_dir(&self, name: &str) -> Result<(), String> {
630            let name = PathBuf::from(name);
631            if name.components().count() != 1 {
632                return Err("folder name must be one path component".into());
633            }
634            let path = self.browser_dir.borrow().join(name);
635            std::fs::create_dir(&path)
636                .map_err(|e| format!("create folder {}: {e}", path.display()))
637        }
638
639        fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
640            let mut file_name = PathBuf::from(name)
641                .file_name()
642                .ok_or("invalid file name")?
643                .to_os_string();
644            if !name.to_ascii_lowercase().ends_with(".json") {
645                file_name.push(MODEL_EXT);
646            }
647            let path = self.browser_dir.borrow().join(file_name);
648            std::fs::write(&path, contents)
649                .map_err(|e| format!("write {}: {e}", path.display()))?;
650            Ok(path.to_string_lossy().into_owned())
651        }
652
653        fn list_external_files(&self, extensions: &[&str]) -> Vec<String> {
654            let wanted: Vec<String> = extensions
655                .iter()
656                .map(|extension| extension.trim_start_matches('.').to_ascii_lowercase())
657                .collect();
658            let mut names: Vec<String> = std::fs::read_dir(&self.dir)
659                .into_iter()
660                .flatten()
661                .flatten()
662                .filter_map(|entry| {
663                    let path = entry.path();
664                    path.is_file().then_some(path)
665                })
666                .filter_map(|path| {
667                    let extension = path.extension()?.to_string_lossy().to_ascii_lowercase();
668                    wanted.contains(&extension).then(|| {
669                        path.file_name()
670                            .unwrap_or_default()
671                            .to_string_lossy()
672                            .into_owned()
673                    })
674                })
675                .collect();
676            names.sort();
677            names
678        }
679
680        fn read_external_file(&self, name: &str) -> Option<Vec<u8>> {
681            let path = PathBuf::from(name);
682            let path = if path.is_absolute() || path.components().count() > 1 {
683                path
684            } else {
685                self.browser_dir.borrow().join(path)
686            };
687            std::fs::read(path).ok()
688        }
689
690        fn export_file_named(&self, file_name: &str, contents: &str) -> Result<(), String> {
691            std::fs::create_dir_all(&self.dir)
692                .map_err(|e| format!("create models dir: {e}"))?;
693            let name = PathBuf::from(file_name)
694                .file_name()
695                .ok_or("invalid export file name")?
696                .to_owned();
697            let path = self.dir.join(name);
698            std::fs::write(&path, contents)
699                .map_err(|e| format!("write {}: {e}", path.display()))
700        }
701    }
702}
703
704// --- The mirrored store: a synchronous facade over an ASYNC backend ------------
705//
706// The browser has no synchronous storage big enough for this application. The
707// measured numbers that forced this module into existence: one imported STEP
708// part serialises to a ~35 MB native BREP payload, and an imported assembly
709// document runs 1.0x-12.8x its source STEP text — against `localStorage`'s
710// ~5-10 MB per-origin quota. Every backend with room (IndexedDB today, a remote
711// server tomorrow) is ASYNC, while [`ModelStore`] is synchronous and the egui
712// frame loop that calls it is immediate-mode.
713//
714// The reconciliation is this module: an in-memory `BTreeMap` MIRROR of the whole
715// key space, hydrated ONCE from the backend inside the already-async wasm entry
716// point (`lib.rs::start`) BEFORE the app is constructed. Because hydration
717// completes before the first `read` can happen, a synchronous read never has a
718// "not loaded yet" state to represent, and none of the ~55 call sites change.
719// Writes mutate the mirror synchronously (so the very next `read` sees them) and
720// are pushed to the backend WRITE-BEHIND; a push that fails afterwards surfaces
721// through [`ModelStore::take_persistence_errors`].
722#[cfg(any(target_arch = "wasm32", test))]
723pub(crate) mod mirror_store {
724    use super::{
725        model_display_name, BrowserEntry, BrowserPlace, ModelStore, PlaceKind, DOCK_LAYOUT_KEY,
726        FEATURE_PALETTE_DISPLAY_KEY, MODEL_EXT, PINNED_KEY, RECOVERY_KEY, SESSION_KEY, SETTINGS_KEY,
727    };
728    use std::cell::{Cell, RefCell};
729    use std::collections::BTreeMap;
730    use std::future::Future;
731    use std::pin::Pin;
732    use std::rc::Rc;
733
734    /// The persisted key scheme, VERBATIM from the `localStorage` era so an
735    /// existing origin's keys keep their meaning under any backend: model
736    /// documents live at `brep-app:model:<relative name>`, explorer folders at
737    /// `brep-app:dir:<relative path>/`, and the three reserved application blobs
738    /// at `brep-app:settings` / `:dock_layout` / `:pinned` (see [`MirrorStore::key`]).
739    pub(crate) const PREFIX: &str = "brep-app:model:";
740    pub(crate) const DIR_PREFIX: &str = "brep-app:dir:";
741
742    /// How many distinct persistence failures the error log keeps before the
743    /// oldest is dropped. The log is a user-facing notice channel, not telemetry.
744    const MAX_ERRORS: usize = 64;
745
746    /// The future a [`StoreBackend`] hands back. Boxed (not `async fn` in the
747    /// trait) because the store is used as `dyn StoreBackend`, and deliberately
748    /// NOT `Send`: every host is single-threaded (the browser main thread; the
749    /// native test executor below).
750    pub(crate) type BackendFuture<T> = Pin<Box<dyn Future<Output = Result<T, String>>>>;
751
752    /// Where a [`MirrorStore`]'s bytes actually live — the swappable persistence
753    /// seam.
754    ///
755    /// The contract is deliberately narrow and platform-free: **string keys,
756    /// string values, `String` errors, async everywhere**. No `JsValue`, no
757    /// `web_sys` type, no IndexedDB concept crosses it, and the mirror above it
758    /// knows nothing about how a key is stored. [`IdbBackend`](super::web_model)
759    /// is the only implementation today; the intended SECOND one is a remote
760    /// HTTP/AJAX backend that pushes and pulls the same key space to a central
761    /// server, and it must be able to land without touching this module.
762    ///
763    /// Two obligations an implementation carries:
764    ///
765    /// * **Per-key FIFO.** Two `put`s of the same key must land in call order,
766    ///   whatever order their futures are polled in — the mirror issues them from
767    ///   a synchronous `write` and never sequences them itself. IndexedDB gets
768    ///   this for free (a `readwrite` transaction commits in creation order, and
769    ///   [`IdbBackend`](super::web_model) creates the transaction and issues the
770    ///   request synchronously inside `put`). An HTTP backend has no such
771    ///   guarantee and will need its own per-key send queue.
772    /// * **Whole-key-space `load_all`.** Hydration is all-or-nothing today.
773    ///
774    /// ## The known ceiling (stated, not solved)
775    ///
776    /// A full hydrate holds EVERY saved document resident for the session. That
777    /// is a non-issue for an origin migrating off `localStorage` (its whole
778    /// corpus fit in ~5 MB), but a user with ten 30 MB assemblies pays ~300 MB at
779    /// boot, and a remote backend makes it untenable outright — you cannot pull a
780    /// server-side library of hundreds of parts at start-up. The escape hatch,
781    /// when it is needed: make the mirror's value a `{ Resident(String), OnDisk {
782    /// bytes: u64 } }` enum so `list` / `browser_entries` / sizes still answer
783    /// from metadata alone, add a prefetch that resolves `OnDisk` entries in the
784    /// background, and give `StoreBackend` a `load_index` + `get(key)` pair
785    /// beside `load_all`. All of that sits behind THIS trait and behind
786    /// [`ModelStore`], so no call site moves. **Do not build it until a real
787    /// corpus needs it.**
788    pub(crate) trait StoreBackend {
789        /// A short human label for the storage panel header (see
790        /// [`ModelStore::backend_label`]).
791        fn label(&self) -> String;
792
793        /// Pull the entire key space. Called ONCE, during the async boot, before
794        /// any [`MirrorStore`] exists.
795        fn load_all(&self) -> BackendFuture<Vec<(String, String)>>;
796
797        /// Persist `value` under `key`, creating or overwriting.
798        fn put(&self, key: &str, value: &str) -> BackendFuture<()>;
799
800        /// Delete `key`. Succeeding on an absent key is correct.
801        fn delete(&self, key: &str) -> BackendFuture<()>;
802    }
803
804    /// The backend installed when persistence could NOT be brought up (IndexedDB
805    /// blocked in a private window, storage denied, the boot hydrate never ran).
806    ///
807    /// There is deliberately no quiet fallback to `localStorage`: a 5 MB backend
808    /// silently standing in for a hundreds-of-megabytes one is the failure mode
809    /// this whole change exists to end. Instead the session stays fully usable
810    /// IN MEMORY — the mirror still holds everything written this session — while
811    /// every write reports, loudly and repeatedly, that nothing is being saved.
812    pub(crate) struct UnavailableBackend {
813        reason: String,
814    }
815
816    impl UnavailableBackend {
817        pub(crate) fn new(reason: impl Into<String>) -> Self {
818            Self {
819                reason: reason.into(),
820            }
821        }
822    }
823
824    impl StoreBackend for UnavailableBackend {
825        fn label(&self) -> String {
826            format!("NOT SAVING — {} · use Download to keep your work", self.reason)
827        }
828
829        fn load_all(&self) -> BackendFuture<Vec<(String, String)>> {
830            let reason = self.reason.clone();
831            Box::pin(async move { Err(reason) })
832        }
833
834        fn put(&self, _key: &str, _value: &str) -> BackendFuture<()> {
835            let reason = self.reason.clone();
836            Box::pin(async move { Err(reason) })
837        }
838
839        fn delete(&self, _key: &str) -> BackendFuture<()> {
840            let reason = self.reason.clone();
841            Box::pin(async move { Err(reason) })
842        }
843    }
844
845    /// Persistence failures that happened AFTER the synchronous `write` returned
846    /// `Ok` — the price of write-behind. Nothing is lost mid-session (the mirror
847    /// holds it), but the user MUST learn that it did not persist, so the log is
848    /// drained into the toast overlay every frame.
849    ///
850    /// The cursor (rather than a `Vec::drain`) keeps the full history addressable
851    /// for the `__brepStoreErrors` verification hook while still handing the UI
852    /// each message exactly once.
853    pub(crate) struct ErrorLog {
854        log: RefCell<Vec<String>>,
855        drained: Cell<usize>,
856        /// Wakes the reactive frame loop so a failure recorded from an async
857        /// callback is toasted THIS frame instead of waiting for stray input.
858        wake: Option<Rc<dyn Fn()>>,
859    }
860
861    impl ErrorLog {
862        fn new(wake: Option<Rc<dyn Fn()>>) -> Self {
863            Self {
864                log: RefCell::new(Vec::new()),
865                drained: Cell::new(0),
866                wake,
867            }
868        }
869
870        /// Record one failure. An identical message that is still UNDRAINED is
871        /// collapsed (a burst of failing writes shows one toast, not six); once
872        /// the UI has shown it, the same message can be recorded again — every
873        /// failed save is reported.
874        pub(crate) fn record(&self, message: String) {
875            {
876                let mut log = self.log.borrow_mut();
877                let undrained = log.len() > self.drained.get();
878                if undrained && log.last().map(|last| *last == message).unwrap_or(false) {
879                    return;
880                }
881                log.push(message);
882                if log.len() > MAX_ERRORS {
883                    log.remove(0);
884                    self.drained.set(self.drained.get().saturating_sub(1));
885                }
886            }
887            if let Some(wake) = &self.wake {
888                wake();
889            }
890        }
891
892        /// Messages recorded since the last drain (what the UI has not shown yet).
893        fn drain(&self) -> Vec<String> {
894            let log = self.log.borrow();
895            let from = self.drained.get().min(log.len());
896            self.drained.set(log.len());
897            log[from..].to_vec()
898        }
899
900        /// Every message recorded this session (verification hook).
901        fn all(&self) -> Vec<String> {
902            self.log.borrow().clone()
903        }
904    }
905
906    /// Drive one write-behind push to completion.
907    ///
908    /// wasm: hand it to the browser's microtask queue, which is the whole point —
909    /// the caller's `write` already returned. Native: the mirror only exists under
910    /// `cfg(test)`, where the test backend's futures are already resolved, so a
911    /// single poll with a no-op waker finishes them. Keeping the shape identical
912    /// means the native tests exercise the REAL write-behind path (spawn, await,
913    /// record the error) rather than a synchronous stand-in.
914    #[cfg(target_arch = "wasm32")]
915    fn spawn(task: impl Future<Output = ()> + 'static) {
916        wasm_bindgen_futures::spawn_local(task);
917    }
918
919    #[cfg(not(target_arch = "wasm32"))]
920    fn spawn(task: impl Future<Output = ()> + 'static) {
921        let mut task = Box::pin(task);
922        let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
923        let _ = task.as_mut().poll(&mut cx);
924    }
925
926    /// A synchronous [`ModelStore`] over an asynchronous [`StoreBackend`]: the
927    /// hydrated mirror plus write-behind. Cheap to clone — every field is shared,
928    /// so a clone is another handle on the SAME session state (used by the
929    /// verification hooks).
930    #[derive(Clone)]
931    pub(crate) struct MirrorStore {
932        /// The whole key space, keyed EXACTLY as the backend keys it.
933        entries: Rc<RefCell<BTreeMap<String, String>>>,
934        /// The explorer's current virtual directory (`/` or `/models/...`).
935        browser_dir: Rc<RefCell<String>>,
936        backend: Rc<dyn StoreBackend>,
937        errors: Rc<ErrorLog>,
938        /// Pushes issued but not yet settled. Zero means everything written so
939        /// far is durable — which is what makes a "save, reload, still there"
940        /// check non-racy (see the `__brepStorePending` hook).
941        pending: Rc<Cell<usize>>,
942    }
943
944    impl MirrorStore {
945        /// Build the session store from an already-hydrated key space.
946        /// `wake` (`None` off-browser) is called when a write-behind failure is
947        /// recorded, to repaint the reactive frame loop.
948        pub(crate) fn new(
949            backend: Rc<dyn StoreBackend>,
950            entries: Vec<(String, String)>,
951            wake: Option<Rc<dyn Fn()>>,
952        ) -> Self {
953            Self {
954                entries: Rc::new(RefCell::new(entries.into_iter().collect())),
955                browser_dir: Rc::new(RefCell::new("/models".into())),
956                backend,
957                errors: Rc::new(ErrorLog::new(wake)),
958                pending: Rc::new(Cell::new(0)),
959            }
960        }
961
962        /// The persisted key for a document/reserved name. UNCHANGED from the
963        /// `localStorage` implementation this replaced, so an existing origin's
964        /// data keeps its identity.
965        pub(crate) fn key(name: &str) -> String {
966            match name {
967                SETTINGS_KEY => "brep-app:settings".into(),
968                FEATURE_PALETTE_DISPLAY_KEY => "brep-app:feature_palette_display".into(),
969                DOCK_LAYOUT_KEY => "brep-app:dock_layout".into(),
970                SESSION_KEY => "brep-app:session".into(),
971                PINNED_KEY => "brep-app:pinned".into(),
972                RECOVERY_KEY => "brep-app:recovery".into(),
973                _ => format!("{PREFIX}{}", Self::model_relative(name)),
974            }
975        }
976
977        /// A document name reduced to its store-relative form: no leading `/`, no
978        /// `/models` root, no model extension.
979        fn model_relative(name: &str) -> String {
980            let name = name
981                .trim_start_matches('/')
982                .strip_prefix("models/")
983                .unwrap_or_else(|| name.trim_start_matches('/'));
984            name.strip_suffix(MODEL_EXT)
985                .or_else(|| name.strip_suffix(".json"))
986                .unwrap_or(name)
987                .trim_matches('/')
988                .to_string()
989        }
990
991        fn virtual_model_path(relative: &str) -> String {
992            format!("/models/{}{MODEL_EXT}", relative.trim_matches('/'))
993        }
994
995        fn child_path(parent: &str, child: &str) -> String {
996            if parent == "/" {
997                format!("/{child}")
998            } else {
999                format!("{}/{child}", parent.trim_end_matches('/'))
1000            }
1001        }
1002
1003        /// Hand one backend push to the executor, counting it in `pending` and
1004        /// routing its eventual failure to the error log.
1005        fn push(&self, name: &str, request: BackendFuture<()>) {
1006            self.pending.set(self.pending.get() + 1);
1007            let pending = self.pending.clone();
1008            let errors = self.errors.clone();
1009            let name = name.to_string();
1010            spawn(async move {
1011                let outcome = request.await;
1012                pending.set(pending.get().saturating_sub(1));
1013                if let Err(message) = outcome {
1014                    errors.record(format!("'{name}' was NOT saved: {message}"));
1015                }
1016            });
1017        }
1018
1019        /// Seed the log with a boot-time failure so the very first frame toasts it.
1020        pub(crate) fn report(&self, message: String) {
1021            self.errors.record(message);
1022        }
1023
1024        /// Backend pushes issued but not yet settled (verification hook).
1025        pub(crate) fn pending(&self) -> usize {
1026            self.pending.get()
1027        }
1028
1029        /// Every persistence failure this session (verification hook).
1030        pub(crate) fn error_history(&self) -> Vec<String> {
1031            self.errors.all()
1032        }
1033
1034        /// Byte length of a stored document, or `None` if absent (verification
1035        /// hook — a multi-megabyte payload is not worth marshalling into JS just
1036        /// to measure it).
1037        pub(crate) fn len_of(&self, name: &str) -> Option<usize> {
1038            self.entries.borrow().get(&Self::key(name)).map(|v| v.len())
1039        }
1040    }
1041
1042    impl ModelStore for MirrorStore {
1043        fn backend_label(&self) -> String {
1044            self.backend.label()
1045        }
1046
1047        fn list(&self) -> Vec<String> {
1048            // `BTreeMap` iterates in key order, so the names come out sorted.
1049            self.entries
1050                .borrow()
1051                .keys()
1052                .filter_map(|key| key.strip_prefix(PREFIX).map(str::to_string))
1053                .collect()
1054        }
1055
1056        fn read(&self, name: &str) -> Option<String> {
1057            self.entries.borrow().get(&Self::key(name)).cloned()
1058        }
1059
1060        fn write(&self, name: &str, contents: &str) -> Result<(), String> {
1061            let key = Self::key(name);
1062            self.entries
1063                .borrow_mut()
1064                .insert(key.clone(), contents.to_string());
1065            // The mirror is authoritative for this session, so `Ok` is honest:
1066            // every subsequent read sees the new bytes. Durability is the
1067            // backend's job and its failure arrives via `take_persistence_errors`.
1068            self.push(name, self.backend.put(&key, contents));
1069            Ok(())
1070        }
1071
1072        fn remove(&self, name: &str) -> Result<(), String> {
1073            let key = Self::key(name);
1074            self.entries.borrow_mut().remove(&key);
1075            self.push(name, self.backend.delete(&key));
1076            Ok(())
1077        }
1078
1079        fn take_persistence_errors(&self) -> Vec<String> {
1080            self.errors.drain()
1081        }
1082
1083        fn browser_location(&self) -> String {
1084            // The RAW navigable path (breadcrumb / back-forward / path-edit rely on
1085            // this being feed-able straight back to `browser_navigate`); the human
1086            // "virtual filesystem" context lives in `backend_label`.
1087            self.browser_dir.borrow().clone()
1088        }
1089
1090        fn browser_entries(&self, extensions: &[&str]) -> Vec<BrowserEntry> {
1091            let current = self.browser_dir.borrow().clone();
1092            if current == "/" {
1093                return vec![BrowserEntry {
1094                    name: "models".into(),
1095                    identity: "/models".into(),
1096                    is_dir: true,
1097                    size: None,
1098                    modified: None,
1099                }];
1100            }
1101            let relative_dir = current
1102                .strip_prefix("/models")
1103                .unwrap_or("")
1104                .trim_matches('/');
1105            let prefix = if relative_dir.is_empty() {
1106                String::new()
1107            } else {
1108                format!("{relative_dir}/")
1109            };
1110            let wanted: Vec<String> = extensions
1111                .iter()
1112                .map(|extension| extension.trim_start_matches('.').to_ascii_lowercase())
1113                .collect();
1114            let mut entries: BTreeMap<String, BrowserEntry> = BTreeMap::new();
1115            for (key, value) in self.entries.borrow().iter() {
1116                let (item, is_model) = if let Some(item) = key.strip_prefix(PREFIX) {
1117                    (item, true)
1118                } else if let Some(item) = key.strip_prefix(DIR_PREFIX) {
1119                    (item.trim_end_matches('/'), false)
1120                } else {
1121                    continue;
1122                };
1123                let Some(rest) = item.strip_prefix(&prefix) else {
1124                    continue;
1125                };
1126                if rest.is_empty() {
1127                    continue;
1128                }
1129                if let Some((child, _)) = rest.split_once('/') {
1130                    entries
1131                        .entry(child.to_string())
1132                        .or_insert_with(|| BrowserEntry {
1133                            name: child.to_string(),
1134                            identity: Self::child_path(&current, child),
1135                            is_dir: true,
1136                            size: None,
1137                            modified: None,
1138                        });
1139                } else if is_model {
1140                    let name = format!("{rest}{MODEL_EXT}");
1141                    let lower = name.to_ascii_lowercase();
1142                    if wanted.is_empty()
1143                        || wanted
1144                            .iter()
1145                            .any(|extension| lower.ends_with(&format!(".{extension}")))
1146                    {
1147                        // Size = the mirrored JSON's byte length. `modified` stays
1148                        // None: the key space carries values, not timestamps.
1149                        entries.insert(
1150                            name.clone(),
1151                            BrowserEntry {
1152                                name,
1153                                identity: Self::virtual_model_path(item),
1154                                is_dir: false,
1155                                size: Some(value.len() as u64),
1156                                modified: None,
1157                            },
1158                        );
1159                    }
1160                } else {
1161                    entries
1162                        .entry(rest.to_string())
1163                        .or_insert_with(|| BrowserEntry {
1164                            name: rest.to_string(),
1165                            identity: Self::child_path(&current, rest),
1166                            is_dir: true,
1167                            size: None,
1168                            modified: None,
1169                        });
1170                }
1171            }
1172            let mut entries: Vec<_> = entries.into_values().collect();
1173            entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name)));
1174            entries
1175        }
1176
1177        fn browser_enter(&self, identity: &str) -> Result<(), String> {
1178            if identity == "/models" || identity.starts_with("/models/") {
1179                *self.browser_dir.borrow_mut() = identity.trim_end_matches('/').to_string();
1180                Ok(())
1181            } else {
1182                Err("the browser virtual filesystem is rooted at /models".into())
1183            }
1184        }
1185
1186        fn browser_up(&self) -> Result<(), String> {
1187            let current = self.browser_dir.borrow().clone();
1188            if current == "/" {
1189                return Ok(());
1190            }
1191            let parent = current
1192                .rsplit_once('/')
1193                .map(|(parent, _)| parent)
1194                .unwrap_or("");
1195            *self.browser_dir.borrow_mut() = if parent.is_empty() {
1196                "/".into()
1197            } else {
1198                parent.into()
1199            };
1200            Ok(())
1201        }
1202
1203        fn browser_home(&self) -> Result<(), String> {
1204            *self.browser_dir.borrow_mut() = "/models".into();
1205            Ok(())
1206        }
1207
1208        fn browser_root(&self) -> Result<(), String> {
1209            *self.browser_dir.borrow_mut() = "/".into();
1210            Ok(())
1211        }
1212
1213        fn browser_navigate(&self, location: &str) -> Result<(), String> {
1214            let loc = location.trim_end_matches('/');
1215            let loc = if loc.is_empty() { "/" } else { loc };
1216            if loc == "/" || loc == "/models" || loc.starts_with("/models/") {
1217                *self.browser_dir.borrow_mut() = loc.to_string();
1218                Ok(())
1219            } else {
1220                Err("the browser virtual filesystem is rooted at /models".into())
1221            }
1222        }
1223
1224        fn browser_places(&self) -> Vec<BrowserPlace> {
1225            vec![
1226                BrowserPlace {
1227                    label: "Models".into(),
1228                    location: "/models".into(),
1229                    kind: PlaceKind::Models,
1230                },
1231                BrowserPlace {
1232                    label: "/".into(),
1233                    location: "/".into(),
1234                    kind: PlaceKind::Root,
1235                },
1236            ]
1237        }
1238
1239        fn browser_create_dir(&self, name: &str) -> Result<(), String> {
1240            if name.is_empty()
1241                || name == "."
1242                || name == ".."
1243                || name.contains('/')
1244                || name.contains('\\')
1245            {
1246                return Err("folder name must be one path component".into());
1247            }
1248            let current = self.browser_dir.borrow().clone();
1249            if !current.starts_with("/models") {
1250                return Err("folders can only be created under /models".into());
1251            }
1252            let relative = Self::child_path(&current, name)
1253                .trim_start_matches("/models/")
1254                .to_string();
1255            // A folder is a zero-length marker key; the explorer derives the tree
1256            // from the key prefixes alone.
1257            let key = format!("{DIR_PREFIX}{relative}/");
1258            self.entries.borrow_mut().insert(key.clone(), String::new());
1259            self.push(name, self.backend.put(&key, ""));
1260            Ok(())
1261        }
1262
1263        fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
1264            let file = model_display_name(name);
1265            if file.is_empty() {
1266                return Err("invalid file name".into());
1267            }
1268            let current = self.browser_dir.borrow().clone();
1269            if !current.starts_with("/models") {
1270                return Err("select a folder under /models".into());
1271            }
1272            let identity = format!("{}{MODEL_EXT}", Self::child_path(&current, &file));
1273            self.write(&identity, contents)?;
1274            Ok(identity)
1275        }
1276    }
1277}
1278
1279// --- Browser: IndexedDB documents + download / upload interchange --------------
1280#[cfg(target_arch = "wasm32")]
1281mod web_model {
1282    use super::mirror_store::{BackendFuture, MirrorStore, StoreBackend, UnavailableBackend};
1283    use super::{
1284        model_display_name, BrowserEntry, BrowserPlace, ImportedFile, ModelStore, MODEL_EXT,
1285    };
1286    use std::cell::RefCell;
1287    use std::future::Future;
1288    use std::pin::Pin;
1289    use std::rc::Rc;
1290    use std::task::{Context, Poll, Waker};
1291    use wasm_bindgen::prelude::*;
1292    use wasm_bindgen::JsCast;
1293
1294    // The origin-private persistent store on the web is **IndexedDB**. It replaced
1295    // `localStorage`, whose ~5-10 MB per-origin quota cannot hold a single native
1296    // BREP payload (a measured STEP import: ~35 MB), and it is the only browser
1297    // store that is both large and enumerable without a permission gesture (unlike
1298    // the File System Access API, which fails headless). Its API is ASYNC, hence
1299    // the mirror in [`mirror_store`](super::mirror_store); the key scheme is the
1300    // localStorage one, byte for byte. **Download + upload** below still bridge to
1301    // the user's REAL filesystem for portability.
1302    const DB_NAME: &str = "brep-app";
1303    const DB_VERSION: u32 = 1;
1304    /// The single key/value object store; keys are the `brep-app:*` strings.
1305    const STORE_NAME: &str = "kv";
1306
1307    thread_local! {
1308        /// The single hidden `<input type=file>`, created once and reused.
1309        static IMPORT_INPUT: RefCell<Option<web_sys::HtmlInputElement>> = const { RefCell::new(None) };
1310        /// The most recent completed upload, awaiting the panel's poll.
1311        static IMPORTED: RefCell<Option<ImportedFile>> = const { RefCell::new(None) };
1312        /// The egui context, so an ASYNC callback (a `FileReader` load, a failed
1313        /// IndexedDB write) can wake the reactive eframe loop (the app only
1314        /// repaints on input events + explicit requests). Without it a completed
1315        /// upload — or a "your model did not save" notice — sits unshown until the
1316        /// user happens to move the mouse. Seeded once at boot via [`set_repaint_ctx`].
1317        static REPAINT_CTX: RefCell<Option<eframe::egui::Context>> = const { RefCell::new(None) };
1318        /// The store built by [`hydrate`] inside the async wasm entry point,
1319        /// waiting for `BrepApp::new` to pick it up through
1320        /// [`default_model_store`](super::default_model_store). The hand-off is a
1321        /// thread-local rather than a closure capture so the app constructor keeps
1322        /// its platform-free signature.
1323        static BOOT_STORE: RefCell<Option<IdbModelStore>> = const { RefCell::new(None) };
1324    }
1325
1326    /// Register the egui context used to wake the frame loop when an async browser
1327    /// upload — or a write-behind persistence failure — completes. Called once from
1328    /// the app shell at construction.
1329    pub fn set_repaint_ctx(ctx: eframe::egui::Context) {
1330        REPAINT_CTX.with(|c| *c.borrow_mut() = Some(ctx));
1331    }
1332
1333    /// Ask the reactive frame loop for a repaint. Reads `REPAINT_CTX` at CALL time,
1334    /// so it does not matter that the store is built (during boot) before the app
1335    /// shell registers the context.
1336    fn wake_frame_loop() {
1337        REPAINT_CTX.with(|c| {
1338            if let Some(ctx) = c.borrow().as_ref() {
1339                ctx.request_repaint();
1340            }
1341        });
1342    }
1343
1344    // --- IndexedDB request/transaction -> Future ----------------------------------
1345    // Hand-rolled rather than pulling `indexed_db_futures`: this crate deliberately
1346    // keeps its wasm dependency tree thin (see the `ehttp` note in Cargo.toml about
1347    // the `getrandom` dep-tree problem), and the whole adapter is the ~60 lines
1348    // below. It owns its `Closure`s — dropping the future clears the handlers — so
1349    // a session of saves does not leak a pair of JS closures per write, which a
1350    // `Closure::forget()` sketch would.
1351
1352    #[derive(Default)]
1353    struct Settled {
1354        outcome: Option<Result<JsValue, String>>,
1355        waker: Option<Waker>,
1356    }
1357
1358    /// Which DOM event pair the future is listening to. Kept so `Drop` can detach
1359    /// the handlers (and with them the Rust closures' reference back to the target).
1360    enum EventSource {
1361        Request(web_sys::IdbRequest),
1362        Transaction(web_sys::IdbTransaction),
1363    }
1364
1365    /// One IndexedDB completion, as a `Future`.
1366    struct IdbFuture {
1367        source: EventSource,
1368        settled: Rc<RefCell<Settled>>,
1369        /// Owned so the closures live exactly as long as the future.
1370        _handlers: Vec<Closure<dyn FnMut(web_sys::Event)>>,
1371    }
1372
1373    fn settle(settled: &Rc<RefCell<Settled>>, outcome: Result<JsValue, String>) {
1374        let waker = {
1375            let mut settled = settled.borrow_mut();
1376            if settled.outcome.is_none() {
1377                settled.outcome = Some(outcome);
1378            }
1379            settled.waker.take()
1380        };
1381        if let Some(waker) = waker {
1382            waker.wake();
1383        }
1384    }
1385
1386    fn request_error(request: &web_sys::IdbRequest) -> String {
1387        request
1388            .error()
1389            .ok()
1390            .flatten()
1391            .map(|error| format!("{}: {}", error.name(), error.message()))
1392            .unwrap_or_else(|| "IndexedDB request failed".into())
1393    }
1394
1395    fn js_error(value: &JsValue) -> String {
1396        value
1397            .as_string()
1398            .or_else(|| js_sys::Reflect::get(value, &JsValue::from_str("message")).ok()?.as_string())
1399            .unwrap_or_else(|| format!("{value:?}"))
1400    }
1401
1402    /// Resolve when `request` succeeds, with its `result`.
1403    fn on_request(request: web_sys::IdbRequest) -> IdbFuture {
1404        let settled = Rc::new(RefCell::new(Settled::default()));
1405        let success = {
1406            let settled = settled.clone();
1407            let request = request.clone();
1408            Closure::wrap(Box::new(move |_event: web_sys::Event| {
1409                let value = request.result().unwrap_or(JsValue::UNDEFINED);
1410                settle(&settled, Ok(value));
1411            }) as Box<dyn FnMut(web_sys::Event)>)
1412        };
1413        let failure = {
1414            let settled = settled.clone();
1415            let request = request.clone();
1416            Closure::wrap(Box::new(move |_event: web_sys::Event| {
1417                settle(&settled, Err(request_error(&request)));
1418            }) as Box<dyn FnMut(web_sys::Event)>)
1419        };
1420        request.set_onsuccess(Some(success.as_ref().unchecked_ref()));
1421        request.set_onerror(Some(failure.as_ref().unchecked_ref()));
1422        IdbFuture {
1423            source: EventSource::Request(request),
1424            settled,
1425            _handlers: vec![success, failure],
1426        }
1427    }
1428
1429    /// Resolve when `transaction` COMMITS.
1430    ///
1431    /// A write must be awaited here, not on the `put` request's `onsuccess`: the
1432    /// request succeeds before the transaction commits, and a page reload can abort
1433    /// an uncommitted `readwrite` transaction. For a multi-megabyte payload that
1434    /// window is real, and "the save is durable" is exactly the claim the pending
1435    /// counter and the reload test rest on.
1436    fn on_transaction(transaction: web_sys::IdbTransaction) -> IdbFuture {
1437        let settled = Rc::new(RefCell::new(Settled::default()));
1438        let complete = {
1439            let settled = settled.clone();
1440            Closure::wrap(Box::new(move |_event: web_sys::Event| {
1441                settle(&settled, Ok(JsValue::UNDEFINED));
1442            }) as Box<dyn FnMut(web_sys::Event)>)
1443        };
1444        let describe = {
1445            let transaction = transaction.clone();
1446            move |fallback: &str| {
1447                transaction
1448                    .error()
1449                    .map(|error| format!("{}: {}", error.name(), error.message()))
1450                    .unwrap_or_else(|| fallback.to_string())
1451            }
1452        };
1453        let failure = {
1454            let settled = settled.clone();
1455            let describe = describe.clone();
1456            Closure::wrap(Box::new(move |_event: web_sys::Event| {
1457                settle(&settled, Err(describe("IndexedDB transaction failed")));
1458            }) as Box<dyn FnMut(web_sys::Event)>)
1459        };
1460        let abort = {
1461            let settled = settled.clone();
1462            Closure::wrap(Box::new(move |_event: web_sys::Event| {
1463                settle(
1464                    &settled,
1465                    Err(describe("IndexedDB transaction aborted (quota?)")),
1466                );
1467            }) as Box<dyn FnMut(web_sys::Event)>)
1468        };
1469        transaction.set_oncomplete(Some(complete.as_ref().unchecked_ref()));
1470        transaction.set_onerror(Some(failure.as_ref().unchecked_ref()));
1471        transaction.set_onabort(Some(abort.as_ref().unchecked_ref()));
1472        IdbFuture {
1473            source: EventSource::Transaction(transaction),
1474            settled,
1475            _handlers: vec![complete, failure, abort],
1476        }
1477    }
1478
1479    impl Future for IdbFuture {
1480        type Output = Result<JsValue, String>;
1481
1482        fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
1483            let mut settled = self.settled.borrow_mut();
1484            if let Some(outcome) = settled.outcome.take() {
1485                return Poll::Ready(outcome);
1486            }
1487            settled.waker = Some(cx.waker().clone());
1488            Poll::Pending
1489        }
1490    }
1491
1492    impl Drop for IdbFuture {
1493        fn drop(&mut self) {
1494            // Detach the handlers, breaking the target -> closure -> target cycle
1495            // so the closures we own are actually freed.
1496            match &self.source {
1497                EventSource::Request(request) => {
1498                    request.set_onsuccess(None);
1499                    request.set_onerror(None);
1500                }
1501                EventSource::Transaction(transaction) => {
1502                    transaction.set_oncomplete(None);
1503                    transaction.set_onerror(None);
1504                    transaction.set_onabort(None);
1505                }
1506            }
1507        }
1508    }
1509
1510    // --- the IndexedDB backend -----------------------------------------------------
1511
1512    /// [`StoreBackend`] over one IndexedDB object store. The ONLY place in the
1513    /// crate that knows what IndexedDB is.
1514    pub(super) struct IdbBackend {
1515        db: web_sys::IdbDatabase,
1516    }
1517
1518    impl IdbBackend {
1519        /// Open (creating on first use) the application database. `Err` on any
1520        /// browser that blocks storage — private windows, "block all cookies"
1521        /// setups — which the caller turns into a loud, non-persisting session.
1522        async fn open() -> Result<Self, String> {
1523            let factory = web_sys::window()
1524                .ok_or("no window")?
1525                .indexed_db()
1526                .map_err(|e| js_error(&e))?
1527                .ok_or("no indexedDB on this window")?;
1528            let request = factory
1529                .open_with_u32(DB_NAME, DB_VERSION)
1530                .map_err(|e| js_error(&e))?;
1531
1532            // Create the object store on first open / version bump. The request is
1533            // captured directly (rather than read off the event target) so no
1534            // `EventTarget` binding is needed.
1535            let upgrading = request.clone();
1536            let upgrade = Closure::wrap(Box::new(move |_event: web_sys::Event| {
1537                if let Ok(value) = upgrading.result() {
1538                    if let Ok(db) = value.dyn_into::<web_sys::IdbDatabase>() {
1539                        // Errors here mean the store already exists — harmless.
1540                        let _ = db.create_object_store(STORE_NAME);
1541                    }
1542                }
1543            }) as Box<dyn FnMut(web_sys::Event)>);
1544            request.set_onupgradeneeded(Some(upgrade.as_ref().unchecked_ref()));
1545
1546            let opened = on_request(request.clone().unchecked_into::<web_sys::IdbRequest>()).await;
1547            request.set_onupgradeneeded(None);
1548            drop(upgrade);
1549
1550            let db = opened?
1551                .dyn_into::<web_sys::IdbDatabase>()
1552                .map_err(|_| "IndexedDB open returned no database".to_string())?;
1553            Ok(Self { db })
1554        }
1555
1556        /// Start a transaction and reach its object store. The transaction handle
1557        /// is returned so a write can await its COMMIT.
1558        fn transact(
1559            &self,
1560            mode: web_sys::IdbTransactionMode,
1561        ) -> Result<(web_sys::IdbTransaction, web_sys::IdbObjectStore), String> {
1562            let transaction = self
1563                .db
1564                .transaction_with_str_and_mode(STORE_NAME, mode)
1565                .map_err(|e| js_error(&e))?;
1566            let store = transaction.object_store(STORE_NAME).map_err(|e| js_error(&e))?;
1567            Ok((transaction, store))
1568        }
1569    }
1570
1571    impl StoreBackend for IdbBackend {
1572        fn label(&self) -> String {
1573            "browser storage (IndexedDB) · download/upload for files".into()
1574        }
1575
1576        fn load_all(&self) -> BackendFuture<Vec<(String, String)>> {
1577            // Two whole-store requests in ONE readonly transaction rather than a
1578            // cursor: `getAllKeys` and `getAll` both come back in key order, so
1579            // zipping them reconstructs the key space in one round trip each.
1580            let started = (|| -> Result<(IdbFuture, IdbFuture), String> {
1581                let (_transaction, store) = self.transact(web_sys::IdbTransactionMode::Readonly)?;
1582                let keys = store.get_all_keys().map_err(|e| js_error(&e))?;
1583                let values = store.get_all().map_err(|e| js_error(&e))?;
1584                Ok((on_request(keys), on_request(values)))
1585            })();
1586            Box::pin(async move {
1587                let (keys, values) = started?;
1588                let keys = js_sys::Array::from(&keys.await?);
1589                let values = js_sys::Array::from(&values.await?);
1590                if keys.length() != values.length() {
1591                    return Err("IndexedDB returned mismatched keys and values".into());
1592                }
1593                let mut entries = Vec::with_capacity(keys.length() as usize);
1594                for i in 0..keys.length() {
1595                    let (Some(key), Some(value)) =
1596                        (keys.get(i).as_string(), values.get(i).as_string())
1597                    else {
1598                        // A non-string entry is not ours; skipping it is safer than
1599                        // failing the whole hydrate.
1600                        continue;
1601                    };
1602                    entries.push((key, value));
1603                }
1604                Ok(entries)
1605            })
1606        }
1607
1608        fn put(&self, key: &str, value: &str) -> BackendFuture<()> {
1609            // The transaction is created and the request issued SYNCHRONOUSLY, so
1610            // two writes of the same key commit in call order (IndexedDB commits
1611            // `readwrite` transactions in creation order) no matter how the futures
1612            // are later polled — the per-key FIFO obligation in `StoreBackend`.
1613            let started = (|| -> Result<IdbFuture, String> {
1614                let (transaction, store) =
1615                    self.transact(web_sys::IdbTransactionMode::Readwrite)?;
1616                store
1617                    .put_with_key(&JsValue::from_str(value), &JsValue::from_str(key))
1618                    .map_err(|e| js_error(&e))?;
1619                Ok(on_transaction(transaction))
1620            })();
1621            Box::pin(async move {
1622                started?.await?;
1623                Ok(())
1624            })
1625        }
1626
1627        fn delete(&self, key: &str) -> BackendFuture<()> {
1628            let started = (|| -> Result<IdbFuture, String> {
1629                let (transaction, store) =
1630                    self.transact(web_sys::IdbTransactionMode::Readwrite)?;
1631                store
1632                    .delete(&JsValue::from_str(key))
1633                    .map_err(|e| js_error(&e))?;
1634                Ok(on_transaction(transaction))
1635            })();
1636            Box::pin(async move {
1637                started?.await?;
1638                Ok(())
1639            })
1640        }
1641    }
1642
1643    // --- boot ----------------------------------------------------------------------
1644
1645    /// Bring up browser persistence and hydrate the whole key space into memory.
1646    ///
1647    /// Called from the async wasm entry point BEFORE `eframe::WebRunner::start`, so
1648    /// by the time any synchronous [`ModelStore::read`] can run, the mirror is
1649    /// complete — that ordering is the entire reason the trait stays synchronous.
1650    ///
1651    /// On failure there is NO quiet fallback: the session gets an
1652    /// [`UnavailableBackend`], which keeps the app usable in memory while saying so
1653    /// in the panel header and toasting every write that does not persist.
1654    pub(super) async fn hydrate() {
1655        let wake: Rc<dyn Fn()> = Rc::new(wake_frame_loop);
1656        let core = match IdbBackend::open().await {
1657            Ok(backend) => {
1658                let backend: Rc<dyn StoreBackend> = Rc::new(backend);
1659                match backend.load_all().await {
1660                    Ok(entries) => MirrorStore::new(backend, entries, Some(wake)),
1661                    // The database opened but would not read. Writing against a
1662                    // half-known key space could overwrite documents the mirror
1663                    // never saw, so treat it as unavailable rather than risk that.
1664                    Err(message) => unavailable(
1665                        format!("IndexedDB could not be read ({message})"),
1666                        Some(wake),
1667                    ),
1668                }
1669            }
1670            Err(message) => unavailable(format!("IndexedDB unavailable ({message})"), Some(wake)),
1671        };
1672        install_verification_hooks(&core);
1673        BOOT_STORE.with(|c| *c.borrow_mut() = Some(IdbModelStore { core }));
1674    }
1675
1676    /// A session with no persistence: empty, in-memory, and loud about it.
1677    fn unavailable(reason: String, wake: Option<Rc<dyn Fn()>>) -> MirrorStore {
1678        let core = MirrorStore::new(
1679            Rc::new(UnavailableBackend::new(reason.clone())),
1680            Vec::new(),
1681            wake,
1682        );
1683        // Seed the notice channel so the FIRST frame tells the user, before they
1684        // have saved anything and discovered it the hard way.
1685        core.report(format!(
1686            "{reason} — this session will not be saved; use Download to keep your work"
1687        ));
1688        core
1689    }
1690
1691    /// Hand the hydrated store to `BrepApp::new`. If boot never ran (no code path
1692    /// does that today), the app still starts — non-persisting and saying so.
1693    pub(super) fn take_boot_store() -> Box<dyn ModelStore> {
1694        let store = BOOT_STORE.with(|c| c.borrow_mut().take()).unwrap_or_else(|| {
1695            let wake: Rc<dyn Fn()> = Rc::new(wake_frame_loop);
1696            IdbModelStore {
1697                core: unavailable("storage was never initialised".into(), Some(wake)),
1698            }
1699        });
1700        Box::new(store)
1701    }
1702
1703    // --- verification hooks ---------------------------------------------------------
1704
1705    /// Publish `window.__brepStore*` handles onto the LIVE store, in the same
1706    /// spirit as the app shell's `__brep*` state globals: the headed verifier needs
1707    /// to write a payload far larger than any UI gesture can type, then prove it
1708    /// survived a reload. `__brepStorePending` is what makes that check non-racy —
1709    /// it reaches zero only once the backing transaction has COMMITTED.
1710    /// `__brepStoreRead` is the read side of the same seam: a script that saves
1711    /// through the UI has to be able to look at what landed.
1712    fn install_verification_hooks(core: &MirrorStore) {
1713        let Some(window) = web_sys::window() else {
1714            return;
1715        };
1716        let publish = |name: &str, value: &JsValue| {
1717            let _ = js_sys::Reflect::set(&window, &JsValue::from_str(name), value);
1718        };
1719
1720        let store = core.clone();
1721        let write = Closure::wrap(Box::new(move |name: String, contents: String| -> JsValue {
1722            match store.write(&name, &contents) {
1723                Ok(()) => JsValue::NULL,
1724                Err(message) => JsValue::from_str(&message),
1725            }
1726        }) as Box<dyn FnMut(String, String) -> JsValue>);
1727        publish("__brepStoreWrite", write.as_ref());
1728        write.forget();
1729
1730        let store = core.clone();
1731        let len = Closure::wrap(Box::new(move |name: String| -> f64 {
1732            store.len_of(&name).map(|n| n as f64).unwrap_or(-1.0)
1733        }) as Box<dyn FnMut(String) -> f64>);
1734        publish("__brepStoreLen", len.as_ref());
1735        len.forget();
1736
1737        // The READ sibling of `__brepStoreWrite`. Without it a headed script can
1738        // only prove a save happened, not WHAT was saved: the assemblies sweep
1739        // used to read the document straight out of `localStorage`, which the
1740        // IndexedDB migration silently emptied.
1741        let store = core.clone();
1742        let read = Closure::wrap(Box::new(move |name: String| -> JsValue {
1743            match store.read(&name) {
1744                Some(contents) => JsValue::from_str(&contents),
1745                None => JsValue::NULL,
1746            }
1747        }) as Box<dyn FnMut(String) -> JsValue>);
1748        publish("__brepStoreRead", read.as_ref());
1749        read.forget();
1750
1751        let store = core.clone();
1752        let list = Closure::wrap(Box::new(move || -> JsValue {
1753            JsValue::from_str(&serde_json::to_string(&store.list()).unwrap_or_default())
1754        }) as Box<dyn FnMut() -> JsValue>);
1755        publish("__brepStoreList", list.as_ref());
1756        list.forget();
1757
1758        let store = core.clone();
1759        let errors = Closure::wrap(Box::new(move || -> JsValue {
1760            JsValue::from_str(&serde_json::to_string(&store.error_history()).unwrap_or_default())
1761        }) as Box<dyn FnMut() -> JsValue>);
1762        publish("__brepStoreErrors", errors.as_ref());
1763        errors.forget();
1764
1765        let store = core.clone();
1766        let pending = Closure::wrap(Box::new(move || -> f64 { store.pending() as f64 })
1767            as Box<dyn FnMut() -> f64>);
1768        publish("__brepStorePending", pending.as_ref());
1769        pending.forget();
1770    }
1771
1772    // --- real-file interchange (download / upload) ----------------------------------
1773    // Free functions, not methods: they are pure browser plumbing with no store
1774    // state, and keeping them out of the store type leaves `IdbModelStore` as a
1775    // thin seam between the mirror and this lane.
1776
1777    /// Lazily create the reusable hidden file input, wiring its `change` handler
1778    /// (which reads the chosen file and stashes it for `take_import`). `accept` is
1779    /// (re)applied every call so the picker's filter matches the current lane (the
1780    /// `.BREP.json` model lane vs. a `.step` import lane).
1781    fn ensure_input(accept: &str) -> Option<web_sys::HtmlInputElement> {
1782        if let Some(existing) = IMPORT_INPUT.with(|c| c.borrow().clone()) {
1783            existing.set_accept(accept);
1784            return Some(existing);
1785        }
1786        let document = web_sys::window()?.document()?;
1787        let input: web_sys::HtmlInputElement =
1788            document.create_element("input").ok()?.dyn_into().ok()?;
1789        input.set_type("file");
1790        input.set_accept(accept);
1791        input.set_hidden(true);
1792
1793        // Read bytes so binary STL is not corrupted at the browser boundary.
1794        let input_for_cb = input.clone();
1795        let onchange = Closure::wrap(Box::new(move |_e: web_sys::Event| {
1796            let Some(files) = input_for_cb.files() else { return };
1797            let Some(file) = files.get(0) else { return };
1798            let name = file.name();
1799            let Ok(reader) = web_sys::FileReader::new() else { return };
1800            let reader_for_load = reader.clone();
1801            let onload = Closure::wrap(Box::new(move |_e: web_sys::Event| {
1802                if let Ok(value) = reader_for_load.result() {
1803                    let bytes = js_sys::Uint8Array::new(&value).to_vec();
1804                    IMPORTED.with(|c| *c.borrow_mut() = Some(ImportedFile {
1805                        name: model_display_name(&name),
1806                        bytes,
1807                    }));
1808                    // Wake the reactive frame loop so the file panel polls
1809                    // `take_import` THIS frame, not on the next stray input event.
1810                    wake_frame_loop();
1811                }
1812            }) as Box<dyn FnMut(web_sys::Event)>);
1813            reader.set_onload(Some(onload.as_ref().unchecked_ref()));
1814            // One small per-import leak (the app runs for the page lifetime).
1815            onload.forget();
1816            let _ = reader.read_as_array_buffer(&file);
1817        }) as Box<dyn FnMut(web_sys::Event)>);
1818        input.set_onchange(Some(onchange.as_ref().unchecked_ref()));
1819        onchange.forget(); // created once — leak is bounded
1820
1821        if let Some(body) = document.body() {
1822            let _ = body.append_child(&input);
1823        }
1824        IMPORT_INPUT.with(|c| *c.borrow_mut() = Some(input.clone()));
1825        Some(input)
1826    }
1827
1828    /// Offer `contents` to the user as a download named EXACTLY `file_name`, via a
1829    /// Blob object-URL + a synthetic anchor click.
1830    fn download(file_name: &str, mime: &str, contents: &str) -> Result<(), String> {
1831        let document = web_sys::window()
1832            .and_then(|w| w.document())
1833            .ok_or("no document")?;
1834        let parts = js_sys::Array::of1(&JsValue::from_str(contents));
1835        let options = web_sys::BlobPropertyBag::new();
1836        options.set_type(mime);
1837        let blob = web_sys::Blob::new_with_str_sequence_and_options(&parts, &options)
1838            .map_err(|_| "blob create failed".to_string())?;
1839        let url = web_sys::Url::create_object_url_with_blob(&blob)
1840            .map_err(|_| "object url failed".to_string())?;
1841        let anchor: web_sys::HtmlAnchorElement = document
1842            .create_element("a")
1843            .map_err(|_| "anchor create failed".to_string())?
1844            .dyn_into()
1845            .map_err(|_| "anchor cast failed".to_string())?;
1846        anchor.set_href(&url);
1847        anchor.set_download(file_name);
1848        anchor.click();
1849        let _ = web_sys::Url::revoke_object_url(&url);
1850        Ok(())
1851    }
1852
1853    /// The browser model store: the mirrored key space (which owns every CRUD and
1854    /// explorer method) plus this platform's real-file interchange lane.
1855    pub(super) struct IdbModelStore {
1856        core: MirrorStore,
1857    }
1858
1859    impl ModelStore for IdbModelStore {
1860        // --- delegated to the mirror ------------------------------------------
1861        fn backend_label(&self) -> String {
1862            self.core.backend_label()
1863        }
1864        fn list(&self) -> Vec<String> {
1865            self.core.list()
1866        }
1867        fn read(&self, name: &str) -> Option<String> {
1868            self.core.read(name)
1869        }
1870        fn write(&self, name: &str, contents: &str) -> Result<(), String> {
1871            self.core.write(name, contents)
1872        }
1873        fn remove(&self, name: &str) -> Result<(), String> {
1874            self.core.remove(name)
1875        }
1876        fn take_persistence_errors(&self) -> Vec<String> {
1877            self.core.take_persistence_errors()
1878        }
1879        fn browser_location(&self) -> String {
1880            self.core.browser_location()
1881        }
1882        fn browser_entries(&self, extensions: &[&str]) -> Vec<BrowserEntry> {
1883            self.core.browser_entries(extensions)
1884        }
1885        fn browser_enter(&self, identity: &str) -> Result<(), String> {
1886            self.core.browser_enter(identity)
1887        }
1888        fn browser_up(&self) -> Result<(), String> {
1889            self.core.browser_up()
1890        }
1891        fn browser_home(&self) -> Result<(), String> {
1892            self.core.browser_home()
1893        }
1894        fn browser_root(&self) -> Result<(), String> {
1895            self.core.browser_root()
1896        }
1897        fn browser_navigate(&self, location: &str) -> Result<(), String> {
1898            self.core.browser_navigate(location)
1899        }
1900        fn browser_places(&self) -> Vec<BrowserPlace> {
1901            self.core.browser_places()
1902        }
1903        fn browser_create_dir(&self, name: &str) -> Result<(), String> {
1904            self.core.browser_create_dir(name)
1905        }
1906        fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
1907            self.core.browser_write(name, contents)
1908        }
1909
1910        // --- the browser's real-file lane -------------------------------------
1911        fn supports_file_interchange(&self) -> bool {
1912            true
1913        }
1914
1915        /// Offer the document as a `<name>.BREP.json` download. The returned
1916        /// identity is the bare name (the browser owns where the download lands).
1917        fn export_file(&self, name: &str, contents: &str) -> Result<Option<String>, String> {
1918            let name = model_display_name(name);
1919            download(&format!("{name}{MODEL_EXT}"), "application/json", contents)?;
1920            Ok(Some(name))
1921        }
1922
1923        fn begin_import(&self) -> Result<(), String> {
1924            let input =
1925                ensure_input(".json,.BREP.json,application/json").ok_or("file input unavailable")?;
1926            // Clear so re-selecting the same file still fires `change`.
1927            input.set_value("");
1928            input.click();
1929            Ok(())
1930        }
1931
1932        fn take_import(&self) -> Option<ImportedFile> {
1933            IMPORTED.with(|c| c.borrow_mut().take())
1934        }
1935
1936        // Format-typed interchange: the same hidden input, refiltered to the
1937        // requested extensions. The onchange handler stashes the file's real name
1938        // (its extension survives `model_display_name`, since that only strips
1939        // `.json` / `.BREP.json`), so the panel routes STEP imports by extension.
1940        fn begin_import_filtered(&self, filter: (&str, &[&str])) -> Result<(), String> {
1941            let accept = filter
1942                .1
1943                .iter()
1944                .map(|ext| format!(".{ext}"))
1945                .collect::<Vec<_>>()
1946                .join(",");
1947            let input = ensure_input(&accept).ok_or("file input unavailable")?;
1948            input.set_value("");
1949            input.click();
1950            Ok(())
1951        }
1952
1953        /// Offer `contents` as a download under EXACTLY `file_name` (extension
1954        /// kept) — the foreign-format sibling of [`Self::export_file`].
1955        fn export_file_named(&self, file_name: &str, contents: &str) -> Result<(), String> {
1956            download(file_name, "application/octet-stream", contents)
1957        }
1958    }
1959}
1960
1961
1962#[cfg(all(test, not(target_arch = "wasm32")))]
1963mod tests {
1964    use super::native_model::FileModelStore;
1965    use super::{ModelStore, DOCK_LAYOUT_KEY, FEATURE_PALETTE_DISPLAY_KEY, SETTINGS_KEY};
1966
1967    #[test]
1968    fn native_model_store_round_trips_named_documents() {
1969        let dir = std::env::temp_dir().join(format!("brep-app-models-{}", std::process::id()));
1970        let _ = std::fs::remove_dir_all(&dir);
1971        let store = FileModelStore::with_dir(dir.clone());
1972
1973        // Empty to start.
1974        assert!(store.list().is_empty());
1975        assert_eq!(store.read("missing"), None);
1976
1977        // Write two documents, read them back verbatim, and enumerate by name.
1978        let doc_a = r#"{"features":[{"type":"P.CU"}]}"#;
1979        let doc_b = r#"{"features":[]}"#;
1980        store.write("alpha", doc_a).unwrap();
1981        store.write("beta", doc_b).unwrap();
1982        assert_eq!(store.read("alpha").as_deref(), Some(doc_a));
1983        assert_eq!(store.read("beta").as_deref(), Some(doc_b));
1984        assert_eq!(store.list(), vec!["alpha".to_string(), "beta".to_string()]);
1985
1986        // The `.BREP.json` extension is transparent to the name.
1987        assert_eq!(store.read("alpha.BREP.json").as_deref(), Some(doc_a));
1988
1989        // Overwrite + remove.
1990        store.write("alpha", doc_b).unwrap();
1991        assert_eq!(store.read("alpha").as_deref(), Some(doc_b));
1992        store.remove("alpha").unwrap();
1993        assert_eq!(store.read("alpha"), None);
1994        assert_eq!(store.list(), vec!["beta".to_string()]);
1995        store.remove("alpha").unwrap(); // removing an absent doc is Ok
1996
1997        let _ = std::fs::remove_dir_all(&dir);
1998    }
1999
2000    #[test]
2001    fn native_store_uses_the_same_read_write_api_for_app_state_and_models() {
2002        let dir = std::env::temp_dir().join(format!(
2003            "brep-app-unified-store-{}",
2004            std::process::id()
2005        ));
2006        let _ = std::fs::remove_dir_all(&dir);
2007        let store = FileModelStore::with_dir(dir.clone());
2008
2009        store.write(SETTINGS_KEY, r#"{"theme":"dark"}"#).unwrap();
2010        store.write(DOCK_LAYOUT_KEY, r#"{"tiles":[]}"#).unwrap();
2011        store.write(super::FEATURE_PALETTE_DISPLAY_KEY, "\"medium_icons\"").unwrap();
2012        store.write("part", r#"{"features":[]}"#).unwrap();
2013
2014        assert_eq!(
2015            store.read(SETTINGS_KEY).as_deref(),
2016            Some(r#"{"theme":"dark"}"#)
2017        );
2018        assert_eq!(
2019            store.read(DOCK_LAYOUT_KEY).as_deref(),
2020            Some(r#"{"tiles":[]}"#)
2021        );
2022        assert_eq!(store.list(), vec!["part".to_string()]);
2023        assert!(dir.join("settings.json").is_file());
2024        assert!(dir.join("dock_layout.json").is_file());
2025        assert_eq!(store.read(FEATURE_PALETTE_DISPLAY_KEY).as_deref(), Some("\"medium_icons\""));
2026        assert!(dir.join("feature_palette_display.json").is_file());
2027        assert!(dir.join("part.BREP.json").is_file());
2028
2029        let _ = std::fs::remove_dir_all(&dir);
2030    }
2031
2032    #[test]
2033    fn native_browser_navigates_and_saves_outside_the_model_directory() {
2034        let root = std::env::temp_dir().join(format!(
2035            "brep-app-browser-navigation-{}",
2036            std::process::id()
2037        ));
2038        let models = root.join("models");
2039        let sibling = root.join("projects");
2040        let _ = std::fs::remove_dir_all(&root);
2041        std::fs::create_dir_all(&models).unwrap();
2042        std::fs::create_dir_all(&sibling).unwrap();
2043        let store = FileModelStore::with_dir(models.clone());
2044
2045        store.browser_up().unwrap();
2046        let projects = store
2047            .browser_entries(&["BREP.json"])
2048            .into_iter()
2049            .find(|entry| entry.name == "projects")
2050            .expect("sibling directory should be visible");
2051        assert!(projects.is_dir);
2052        store.browser_enter(&projects.identity).unwrap();
2053        let identity = store
2054            .browser_write("assembly", r#"{"features":[]}"#)
2055            .unwrap();
2056
2057        assert_eq!(std::path::PathBuf::from(&identity), sibling.join("assembly.BREP.json"));
2058        assert_eq!(store.read(&identity).as_deref(), Some(r#"{"features":[]}"#));
2059        assert_eq!(store.browser_location(), sibling.display().to_string());
2060
2061        let _ = std::fs::remove_dir_all(&root);
2062    }
2063
2064    #[test]
2065    fn native_store_exposes_foreign_files_to_the_common_explorer() {
2066        let dir = std::env::temp_dir().join(format!(
2067            "brep-app-explorer-store-{}",
2068            std::process::id()
2069        ));
2070        let _ = std::fs::remove_dir_all(&dir);
2071        let store = FileModelStore::with_dir(dir.clone());
2072
2073        store.export_file_named("bracket.step", "STEP DATA").unwrap();
2074        store.export_file_named("preview.stl", "solid preview").unwrap();
2075
2076        assert_eq!(
2077            store.list_external_files(&["step", "stp"]),
2078            vec!["bracket.step".to_string()]
2079        );
2080        assert_eq!(
2081            store.read_external_file("bracket.step").as_deref(),
2082            Some(b"STEP DATA".as_slice())
2083        );
2084        assert!(dir.join("bracket.step").is_file());
2085
2086        let _ = std::fs::remove_dir_all(&dir);
2087    }
2088}
2089
2090/// The mirrored browser store, exercised WITHOUT a browser: `MirrorStore` and the
2091/// `StoreBackend` seam are platform-neutral, so a recording backend proves the
2092/// mirror semantics, the (verbatim) key scheme, the explorer's virtual tree, and
2093/// the write-behind error channel on the native test target. The IndexedDB
2094/// implementation of the same trait is what the headed `web/verify_store.mjs`
2095/// check covers.
2096#[cfg(all(test, not(target_arch = "wasm32")))]
2097mod mirror_tests {
2098    use super::mirror_store::{BackendFuture, MirrorStore, StoreBackend, UnavailableBackend};
2099    use super::{ModelStore, DOCK_LAYOUT_KEY, PINNED_KEY, SETTINGS_KEY};
2100    use std::cell::RefCell;
2101    use std::rc::Rc;
2102
2103    /// A [`StoreBackend`] that records what the mirror asked it to persist and can
2104    /// be told to fail — the same seam a future HTTP backend implements.
2105    #[derive(Default)]
2106    struct RecordingBackend {
2107        puts: RefCell<Vec<(String, String)>>,
2108        deletes: RefCell<Vec<String>>,
2109        fail_with: RefCell<Option<String>>,
2110    }
2111
2112    impl RecordingBackend {
2113        fn shared() -> Rc<Self> {
2114            Rc::new(Self::default())
2115        }
2116
2117        fn put_keys(&self) -> Vec<String> {
2118            self.puts.borrow().iter().map(|(k, _)| k.clone()).collect()
2119        }
2120
2121        fn outcome(&self) -> BackendFuture<()> {
2122            let failure = self.fail_with.borrow().clone();
2123            Box::pin(async move {
2124                match failure {
2125                    Some(message) => Err(message),
2126                    None => Ok(()),
2127                }
2128            })
2129        }
2130    }
2131
2132    impl StoreBackend for RecordingBackend {
2133        fn label(&self) -> String {
2134            "recording test backend".into()
2135        }
2136
2137        fn load_all(&self) -> BackendFuture<Vec<(String, String)>> {
2138            Box::pin(async move { Ok(Vec::new()) })
2139        }
2140
2141        fn put(&self, key: &str, value: &str) -> BackendFuture<()> {
2142            self.puts
2143                .borrow_mut()
2144                .push((key.to_string(), value.to_string()));
2145            self.outcome()
2146        }
2147
2148        fn delete(&self, key: &str) -> BackendFuture<()> {
2149            self.deletes.borrow_mut().push(key.to_string());
2150            self.outcome()
2151        }
2152    }
2153
2154    fn store(backend: &Rc<RecordingBackend>) -> MirrorStore {
2155        MirrorStore::new(backend.clone(), Vec::new(), None)
2156    }
2157
2158    #[test]
2159    fn mirror_serves_reads_synchronously_and_pushes_writes_behind() {
2160        let backend = RecordingBackend::shared();
2161        let store = store(&backend);
2162
2163        assert!(store.list().is_empty());
2164        assert_eq!(store.read("alpha"), None);
2165
2166        let doc = r#"{"features":[{"type":"P.CU"}]}"#;
2167        // `write` returns Ok off the mirror and the read is visible IMMEDIATELY —
2168        // the whole point of the mirror in front of an async backend.
2169        store.write("alpha", doc).unwrap();
2170        assert_eq!(store.read("alpha").as_deref(), Some(doc));
2171        assert_eq!(store.list(), vec!["alpha".to_string()]);
2172        // ...and the backend was asked to persist it under the verbatim key.
2173        assert_eq!(
2174            backend.puts.borrow().as_slice(),
2175            [("brep-app:model:alpha".to_string(), doc.to_string())]
2176        );
2177        // The push settled, so nothing is outstanding and nothing failed.
2178        assert_eq!(store.pending(), 0);
2179        assert!(store.take_persistence_errors().is_empty());
2180
2181        // The `.BREP.json` extension is transparent to the name, as on native.
2182        assert_eq!(store.read("alpha.BREP.json").as_deref(), Some(doc));
2183
2184        store.remove("alpha").unwrap();
2185        assert_eq!(store.read("alpha"), None);
2186        assert!(store.list().is_empty());
2187        assert_eq!(
2188            backend.deletes.borrow().as_slice(),
2189            ["brep-app:model:alpha".to_string()]
2190        );
2191    }
2192
2193    #[test]
2194    fn key_scheme_is_verbatim_from_the_local_storage_era() {
2195        assert_eq!(MirrorStore::key(SETTINGS_KEY), "brep-app:settings");
2196        assert_eq!(MirrorStore::key(DOCK_LAYOUT_KEY), "brep-app:dock_layout");
2197        assert_eq!(MirrorStore::key(PINNED_KEY), "brep-app:pinned");
2198        assert_eq!(MirrorStore::key(super::FEATURE_PALETTE_DISPLAY_KEY), "brep-app:feature_palette_display");
2199        // Documents lose the leading slash, the `/models` root and the extension.
2200        assert_eq!(MirrorStore::key("part"), "brep-app:model:part");
2201        assert_eq!(MirrorStore::key("/models/part"), "brep-app:model:part");
2202        assert_eq!(
2203            MirrorStore::key("/models/sub/part.BREP.json"),
2204            "brep-app:model:sub/part"
2205        );
2206        assert_eq!(MirrorStore::key("part.json"), "brep-app:model:part");
2207    }
2208
2209    #[test]
2210    fn reserved_application_blobs_share_the_document_api_and_never_list() {
2211        let backend = RecordingBackend::shared();
2212        let store = store(&backend);
2213
2214        store.write(SETTINGS_KEY, r#"{"theme":"dark"}"#).unwrap();
2215        store.write(DOCK_LAYOUT_KEY, r#"{"tiles":[]}"#).unwrap();
2216        store.write(super::FEATURE_PALETTE_DISPLAY_KEY, "\"medium_icons\"").unwrap();
2217        store.write(super::RECOVERY_KEY, r#"{"schema":1,"documents":[]}"#).unwrap();
2218        store.write("part", r#"{"features":[]}"#).unwrap();
2219
2220        assert_eq!(
2221            store.read(SETTINGS_KEY).as_deref(),
2222            Some(r#"{"theme":"dark"}"#)
2223        );
2224        // Only real documents are offerable to Open.
2225        assert_eq!(store.list(), vec!["part".to_string()]);
2226        assert_eq!(
2227            backend.put_keys(),
2228            vec![
2229                "brep-app:settings".to_string(),
2230                "brep-app:dock_layout".to_string(),
2231                "brep-app:feature_palette_display".to_string(),
2232                "brep-app:recovery".to_string(),
2233                "brep-app:model:part".to_string(),
2234            ]
2235        );
2236    }
2237
2238    #[test]
2239    fn hydrated_entries_are_visible_to_the_first_synchronous_read() {
2240        // What the boot hydrate hands over: a whole key space, already resolved.
2241        let store = MirrorStore::new(
2242            RecordingBackend::shared(),
2243            vec![
2244                ("brep-app:model:alpha".into(), "A".into()),
2245                ("brep-app:model:sub/beta".into(), "B".into()),
2246                ("brep-app:settings".into(), r#"{"theme":"dark"}"#.into()),
2247            ],
2248            None,
2249        );
2250        assert_eq!(
2251            store.list(),
2252            vec!["alpha".to_string(), "sub/beta".to_string()]
2253        );
2254        assert_eq!(store.read("alpha").as_deref(), Some("A"));
2255        assert_eq!(store.read("/models/sub/beta").as_deref(), Some("B"));
2256        assert_eq!(
2257            store.read(SETTINGS_KEY).as_deref(),
2258            Some(r#"{"theme":"dark"}"#)
2259        );
2260    }
2261
2262    #[test]
2263    fn explorer_walks_the_virtual_tree_the_key_prefixes_describe() {
2264        let backend = RecordingBackend::shared();
2265        let store = store(&backend);
2266        store.write("alpha", "AAAA").unwrap();
2267        store.browser_create_dir("sub").unwrap();
2268        store.write("/models/sub/beta", "BB").unwrap();
2269
2270        // The folder marker is a persisted zero-length key.
2271        assert!(backend.put_keys().contains(&"brep-app:dir:sub/".to_string()));
2272
2273        // /models lists the folder first, then the document with its byte size.
2274        let entries = store.browser_entries(&["BREP.json"]);
2275        let names: Vec<_> = entries.iter().map(|e| e.name.clone()).collect();
2276        assert_eq!(names, vec!["sub".to_string(), "alpha.BREP.json".to_string()]);
2277        assert!(entries[0].is_dir);
2278        assert_eq!(entries[1].size, Some(4));
2279        assert_eq!(entries[1].identity, "/models/alpha.BREP.json");
2280
2281        // Descend, and only the child document is in view.
2282        store.browser_enter("/models/sub").unwrap();
2283        assert_eq!(store.browser_location(), "/models/sub");
2284        let entries = store.browser_entries(&["BREP.json"]);
2285        assert_eq!(entries.len(), 1);
2286        assert_eq!(entries[0].name, "beta.BREP.json");
2287        assert_eq!(entries[0].identity, "/models/sub/beta.BREP.json");
2288
2289        // A Save into the current folder returns the identity to re-save to.
2290        let identity = store.browser_write("gamma", "G").unwrap();
2291        assert_eq!(identity, "/models/sub/gamma.BREP.json");
2292        assert_eq!(store.read(&identity).as_deref(), Some("G"));
2293
2294        // The root shows the single models mount; outside it is not navigable.
2295        store.browser_root().unwrap();
2296        assert_eq!(
2297            store
2298                .browser_entries(&["BREP.json"])
2299                .into_iter()
2300                .map(|e| e.identity)
2301                .collect::<Vec<_>>(),
2302            vec!["/models".to_string()]
2303        );
2304        assert!(store.browser_navigate("/etc").is_err());
2305    }
2306
2307    #[test]
2308    fn a_write_behind_failure_reaches_the_user_exactly_once() {
2309        let backend = RecordingBackend::shared();
2310        *backend.fail_with.borrow_mut() = Some("QuotaExceededError: out of room".into());
2311        let store = store(&backend);
2312
2313        // The synchronous contract still holds: Ok, and the doc is readable.
2314        store.write("alpha", "A").unwrap();
2315        assert_eq!(store.read("alpha").as_deref(), Some("A"));
2316
2317        // ...but the failure is queued for the UI, naming the document.
2318        let drained = store.take_persistence_errors();
2319        assert_eq!(drained.len(), 1);
2320        assert!(drained[0].contains("alpha"), "{drained:?}");
2321        assert!(drained[0].contains("QuotaExceededError"), "{drained:?}");
2322        // Drained means shown — it is not repeated.
2323        assert!(store.take_persistence_errors().is_empty());
2324
2325        // A fresh failure of the same kind IS reported again (the collapse only
2326        // suppresses an identical message the user has not seen yet).
2327        store.write("alpha", "AA").unwrap();
2328        assert_eq!(store.take_persistence_errors().len(), 1);
2329        // Everything recorded stays addressable for the verification hook.
2330        assert_eq!(store.error_history().len(), 2);
2331    }
2332
2333    #[test]
2334    fn an_unavailable_backend_is_usable_in_memory_and_loud_about_not_saving() {
2335        let backend = Rc::new(UnavailableBackend::new("IndexedDB unavailable (blocked)"));
2336        let store = MirrorStore::new(backend, Vec::new(), None);
2337
2338        assert!(store.backend_label().contains("NOT SAVING"));
2339        assert!(store.backend_label().contains("blocked"));
2340
2341        // The session still works — nothing is lost while the tab is open...
2342        store.write("alpha", "A").unwrap();
2343        assert_eq!(store.read("alpha").as_deref(), Some("A"));
2344        assert_eq!(store.len_of("alpha"), Some(1));
2345        // ...and every write says, in the user's face, that it did not persist.
2346        let drained = store.take_persistence_errors();
2347        assert_eq!(drained.len(), 1);
2348        assert!(drained[0].contains("alpha"), "{drained:?}");
2349    }
2350
2351    #[test]
2352    fn recording_a_failure_wakes_the_frame_loop() {
2353        // The browser wires this to `ctx.request_repaint()`; without it a failure
2354        // recorded from an async callback would sit unshown until stray input.
2355        let woken = Rc::new(std::cell::Cell::new(0usize));
2356        let wake = {
2357            let woken = woken.clone();
2358            Rc::new(move || woken.set(woken.get() + 1)) as Rc<dyn Fn()>
2359        };
2360        let backend = RecordingBackend::shared();
2361        *backend.fail_with.borrow_mut() = Some("boom".into());
2362        let store = MirrorStore::new(backend, Vec::new(), Some(wake));
2363
2364        store.write("alpha", "A").unwrap();
2365        assert_eq!(woken.get(), 1);
2366    }
2367}