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