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.
262/// A native file store rooted at `dir` — the seam an automation host uses to
263/// keep a session's settings, autosave and recovery blob out of the user's
264/// own config directory (spec §8).
265#[cfg(not(target_arch = "wasm32"))]
266pub fn native_store_at(dir: std::path::PathBuf) -> Box<dyn ModelStore> {
267    Box::new(native_model::FileModelStore::with_dir(dir))
268}
269
270pub fn default_model_store() -> Box<dyn ModelStore> {
271    #[cfg(not(target_arch = "wasm32"))]
272    {
273        Box::new(native_model::FileModelStore::new())
274    }
275    #[cfg(target_arch = "wasm32")]
276    {
277        // Already built and HYDRATED by [`hydrate_web_store`] inside the async
278        // wasm entry point, so this is a hand-off, not a construction.
279        web_model::take_boot_store()
280    }
281}
282
283/// wasm: bring up browser persistence and pull the whole key space into memory.
284/// MUST be awaited before `eframe::WebRunner::start`, because everything
285/// downstream of it — [`default_model_store`], every `read` in `BrepApp::new` and
286/// in the frame loop — is synchronous and assumes a complete mirror.
287#[cfg(target_arch = "wasm32")]
288pub async fn hydrate_web_store() {
289    web_model::hydrate().await;
290}
291
292/// wasm: wake the reactive frame loop when an async file upload completes (see
293/// [`web_model::set_repaint_ctx`]). Re-exported here so the app shell reaches it
294/// as `store::set_repaint_ctx` without knowing the platform module.
295#[cfg(target_arch = "wasm32")]
296pub use web_model::set_repaint_ctx;
297
298/// The document extension for a model recipe (`<name>.BREP.json`).
299pub const MODEL_EXT: &str = ".BREP.json";
300
301// BREP private tests: 70dbc0a2bff45147
302
303// BREP private tests: cca838fb46080473
304
305// BREP private tests: cb54805f276f1b7e
306
307// BREP private tests: 3e292cde0ccd1207
308
309/// Strip the model extension (and any directory) from a filename to get the
310/// bare display name — shared by both platform impls (and the file panel, which
311/// shows the bare name while keeping the raw identity for re-saves).
312pub(crate) fn model_display_name(file_name: &str) -> String {
313    let base = file_name
314        .rsplit(['/', '\\'])
315        .next()
316        .unwrap_or(file_name);
317    base.strip_suffix(MODEL_EXT)
318        .or_else(|| base.strip_suffix(".json"))
319        .unwrap_or(base)
320        .to_string()
321}
322
323// --- Desktop: application models/files directory -------------------------------
324#[cfg(not(target_arch = "wasm32"))]
325mod native_model {
326    use super::{
327        model_display_name, BrowserEntry, BrowserPlace, ModelStore, PlaceKind, DOCK_LAYOUT_KEY,
328        FEATURE_PALETTE_DISPLAY_KEY, MODEL_EXT, PINNED_KEY, RECOVERY_KEY, SESSION_KEY, SETTINGS_KEY,
329    };
330    use std::cell::RefCell;
331    use std::path::{Path, PathBuf};
332
333    /// Persist models as `<config>/brep-app/models/<name>.BREP.json` and reserved
334    /// application state as files under `<config>/brep-app`. Enumerable (for the
335    /// Open list) and unit-testable without a display.
336    ///
337    /// Open, Save As, import, and export are all driven by the application's
338    /// common egui explorer; no OS-native dialog is involved.
339    pub struct FileModelStore {
340        app_dir: PathBuf,
341        dir: PathBuf,
342        browser_dir: RefCell<PathBuf>,
343    }
344
345    impl FileModelStore {
346        pub fn new() -> Self {
347            let base = std::env::var_os("XDG_CONFIG_HOME")
348                .map(PathBuf::from)
349                .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
350                .unwrap_or_else(|| PathBuf::from("."));
351            let app_dir = base.join("brep-app");
352            Self {
353                dir: app_dir.join("models"),
354                browser_dir: RefCell::new(app_dir.join("models")),
355                app_dir,
356            }
357        }
358
359        /// A store rooted at an explicit directory — used by the round-trip test.
360        /// The tests stay headless and exercise the same egui explorer path.
361        #[cfg_attr(not(test), allow(dead_code))]
362        pub fn with_dir(dir: PathBuf) -> Self {
363            Self {
364                app_dir: dir.clone(),
365                browser_dir: RefCell::new(dir.clone()),
366                dir,
367            }
368        }
369
370        /// Resolve a name to a path. Explicit paths remain supported for existing
371        /// callers; a bare name maps to `<dir>/<sanitized>.BREP.json`.
372        fn resolve(&self, name: &str) -> PathBuf {
373            match name {
374                SETTINGS_KEY => return self.app_dir.join("settings.json"),
375                FEATURE_PALETTE_DISPLAY_KEY => return self.app_dir.join("feature_palette_display.json"),
376                DOCK_LAYOUT_KEY => return self.app_dir.join("dock_layout.json"),
377                SESSION_KEY => return self.app_dir.join("session.json"),
378                PINNED_KEY => return self.app_dir.join("pinned.json"),
379                RECOVERY_KEY => return self.app_dir.join("recovery.json"),
380                _ => {}
381            }
382            if name.contains('/') || name.contains('\\') {
383                return PathBuf::from(name);
384            }
385            let safe: String = name
386                .strip_suffix(MODEL_EXT)
387                .unwrap_or(name)
388                .chars()
389                .map(|c| {
390                    if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
391                        c
392                    } else {
393                        '_'
394                    }
395                })
396                .collect();
397            self.dir.join(format!("{safe}{MODEL_EXT}"))
398        }
399
400        fn home_dir() -> PathBuf {
401            std::env::var_os("HOME")
402                .map(PathBuf::from)
403                .or_else(|| std::env::current_dir().ok())
404                .unwrap_or_else(|| PathBuf::from("."))
405        }
406
407        fn matches_extension(path: &Path, extensions: &[&str]) -> bool {
408            extensions.is_empty()
409                || extensions.iter().any(|extension| {
410                    let wanted = extension.trim_start_matches('.').to_ascii_lowercase();
411                    let name = path
412                        .file_name()
413                        .unwrap_or_default()
414                        .to_string_lossy()
415                        .to_ascii_lowercase();
416                    name.ends_with(&format!(".{wanted}"))
417                })
418        }
419    }
420
421    impl ModelStore for FileModelStore {
422        fn backend_label(&self) -> String {
423            format!("filesystem: {}", self.dir.display())
424        }
425
426        fn list(&self) -> Vec<String> {
427            let mut names: Vec<String> = std::fs::read_dir(&self.dir)
428                .into_iter()
429                .flatten()
430                .flatten()
431                .filter_map(|entry| {
432                    let name = entry.file_name().to_string_lossy().into_owned();
433                    name.ends_with(MODEL_EXT).then(|| model_display_name(&name))
434                })
435                .collect();
436            names.sort();
437            names
438        }
439
440        fn read(&self, name: &str) -> Option<String> {
441            std::fs::read_to_string(self.resolve(name)).ok()
442        }
443
444        fn write(&self, name: &str, contents: &str) -> Result<(), String> {
445            let path = self.resolve(name);
446            if let Some(parent) = path.parent() {
447                std::fs::create_dir_all(parent).map_err(|e| format!("create models dir: {e}"))?;
448            }
449            std::fs::write(&path, contents).map_err(|e| format!("write {}: {e}", path.display()))
450        }
451
452        fn remove(&self, name: &str) -> Result<(), String> {
453            match std::fs::remove_file(self.resolve(name)) {
454                Ok(()) => Ok(()),
455                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
456                Err(e) => Err(format!("remove: {e}")),
457            }
458        }
459
460        fn browser_location(&self) -> String {
461            self.browser_dir.borrow().display().to_string()
462        }
463
464        fn browser_entries(&self, extensions: &[&str]) -> Vec<BrowserEntry> {
465            let mut entries: Vec<BrowserEntry> = std::fs::read_dir(&*self.browser_dir.borrow())
466                .into_iter()
467                .flatten()
468                .flatten()
469                .filter_map(|entry| {
470                    let path = entry.path();
471                    let is_dir = path.is_dir();
472                    if !(is_dir || (path.is_file() && Self::matches_extension(&path, extensions))) {
473                        return None;
474                    }
475                    let meta = entry.metadata().ok();
476                    let size = meta.as_ref().filter(|m| m.is_file()).map(|m| m.len());
477                    let modified = meta
478                        .as_ref()
479                        .and_then(|m| m.modified().ok())
480                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
481                        .map(|d| d.as_secs_f64());
482                    Some(BrowserEntry {
483                        name: entry.file_name().to_string_lossy().into_owned(),
484                        identity: path.to_string_lossy().into_owned(),
485                        is_dir,
486                        size,
487                        modified,
488                    })
489                })
490                .collect();
491            entries.sort_by(|a, b| {
492                b.is_dir
493                    .cmp(&a.is_dir)
494                    .then_with(|| a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase()))
495            });
496            entries
497        }
498
499        fn browser_enter(&self, identity: &str) -> Result<(), String> {
500            let path = PathBuf::from(identity);
501            if !path.is_dir() {
502                return Err(format!("not a directory: {}", path.display()));
503            }
504            *self.browser_dir.borrow_mut() = path;
505            Ok(())
506        }
507
508        fn browser_up(&self) -> Result<(), String> {
509            let parent = self.browser_dir.borrow().parent().map(Path::to_path_buf);
510            if let Some(parent) = parent {
511                *self.browser_dir.borrow_mut() = parent;
512            }
513            Ok(())
514        }
515
516        fn browser_home(&self) -> Result<(), String> {
517            *self.browser_dir.borrow_mut() = Self::home_dir();
518            Ok(())
519        }
520
521        fn browser_root(&self) -> Result<(), String> {
522            let current = self.browser_dir.borrow().clone();
523            let root = current
524                .ancestors()
525                .last()
526                .map(Path::to_path_buf)
527                .unwrap_or_else(|| PathBuf::from(std::path::MAIN_SEPARATOR.to_string()));
528            *self.browser_dir.borrow_mut() = root;
529            Ok(())
530        }
531
532        fn browser_navigate(&self, location: &str) -> Result<(), String> {
533            let path = PathBuf::from(location);
534            if path.is_dir() {
535                *self.browser_dir.borrow_mut() = path;
536                Ok(())
537            } else {
538                Err(format!("not a directory: {location}"))
539            }
540        }
541
542        fn browser_places(&self) -> Vec<BrowserPlace> {
543            let home = Self::home_dir();
544            let mut places = vec![BrowserPlace {
545                label: "Home".into(),
546                location: home.display().to_string(),
547                kind: PlaceKind::Home,
548            }];
549            for (label, sub, kind) in [
550                ("Documents", "Documents", PlaceKind::Documents),
551                ("Downloads", "Downloads", PlaceKind::Downloads),
552            ] {
553                let path = home.join(sub);
554                if path.is_dir() {
555                    places.push(BrowserPlace {
556                        label: label.into(),
557                        location: path.display().to_string(),
558                        kind,
559                    });
560                }
561            }
562            places.push(BrowserPlace {
563                label: "Models".into(),
564                location: self.dir.display().to_string(),
565                kind: PlaceKind::Models,
566            });
567            places.push(BrowserPlace {
568                label: "/".into(),
569                location: "/".into(),
570                kind: PlaceKind::Root,
571            });
572            places
573        }
574
575        fn browser_create_dir(&self, name: &str) -> Result<(), String> {
576            let name = PathBuf::from(name);
577            if name.components().count() != 1 {
578                return Err("folder name must be one path component".into());
579            }
580            let path = self.browser_dir.borrow().join(name);
581            std::fs::create_dir(&path)
582                .map_err(|e| format!("create folder {}: {e}", path.display()))
583        }
584
585        fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
586            let mut file_name = PathBuf::from(name)
587                .file_name()
588                .ok_or("invalid file name")?
589                .to_os_string();
590            if !name.to_ascii_lowercase().ends_with(".json") {
591                file_name.push(MODEL_EXT);
592            }
593            let path = self.browser_dir.borrow().join(file_name);
594            std::fs::write(&path, contents)
595                .map_err(|e| format!("write {}: {e}", path.display()))?;
596            Ok(path.to_string_lossy().into_owned())
597        }
598
599        fn list_external_files(&self, extensions: &[&str]) -> Vec<String> {
600            let wanted: Vec<String> = extensions
601                .iter()
602                .map(|extension| extension.trim_start_matches('.').to_ascii_lowercase())
603                .collect();
604            let mut names: Vec<String> = std::fs::read_dir(&self.dir)
605                .into_iter()
606                .flatten()
607                .flatten()
608                .filter_map(|entry| {
609                    let path = entry.path();
610                    path.is_file().then_some(path)
611                })
612                .filter_map(|path| {
613                    let extension = path.extension()?.to_string_lossy().to_ascii_lowercase();
614                    wanted.contains(&extension).then(|| {
615                        path.file_name()
616                            .unwrap_or_default()
617                            .to_string_lossy()
618                            .into_owned()
619                    })
620                })
621                .collect();
622            names.sort();
623            names
624        }
625
626        fn read_external_file(&self, name: &str) -> Option<Vec<u8>> {
627            let path = PathBuf::from(name);
628            let path = if path.is_absolute() || path.components().count() > 1 {
629                path
630            } else {
631                self.browser_dir.borrow().join(path)
632            };
633            std::fs::read(path).ok()
634        }
635
636        fn export_file_named(&self, file_name: &str, contents: &str) -> Result<(), String> {
637            std::fs::create_dir_all(&self.dir)
638                .map_err(|e| format!("create models dir: {e}"))?;
639            let name = PathBuf::from(file_name)
640                .file_name()
641                .ok_or("invalid export file name")?
642                .to_owned();
643            let path = self.dir.join(name);
644            std::fs::write(&path, contents)
645                .map_err(|e| format!("write {}: {e}", path.display()))
646        }
647    }
648}
649
650// --- The mirrored store: a synchronous facade over an ASYNC backend ------------
651//
652// The browser has no synchronous storage big enough for this application. The
653// measured numbers that forced this module into existence: one imported STEP
654// part serialises to a ~35 MB native BREP payload, and an imported assembly
655// document runs 1.0x-12.8x its source STEP text — against `localStorage`'s
656// ~5-10 MB per-origin quota. Every backend with room (IndexedDB today, a remote
657// server tomorrow) is ASYNC, while [`ModelStore`] is synchronous and the egui
658// frame loop that calls it is immediate-mode.
659//
660// The reconciliation is this module: an in-memory `BTreeMap` MIRROR of the whole
661// key space, hydrated ONCE from the backend inside the already-async wasm entry
662// point (`lib.rs::start`) BEFORE the app is constructed. Because hydration
663// completes before the first `read` can happen, a synchronous read never has a
664// "not loaded yet" state to represent, and none of the ~55 call sites change.
665// Writes mutate the mirror synchronously (so the very next `read` sees them) and
666// are pushed to the backend WRITE-BEHIND; a push that fails afterwards surfaces
667// through [`ModelStore::take_persistence_errors`].
668#[cfg(any(target_arch = "wasm32", test))]
669pub(crate) mod mirror_store {
670    use super::{
671        model_display_name, BrowserEntry, BrowserPlace, ModelStore, PlaceKind, DOCK_LAYOUT_KEY,
672        FEATURE_PALETTE_DISPLAY_KEY, MODEL_EXT, PINNED_KEY, RECOVERY_KEY, SESSION_KEY, SETTINGS_KEY,
673    };
674    use std::cell::{Cell, RefCell};
675    use std::collections::BTreeMap;
676    use std::future::Future;
677    use std::pin::Pin;
678    use std::rc::Rc;
679
680    /// The persisted key scheme, VERBATIM from the `localStorage` era so an
681    /// existing origin's keys keep their meaning under any backend: model
682    /// documents live at `brep-app:model:<relative name>`, explorer folders at
683    /// `brep-app:dir:<relative path>/`, and the three reserved application blobs
684    /// at `brep-app:settings` / `:dock_layout` / `:pinned` (see [`MirrorStore::key`]).
685    pub(crate) const PREFIX: &str = "brep-app:model:";
686    pub(crate) const DIR_PREFIX: &str = "brep-app:dir:";
687
688    /// How many distinct persistence failures the error log keeps before the
689    /// oldest is dropped. The log is a user-facing notice channel, not telemetry.
690    const MAX_ERRORS: usize = 64;
691
692    /// The future a [`StoreBackend`] hands back. Boxed (not `async fn` in the
693    /// trait) because the store is used as `dyn StoreBackend`, and deliberately
694    /// NOT `Send`: every host is single-threaded (the browser main thread; the
695    /// native test executor below).
696    pub(crate) type BackendFuture<T> = Pin<Box<dyn Future<Output = Result<T, String>>>>;
697
698    /// Where a [`MirrorStore`]'s bytes actually live — the swappable persistence
699    /// seam.
700    ///
701    /// The contract is deliberately narrow and platform-free: **string keys,
702    /// string values, `String` errors, async everywhere**. No `JsValue`, no
703    /// `web_sys` type, no IndexedDB concept crosses it, and the mirror above it
704    /// knows nothing about how a key is stored. [`IdbBackend`](super::web_model)
705    /// is the only implementation today; the intended SECOND one is a remote
706    /// HTTP/AJAX backend that pushes and pulls the same key space to a central
707    /// server, and it must be able to land without touching this module.
708    ///
709    /// Two obligations an implementation carries:
710    ///
711    /// * **Per-key FIFO.** Two `put`s of the same key must land in call order,
712    ///   whatever order their futures are polled in — the mirror issues them from
713    ///   a synchronous `write` and never sequences them itself. IndexedDB gets
714    ///   this for free (a `readwrite` transaction commits in creation order, and
715    ///   [`IdbBackend`](super::web_model) creates the transaction and issues the
716    ///   request synchronously inside `put`). An HTTP backend has no such
717    ///   guarantee and will need its own per-key send queue.
718    /// * **Whole-key-space `load_all`.** Hydration is all-or-nothing today.
719    ///
720    /// ## The known ceiling (stated, not solved)
721    ///
722    /// A full hydrate holds EVERY saved document resident for the session. That
723    /// is a non-issue for an origin migrating off `localStorage` (its whole
724    /// corpus fit in ~5 MB), but a user with ten 30 MB assemblies pays ~300 MB at
725    /// boot, and a remote backend makes it untenable outright — you cannot pull a
726    /// server-side library of hundreds of parts at start-up. The escape hatch,
727    /// when it is needed: make the mirror's value a `{ Resident(String), OnDisk {
728    /// bytes: u64 } }` enum so `list` / `browser_entries` / sizes still answer
729    /// from metadata alone, add a prefetch that resolves `OnDisk` entries in the
730    /// background, and give `StoreBackend` a `load_index` + `get(key)` pair
731    /// beside `load_all`. All of that sits behind THIS trait and behind
732    /// [`ModelStore`], so no call site moves. **Do not build it until a real
733    /// corpus needs it.**
734    pub(crate) trait StoreBackend {
735        /// A short human label for the storage panel header (see
736        /// [`ModelStore::backend_label`]).
737        fn label(&self) -> String;
738
739        /// Pull the entire key space. Called ONCE, during the async boot, before
740        /// any [`MirrorStore`] exists.
741        fn load_all(&self) -> BackendFuture<Vec<(String, String)>>;
742
743        /// Persist `value` under `key`, creating or overwriting.
744        fn put(&self, key: &str, value: &str) -> BackendFuture<()>;
745
746        /// Delete `key`. Succeeding on an absent key is correct.
747        fn delete(&self, key: &str) -> BackendFuture<()>;
748    }
749
750    /// The backend installed when persistence could NOT be brought up (IndexedDB
751    /// blocked in a private window, storage denied, the boot hydrate never ran).
752    ///
753    /// There is deliberately no quiet fallback to `localStorage`: a 5 MB backend
754    /// silently standing in for a hundreds-of-megabytes one is the failure mode
755    /// this whole change exists to end. Instead the session stays fully usable
756    /// IN MEMORY — the mirror still holds everything written this session — while
757    /// every write reports, loudly and repeatedly, that nothing is being saved.
758    pub(crate) struct UnavailableBackend {
759        reason: String,
760    }
761
762    impl UnavailableBackend {
763        pub(crate) fn new(reason: impl Into<String>) -> Self {
764            Self {
765                reason: reason.into(),
766            }
767        }
768    }
769
770    impl StoreBackend for UnavailableBackend {
771        fn label(&self) -> String {
772            format!("NOT SAVING — {} · use Download to keep your work", self.reason)
773        }
774
775        fn load_all(&self) -> BackendFuture<Vec<(String, String)>> {
776            let reason = self.reason.clone();
777            Box::pin(async move { Err(reason) })
778        }
779
780        fn put(&self, _key: &str, _value: &str) -> BackendFuture<()> {
781            let reason = self.reason.clone();
782            Box::pin(async move { Err(reason) })
783        }
784
785        fn delete(&self, _key: &str) -> BackendFuture<()> {
786            let reason = self.reason.clone();
787            Box::pin(async move { Err(reason) })
788        }
789    }
790
791    /// Persistence failures that happened AFTER the synchronous `write` returned
792    /// `Ok` — the price of write-behind. Nothing is lost mid-session (the mirror
793    /// holds it), but the user MUST learn that it did not persist, so the log is
794    /// drained into the toast overlay every frame.
795    ///
796    /// The cursor (rather than a `Vec::drain`) keeps the full history addressable
797    /// for the `__brepStoreErrors` verification hook while still handing the UI
798    /// each message exactly once.
799    pub(crate) struct ErrorLog {
800        log: RefCell<Vec<String>>,
801        drained: Cell<usize>,
802        /// Wakes the reactive frame loop so a failure recorded from an async
803        /// callback is toasted THIS frame instead of waiting for stray input.
804        wake: Option<Rc<dyn Fn()>>,
805    }
806
807    impl ErrorLog {
808        fn new(wake: Option<Rc<dyn Fn()>>) -> Self {
809            Self {
810                log: RefCell::new(Vec::new()),
811                drained: Cell::new(0),
812                wake,
813            }
814        }
815
816        /// Record one failure. An identical message that is still UNDRAINED is
817        /// collapsed (a burst of failing writes shows one toast, not six); once
818        /// the UI has shown it, the same message can be recorded again — every
819        /// failed save is reported.
820        pub(crate) fn record(&self, message: String) {
821            {
822                let mut log = self.log.borrow_mut();
823                let undrained = log.len() > self.drained.get();
824                if undrained && log.last().map(|last| *last == message).unwrap_or(false) {
825                    return;
826                }
827                log.push(message);
828                if log.len() > MAX_ERRORS {
829                    log.remove(0);
830                    self.drained.set(self.drained.get().saturating_sub(1));
831                }
832            }
833            if let Some(wake) = &self.wake {
834                wake();
835            }
836        }
837
838        /// Messages recorded since the last drain (what the UI has not shown yet).
839        fn drain(&self) -> Vec<String> {
840            let log = self.log.borrow();
841            let from = self.drained.get().min(log.len());
842            self.drained.set(log.len());
843            log[from..].to_vec()
844        }
845
846        /// Every message recorded this session (verification hook).
847        fn all(&self) -> Vec<String> {
848            self.log.borrow().clone()
849        }
850    }
851
852    /// Drive one write-behind push to completion.
853    ///
854    /// wasm: hand it to the browser's microtask queue, which is the whole point —
855    /// the caller's `write` already returned. Native: the mirror only exists under
856    /// `cfg(test)`, where the test backend's futures are already resolved, so a
857    /// single poll with a no-op waker finishes them. Keeping the shape identical
858    /// means the native tests exercise the REAL write-behind path (spawn, await,
859    /// record the error) rather than a synchronous stand-in.
860    #[cfg(target_arch = "wasm32")]
861    fn spawn(task: impl Future<Output = ()> + 'static) {
862        wasm_bindgen_futures::spawn_local(task);
863    }
864
865    #[cfg(not(target_arch = "wasm32"))]
866    fn spawn(task: impl Future<Output = ()> + 'static) {
867        let mut task = Box::pin(task);
868        let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
869        let _ = task.as_mut().poll(&mut cx);
870    }
871
872    /// A synchronous [`ModelStore`] over an asynchronous [`StoreBackend`]: the
873    /// hydrated mirror plus write-behind. Cheap to clone — every field is shared,
874    /// so a clone is another handle on the SAME session state (used by the
875    /// verification hooks).
876    #[derive(Clone)]
877    pub(crate) struct MirrorStore {
878        /// The whole key space, keyed EXACTLY as the backend keys it.
879        entries: Rc<RefCell<BTreeMap<String, String>>>,
880        /// The explorer's current virtual directory (`/` or `/models/...`).
881        browser_dir: Rc<RefCell<String>>,
882        backend: Rc<dyn StoreBackend>,
883        errors: Rc<ErrorLog>,
884        /// Pushes issued but not yet settled. Zero means everything written so
885        /// far is durable — which is what makes a "save, reload, still there"
886        /// check non-racy (see the `__brepStorePending` hook).
887        pending: Rc<Cell<usize>>,
888    }
889
890    impl MirrorStore {
891        /// Build the session store from an already-hydrated key space.
892        /// `wake` (`None` off-browser) is called when a write-behind failure is
893        /// recorded, to repaint the reactive frame loop.
894        pub(crate) fn new(
895            backend: Rc<dyn StoreBackend>,
896            entries: Vec<(String, String)>,
897            wake: Option<Rc<dyn Fn()>>,
898        ) -> Self {
899            Self {
900                entries: Rc::new(RefCell::new(entries.into_iter().collect())),
901                browser_dir: Rc::new(RefCell::new("/models".into())),
902                backend,
903                errors: Rc::new(ErrorLog::new(wake)),
904                pending: Rc::new(Cell::new(0)),
905            }
906        }
907
908        /// The persisted key for a document/reserved name. UNCHANGED from the
909        /// `localStorage` implementation this replaced, so an existing origin's
910        /// data keeps its identity.
911        pub(crate) fn key(name: &str) -> String {
912            match name {
913                SETTINGS_KEY => "brep-app:settings".into(),
914                FEATURE_PALETTE_DISPLAY_KEY => "brep-app:feature_palette_display".into(),
915                DOCK_LAYOUT_KEY => "brep-app:dock_layout".into(),
916                SESSION_KEY => "brep-app:session".into(),
917                PINNED_KEY => "brep-app:pinned".into(),
918                RECOVERY_KEY => "brep-app:recovery".into(),
919                _ => format!("{PREFIX}{}", Self::model_relative(name)),
920            }
921        }
922
923        /// A document name reduced to its store-relative form: no leading `/`, no
924        /// `/models` root, no model extension.
925        fn model_relative(name: &str) -> String {
926            let name = name
927                .trim_start_matches('/')
928                .strip_prefix("models/")
929                .unwrap_or_else(|| name.trim_start_matches('/'));
930            name.strip_suffix(MODEL_EXT)
931                .or_else(|| name.strip_suffix(".json"))
932                .unwrap_or(name)
933                .trim_matches('/')
934                .to_string()
935        }
936
937        fn virtual_model_path(relative: &str) -> String {
938            format!("/models/{}{MODEL_EXT}", relative.trim_matches('/'))
939        }
940
941        fn child_path(parent: &str, child: &str) -> String {
942            if parent == "/" {
943                format!("/{child}")
944            } else {
945                format!("{}/{child}", parent.trim_end_matches('/'))
946            }
947        }
948
949        /// Hand one backend push to the executor, counting it in `pending` and
950        /// routing its eventual failure to the error log.
951        fn push(&self, name: &str, request: BackendFuture<()>) {
952            self.pending.set(self.pending.get() + 1);
953            let pending = self.pending.clone();
954            let errors = self.errors.clone();
955            let name = name.to_string();
956            spawn(async move {
957                let outcome = request.await;
958                pending.set(pending.get().saturating_sub(1));
959                if let Err(message) = outcome {
960                    errors.record(format!("'{name}' was NOT saved: {message}"));
961                }
962            });
963        }
964
965        /// Seed the log with a boot-time failure so the very first frame toasts it.
966        pub(crate) fn report(&self, message: String) {
967            self.errors.record(message);
968        }
969
970        /// Backend pushes issued but not yet settled (verification hook).
971        pub(crate) fn pending(&self) -> usize {
972            self.pending.get()
973        }
974
975        /// Every persistence failure this session (verification hook).
976        pub(crate) fn error_history(&self) -> Vec<String> {
977            self.errors.all()
978        }
979
980        /// Byte length of a stored document, or `None` if absent (verification
981        /// hook — a multi-megabyte payload is not worth marshalling into JS just
982        /// to measure it).
983        pub(crate) fn len_of(&self, name: &str) -> Option<usize> {
984            self.entries.borrow().get(&Self::key(name)).map(|v| v.len())
985        }
986    }
987
988    impl ModelStore for MirrorStore {
989        fn backend_label(&self) -> String {
990            self.backend.label()
991        }
992
993        fn list(&self) -> Vec<String> {
994            // `BTreeMap` iterates in key order, so the names come out sorted.
995            self.entries
996                .borrow()
997                .keys()
998                .filter_map(|key| key.strip_prefix(PREFIX).map(str::to_string))
999                .collect()
1000        }
1001
1002        fn read(&self, name: &str) -> Option<String> {
1003            self.entries.borrow().get(&Self::key(name)).cloned()
1004        }
1005
1006        fn write(&self, name: &str, contents: &str) -> Result<(), String> {
1007            let key = Self::key(name);
1008            self.entries
1009                .borrow_mut()
1010                .insert(key.clone(), contents.to_string());
1011            // The mirror is authoritative for this session, so `Ok` is honest:
1012            // every subsequent read sees the new bytes. Durability is the
1013            // backend's job and its failure arrives via `take_persistence_errors`.
1014            self.push(name, self.backend.put(&key, contents));
1015            Ok(())
1016        }
1017
1018        fn remove(&self, name: &str) -> Result<(), String> {
1019            let key = Self::key(name);
1020            self.entries.borrow_mut().remove(&key);
1021            self.push(name, self.backend.delete(&key));
1022            Ok(())
1023        }
1024
1025        fn take_persistence_errors(&self) -> Vec<String> {
1026            self.errors.drain()
1027        }
1028
1029        fn browser_location(&self) -> String {
1030            // The RAW navigable path (breadcrumb / back-forward / path-edit rely on
1031            // this being feed-able straight back to `browser_navigate`); the human
1032            // "virtual filesystem" context lives in `backend_label`.
1033            self.browser_dir.borrow().clone()
1034        }
1035
1036        fn browser_entries(&self, extensions: &[&str]) -> Vec<BrowserEntry> {
1037            let current = self.browser_dir.borrow().clone();
1038            if current == "/" {
1039                return vec![BrowserEntry {
1040                    name: "models".into(),
1041                    identity: "/models".into(),
1042                    is_dir: true,
1043                    size: None,
1044                    modified: None,
1045                }];
1046            }
1047            let relative_dir = current
1048                .strip_prefix("/models")
1049                .unwrap_or("")
1050                .trim_matches('/');
1051            let prefix = if relative_dir.is_empty() {
1052                String::new()
1053            } else {
1054                format!("{relative_dir}/")
1055            };
1056            let wanted: Vec<String> = extensions
1057                .iter()
1058                .map(|extension| extension.trim_start_matches('.').to_ascii_lowercase())
1059                .collect();
1060            let mut entries: BTreeMap<String, BrowserEntry> = BTreeMap::new();
1061            for (key, value) in self.entries.borrow().iter() {
1062                let (item, is_model) = if let Some(item) = key.strip_prefix(PREFIX) {
1063                    (item, true)
1064                } else if let Some(item) = key.strip_prefix(DIR_PREFIX) {
1065                    (item.trim_end_matches('/'), false)
1066                } else {
1067                    continue;
1068                };
1069                let Some(rest) = item.strip_prefix(&prefix) else {
1070                    continue;
1071                };
1072                if rest.is_empty() {
1073                    continue;
1074                }
1075                if let Some((child, _)) = rest.split_once('/') {
1076                    entries
1077                        .entry(child.to_string())
1078                        .or_insert_with(|| BrowserEntry {
1079                            name: child.to_string(),
1080                            identity: Self::child_path(&current, child),
1081                            is_dir: true,
1082                            size: None,
1083                            modified: None,
1084                        });
1085                } else if is_model {
1086                    let name = format!("{rest}{MODEL_EXT}");
1087                    let lower = name.to_ascii_lowercase();
1088                    if wanted.is_empty()
1089                        || wanted
1090                            .iter()
1091                            .any(|extension| lower.ends_with(&format!(".{extension}")))
1092                    {
1093                        // Size = the mirrored JSON's byte length. `modified` stays
1094                        // None: the key space carries values, not timestamps.
1095                        entries.insert(
1096                            name.clone(),
1097                            BrowserEntry {
1098                                name,
1099                                identity: Self::virtual_model_path(item),
1100                                is_dir: false,
1101                                size: Some(value.len() as u64),
1102                                modified: None,
1103                            },
1104                        );
1105                    }
1106                } else {
1107                    entries
1108                        .entry(rest.to_string())
1109                        .or_insert_with(|| BrowserEntry {
1110                            name: rest.to_string(),
1111                            identity: Self::child_path(&current, rest),
1112                            is_dir: true,
1113                            size: None,
1114                            modified: None,
1115                        });
1116                }
1117            }
1118            let mut entries: Vec<_> = entries.into_values().collect();
1119            entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name)));
1120            entries
1121        }
1122
1123        fn browser_enter(&self, identity: &str) -> Result<(), String> {
1124            if identity == "/models" || identity.starts_with("/models/") {
1125                *self.browser_dir.borrow_mut() = identity.trim_end_matches('/').to_string();
1126                Ok(())
1127            } else {
1128                Err("the browser virtual filesystem is rooted at /models".into())
1129            }
1130        }
1131
1132        fn browser_up(&self) -> Result<(), String> {
1133            let current = self.browser_dir.borrow().clone();
1134            if current == "/" {
1135                return Ok(());
1136            }
1137            let parent = current
1138                .rsplit_once('/')
1139                .map(|(parent, _)| parent)
1140                .unwrap_or("");
1141            *self.browser_dir.borrow_mut() = if parent.is_empty() {
1142                "/".into()
1143            } else {
1144                parent.into()
1145            };
1146            Ok(())
1147        }
1148
1149        fn browser_home(&self) -> Result<(), String> {
1150            *self.browser_dir.borrow_mut() = "/models".into();
1151            Ok(())
1152        }
1153
1154        fn browser_root(&self) -> Result<(), String> {
1155            *self.browser_dir.borrow_mut() = "/".into();
1156            Ok(())
1157        }
1158
1159        fn browser_navigate(&self, location: &str) -> Result<(), String> {
1160            let loc = location.trim_end_matches('/');
1161            let loc = if loc.is_empty() { "/" } else { loc };
1162            if loc == "/" || loc == "/models" || loc.starts_with("/models/") {
1163                *self.browser_dir.borrow_mut() = loc.to_string();
1164                Ok(())
1165            } else {
1166                Err("the browser virtual filesystem is rooted at /models".into())
1167            }
1168        }
1169
1170        fn browser_places(&self) -> Vec<BrowserPlace> {
1171            vec![
1172                BrowserPlace {
1173                    label: "Models".into(),
1174                    location: "/models".into(),
1175                    kind: PlaceKind::Models,
1176                },
1177                BrowserPlace {
1178                    label: "/".into(),
1179                    location: "/".into(),
1180                    kind: PlaceKind::Root,
1181                },
1182            ]
1183        }
1184
1185        fn browser_create_dir(&self, name: &str) -> Result<(), String> {
1186            if name.is_empty()
1187                || name == "."
1188                || name == ".."
1189                || name.contains('/')
1190                || name.contains('\\')
1191            {
1192                return Err("folder name must be one path component".into());
1193            }
1194            let current = self.browser_dir.borrow().clone();
1195            if !current.starts_with("/models") {
1196                return Err("folders can only be created under /models".into());
1197            }
1198            let relative = Self::child_path(&current, name)
1199                .trim_start_matches("/models/")
1200                .to_string();
1201            // A folder is a zero-length marker key; the explorer derives the tree
1202            // from the key prefixes alone.
1203            let key = format!("{DIR_PREFIX}{relative}/");
1204            self.entries.borrow_mut().insert(key.clone(), String::new());
1205            self.push(name, self.backend.put(&key, ""));
1206            Ok(())
1207        }
1208
1209        fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
1210            let file = model_display_name(name);
1211            if file.is_empty() {
1212                return Err("invalid file name".into());
1213            }
1214            let current = self.browser_dir.borrow().clone();
1215            if !current.starts_with("/models") {
1216                return Err("select a folder under /models".into());
1217            }
1218            let identity = format!("{}{MODEL_EXT}", Self::child_path(&current, &file));
1219            self.write(&identity, contents)?;
1220            Ok(identity)
1221        }
1222    }
1223}
1224
1225// --- Browser: IndexedDB documents + download / upload interchange --------------
1226#[cfg(target_arch = "wasm32")]
1227mod web_model {
1228    use super::mirror_store::{BackendFuture, MirrorStore, StoreBackend, UnavailableBackend};
1229    use super::{
1230        model_display_name, BrowserEntry, BrowserPlace, ImportedFile, ModelStore, MODEL_EXT,
1231    };
1232    use std::cell::RefCell;
1233    use std::future::Future;
1234    use std::pin::Pin;
1235    use std::rc::Rc;
1236    use std::task::{Context, Poll, Waker};
1237    use wasm_bindgen::prelude::*;
1238    use wasm_bindgen::JsCast;
1239
1240    // The origin-private persistent store on the web is **IndexedDB**. It replaced
1241    // `localStorage`, whose ~5-10 MB per-origin quota cannot hold a single native
1242    // BREP payload (a measured STEP import: ~35 MB), and it is the only browser
1243    // store that is both large and enumerable without a permission gesture (unlike
1244    // the File System Access API, which fails headless). Its API is ASYNC, hence
1245    // the mirror in [`mirror_store`](super::mirror_store); the key scheme is the
1246    // localStorage one, byte for byte. **Download + upload** below still bridge to
1247    // the user's REAL filesystem for portability.
1248    const DB_NAME: &str = "brep-app";
1249    const DB_VERSION: u32 = 1;
1250    /// The single key/value object store; keys are the `brep-app:*` strings.
1251    const STORE_NAME: &str = "kv";
1252
1253    thread_local! {
1254        /// The single hidden `<input type=file>`, created once and reused.
1255        static IMPORT_INPUT: RefCell<Option<web_sys::HtmlInputElement>> = const { RefCell::new(None) };
1256        /// The most recent completed upload, awaiting the panel's poll.
1257        static IMPORTED: RefCell<Option<ImportedFile>> = const { RefCell::new(None) };
1258        /// The egui context, so an ASYNC callback (a `FileReader` load, a failed
1259        /// IndexedDB write) can wake the reactive eframe loop (the app only
1260        /// repaints on input events + explicit requests). Without it a completed
1261        /// upload — or a "your model did not save" notice — sits unshown until the
1262        /// user happens to move the mouse. Seeded once at boot via [`set_repaint_ctx`].
1263        static REPAINT_CTX: RefCell<Option<eframe::egui::Context>> = const { RefCell::new(None) };
1264        /// The store built by [`hydrate`] inside the async wasm entry point,
1265        /// waiting for `BrepApp::new` to pick it up through
1266        /// [`default_model_store`](super::default_model_store). The hand-off is a
1267        /// thread-local rather than a closure capture so the app constructor keeps
1268        /// its platform-free signature.
1269        static BOOT_STORE: RefCell<Option<IdbModelStore>> = const { RefCell::new(None) };
1270    }
1271
1272    /// Register the egui context used to wake the frame loop when an async browser
1273    /// upload — or a write-behind persistence failure — completes. Called once from
1274    /// the app shell at construction.
1275    pub fn set_repaint_ctx(ctx: eframe::egui::Context) {
1276        REPAINT_CTX.with(|c| *c.borrow_mut() = Some(ctx));
1277    }
1278
1279    /// Ask the reactive frame loop for a repaint. Reads `REPAINT_CTX` at CALL time,
1280    /// so it does not matter that the store is built (during boot) before the app
1281    /// shell registers the context.
1282    fn wake_frame_loop() {
1283        REPAINT_CTX.with(|c| {
1284            if let Some(ctx) = c.borrow().as_ref() {
1285                ctx.request_repaint();
1286            }
1287        });
1288    }
1289
1290    // --- IndexedDB request/transaction -> Future ----------------------------------
1291    // Hand-rolled rather than pulling `indexed_db_futures`: this crate deliberately
1292    // keeps its wasm dependency tree thin (see the `ehttp` note in Cargo.toml about
1293    // the `getrandom` dep-tree problem), and the whole adapter is the ~60 lines
1294    // below. It owns its `Closure`s — dropping the future clears the handlers — so
1295    // a session of saves does not leak a pair of JS closures per write, which a
1296    // `Closure::forget()` sketch would.
1297
1298    #[derive(Default)]
1299    struct Settled {
1300        outcome: Option<Result<JsValue, String>>,
1301        waker: Option<Waker>,
1302    }
1303
1304    /// Which DOM event pair the future is listening to. Kept so `Drop` can detach
1305    /// the handlers (and with them the Rust closures' reference back to the target).
1306    enum EventSource {
1307        Request(web_sys::IdbRequest),
1308        Transaction(web_sys::IdbTransaction),
1309    }
1310
1311    /// One IndexedDB completion, as a `Future`.
1312    struct IdbFuture {
1313        source: EventSource,
1314        settled: Rc<RefCell<Settled>>,
1315        /// Owned so the closures live exactly as long as the future.
1316        _handlers: Vec<Closure<dyn FnMut(web_sys::Event)>>,
1317    }
1318
1319    fn settle(settled: &Rc<RefCell<Settled>>, outcome: Result<JsValue, String>) {
1320        let waker = {
1321            let mut settled = settled.borrow_mut();
1322            if settled.outcome.is_none() {
1323                settled.outcome = Some(outcome);
1324            }
1325            settled.waker.take()
1326        };
1327        if let Some(waker) = waker {
1328            waker.wake();
1329        }
1330    }
1331
1332    fn request_error(request: &web_sys::IdbRequest) -> String {
1333        request
1334            .error()
1335            .ok()
1336            .flatten()
1337            .map(|error| format!("{}: {}", error.name(), error.message()))
1338            .unwrap_or_else(|| "IndexedDB request failed".into())
1339    }
1340
1341    fn js_error(value: &JsValue) -> String {
1342        value
1343            .as_string()
1344            .or_else(|| js_sys::Reflect::get(value, &JsValue::from_str("message")).ok()?.as_string())
1345            .unwrap_or_else(|| format!("{value:?}"))
1346    }
1347
1348    /// Resolve when `request` succeeds, with its `result`.
1349    fn on_request(request: web_sys::IdbRequest) -> IdbFuture {
1350        let settled = Rc::new(RefCell::new(Settled::default()));
1351        let success = {
1352            let settled = settled.clone();
1353            let request = request.clone();
1354            Closure::wrap(Box::new(move |_event: web_sys::Event| {
1355                let value = request.result().unwrap_or(JsValue::UNDEFINED);
1356                settle(&settled, Ok(value));
1357            }) as Box<dyn FnMut(web_sys::Event)>)
1358        };
1359        let failure = {
1360            let settled = settled.clone();
1361            let request = request.clone();
1362            Closure::wrap(Box::new(move |_event: web_sys::Event| {
1363                settle(&settled, Err(request_error(&request)));
1364            }) as Box<dyn FnMut(web_sys::Event)>)
1365        };
1366        request.set_onsuccess(Some(success.as_ref().unchecked_ref()));
1367        request.set_onerror(Some(failure.as_ref().unchecked_ref()));
1368        IdbFuture {
1369            source: EventSource::Request(request),
1370            settled,
1371            _handlers: vec![success, failure],
1372        }
1373    }
1374
1375    /// Resolve when `transaction` COMMITS.
1376    ///
1377    /// A write must be awaited here, not on the `put` request's `onsuccess`: the
1378    /// request succeeds before the transaction commits, and a page reload can abort
1379    /// an uncommitted `readwrite` transaction. For a multi-megabyte payload that
1380    /// window is real, and "the save is durable" is exactly the claim the pending
1381    /// counter and the reload test rest on.
1382    fn on_transaction(transaction: web_sys::IdbTransaction) -> IdbFuture {
1383        let settled = Rc::new(RefCell::new(Settled::default()));
1384        let complete = {
1385            let settled = settled.clone();
1386            Closure::wrap(Box::new(move |_event: web_sys::Event| {
1387                settle(&settled, Ok(JsValue::UNDEFINED));
1388            }) as Box<dyn FnMut(web_sys::Event)>)
1389        };
1390        let describe = {
1391            let transaction = transaction.clone();
1392            move |fallback: &str| {
1393                transaction
1394                    .error()
1395                    .map(|error| format!("{}: {}", error.name(), error.message()))
1396                    .unwrap_or_else(|| fallback.to_string())
1397            }
1398        };
1399        let failure = {
1400            let settled = settled.clone();
1401            let describe = describe.clone();
1402            Closure::wrap(Box::new(move |_event: web_sys::Event| {
1403                settle(&settled, Err(describe("IndexedDB transaction failed")));
1404            }) as Box<dyn FnMut(web_sys::Event)>)
1405        };
1406        let abort = {
1407            let settled = settled.clone();
1408            Closure::wrap(Box::new(move |_event: web_sys::Event| {
1409                settle(
1410                    &settled,
1411                    Err(describe("IndexedDB transaction aborted (quota?)")),
1412                );
1413            }) as Box<dyn FnMut(web_sys::Event)>)
1414        };
1415        transaction.set_oncomplete(Some(complete.as_ref().unchecked_ref()));
1416        transaction.set_onerror(Some(failure.as_ref().unchecked_ref()));
1417        transaction.set_onabort(Some(abort.as_ref().unchecked_ref()));
1418        IdbFuture {
1419            source: EventSource::Transaction(transaction),
1420            settled,
1421            _handlers: vec![complete, failure, abort],
1422        }
1423    }
1424
1425    impl Future for IdbFuture {
1426        type Output = Result<JsValue, String>;
1427
1428        fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
1429            let mut settled = self.settled.borrow_mut();
1430            if let Some(outcome) = settled.outcome.take() {
1431                return Poll::Ready(outcome);
1432            }
1433            settled.waker = Some(cx.waker().clone());
1434            Poll::Pending
1435        }
1436    }
1437
1438    impl Drop for IdbFuture {
1439        fn drop(&mut self) {
1440            // Detach the handlers, breaking the target -> closure -> target cycle
1441            // so the closures we own are actually freed.
1442            match &self.source {
1443                EventSource::Request(request) => {
1444                    request.set_onsuccess(None);
1445                    request.set_onerror(None);
1446                }
1447                EventSource::Transaction(transaction) => {
1448                    transaction.set_oncomplete(None);
1449                    transaction.set_onerror(None);
1450                    transaction.set_onabort(None);
1451                }
1452            }
1453        }
1454    }
1455
1456    // --- the IndexedDB backend -----------------------------------------------------
1457
1458    /// [`StoreBackend`] over one IndexedDB object store. The ONLY place in the
1459    /// crate that knows what IndexedDB is.
1460    pub(super) struct IdbBackend {
1461        db: web_sys::IdbDatabase,
1462    }
1463
1464    impl IdbBackend {
1465        /// Open (creating on first use) the application database. `Err` on any
1466        /// browser that blocks storage — private windows, "block all cookies"
1467        /// setups — which the caller turns into a loud, non-persisting session.
1468        async fn open() -> Result<Self, String> {
1469            let factory = web_sys::window()
1470                .ok_or("no window")?
1471                .indexed_db()
1472                .map_err(|e| js_error(&e))?
1473                .ok_or("no indexedDB on this window")?;
1474            let request = factory
1475                .open_with_u32(DB_NAME, DB_VERSION)
1476                .map_err(|e| js_error(&e))?;
1477
1478            // Create the object store on first open / version bump. The request is
1479            // captured directly (rather than read off the event target) so no
1480            // `EventTarget` binding is needed.
1481            let upgrading = request.clone();
1482            let upgrade = Closure::wrap(Box::new(move |_event: web_sys::Event| {
1483                if let Ok(value) = upgrading.result() {
1484                    if let Ok(db) = value.dyn_into::<web_sys::IdbDatabase>() {
1485                        // Errors here mean the store already exists — harmless.
1486                        let _ = db.create_object_store(STORE_NAME);
1487                    }
1488                }
1489            }) as Box<dyn FnMut(web_sys::Event)>);
1490            request.set_onupgradeneeded(Some(upgrade.as_ref().unchecked_ref()));
1491
1492            let opened = on_request(request.clone().unchecked_into::<web_sys::IdbRequest>()).await;
1493            request.set_onupgradeneeded(None);
1494            drop(upgrade);
1495
1496            let db = opened?
1497                .dyn_into::<web_sys::IdbDatabase>()
1498                .map_err(|_| "IndexedDB open returned no database".to_string())?;
1499            Ok(Self { db })
1500        }
1501
1502        /// Start a transaction and reach its object store. The transaction handle
1503        /// is returned so a write can await its COMMIT.
1504        fn transact(
1505            &self,
1506            mode: web_sys::IdbTransactionMode,
1507        ) -> Result<(web_sys::IdbTransaction, web_sys::IdbObjectStore), String> {
1508            let transaction = self
1509                .db
1510                .transaction_with_str_and_mode(STORE_NAME, mode)
1511                .map_err(|e| js_error(&e))?;
1512            let store = transaction.object_store(STORE_NAME).map_err(|e| js_error(&e))?;
1513            Ok((transaction, store))
1514        }
1515    }
1516
1517    impl StoreBackend for IdbBackend {
1518        fn label(&self) -> String {
1519            "browser storage (IndexedDB) · download/upload for files".into()
1520        }
1521
1522        fn load_all(&self) -> BackendFuture<Vec<(String, String)>> {
1523            // Two whole-store requests in ONE readonly transaction rather than a
1524            // cursor: `getAllKeys` and `getAll` both come back in key order, so
1525            // zipping them reconstructs the key space in one round trip each.
1526            let started = (|| -> Result<(IdbFuture, IdbFuture), String> {
1527                let (_transaction, store) = self.transact(web_sys::IdbTransactionMode::Readonly)?;
1528                let keys = store.get_all_keys().map_err(|e| js_error(&e))?;
1529                let values = store.get_all().map_err(|e| js_error(&e))?;
1530                Ok((on_request(keys), on_request(values)))
1531            })();
1532            Box::pin(async move {
1533                let (keys, values) = started?;
1534                let keys = js_sys::Array::from(&keys.await?);
1535                let values = js_sys::Array::from(&values.await?);
1536                if keys.length() != values.length() {
1537                    return Err("IndexedDB returned mismatched keys and values".into());
1538                }
1539                let mut entries = Vec::with_capacity(keys.length() as usize);
1540                for i in 0..keys.length() {
1541                    let (Some(key), Some(value)) =
1542                        (keys.get(i).as_string(), values.get(i).as_string())
1543                    else {
1544                        // A non-string entry is not ours; skipping it is safer than
1545                        // failing the whole hydrate.
1546                        continue;
1547                    };
1548                    entries.push((key, value));
1549                }
1550                Ok(entries)
1551            })
1552        }
1553
1554        fn put(&self, key: &str, value: &str) -> BackendFuture<()> {
1555            // The transaction is created and the request issued SYNCHRONOUSLY, so
1556            // two writes of the same key commit in call order (IndexedDB commits
1557            // `readwrite` transactions in creation order) no matter how the futures
1558            // are later polled — the per-key FIFO obligation in `StoreBackend`.
1559            let started = (|| -> Result<IdbFuture, String> {
1560                let (transaction, store) =
1561                    self.transact(web_sys::IdbTransactionMode::Readwrite)?;
1562                store
1563                    .put_with_key(&JsValue::from_str(value), &JsValue::from_str(key))
1564                    .map_err(|e| js_error(&e))?;
1565                Ok(on_transaction(transaction))
1566            })();
1567            Box::pin(async move {
1568                started?.await?;
1569                Ok(())
1570            })
1571        }
1572
1573        fn delete(&self, key: &str) -> BackendFuture<()> {
1574            let started = (|| -> Result<IdbFuture, String> {
1575                let (transaction, store) =
1576                    self.transact(web_sys::IdbTransactionMode::Readwrite)?;
1577                store
1578                    .delete(&JsValue::from_str(key))
1579                    .map_err(|e| js_error(&e))?;
1580                Ok(on_transaction(transaction))
1581            })();
1582            Box::pin(async move {
1583                started?.await?;
1584                Ok(())
1585            })
1586        }
1587    }
1588
1589    // --- boot ----------------------------------------------------------------------
1590
1591    /// Bring up browser persistence and hydrate the whole key space into memory.
1592    ///
1593    /// Called from the async wasm entry point BEFORE `eframe::WebRunner::start`, so
1594    /// by the time any synchronous [`ModelStore::read`] can run, the mirror is
1595    /// complete — that ordering is the entire reason the trait stays synchronous.
1596    ///
1597    /// On failure there is NO quiet fallback: the session gets an
1598    /// [`UnavailableBackend`], which keeps the app usable in memory while saying so
1599    /// in the panel header and toasting every write that does not persist.
1600    pub(super) async fn hydrate() {
1601        let wake: Rc<dyn Fn()> = Rc::new(wake_frame_loop);
1602        let core = match IdbBackend::open().await {
1603            Ok(backend) => {
1604                let backend: Rc<dyn StoreBackend> = Rc::new(backend);
1605                match backend.load_all().await {
1606                    Ok(entries) => MirrorStore::new(backend, entries, Some(wake)),
1607                    // The database opened but would not read. Writing against a
1608                    // half-known key space could overwrite documents the mirror
1609                    // never saw, so treat it as unavailable rather than risk that.
1610                    Err(message) => unavailable(
1611                        format!("IndexedDB could not be read ({message})"),
1612                        Some(wake),
1613                    ),
1614                }
1615            }
1616            Err(message) => unavailable(format!("IndexedDB unavailable ({message})"), Some(wake)),
1617        };
1618        install_verification_hooks(&core);
1619        BOOT_STORE.with(|c| *c.borrow_mut() = Some(IdbModelStore { core }));
1620    }
1621
1622    /// A session with no persistence: empty, in-memory, and loud about it.
1623    fn unavailable(reason: String, wake: Option<Rc<dyn Fn()>>) -> MirrorStore {
1624        let core = MirrorStore::new(
1625            Rc::new(UnavailableBackend::new(reason.clone())),
1626            Vec::new(),
1627            wake,
1628        );
1629        // Seed the notice channel so the FIRST frame tells the user, before they
1630        // have saved anything and discovered it the hard way.
1631        core.report(format!(
1632            "{reason} — this session will not be saved; use Download to keep your work"
1633        ));
1634        core
1635    }
1636
1637    /// Hand the hydrated store to `BrepApp::new`. If boot never ran (no code path
1638    /// does that today), the app still starts — non-persisting and saying so.
1639    pub(super) fn take_boot_store() -> Box<dyn ModelStore> {
1640        let store = BOOT_STORE.with(|c| c.borrow_mut().take()).unwrap_or_else(|| {
1641            let wake: Rc<dyn Fn()> = Rc::new(wake_frame_loop);
1642            IdbModelStore {
1643                core: unavailable("storage was never initialised".into(), Some(wake)),
1644            }
1645        });
1646        Box::new(store)
1647    }
1648
1649    // --- verification hooks ---------------------------------------------------------
1650
1651    /// Publish `window.__brepStore*` handles onto the LIVE store, in the same
1652    /// spirit as the app shell's `__brep*` state globals: the headed verifier needs
1653    /// to write a payload far larger than any UI gesture can type, then prove it
1654    /// survived a reload. `__brepStorePending` is what makes that check non-racy —
1655    /// it reaches zero only once the backing transaction has COMMITTED.
1656    /// `__brepStoreRead` is the read side of the same seam: a script that saves
1657    /// through the UI has to be able to look at what landed.
1658    fn install_verification_hooks(core: &MirrorStore) {
1659        let Some(window) = web_sys::window() else {
1660            return;
1661        };
1662        let publish = |name: &str, value: &JsValue| {
1663            let _ = js_sys::Reflect::set(&window, &JsValue::from_str(name), value);
1664        };
1665
1666        let store = core.clone();
1667        let write = Closure::wrap(Box::new(move |name: String, contents: String| -> JsValue {
1668            match store.write(&name, &contents) {
1669                Ok(()) => JsValue::NULL,
1670                Err(message) => JsValue::from_str(&message),
1671            }
1672        }) as Box<dyn FnMut(String, String) -> JsValue>);
1673        publish("__brepStoreWrite", write.as_ref());
1674        write.forget();
1675
1676        let store = core.clone();
1677        let len = Closure::wrap(Box::new(move |name: String| -> f64 {
1678            store.len_of(&name).map(|n| n as f64).unwrap_or(-1.0)
1679        }) as Box<dyn FnMut(String) -> f64>);
1680        publish("__brepStoreLen", len.as_ref());
1681        len.forget();
1682
1683        // The READ sibling of `__brepStoreWrite`. Without it a headed script can
1684        // only prove a save happened, not WHAT was saved: the assemblies sweep
1685        // used to read the document straight out of `localStorage`, which the
1686        // IndexedDB migration silently emptied.
1687        let store = core.clone();
1688        let read = Closure::wrap(Box::new(move |name: String| -> JsValue {
1689            match store.read(&name) {
1690                Some(contents) => JsValue::from_str(&contents),
1691                None => JsValue::NULL,
1692            }
1693        }) as Box<dyn FnMut(String) -> JsValue>);
1694        publish("__brepStoreRead", read.as_ref());
1695        read.forget();
1696
1697        let store = core.clone();
1698        let list = Closure::wrap(Box::new(move || -> JsValue {
1699            JsValue::from_str(&serde_json::to_string(&store.list()).unwrap_or_default())
1700        }) as Box<dyn FnMut() -> JsValue>);
1701        publish("__brepStoreList", list.as_ref());
1702        list.forget();
1703
1704        let store = core.clone();
1705        let errors = Closure::wrap(Box::new(move || -> JsValue {
1706            JsValue::from_str(&serde_json::to_string(&store.error_history()).unwrap_or_default())
1707        }) as Box<dyn FnMut() -> JsValue>);
1708        publish("__brepStoreErrors", errors.as_ref());
1709        errors.forget();
1710
1711        let store = core.clone();
1712        let pending = Closure::wrap(Box::new(move || -> f64 { store.pending() as f64 })
1713            as Box<dyn FnMut() -> f64>);
1714        publish("__brepStorePending", pending.as_ref());
1715        pending.forget();
1716    }
1717
1718    // --- real-file interchange (download / upload) ----------------------------------
1719    // Free functions, not methods: they are pure browser plumbing with no store
1720    // state, and keeping them out of the store type leaves `IdbModelStore` as a
1721    // thin seam between the mirror and this lane.
1722
1723    /// Lazily create the reusable hidden file input, wiring its `change` handler
1724    /// (which reads the chosen file and stashes it for `take_import`). `accept` is
1725    /// (re)applied every call so the picker's filter matches the current lane (the
1726    /// `.BREP.json` model lane vs. a `.step` import lane).
1727    fn ensure_input(accept: &str) -> Option<web_sys::HtmlInputElement> {
1728        if let Some(existing) = IMPORT_INPUT.with(|c| c.borrow().clone()) {
1729            existing.set_accept(accept);
1730            return Some(existing);
1731        }
1732        let document = web_sys::window()?.document()?;
1733        let input: web_sys::HtmlInputElement =
1734            document.create_element("input").ok()?.dyn_into().ok()?;
1735        input.set_type("file");
1736        input.set_accept(accept);
1737        input.set_hidden(true);
1738
1739        // Read bytes so binary STL is not corrupted at the browser boundary.
1740        let input_for_cb = input.clone();
1741        let onchange = Closure::wrap(Box::new(move |_e: web_sys::Event| {
1742            let Some(files) = input_for_cb.files() else { return };
1743            let Some(file) = files.get(0) else { return };
1744            let name = file.name();
1745            let Ok(reader) = web_sys::FileReader::new() else { return };
1746            let reader_for_load = reader.clone();
1747            let onload = Closure::wrap(Box::new(move |_e: web_sys::Event| {
1748                if let Ok(value) = reader_for_load.result() {
1749                    let bytes = js_sys::Uint8Array::new(&value).to_vec();
1750                    IMPORTED.with(|c| *c.borrow_mut() = Some(ImportedFile {
1751                        name: model_display_name(&name),
1752                        bytes,
1753                    }));
1754                    // Wake the reactive frame loop so the file panel polls
1755                    // `take_import` THIS frame, not on the next stray input event.
1756                    wake_frame_loop();
1757                }
1758            }) as Box<dyn FnMut(web_sys::Event)>);
1759            reader.set_onload(Some(onload.as_ref().unchecked_ref()));
1760            // One small per-import leak (the app runs for the page lifetime).
1761            onload.forget();
1762            let _ = reader.read_as_array_buffer(&file);
1763        }) as Box<dyn FnMut(web_sys::Event)>);
1764        input.set_onchange(Some(onchange.as_ref().unchecked_ref()));
1765        onchange.forget(); // created once — leak is bounded
1766
1767        if let Some(body) = document.body() {
1768            let _ = body.append_child(&input);
1769        }
1770        IMPORT_INPUT.with(|c| *c.borrow_mut() = Some(input.clone()));
1771        Some(input)
1772    }
1773
1774    /// Offer `contents` to the user as a download named EXACTLY `file_name`, via a
1775    /// Blob object-URL + a synthetic anchor click.
1776    fn download(file_name: &str, mime: &str, contents: &str) -> Result<(), String> {
1777        let document = web_sys::window()
1778            .and_then(|w| w.document())
1779            .ok_or("no document")?;
1780        let parts = js_sys::Array::of1(&JsValue::from_str(contents));
1781        let options = web_sys::BlobPropertyBag::new();
1782        options.set_type(mime);
1783        let blob = web_sys::Blob::new_with_str_sequence_and_options(&parts, &options)
1784            .map_err(|_| "blob create failed".to_string())?;
1785        let url = web_sys::Url::create_object_url_with_blob(&blob)
1786            .map_err(|_| "object url failed".to_string())?;
1787        let anchor: web_sys::HtmlAnchorElement = document
1788            .create_element("a")
1789            .map_err(|_| "anchor create failed".to_string())?
1790            .dyn_into()
1791            .map_err(|_| "anchor cast failed".to_string())?;
1792        anchor.set_href(&url);
1793        anchor.set_download(file_name);
1794        anchor.click();
1795        let _ = web_sys::Url::revoke_object_url(&url);
1796        Ok(())
1797    }
1798
1799    /// The browser model store: the mirrored key space (which owns every CRUD and
1800    /// explorer method) plus this platform's real-file interchange lane.
1801    pub(super) struct IdbModelStore {
1802        core: MirrorStore,
1803    }
1804
1805    impl ModelStore for IdbModelStore {
1806        // --- delegated to the mirror ------------------------------------------
1807        fn backend_label(&self) -> String {
1808            self.core.backend_label()
1809        }
1810        fn list(&self) -> Vec<String> {
1811            self.core.list()
1812        }
1813        fn read(&self, name: &str) -> Option<String> {
1814            self.core.read(name)
1815        }
1816        fn write(&self, name: &str, contents: &str) -> Result<(), String> {
1817            self.core.write(name, contents)
1818        }
1819        fn remove(&self, name: &str) -> Result<(), String> {
1820            self.core.remove(name)
1821        }
1822        fn take_persistence_errors(&self) -> Vec<String> {
1823            self.core.take_persistence_errors()
1824        }
1825        fn browser_location(&self) -> String {
1826            self.core.browser_location()
1827        }
1828        fn browser_entries(&self, extensions: &[&str]) -> Vec<BrowserEntry> {
1829            self.core.browser_entries(extensions)
1830        }
1831        fn browser_enter(&self, identity: &str) -> Result<(), String> {
1832            self.core.browser_enter(identity)
1833        }
1834        fn browser_up(&self) -> Result<(), String> {
1835            self.core.browser_up()
1836        }
1837        fn browser_home(&self) -> Result<(), String> {
1838            self.core.browser_home()
1839        }
1840        fn browser_root(&self) -> Result<(), String> {
1841            self.core.browser_root()
1842        }
1843        fn browser_navigate(&self, location: &str) -> Result<(), String> {
1844            self.core.browser_navigate(location)
1845        }
1846        fn browser_places(&self) -> Vec<BrowserPlace> {
1847            self.core.browser_places()
1848        }
1849        fn browser_create_dir(&self, name: &str) -> Result<(), String> {
1850            self.core.browser_create_dir(name)
1851        }
1852        fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
1853            self.core.browser_write(name, contents)
1854        }
1855
1856        // --- the browser's real-file lane -------------------------------------
1857        fn supports_file_interchange(&self) -> bool {
1858            true
1859        }
1860
1861        /// Offer the document as a `<name>.BREP.json` download. The returned
1862        /// identity is the bare name (the browser owns where the download lands).
1863        fn export_file(&self, name: &str, contents: &str) -> Result<Option<String>, String> {
1864            let name = model_display_name(name);
1865            download(&format!("{name}{MODEL_EXT}"), "application/json", contents)?;
1866            Ok(Some(name))
1867        }
1868
1869        fn begin_import(&self) -> Result<(), String> {
1870            let input =
1871                ensure_input(".json,.BREP.json,application/json").ok_or("file input unavailable")?;
1872            // Clear so re-selecting the same file still fires `change`.
1873            input.set_value("");
1874            input.click();
1875            Ok(())
1876        }
1877
1878        fn take_import(&self) -> Option<ImportedFile> {
1879            IMPORTED.with(|c| c.borrow_mut().take())
1880        }
1881
1882        // Format-typed interchange: the same hidden input, refiltered to the
1883        // requested extensions. The onchange handler stashes the file's real name
1884        // (its extension survives `model_display_name`, since that only strips
1885        // `.json` / `.BREP.json`), so the panel routes STEP imports by extension.
1886        fn begin_import_filtered(&self, filter: (&str, &[&str])) -> Result<(), String> {
1887            let accept = filter
1888                .1
1889                .iter()
1890                .map(|ext| format!(".{ext}"))
1891                .collect::<Vec<_>>()
1892                .join(",");
1893            let input = ensure_input(&accept).ok_or("file input unavailable")?;
1894            input.set_value("");
1895            input.click();
1896            Ok(())
1897        }
1898
1899        /// Offer `contents` as a download under EXACTLY `file_name` (extension
1900        /// kept) — the foreign-format sibling of [`Self::export_file`].
1901        fn export_file_named(&self, file_name: &str, contents: &str) -> Result<(), String> {
1902            download(file_name, "application/octet-stream", contents)
1903        }
1904    }
1905}
1906
1907
1908// BREP private tests: 0516c3c4f5aa076b
1909
1910// BREP private tests: 69df063b576f11cb