Skip to main content

brep_app/
store.rs

1//! The storage / filesystem seam — the ONE platform exception in the
2//! engine-native UI (see `engine-native-ui-design.md`). Everything else in the
3//! UI is platform-agnostic egui; only persistence differs by platform, so it
4//! goes through this trait with two `#[cfg]`-gated impls:
5//!
6//! * **Desktop** → a small config file under the OS config dir.
7//! * **Browser** → `localStorage` via `web_sys`.
8//!
9//! This slice uses it for settings persistence (`key = "settings"`, `val =` the
10//! full `RenderSettings::to_json()`). Model save/load + import/export will cross
11//! the SAME trait in a later slice.
12
13/// A minimal key→string blob store. Keys are short, filesystem-safe slugs.
14pub trait Store {
15    /// Load a previously-saved value, or `None` if absent/unreadable.
16    fn load(&self, key: &str) -> Option<String>;
17    /// Persist a value under `key`, overwriting any prior value. Best-effort:
18    /// a failure is swallowed (persistence is a convenience, not correctness).
19    fn save(&self, key: &str, val: &str);
20}
21
22/// Construct the platform's default store. The ONLY platform-specific
23/// construction in the app — the trait object it returns is used identically on
24/// web and desktop.
25pub fn default_store() -> Box<dyn Store> {
26    #[cfg(not(target_arch = "wasm32"))]
27    {
28        Box::new(native::FileStore::new())
29    }
30    #[cfg(target_arch = "wasm32")]
31    {
32        Box::new(web::LocalStore)
33    }
34}
35
36// --- Desktop: a config file ----------------------------------------------------
37#[cfg(not(target_arch = "wasm32"))]
38mod native {
39    use super::Store;
40    use std::path::PathBuf;
41
42    /// Persist each key as `<config>/brep-app/<key>.json`. `<config>` is
43    /// `$XDG_CONFIG_HOME`, else `$HOME/.config`, else the current dir — a sane
44    /// default without pulling in a directories crate.
45    pub struct FileStore {
46        dir: PathBuf,
47    }
48
49    impl FileStore {
50        pub fn new() -> Self {
51            let base = std::env::var_os("XDG_CONFIG_HOME")
52                .map(PathBuf::from)
53                .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
54                .unwrap_or_else(|| PathBuf::from("."));
55            Self {
56                dir: base.join("brep-app"),
57            }
58        }
59
60        /// Filesystem-safe path for a key (keys are our own short slugs, but be
61        /// defensive against separators).
62        fn path(&self, key: &str) -> PathBuf {
63            let safe: String = key
64                .chars()
65                .map(|c| if c.is_ascii_alphanumeric() || c == '-' || c == '_' { c } else { '_' })
66                .collect();
67            self.dir.join(format!("{safe}.json"))
68        }
69    }
70
71    impl Store for FileStore {
72        fn load(&self, key: &str) -> Option<String> {
73            std::fs::read_to_string(self.path(key)).ok()
74        }
75
76        fn save(&self, key: &str, val: &str) {
77            let _ = std::fs::create_dir_all(&self.dir);
78            let _ = std::fs::write(self.path(key), val);
79        }
80    }
81}
82
83// --- Browser: localStorage -----------------------------------------------------
84#[cfg(target_arch = "wasm32")]
85mod web {
86    use super::Store;
87
88    /// `window.localStorage`-backed store. Keys are namespaced so they can't
89    /// collide with anything else on the origin.
90    pub struct LocalStore;
91
92    impl LocalStore {
93        fn storage() -> Option<web_sys::Storage> {
94            web_sys::window()?.local_storage().ok()?
95        }
96        fn namespaced(key: &str) -> String {
97            format!("brep-app:{key}")
98        }
99    }
100
101    impl Store for LocalStore {
102        fn load(&self, key: &str) -> Option<String> {
103            Self::storage()?.get_item(&Self::namespaced(key)).ok()?
104        }
105
106        fn save(&self, key: &str, val: &str) {
107            if let Some(storage) = Self::storage() {
108                let _ = storage.set_item(&Self::namespaced(key), val);
109            }
110        }
111    }
112}
113
114// =============================================================================
115// Model documents — the OTHER half of the storage seam
116// =============================================================================
117//
118// The `Store` above is a settings key→value blob. A MODEL is a whole document
119// (the engine-owned `HistoryRequest` JSON — one `.BREP.json` recipe), and the
120// file panel needs to *enumerate*, read, and write NAMED documents, plus (in the
121// browser) hand a file to / take a file from the user's real filesystem. That is
122// a different shape than key/value, so it gets its OWN trait, still with the two
123// `#[cfg]`-gated platform impls behind it (the storage seam is the ONE platform
124// exception — see `engine-native-ui-design.md`).
125//
126// The trait is deliberately a **named-document CRUD** (`list` / `read` / `write`
127// / `remove`) plus a poll-based **file-interchange** side-channel. That shape is
128// exactly what a later **GitHub backend** needs: `list` = a repo directory
129// listing, `read` = fetch a file's contents, `write` = create/update (commit)
130// a file, `remove` = delete a file — the model name is the path within the repo.
131// An async backend (GitHub over HTTP, or the browser File System Access API /
132// OPFS whose main-thread API is async) slots in behind the SAME trait by driving
133// its request on a background task and surfacing the result through the
134// `begin_import` → `take_import` poll pattern used here for uploads, so the
135// synchronous panel code never changes. GitHub itself is a deferred follow-up;
136// this lands the seam it plugs into.
137
138/// A store of NAMED model documents — the model-file half of the storage seam
139/// (distinct from the settings key/value [`Store`]). All methods take `&self`
140/// (any per-backend mutable state — the browser's upload stash — lives behind
141/// interior mutability) so the app can hold one `Box<dyn ModelStore>` and hand
142/// panels a shared `&dyn ModelStore`, exactly like [`Store`].
143pub trait ModelStore {
144    /// A short human label of where documents persist, for the panel header
145    /// (e.g. `"filesystem: ~/.config/brep-app/models"` or `"browser storage"`).
146    fn backend_label(&self) -> String;
147
148    /// The names of the documents currently available to **Open** (bare names,
149    /// no extension). May be empty on a backend that cannot enumerate — then the
150    /// panel falls back to the name field / import.
151    fn list(&self) -> Vec<String>;
152
153    /// Read a stored document by name, or `None` if absent/unreadable.
154    fn read(&self, name: &str) -> Option<String>;
155
156    /// Create or overwrite the document `name` with `contents`. `Err` carries a
157    /// message the panel surfaces in its status line.
158    fn write(&self, name: &str, contents: &str) -> Result<(), String>;
159
160    /// Delete the document `name` (best-effort; `Ok` if it is already gone).
161    fn remove(&self, name: &str) -> Result<(), String>;
162
163    // --- real-file interchange (the platform "fallback") ----------------------
164    // The browser cannot silently write to an arbitrary path, and a headless
165    // native box has no dialog; these let the panel move a document to/from the
166    // user's REAL filesystem where the platform supports it. Default: unsupported.
167
168    /// Whether this backend can exchange files with the user's real filesystem
169    /// (browser download+upload, or a native file dialog). The panel shows the
170    /// Download / Import affordances only when this is `true`.
171    fn supports_file_interchange(&self) -> bool {
172        false
173    }
174
175    /// Hand `contents` to the user as a file named after `name` (browser: a
176    /// download; native w/ dialog: a Save-As). Returns the saved document's
177    /// identity — the full path the user chose on native (so the caller can
178    /// re-save straight to it), the bare download name on the web — or `None`
179    /// when the user cancelled. No-op (`None`) by default.
180    fn export_file(&self, name: &str, contents: &str) -> Result<Option<String>, String> {
181        let _ = (name, contents);
182        Ok(None)
183    }
184
185    /// Begin importing a real file — opens the platform picker. The result is
186    /// retrieved later via [`Self::take_import`] (upload/read is async in the
187    /// browser). No-op by default.
188    fn begin_import(&self) -> Result<(), String> {
189        Ok(())
190    }
191
192    /// Poll for a completed import as `(suggested_name, contents)`, consuming it.
193    /// `None` until a `begin_import` finishes. Default: never any.
194    fn take_import(&self) -> Option<(String, String)> {
195        None
196    }
197
198    // --- format-typed interchange (STEP / STL) --------------------------------
199    // The model lanes above trade the `.BREP.json` recipe; import/export of a
200    // foreign format (STEP text, ASCII STL) needs a DIFFERENT picker filter and
201    // must NOT mangle the file extension. These two methods add that lane while
202    // leaving the model lanes byte-for-byte. They share the SAME `take_import`
203    // pickup channel — the panel routes the result by its filename extension.
204
205    /// Begin importing a real file behind a specific picker `filter` (a human
206    /// label + dot-less extensions, e.g. `("STEP", &["step","stp"])`). The chosen
207    /// file's contents + its FULL name (extension preserved, so the panel can
208    /// route it) arrive via [`Self::take_import`]. Default: reuse [`Self::begin_import`].
209    fn begin_import_filtered(&self, _filter: (&str, &[&str])) -> Result<(), String> {
210        self.begin_import()
211    }
212
213    /// Hand `contents` to the user under EXACTLY `file_name` (extension included,
214    /// no model-extension munging) — the Save-As / download for a foreign export
215    /// format. Default no-op (interchange unsupported).
216    fn export_file_named(&self, _file_name: &str, _contents: &str) -> Result<(), String> {
217        Ok(())
218    }
219}
220
221/// Construct the platform's default model-document store — the sibling of
222/// [`default_store`] for whole model files.
223pub fn default_model_store() -> Box<dyn ModelStore> {
224    #[cfg(not(target_arch = "wasm32"))]
225    {
226        Box::new(native_model::FileModelStore::new())
227    }
228    #[cfg(target_arch = "wasm32")]
229    {
230        Box::new(web_model::LocalModelStore::new())
231    }
232}
233
234/// The document extension for a model recipe (`<name>.BREP.json`).
235pub const MODEL_EXT: &str = ".BREP.json";
236
237/// Test-only: a native model store rooted at an explicit directory (a temp dir
238/// in tests) so a round-trip test never touches the real config dir.
239#[cfg(all(test, not(target_arch = "wasm32")))]
240pub fn native_test_store(dir: std::path::PathBuf) -> Box<dyn ModelStore> {
241    Box::new(native_model::FileModelStore::with_dir(dir))
242}
243
244/// Strip the model extension (and any directory) from a filename to get the
245/// bare display name — shared by both platform impls (and the file panel, which
246/// shows the bare name while keeping the raw identity for re-saves).
247pub(crate) fn model_display_name(file_name: &str) -> String {
248    let base = file_name
249        .rsplit(['/', '\\'])
250        .next()
251        .unwrap_or(file_name);
252    base.strip_suffix(MODEL_EXT)
253        .or_else(|| base.strip_suffix(".json"))
254        .unwrap_or(base)
255        .to_string()
256}
257
258// --- Desktop: a models directory (+ optional native file dialog) ---------------
259#[cfg(not(target_arch = "wasm32"))]
260mod native_model {
261    use super::{model_display_name, ModelStore, MODEL_EXT};
262    use std::path::PathBuf;
263
264    /// Persist each model as `<config>/brep-app/models/<name>.BREP.json`, using
265    /// the same config-dir resolution as the settings [`super::native::FileStore`]
266    /// so both halves of the seam live together. Enumerable (for the Open list)
267    /// and unit-testable without a display.
268    ///
269    /// With the (default-on) `native-dialog` cargo feature, Open / Save As /
270    /// Import / Export drive a real `rfd` OS file dialog against the real
271    /// filesystem; built with `--no-default-features` (the lean headless gate)
272    /// file interchange is off and the panel uses the name field + the
273    /// models-dir listing instead. Test stores ([`Self::with_dir`]) never open
274    /// dialogs either way, keeping the suite headless.
275    pub struct FileModelStore {
276        dir: PathBuf,
277        /// Whether this store may open OS dialogs: `true` for the real app store
278        /// ([`Self::new`]), `false` for test stores ([`Self::with_dir`]). Only
279        /// read by the `native-dialog` paths — hence the conditional allow.
280        #[cfg_attr(not(feature = "native-dialog"), allow(dead_code))]
281        dialogs: bool,
282        /// A dialog-imported document awaiting pickup (only ever set by the
283        /// `native-dialog` path). `RefCell` keeps the trait `&self`. Unread when
284        /// the feature is off — hence the conditional allow.
285        #[cfg_attr(not(feature = "native-dialog"), allow(dead_code))]
286        pending: std::cell::RefCell<Option<(String, String)>>,
287    }
288
289    impl FileModelStore {
290        pub fn new() -> Self {
291            let base = std::env::var_os("XDG_CONFIG_HOME")
292                .map(PathBuf::from)
293                .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
294                .unwrap_or_else(|| PathBuf::from("."));
295            Self {
296                dir: base.join("brep-app").join("models"),
297                dialogs: true,
298                pending: std::cell::RefCell::new(None),
299            }
300        }
301
302        /// A store rooted at an explicit directory — used by the round-trip test.
303        /// Never opens dialogs, so the tests stay headless (and keep exercising
304        /// the egui-modal fallback path).
305        #[cfg_attr(not(test), allow(dead_code))]
306        pub fn with_dir(dir: PathBuf) -> Self {
307            Self {
308                dir,
309                dialogs: false,
310                pending: std::cell::RefCell::new(None),
311            }
312        }
313
314        /// Resolve a name to a path. A name containing a path separator is taken
315        /// as an explicit path (e.g. one an `rfd` dialog returned); a bare name
316        /// maps to `<dir>/<sanitized>.BREP.json`.
317        fn resolve(&self, name: &str) -> PathBuf {
318            if name.contains('/') || name.contains('\\') {
319                return PathBuf::from(name);
320            }
321            let safe: String = name
322                .strip_suffix(MODEL_EXT)
323                .unwrap_or(name)
324                .chars()
325                .map(|c| {
326                    if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
327                        c
328                    } else {
329                        '_'
330                    }
331                })
332                .collect();
333            self.dir.join(format!("{safe}{MODEL_EXT}"))
334        }
335    }
336
337    impl ModelStore for FileModelStore {
338        fn backend_label(&self) -> String {
339            format!("filesystem: {}", self.dir.display())
340        }
341
342        fn list(&self) -> Vec<String> {
343            let mut names: Vec<String> = std::fs::read_dir(&self.dir)
344                .into_iter()
345                .flatten()
346                .flatten()
347                .filter_map(|entry| {
348                    let name = entry.file_name().to_string_lossy().into_owned();
349                    name.ends_with(MODEL_EXT).then(|| model_display_name(&name))
350                })
351                .collect();
352            names.sort();
353            names
354        }
355
356        fn read(&self, name: &str) -> Option<String> {
357            std::fs::read_to_string(self.resolve(name)).ok()
358        }
359
360        fn write(&self, name: &str, contents: &str) -> Result<(), String> {
361            let path = self.resolve(name);
362            if let Some(parent) = path.parent() {
363                std::fs::create_dir_all(parent).map_err(|e| format!("create models dir: {e}"))?;
364            }
365            std::fs::write(&path, contents).map_err(|e| format!("write {}: {e}", path.display()))
366        }
367
368        fn remove(&self, name: &str) -> Result<(), String> {
369            match std::fs::remove_file(self.resolve(name)) {
370                Ok(()) => Ok(()),
371                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
372                Err(e) => Err(format!("remove: {e}")),
373            }
374        }
375
376        // File interchange is available only when built with a native dialog
377        // (and never on a test store).
378        #[cfg(feature = "native-dialog")]
379        fn supports_file_interchange(&self) -> bool {
380            self.dialogs
381        }
382
383        #[cfg(feature = "native-dialog")]
384        fn export_file(&self, name: &str, contents: &str) -> Result<Option<String>, String> {
385            if !self.dialogs {
386                return Ok(None);
387            }
388            let Some(mut path) = rfd::FileDialog::new()
389                .set_file_name(format!("{}{MODEL_EXT}", model_display_name(name)))
390                .add_filter("BREP model", &["BREP.json", "json"])
391                .save_file()
392            else {
393                return Ok(None); // user cancelled
394            };
395            // An extension-less chosen name would be invisible to Open's filter
396            // and `list()` — normalize it to the model extension.
397            if !path.to_string_lossy().to_ascii_lowercase().ends_with(".json") {
398                path = PathBuf::from(format!("{}{MODEL_EXT}", path.display()));
399            }
400            std::fs::write(&path, contents)
401                .map_err(|e| format!("write {}: {e}", path.display()))?;
402            Ok(Some(path.display().to_string()))
403        }
404
405        #[cfg(feature = "native-dialog")]
406        fn begin_import(&self) -> Result<(), String> {
407            if !self.dialogs {
408                return Ok(());
409            }
410            if let Some(path) = rfd::FileDialog::new()
411                .add_filter("BREP model", &["BREP.json", "json"])
412                .pick_file()
413            {
414                let contents =
415                    std::fs::read_to_string(&path).map_err(|e| format!("read: {e}"))?;
416                // Stash the FULL path as the document identity so a later plain
417                // Save writes back to the file that was opened (`resolve()`
418                // treats a separator-bearing name as an explicit path).
419                *self.pending.borrow_mut() = Some((path.display().to_string(), contents));
420            }
421            Ok(())
422        }
423
424        #[cfg(feature = "native-dialog")]
425        fn take_import(&self) -> Option<(String, String)> {
426            self.pending.borrow_mut().take()
427        }
428
429        // Format-typed interchange: an rfd picker with the given filter; the FULL
430        // file name (extension kept) is stashed so the panel routes by extension.
431        #[cfg(feature = "native-dialog")]
432        fn begin_import_filtered(&self, filter: (&str, &[&str])) -> Result<(), String> {
433            if !self.dialogs {
434                return Ok(());
435            }
436            let (label, extensions) = filter;
437            if let Some(path) = rfd::FileDialog::new()
438                .add_filter(label, extensions)
439                .pick_file()
440            {
441                let contents =
442                    std::fs::read_to_string(&path).map_err(|e| format!("read: {e}"))?;
443                let name = path
444                    .file_name()
445                    .map(|n| n.to_string_lossy().into_owned())
446                    .unwrap_or_else(|| path.to_string_lossy().into_owned());
447                *self.pending.borrow_mut() = Some((name, contents));
448            }
449            Ok(())
450        }
451
452        #[cfg(feature = "native-dialog")]
453        fn export_file_named(&self, file_name: &str, contents: &str) -> Result<(), String> {
454            if !self.dialogs {
455                return Ok(());
456            }
457            if let Some(path) = rfd::FileDialog::new().set_file_name(file_name).save_file() {
458                std::fs::write(&path, contents)
459                    .map_err(|e| format!("write {}: {e}", path.display()))
460            } else {
461                Ok(()) // user cancelled
462            }
463        }
464    }
465}
466
467// --- Browser: localStorage documents + download / upload interchange -----------
468#[cfg(target_arch = "wasm32")]
469mod web_model {
470    use super::{model_display_name, ModelStore, MODEL_EXT};
471    use std::cell::RefCell;
472    use wasm_bindgen::prelude::*;
473    use wasm_bindgen::JsCast;
474
475    // The origin-private persistent store on the web is `localStorage` (keys
476    // `brep-app:model:<name>`): synchronous, enumerable, works on eframe's main
477    // thread AND headless — unlike OPFS (async-only on the main thread) and the
478    // File System Access API (needs a permission gesture that fails headless).
479    // It plays the exact "origin-private document store" role and matches the
480    // settings `Store` precedent; **download + upload** below bridge to the
481    // user's REAL filesystem for portability.
482    const PREFIX: &str = "brep-app:model:";
483
484    thread_local! {
485        /// The single hidden `<input type=file>`, created once and reused.
486        static IMPORT_INPUT: RefCell<Option<web_sys::HtmlInputElement>> = const { RefCell::new(None) };
487        /// The most recent completed upload, awaiting the panel's poll.
488        static IMPORTED: RefCell<Option<(String, String)>> = const { RefCell::new(None) };
489    }
490
491    pub struct LocalModelStore;
492
493    impl LocalModelStore {
494        pub fn new() -> Self {
495            Self
496        }
497
498        fn storage() -> Option<web_sys::Storage> {
499            web_sys::window()?.local_storage().ok()?
500        }
501
502        fn key(name: &str) -> String {
503            format!("{PREFIX}{}", model_display_name(name))
504        }
505
506        /// Lazily create the reusable hidden file input, wiring its `change`
507        /// handler (which reads the chosen file and stashes it for `take_import`).
508        /// `accept` is (re)applied every call so the picker's filter matches the
509        /// current lane (the `.BREP.json` model lane vs. a `.step` import lane).
510        fn ensure_input(accept: &str) -> Option<web_sys::HtmlInputElement> {
511            if let Some(existing) = IMPORT_INPUT.with(|c| c.borrow().clone()) {
512                existing.set_accept(accept);
513                return Some(existing);
514            }
515            let document = web_sys::window()?.document()?;
516            let input: web_sys::HtmlInputElement =
517                document.create_element("input").ok()?.dyn_into().ok()?;
518            input.set_type("file");
519            input.set_accept(accept);
520            input.set_hidden(true);
521
522            // On change: read files[0] as text; on the reader's load, stash it.
523            let input_for_cb = input.clone();
524            let onchange = Closure::wrap(Box::new(move |_e: web_sys::Event| {
525                let Some(files) = input_for_cb.files() else { return };
526                let Some(file) = files.get(0) else { return };
527                let name = file.name();
528                let Ok(reader) = web_sys::FileReader::new() else { return };
529                let reader_for_load = reader.clone();
530                let onload = Closure::wrap(Box::new(move |_e: web_sys::Event| {
531                    if let Some(text) = reader_for_load.result().ok().and_then(|v| v.as_string())
532                    {
533                        IMPORTED
534                            .with(|c| *c.borrow_mut() = Some((model_display_name(&name), text)));
535                    }
536                }) as Box<dyn FnMut(web_sys::Event)>);
537                reader.set_onload(Some(onload.as_ref().unchecked_ref()));
538                // One small per-import leak (the app runs for the page lifetime).
539                onload.forget();
540                let _ = reader.read_as_text(&file);
541            }) as Box<dyn FnMut(web_sys::Event)>);
542            input.set_onchange(Some(onchange.as_ref().unchecked_ref()));
543            onchange.forget(); // created once — leak is bounded
544
545            if let Some(body) = document.body() {
546                let _ = body.append_child(&input);
547            }
548            IMPORT_INPUT.with(|c| *c.borrow_mut() = Some(input.clone()));
549            Some(input)
550        }
551    }
552
553    impl ModelStore for LocalModelStore {
554        fn backend_label(&self) -> String {
555            "browser storage (localStorage) · download/upload for files".into()
556        }
557
558        fn list(&self) -> Vec<String> {
559            let Some(storage) = Self::storage() else {
560                return Vec::new();
561            };
562            let mut names = Vec::new();
563            let len = storage.length().unwrap_or(0);
564            for i in 0..len {
565                if let Ok(Some(key)) = storage.key(i) {
566                    if let Some(rest) = key.strip_prefix(PREFIX) {
567                        names.push(rest.to_string());
568                    }
569                }
570            }
571            names.sort();
572            names
573        }
574
575        fn read(&self, name: &str) -> Option<String> {
576            Self::storage()?.get_item(&Self::key(name)).ok()?
577        }
578
579        fn write(&self, name: &str, contents: &str) -> Result<(), String> {
580            let storage = Self::storage().ok_or("no localStorage")?;
581            storage
582                .set_item(&Self::key(name), contents)
583                .map_err(|_| "localStorage write failed (quota?)".to_string())
584        }
585
586        fn remove(&self, name: &str) -> Result<(), String> {
587            if let Some(storage) = Self::storage() {
588                let _ = storage.remove_item(&Self::key(name));
589            }
590            Ok(())
591        }
592
593        fn supports_file_interchange(&self) -> bool {
594            true
595        }
596
597        /// Offer the document to the user as a `<name>.BREP.json` download via a
598        /// Blob object-URL + a synthetic anchor click. The returned identity is
599        /// the bare name (the browser owns where the download lands).
600        fn export_file(&self, name: &str, contents: &str) -> Result<Option<String>, String> {
601            let document = web_sys::window()
602                .and_then(|w| w.document())
603                .ok_or("no document")?;
604            let parts = js_sys::Array::of1(&JsValue::from_str(contents));
605            let options = web_sys::BlobPropertyBag::new();
606            options.set_type("application/json");
607            let blob = web_sys::Blob::new_with_str_sequence_and_options(&parts, &options)
608                .map_err(|_| "blob create failed".to_string())?;
609            let url = web_sys::Url::create_object_url_with_blob(&blob)
610                .map_err(|_| "object url failed".to_string())?;
611            let anchor: web_sys::HtmlAnchorElement = document
612                .create_element("a")
613                .map_err(|_| "anchor create failed".to_string())?
614                .dyn_into()
615                .map_err(|_| "anchor cast failed".to_string())?;
616            anchor.set_href(&url);
617            anchor.set_download(&format!("{}{MODEL_EXT}", model_display_name(name)));
618            anchor.click();
619            let _ = web_sys::Url::revoke_object_url(&url);
620            Ok(Some(model_display_name(name)))
621        }
622
623        fn begin_import(&self) -> Result<(), String> {
624            let input = Self::ensure_input(".json,.BREP.json,application/json")
625                .ok_or("file input unavailable")?;
626            // Clear so re-selecting the same file still fires `change`.
627            input.set_value("");
628            input.click();
629            Ok(())
630        }
631
632        fn take_import(&self) -> Option<(String, String)> {
633            IMPORTED.with(|c| c.borrow_mut().take())
634        }
635
636        // Format-typed interchange: the same hidden input, refiltered to the
637        // requested extensions. The onchange handler stashes the file's real name
638        // (its extension survives `model_display_name`, since that only strips
639        // `.json` / `.BREP.json`), so the panel routes STEP imports by extension.
640        fn begin_import_filtered(&self, filter: (&str, &[&str])) -> Result<(), String> {
641            let accept = filter
642                .1
643                .iter()
644                .map(|ext| format!(".{ext}"))
645                .collect::<Vec<_>>()
646                .join(",");
647            let input = Self::ensure_input(&accept).ok_or("file input unavailable")?;
648            input.set_value("");
649            input.click();
650            Ok(())
651        }
652
653        /// Offer `contents` as a download under EXACTLY `file_name` (extension
654        /// kept) — the foreign-format sibling of [`Self::export_file`].
655        fn export_file_named(&self, file_name: &str, contents: &str) -> Result<(), String> {
656            let document = web_sys::window()
657                .and_then(|w| w.document())
658                .ok_or("no document")?;
659            let parts = js_sys::Array::of1(&JsValue::from_str(contents));
660            let options = web_sys::BlobPropertyBag::new();
661            options.set_type("application/octet-stream");
662            let blob = web_sys::Blob::new_with_str_sequence_and_options(&parts, &options)
663                .map_err(|_| "blob create failed".to_string())?;
664            let url = web_sys::Url::create_object_url_with_blob(&blob)
665                .map_err(|_| "object url failed".to_string())?;
666            let anchor: web_sys::HtmlAnchorElement = document
667                .create_element("a")
668                .map_err(|_| "anchor create failed".to_string())?
669                .dyn_into()
670                .map_err(|_| "anchor cast failed".to_string())?;
671            anchor.set_href(&url);
672            anchor.set_download(file_name);
673            anchor.click();
674            let _ = web_sys::Url::revoke_object_url(&url);
675            Ok(())
676        }
677    }
678}
679
680#[cfg(all(test, not(target_arch = "wasm32")))]
681mod tests {
682    use super::native_model::FileModelStore;
683    use super::ModelStore;
684
685    #[test]
686    fn native_model_store_round_trips_named_documents() {
687        let dir = std::env::temp_dir().join(format!("brep-app-models-{}", std::process::id()));
688        let _ = std::fs::remove_dir_all(&dir);
689        let store = FileModelStore::with_dir(dir.clone());
690
691        // Empty to start.
692        assert!(store.list().is_empty());
693        assert_eq!(store.read("missing"), None);
694
695        // Write two documents, read them back verbatim, and enumerate by name.
696        let doc_a = r#"{"features":[{"type":"P.CU"}]}"#;
697        let doc_b = r#"{"features":[]}"#;
698        store.write("alpha", doc_a).unwrap();
699        store.write("beta", doc_b).unwrap();
700        assert_eq!(store.read("alpha").as_deref(), Some(doc_a));
701        assert_eq!(store.read("beta").as_deref(), Some(doc_b));
702        assert_eq!(store.list(), vec!["alpha".to_string(), "beta".to_string()]);
703
704        // The `.BREP.json` extension is transparent to the name.
705        assert_eq!(store.read("alpha.BREP.json").as_deref(), Some(doc_a));
706
707        // Overwrite + remove.
708        store.write("alpha", doc_b).unwrap();
709        assert_eq!(store.read("alpha").as_deref(), Some(doc_b));
710        store.remove("alpha").unwrap();
711        assert_eq!(store.read("alpha"), None);
712        assert_eq!(store.list(), vec!["beta".to_string()]);
713        store.remove("alpha").unwrap(); // removing an absent doc is Ok
714
715        let _ = std::fs::remove_dir_all(&dir);
716    }
717}