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//! Desktop reads the real filesystem on every call. The browser CANNOT: IndexedDB
9//! has no synchronous read on the main thread, so "go to the live filesystem every
10//! time" is not implementable there — the mirror is not a convenience, it is the
11//! only way this API can be synchronous at all. What the mirror owes in exchange
12//! is that it never falls behind the storage it stands for, and the key space has
13//! MORE than one writer: every other tab of the same origin. So each tab announces
14//! the key it committed and re-reads the keys it is told about
15//! ([`mirror_store::MirrorStore::refresh_key`], over the `brep-app:store` broadcast
16//! in [`web_model`]). Save a part in one tab and the tab holding the assembly can
17//! insert it — no reload.
18
19/// Reserved persistent-object names used internally for application state.
20pub const SETTINGS_KEY: &str = "@settings";
21pub const FEATURE_PALETTE_DISPLAY_KEY: &str = "@feature_palette_display";
22pub const DOCK_LAYOUT_KEY: &str = "@dock_layout";
23/// Pinned explorer locations (a JSON array of navigable location strings),
24/// persisted through the ordinary `read`/`write` CRUD like the other reserved
25/// keys so the sidebar needs no dedicated trait surface.
26pub const PINNED_KEY: &str = "@pinned";
27/// The AUTOSAVE blob: every open document that had unsaved changes the last
28/// time the shell's autosave fired (see `crate::recovery`), so a crash, a
29/// closed tab or a reload can offer that work back. Written debounced, removed
30/// the moment nothing is dirty; never listed as a document.
31pub const RECOVERY_KEY: &str = "@recovery";
32/// The KiCad library folders the KiCad import last used (`panels::kicad_import`).
33/// Desktop only: the web build cannot read a KiCad library, so it never writes it.
34pub const KICAD_LIBRARY_KEY: &str = "@kicad_library";
35/// The adoption importer's ledgers (`plm::adoption::ImportLedgers`), per PLM
36/// server and per imported folder: what a stopped import resumes from. It
37/// never leaves the machine — it records THIS machine's runs against a
38/// server, and the source tree it describes is on this machine.
39pub const PLM_IMPORT_KEY: &str = "@plm_import";
40
41/// The reserved names that are a user's PREFERENCES (plm-cad-integration-todo
42/// D1): state that follows the user, not the machine. A file backend keeps them
43/// where it always has; a PLM backend serves them from its per-user store (P5).
44///
45/// `@pinned` is listed here as what it IS — explorer FOLDER locations, not
46/// document identities — so in PLM mode it pins workspace folders (S14), never
47/// `part/…/rev/…` keys.
48pub const PREFERENCE_KEYS: &[&str] = &[
49    SETTINGS_KEY,
50    DOCK_LAYOUT_KEY,
51    PINNED_KEY,
52    FEATURE_PALETTE_DISPLAY_KEY,
53    KICAD_LIBRARY_KEY,
54];
55
56/// The reserved names that never have to leave the machine to be safe: the
57/// local-first copy of `@recovery` — a PLM session MIRRORS it to the server
58/// after the local write (D1, S1's composition); the local copy is what a
59/// network drop cannot take away — and `@plm_import`, which is never
60/// mirrored.
61pub const LOCAL_KEYS: &[&str] = &[RECOVERY_KEY, PLM_IMPORT_KEY];
62
63/// Which of the three key spaces a store name belongs to (D1, the round 10
64/// answer). Every name is exactly one of them:
65///
66/// * **Documents** — model documents; the PLM's store routes
67///   (`part/<part>/rev/<revision>`), a file path or a browser `/models/...`
68///   name on the file backends.
69/// * **Preferences** — [`PREFERENCE_KEYS`].
70/// * **Local** — [`LOCAL_KEYS`].
71///
72/// The browser's explorer DIRECTORY rows are none of the three: they belong to
73/// the file backends only (a PLM user's folders are the workspace, S14), so they
74/// never reach this classifier — see `mirror_store::key_space`.
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76pub enum KeySpace {
77    Documents,
78    Preferences,
79    Local,
80}
81
82impl KeySpace {
83    /// The key space of a store NAME (what callers pass to
84    /// [`ModelStore::read`] / [`ModelStore::write`]).
85    pub fn of(name: &str) -> Self {
86        if PREFERENCE_KEYS.contains(&name) {
87            KeySpace::Preferences
88        } else if LOCAL_KEYS.contains(&name) {
89            KeySpace::Local
90        } else {
91            KeySpace::Documents
92        }
93    }
94}
95
96/// How much of a document a store holds right now — the synchronous answer to
97/// "can `read` give me this?" that a store with an index-only hydrate owes.
98///
99/// A store that holds everything it lists (the native filesystem, a browser
100/// origin hydrated whole) only ever answers `Absent` or `Resident`. A store
101/// hydrated from an INDEX (the mirror's `OnDisk` entries — the PLM catalog is
102/// the corpus that needs it) also answers `OnDisk`: the name exists and its
103/// size is known, but its bytes are still on the backend, and a `read` of it
104/// returns `None` while it asks for them.
105#[derive(Clone, Copy, Debug, PartialEq, Eq)]
106pub enum Residency {
107    Absent,
108    OnDisk,
109    Resident,
110}
111
112/// A document as a caller can use it this frame: its bytes, still loading,
113/// or not there ([`read_now`]).
114#[derive(Clone, Debug, PartialEq, Eq)]
115pub enum ReadNow {
116    Ready(String),
117    Loading,
118    Absent,
119}
120
121/// Read `name`, telling "not loaded yet" from "gone".
122///
123/// Not simply `read` then `residency`: a `read` of an `OnDisk` entry asks the
124/// backend for the bytes, and a backend that answers at once (an in-process
125/// router, a local cache) lands them INSIDE that read. `read` has already
126/// said `None`, and `residency` now says `Resident`. Read naively, that is a
127/// document reported missing that is there. So a `None` that turned resident
128/// is read again.
129pub fn read_now(store: &dyn ModelStore, name: &str) -> ReadNow {
130    if let Some(contents) = store.read(name) {
131        return ReadNow::Ready(contents);
132    }
133    match store.residency(name) {
134        Residency::Resident => store.read(name).map_or(ReadNow::Absent, ReadNow::Ready),
135        Residency::OnDisk => ReadNow::Loading,
136        Residency::Absent => ReadNow::Absent,
137    }
138}
139
140/// A file selected outside the model store. Keeping bytes verbatim allows the
141/// same upload channel to carry binary STL as well as text CAD formats.
142pub struct ImportedFile {
143    pub name: String,
144    pub bytes: Vec<u8>,
145}
146
147/// One entry exposed to the common in-application filesystem browser.
148#[derive(Clone, Debug, PartialEq)]
149pub struct BrowserEntry {
150    pub name: String,
151    pub identity: String,
152    pub is_dir: bool,
153    /// File size in bytes for the Size column. `None` for directories or where
154    /// the backend cannot report it.
155    pub size: Option<u64>,
156    /// Last-modified time as whole Unix seconds for the Date column. `None` where
157    /// unavailable — the browser key/value backends store values, not timestamps.
158    pub modified: Option<f64>,
159}
160
161/// A quick-access destination for the explorer's left sidebar.
162#[derive(Clone, Debug, PartialEq, Eq)]
163pub struct BrowserPlace {
164    /// Human label shown in the sidebar (e.g. `"Home"`, `"Models"`, a disk name).
165    pub label: String,
166    /// The navigable location — pass to [`ModelStore::browser_navigate`].
167    pub location: String,
168    /// Which built-in glyph the widget draws for this place.
169    pub kind: PlaceKind,
170}
171
172/// The category of a [`BrowserPlace`], so the widget owns the icon styling.
173#[derive(Clone, Copy, Debug, PartialEq, Eq)]
174pub enum PlaceKind {
175    Home,
176    Documents,
177    Downloads,
178    Models,
179    Root,
180}
181// A MODEL is a whole document
182// (the engine-owned `HistoryRequest` JSON — one `.nbrep` recipe), and the
183// file panel needs to *enumerate*, read, and write NAMED documents, plus (in the
184// browser) hand a file to / take a file from the user's real filesystem. The same
185// trait also stores reserved application-state blobs. Platform-specific behavior
186// remains behind two `#[cfg]`-gated implementations.
187//
188// The trait is deliberately a **named-document CRUD** (`list` / `read` / `write`
189// / `remove`) plus a poll-based **file-interchange** side-channel. That shape is
190// exactly what a later **GitHub backend** needs: `list` = a repo directory
191// listing, `read` = fetch a file's contents, `write` = create/update (commit)
192// a file, `remove` = delete a file — the model name is the path within the repo.
193// An async backend (GitHub over HTTP, or the browser File System Access API /
194// OPFS whose main-thread API is async) slots in behind the SAME trait by driving
195// its request on a background task and surfacing the result through the
196// `begin_import` → `take_import` poll pattern used here for uploads, so the
197// synchronous panel code never changes. GitHub itself is a deferred follow-up;
198// this lands the seam it plugs into.
199
200/// The media type an exported file is handed to the user with, from its own
201/// extension.
202///
203/// A browser download IS its media type: the type on the `Blob` is what decides
204/// whether the file opens in a viewer, is offered as a download, or is mailed on
205/// correctly — the name alone is a hint. So the two export doors below take it
206/// from here rather than each hard-coding one, which is what they used to do
207/// (`model/gltf-binary` on everything the byte lane carried, because GLB was the
208/// only thing it carried, and `application/octet-stream` on every text export).
209///
210/// Unknown extensions fall back to `application/octet-stream`, which is the
211/// correct "some bytes, download them" answer.
212pub fn media_type(file_name: &str) -> &'static str {
213    let extension = std::path::Path::new(file_name)
214        .extension()
215        .map(|extension| extension.to_string_lossy().to_ascii_lowercase())
216        .unwrap_or_default();
217    match extension.as_str() {
218        "pdf" => "application/pdf",
219        "svg" => "image/svg+xml",
220        "glb" => "model/gltf-binary",
221        "json" => "application/json",
222        // The PCB's fabrication bundle.
223        "zip" => "application/zip",
224        // A model document is JSON inside, but typed as opaque bytes: a browser
225        // that sees a JSON type on a download may "correct" the name to
226        // `bracket.nbrep.json`, and the name is the only thing that routes it.
227        "nbrep" | "fbrep" | "tbrep" => "application/octet-stream",
228        // The CAD text formats. `model/step+zip` is the registered type for a
229        // zipped STEP; a plain `.step` file is `model/step`.
230        "step" | "stp" => "model/step",
231        "iges" | "igs" => "model/iges",
232        "stl" => "model/stl",
233        "obj" => "model/obj",
234        "dxf" => "image/vnd.dxf",
235        "csv" => "text/csv",
236        "txt" => "text/plain",
237        _ => "application/octet-stream",
238    }
239}
240
241/// The application's single persistent-storage abstraction. Reserved application
242/// keys and named model documents are read and written through the same methods.
243/// All methods take `&self`; mutable backend state lives behind interior mutability.
244pub trait ModelStore {
245    /// A short human label of where documents persist, for the panel header
246    /// (e.g. `"filesystem: ~/.config/brep-app/models"` or `"browser storage"`).
247    fn backend_label(&self) -> String {
248        "persistent storage".into()
249    }
250
251    /// The names of the documents currently available to **Open** (bare names,
252    /// no extension). May be empty on a backend that cannot enumerate — then the
253    /// panel falls back to the name field / import.
254    fn list(&self) -> Vec<String> {
255        Vec::new()
256    }
257
258    /// Read a stored document by name, or `None` if absent/unreadable.
259    fn read(&self, name: &str) -> Option<String>;
260
261    /// Create or overwrite the document `name` with `contents`. `Err` carries a
262    /// message the panel surfaces in its status line.
263    fn write(&self, name: &str, contents: &str) -> Result<(), String>;
264
265    /// Delete the document `name` (best-effort; `Ok` if it is already gone).
266    fn remove(&self, _name: &str) -> Result<(), String> {
267        Ok(())
268    }
269
270    /// A monotonic count of this store's MUTATIONS — every `write`, `remove`
271    /// and `browser_write` that landed, WHOEVER made it. A read never moves
272    /// it; two reads that agree mean nothing was written in between.
273    ///
274    /// This is a cache-invalidation key, which is why it is REQUIRED rather
275    /// than defaulted: a default would let a new backend (the GitHub one this
276    /// trait is shaped for) compile while silently never invalidating
277    /// anything, which is the failure it exists to stop. The outdated-parts
278    /// badge (`panels::update_components`) used to key on the file dialog's
279    /// own save counter, which counts Save / Save As / native Save-As and
280    /// NOTHING else — so a KiCad re-import (`panels::kicad_bulk::write_part`)
281    /// or the explorer's delete row could change a part's source under an open
282    /// assembly, move no other generation, and leave the badge dark.
283    fn mutation_generation(&self) -> u64;
284
285    /// Whether `name` is absent, listed but not yet loaded, or readable now
286    /// (see [`Residency`]). The default answers from `read`, which is exact for
287    /// every store that holds what it lists; only an index-hydrated store
288    /// overrides it. No caller branches on `OnDisk` yet — it is here so one
289    /// CAN tell "not loaded yet" from "gone" without a second store API.
290    fn residency(&self, name: &str) -> Residency {
291        match self.read(name) {
292            Some(_) => Residency::Resident,
293            None => Residency::Absent,
294        }
295    }
296
297    /// What kind of identity `identity` is on THIS store (see
298    /// [`DocumentIdentity`] and the enumeration above it). Every file backend
299    /// keys by path, so the default never parses: a browser folder named
300    /// `part/7/rev` holds a document, not a revision.
301    fn identity(&self, identity: &str) -> DocumentIdentity {
302        DocumentIdentity::Path(identity.to_string())
303    }
304
305    /// The name a person sees for `identity`: the tab title, the name field.
306    /// The default is the file rule, [`model_display_name`]; a PLM store knows
307    /// the part number and revision label from its index, which the key alone
308    /// does not carry.
309    fn display_name(&self, identity: &str) -> String {
310        model_display_name(identity)
311    }
312
313    /// The identity of `file_name` beside the document `identity` — or `None`
314    /// on a store with no folders to be beside (the PLM, where a family member
315    /// or a spun-out part is a new part instead, S7 / S9). The default is the
316    /// file rule, [`sibling_identity`].
317    fn sibling_of(&self, identity: &str, file_name: &str) -> Option<String> {
318        Some(sibling_identity(identity, file_name))
319    }
320
321    /// The ONE spelling of the document `name` resolves to, as the explorer
322    /// spells it (`BrowserEntry::identity`), so two names for one document
323    /// compare equal: a bare `bolt` and `/home/u/.config/brep-app/models/bolt.nbrep`
324    /// on the desktop, `sub/bolt` and `/models/sub/bolt.nbrep` in the browser.
325    ///
326    /// The adoption importer (S13) needs it. An assembly's `sourceKey`s are
327    /// whatever name the part was inserted by, and the importer must match
328    /// each one to the file it walked. The default is `name` unchanged, which
329    /// is right for a store with one spelling per document (a PLM store's
330    /// revision keys).
331    fn canonical_identity(&self, name: &str) -> String {
332        name.to_string()
333    }
334
335    /// Persistence failures that surfaced AFTER a `write` / `remove` already
336    /// returned `Ok` — the price of a WRITE-BEHIND backend (see
337    /// [`mirror_store`]). The app shell drains this once per frame into the toast
338    /// overlay, so a save that never reached storage is never silent: the
339    /// in-memory copy means nothing is lost mid-session, but the user has to know
340    /// it will not survive a reload. Empty on a backend that writes synchronously
341    /// (native files), which reports through `write`'s `Err` instead.
342    fn take_persistence_errors(&self) -> Vec<String> {
343        Vec::new()
344    }
345
346    /// The PLM this session is on, or is configured for and not on (why, when
347    /// known): what the Settings PLM tab shows (plan S1 part 3). `None` is the
348    /// file-based app, and the tab is absent.
349    fn plm_session(&self) -> Option<crate::plm::connection::Session> {
350        None
351    }
352
353    /// The signed-in PLM client of a session whose store IS the PLM, for the
354    /// calls that are not store reads and writes (Generate, spin-out, the
355    /// lifecycle verbs). `None` on every file store, and on a PLM configured
356    /// but not connected.
357    fn plm_client(&self) -> Option<std::rc::Rc<crate::plm::client::PlmClient>> {
358        None
359    }
360
361    /// What the PLM's store index says of revision `key` (`part/…/rev/…`):
362    /// its part number, label, lifecycle and lock, as the session last read
363    /// them. `None` on every file store and for a key the index does not list.
364    /// No request: this is the index the session already holds.
365    fn plm_revision(&self, key: &str) -> Option<crate::plm::client::IndexEntry> {
366        let _ = key;
367        None
368    }
369
370    /// Bring the held index up to date after the change feed moved: `keys`
371    /// are the revisions the feed named, `stale` means it could not say. A
372    /// no-op on every file store.
373    fn refresh_plm_index(&self, keys: &[String], stale: bool) {
374        let _ = (keys, stale);
375    }
376
377    /// This machine's own files, browsable, for the file chooser (any file,
378    /// any folder): the native file store itself, or the file store a PLM
379    /// session keeps beside the server. `None` in the browser, which picks
380    /// through [`Self::begin_pick_file`] instead.
381    fn local_files(&self) -> Option<&dyn ModelStore> {
382        None
383    }
384
385    /// The browser's file chooser for ANY file (not the model lane's
386    /// `.nbrep` input): the choice arrives later through
387    /// [`Self::take_picked_file`], under the file's own name.
388    fn begin_pick_file(&self) -> Result<(), String> {
389        Err("this platform has no file chooser".into())
390    }
391
392    /// The file [`Self::begin_pick_file`]'s chooser delivered, once.
393    fn take_picked_file(&self) -> Option<ImportedFile> {
394        None
395    }
396
397    /// Writes handed to a WRITE-BEHIND backend that have not settled yet — the
398    /// status bar's working indicator reports them ("Saving to browser
399    /// storage"). Always 0 on a backend that writes synchronously.
400    fn pending_writes(&self) -> usize {
401        0
402    }
403
404    // --- common file-browser filesystem --------------------------------------
405
406    /// Current directory shown by the embedded explorer.
407    fn browser_location(&self) -> String {
408        self.backend_label()
409    }
410
411    /// Directories and matching files at the current explorer location.
412    /// Directories are never filtered. The default implementation presents the
413    /// backend's named model collection as a flat virtual directory.
414    fn browser_entries(&self, _extensions: &[&str]) -> Vec<BrowserEntry> {
415        self.list()
416            .into_iter()
417            .map(|name| BrowserEntry {
418                identity: name.clone(),
419                name,
420                is_dir: false,
421                size: None,
422                modified: None,
423            })
424            .collect()
425    }
426
427    fn browser_enter(&self, _identity: &str) -> Result<(), String> {
428        Err("this storage backend has no directories".into())
429    }
430
431    fn browser_up(&self) -> Result<(), String> {
432        Ok(())
433    }
434
435    fn browser_home(&self) -> Result<(), String> {
436        Ok(())
437    }
438
439    fn browser_root(&self) -> Result<(), String> {
440        Ok(())
441    }
442
443    /// Navigate the explorer directly to `location` — a value previously returned
444    /// by [`Self::browser_location`], a [`BrowserPlace::location`], a breadcrumb
445    /// ancestor, or a user-typed path. `Err` if it is not a directory this backend
446    /// can browse. Powers the breadcrumb, the sidebar, back/forward, and the
447    /// path-edit field. Default: unsupported.
448    fn browser_navigate(&self, _location: &str) -> Result<(), String> {
449        Err("this storage backend cannot navigate to a path".into())
450    }
451
452    /// Quick-access places for the explorer's left sidebar (home, documents, the
453    /// models root, disks…). Default: none, and the sidebar hides itself.
454    fn browser_places(&self) -> Vec<BrowserPlace> {
455        Vec::new()
456    }
457
458    /// Create a child directory at the current browser location.
459    fn browser_create_dir(&self, _name: &str) -> Result<(), String> {
460        Err("this storage backend cannot create directories".into())
461    }
462
463    /// Write a filename at the current browser location and return its stable
464    /// identity for subsequent plain Save operations.
465    fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
466        self.write(name, contents)?;
467        Ok(name.to_string())
468    }
469
470    /// Files available to the in-app explorer for a foreign-format import.
471    /// Names retain their extension. Browser storage returns none and offers an
472    /// Upload button instead; desktop enumerates its application files directory.
473    fn list_external_files(&self, _extensions: &[&str]) -> Vec<String> {
474        Vec::new()
475    }
476
477    /// Read one entry returned by [`Self::list_external_files`].
478    fn read_external_file(&self, _name: &str) -> Option<Vec<u8>> {
479        None
480    }
481
482    // --- real-file interchange (the platform "fallback") ----------------------
483    // The browser cannot silently write to an arbitrary path; these methods let
484    // it move a document to/from the user's real filesystem. Desktop instead
485    // browses and writes its application files directory directly.
486
487    /// Whether this backend can exchange files with the user's real filesystem
488    /// (browser download+upload). The panel shows the Upload affordance only when
489    /// this is `true`.
490    fn supports_file_interchange(&self) -> bool {
491        false
492    }
493
494    /// Hand `contents` to the user as a file named after `name` (browser: a
495    /// download; native w/ dialog: a Save-As). Returns the saved document's
496    /// identity — the full path the user chose on native (so the caller can
497    /// re-save straight to it), the bare download name on the web — or `None`
498    /// when the user cancelled. No-op (`None`) by default.
499    fn export_file(&self, name: &str, contents: &str) -> Result<Option<String>, String> {
500        let _ = (name, contents);
501        Ok(None)
502    }
503
504    /// Begin importing a real file — opens the platform picker. The result is
505    /// retrieved later via [`Self::take_import`] (upload/read is async in the
506    /// browser). No-op by default.
507    fn begin_import(&self) -> Result<(), String> {
508        Ok(())
509    }
510
511    /// Poll for a completed import, consuming it.
512    /// `None` until a `begin_import` finishes. Default: never any.
513    fn take_import(&self) -> Option<ImportedFile> {
514        None
515    }
516
517    // --- format-typed interchange (CAD / mesh) --------------------------------
518    // The model lanes above trade the `.nbrep` recipe; import/export of a
519    // foreign format (STEP text, ASCII STL) needs a DIFFERENT picker filter and
520    // must NOT mangle the file extension. These two methods add that lane while
521    // leaving the model lanes byte-for-byte. They share the SAME `take_import`
522    // pickup channel — the panel routes the result by its filename extension.
523
524    /// Begin importing a real file behind a specific picker `filter` (a human
525    /// label + dot-less extensions, e.g. `("STEP", &["step","stp"])`). The chosen
526    /// file's contents + its FULL name (extension preserved, so the panel can
527    /// route it) arrive via [`Self::take_import`]. Default: reuse [`Self::begin_import`].
528    fn begin_import_filtered(&self, _filter: (&str, &[&str])) -> Result<(), String> {
529        self.begin_import()
530    }
531
532    /// Hand `contents` to the user under EXACTLY `file_name` (extension included,
533    /// no model-extension munging) — the Save-As / download for a foreign export
534    /// format. The file's media type comes from its extension
535    /// ([`media_type`]). Default no-op (interchange unsupported).
536    fn export_file_named(&self, _file_name: &str, _contents: &str) -> Result<(), String> {
537        Ok(())
538    }
539
540    /// [`Self::export_file_named`] for a BINARY format (GLB, a sheet PDF). Every
541    /// text export could go through this one — text is bytes — but the text
542    /// method stays because the browser's `Blob` takes a string directly and
543    /// routing UTF-8 through a `Uint8Array` for every STEP file would be a copy
544    /// for nothing. Both lanes type the file the same way, from its extension,
545    /// so which lane an export takes is a question about copies and never about
546    /// what the user receives. Default no-op, like its text sibling.
547    fn export_file_named_bytes(&self, _file_name: &str, _contents: &[u8]) -> Result<(), String> {
548        Ok(())
549    }
550}
551
552/// Construct the platform's one persistent store for application state and models.
553/// A native file store rooted at `dir` — the seam an automation host uses to keep
554/// a session's settings, autosave and recovery blob out of the user's own config
555/// directory.
556///
557/// Laid out the way the SHIPPED app lays out its own config directory: models
558/// in `<dir>/models`, reserved application state beside them in `<dir>`. A
559/// session whose store collapsed the two would show the app its own
560/// `dock_layout.json` and `recovery.json` as documents in the Open dialog —
561/// rows a user never sees, in the one place automation exists to drive.
562///
563/// A `plm.json` in `dir` (what the test-mcp PLM fixture writes) makes the
564/// session a PLM session instead: see [`boot_native_store`]. Only the files
565/// count here — never `BREP_PLM_URL` — so a host's own environment cannot leak
566/// into a session it isolated. No `plm.json` is exactly the file store of
567/// before the PLM existed.
568#[cfg(not(target_arch = "wasm32"))]
569pub fn native_store_at(dir: std::path::PathBuf) -> Box<dyn ModelStore> {
570    match crate::plm::config::resolve(&dir, &|_| None, &Default::default()) {
571        Ok(None) => Box::new(native_model::FileModelStore::rooted_at(dir)),
572        Ok(Some(config)) => boot_native_store(dir, config),
573        Err(e) => file_store_with_notice(dir, format!("PLM config: {e}")),
574    }
575}
576
577/// The file store at `root`, saying on its first frame why it is not the PLM.
578#[cfg(not(target_arch = "wasm32"))]
579pub fn file_store_with_notice(root: std::path::PathBuf, reason: String) -> Box<dyn ModelStore> {
580    Box::new(
581        native_model::FileModelStore::rooted_at(root)
582            .with_notice(format!("{reason} — this session uses the file stores"))
583            .with_plm_reason(reason),
584    )
585}
586
587#[cfg(not(target_arch = "wasm32"))]
588pub use native_boot::{boot_native_store, check_native_plm, NativeFileBackend, BOOT_TIMEOUT};
589
590pub fn default_model_store() -> Box<dyn ModelStore> {
591    #[cfg(not(target_arch = "wasm32"))]
592    {
593        Box::new(native_model::FileModelStore::new())
594    }
595    #[cfg(target_arch = "wasm32")]
596    {
597        // Already built and HYDRATED by [`hydrate_web_store`] inside the async
598        // wasm entry point, so this is a hand-off, not a construction.
599        web_model::take_boot_store()
600    }
601}
602
603/// wasm: bring up browser persistence and pull the whole key space into memory.
604/// MUST be awaited before `eframe::WebRunner::start`, because everything
605/// downstream of it — [`default_model_store`], every `read` in `BrepApp::new` and
606/// in the frame loop — is synchronous and assumes a complete mirror.
607#[cfg(target_arch = "wasm32")]
608pub async fn hydrate_web_store() {
609    web_model::hydrate().await;
610}
611
612/// wasm: wake the reactive frame loop when an async file upload completes (see
613/// [`web_model::set_repaint_ctx`]). Re-exported here so the app shell reaches it
614/// as `store::set_repaint_ctx` without knowing the platform module.
615#[cfg(target_arch = "wasm32")]
616pub use web_model::set_repaint_ctx;
617
618/// The document extension for a model recipe (`<name>.nbrep`) — the NORMAL
619/// part's. A bare name with no class extension means this one; a family
620/// (`.fbrep`) or template (`.tbrep`) keeps its extension in its name, so its
621/// class survives every round trip (see [`crate::document_class`]).
622pub const MODEL_EXT: &str = ".nbrep";
623
624/// Every model document extension, dot-less, for the explorer's filters.
625pub const MODEL_EXTENSIONS: &[&str] = &["nbrep", "fbrep", "tbrep"];
626
627/// A document file name with its class extension made lowercase, and
628/// `.nbrep` added when it has none: `Bolt` -> `Bolt.nbrep`,
629/// `Bolt.FBREP` -> `Bolt.fbrep`.
630pub fn model_file_name(name: &str) -> String {
631    let class = crate::document_class::DocumentClass::of_name(name).unwrap_or_default();
632    class.file_name(name)
633}
634
635// --- Desktop: the PLM session store ---------------------------------------------
636/// The native half of plan S2: the mirror over a remote backend, hydrated
637/// before the first frame, exactly as the wasm entry point hydrates IndexedDB
638/// before `WebRunner::start`. Built ONLY when a PLM is configured; the
639/// file-based app never reaches this module.
640#[cfg(not(target_arch = "wasm32"))]
641mod native_boot {
642    use super::mirror_store::{BackendFuture, MirrorStore, StoreBackend, DIR_PREFIX, PREFIX};
643    use super::native_model::FileModelStore;
644    use super::{BrowserEntry, BrowserPlace, ModelStore, Residency};
645    use crate::plm::config::PlmConfig;
646    use crate::plm::PlmFuture;
647    use std::path::{Path, PathBuf};
648    use std::rc::Rc;
649
650    /// How long the boot waits for sign-in and the index before giving up and
651    /// starting on the file stores. The window is blank while it waits, so this
652    /// is a bound on a hung server, not an expected duration.
653    pub const BOOT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(20);
654
655    /// Every reserved name this backend stores: exactly the user's preferences
656    /// and the machine's local keys (D1's split, `PREFERENCE_KEYS` ∪
657    /// `LOCAL_KEYS`). DERIVED, not listed: a hand-kept copy once lacked
658    /// `@plm_import`, and every PLM session refused to keep its import ledger.
659    fn reserved() -> impl Iterator<Item = &'static str> {
660        super::PREFERENCE_KEYS.iter().chain(super::LOCAL_KEYS).copied()
661    }
662
663    /// A [`StoreBackend`] over the native file store: the mirror's keys mapped
664    /// back to the names `FileModelStore` has always used, so `@recovery` lands
665    /// in `<root>/recovery.json` in a PLM session exactly as it does in a file
666    /// session. It is the PLM session's LOCAL key space (D1: the local-first
667    /// copy of `@recovery`), handed to the PLM client to compose.
668    ///
669    /// Every call is synchronous file I/O answered with a resolved future, so
670    /// it lands writes of one key in call order by itself.
671    pub struct NativeFileBackend {
672        files: FileModelStore,
673    }
674
675    impl NativeFileBackend {
676        pub fn rooted_at(root: PathBuf) -> Self {
677            Self { files: FileModelStore::rooted_at(root) }
678        }
679
680        /// The store name a mirror key stands for; `None` for an explorer
681        /// directory row, which this backend does not hold.
682        fn name_of(key: &str) -> Option<String> {
683            if let Some(name) = reserved().find(|name| MirrorStore::key(name) == key) {
684                return Some(name.to_string());
685            }
686            if key.starts_with(DIR_PREFIX) {
687                return None;
688            }
689            key.strip_prefix(PREFIX).map(str::to_string)
690        }
691    }
692
693    fn ready<T: 'static>(value: Result<T, String>) -> BackendFuture<T> {
694        Box::pin(std::future::ready(value))
695    }
696
697    impl StoreBackend for NativeFileBackend {
698        fn label(&self) -> String {
699            self.files.backend_label()
700        }
701
702        fn load_all(&self) -> BackendFuture<Vec<(String, String)>> {
703            let reserved = reserved().map(|name| name.to_string());
704            let documents = self.files.list();
705            let all = reserved
706                .chain(documents)
707                .filter_map(|name| self.files.read(&name).map(|value| (MirrorStore::key(&name), value)))
708                .collect();
709            ready(Ok(all))
710        }
711
712        fn orders_writes_per_key(&self) -> bool {
713            true
714        }
715
716        fn get(&self, key: &str) -> BackendFuture<Option<String>> {
717            ready(Ok(Self::name_of(key).and_then(|name| self.files.read(&name))))
718        }
719
720        fn put(&self, key: &str, value: &str) -> BackendFuture<()> {
721            ready(match Self::name_of(key) {
722                Some(name) => self.files.write(&name, value),
723                None => Err(format!("{key}: the local store keeps no explorer folders")),
724            })
725        }
726
727        fn delete(&self, key: &str) -> BackendFuture<()> {
728            ready(match Self::name_of(key) {
729                Some(name) => self.files.remove(&name),
730                None => Ok(()),
731            })
732        }
733    }
734
735    /// Connect to the configured PLM: sign in, check versions, and hand back
736    /// the composed backend (documents and preferences on the server, `local`
737    /// on this machine): S1's `plm::client::open_session`. A refusal is a
738    /// sentence, and the session starts on the file stores saying it.
739    fn plm_connect(config: PlmConfig, local: Rc<dyn StoreBackend>) -> PlmFuture<Rc<dyn StoreBackend>> {
740        crate::plm::client::open_session(config, local)
741    }
742
743    /// The session store for a configured PLM, hydrated before this returns.
744    /// Any failure — refused sign-in, a version the server does not serve, an
745    /// index that will not load, no answer within [`BOOT_TIMEOUT`] — starts the
746    /// session on the file stores at `root` instead, saying why on the first
747    /// frame. A PLM never makes the app fail to start.
748    pub fn boot_native_store(root: PathBuf, config: PlmConfig) -> Box<dyn ModelStore> {
749        let url = config.url.clone();
750        match boot_with(&root, config, plm_connect, BOOT_TIMEOUT) {
751            Ok(store) => Box::new(store),
752            Err(reason) => super::file_store_with_notice(root, format!("PLM at {url}: {reason}")),
753        }
754    }
755
756    /// `brep-app --plm-check`: the boot's own sign-in and version check, and
757    /// nothing else — no index, no store.
758    pub fn check_native_plm(root: PathBuf, config: PlmConfig) -> Result<(), String> {
759        let local: Rc<dyn StoreBackend> = Rc::new(NativeFileBackend::rooted_at(root));
760        crate::plm::native::block_on_for(plm_connect(config, local), BOOT_TIMEOUT)
761            .ok_or_else(|| format!("no answer within {} s", BOOT_TIMEOUT.as_secs()))?
762            .map(|_| ())
763    }
764
765    /// [`boot_native_store`] with the connector and the time limit passed in,
766    /// so a test can boot against a backend of its own.
767    pub(super) fn boot_with(
768        root: &Path,
769        config: PlmConfig,
770        connect: impl FnOnce(PlmConfig, Rc<dyn StoreBackend>) -> PlmFuture<Rc<dyn StoreBackend>>,
771        timeout: std::time::Duration,
772    ) -> Result<NativeMirrorStore, String> {
773        use crate::plm::native::block_on_for;
774        let late = || format!("no answer within {} s", timeout.as_secs());
775        let local: Rc<dyn StoreBackend> = Rc::new(NativeFileBackend::rooted_at(root.to_path_buf()));
776        let backend = block_on_for(connect(config, local), timeout).ok_or_else(late)??;
777        // The index, not the documents: a catalog of thousands of parts must
778        // not load in front of the first frame. `prefetch` streams them after.
779        let entries = block_on_for(backend.load_index(), timeout)
780            .ok_or_else(late)?
781            .map_err(|e| format!("the store index would not load ({e})"))?;
782        let wake: Rc<dyn Fn()> = Rc::new(crate::plm::native::request_wake);
783        let core = MirrorStore::from_index(backend, entries, Some(wake));
784        core.prefetch();
785        Ok(NativeMirrorStore { core, files: FileModelStore::rooted_at(root.to_path_buf()) })
786    }
787
788    /// The native PLM session store: the mirror (every CRUD and explorer
789    /// method) plus this machine's real-file lane — a STEP or PDF export still
790    /// lands in `<root>/models` and an external file is still read from disk,
791    /// as in a file session.
792    pub(super) struct NativeMirrorStore {
793        pub(super) core: MirrorStore,
794        files: FileModelStore,
795    }
796
797    impl ModelStore for NativeMirrorStore {
798        // --- delegated to the mirror ------------------------------------------
799        fn backend_label(&self) -> String {
800            self.core.backend_label()
801        }
802        fn plm_client(&self) -> Option<std::rc::Rc<crate::plm::client::PlmClient>> {
803            self.core.plm_client()
804        }
805        fn plm_revision(&self, key: &str) -> Option<crate::plm::client::IndexEntry> {
806            self.core.plm_revision(key)
807        }
808        fn refresh_plm_index(&self, keys: &[String], stale: bool) {
809            self.core.refresh_plm_index(keys, stale)
810        }
811        fn list(&self) -> Vec<String> {
812            self.core.list()
813        }
814        fn read(&self, name: &str) -> Option<String> {
815            self.core.read(name)
816        }
817        fn residency(&self, name: &str) -> Residency {
818            self.core.residency(name)
819        }
820        fn canonical_identity(&self, name: &str) -> String {
821            self.core.canonical_identity(name)
822        }
823        fn write(&self, name: &str, contents: &str) -> Result<(), String> {
824            self.core.write(name, contents)
825        }
826        fn remove(&self, name: &str) -> Result<(), String> {
827            self.core.remove(name)
828        }
829        fn mutation_generation(&self) -> u64 {
830            self.core.mutation_generation()
831        }
832        // The native queue these failures come from is run by the frame loop
833        // itself (`BrepApp::ui`'s first line), not here: a store call behind an
834        // early return would stall it.
835        fn take_persistence_errors(&self) -> Vec<String> {
836            self.core.take_persistence_errors()
837        }
838        fn plm_session(&self) -> Option<crate::plm::connection::Session> {
839            use crate::plm::connection::{parse_label, Keep, Session, Status};
840            let (url, username) = parse_label(&self.core.backend_label())?;
841            Some(Session { status: Status::Connected { url, username }, keep: Keep::Files(self.files.root().to_path_buf()) })
842        }
843        fn pending_writes(&self) -> usize {
844            self.core.pending_writes()
845        }
846        fn browser_location(&self) -> String {
847            self.core.browser_location()
848        }
849        fn browser_entries(&self, extensions: &[&str]) -> Vec<BrowserEntry> {
850            self.core.browser_entries(extensions)
851        }
852        fn browser_enter(&self, identity: &str) -> Result<(), String> {
853            self.core.browser_enter(identity)
854        }
855        fn browser_up(&self) -> Result<(), String> {
856            self.core.browser_up()
857        }
858        fn browser_home(&self) -> Result<(), String> {
859            self.core.browser_home()
860        }
861        fn browser_root(&self) -> Result<(), String> {
862            self.core.browser_root()
863        }
864        fn browser_navigate(&self, location: &str) -> Result<(), String> {
865            self.core.browser_navigate(location)
866        }
867        fn browser_places(&self) -> Vec<BrowserPlace> {
868            self.core.browser_places()
869        }
870        fn browser_create_dir(&self, name: &str) -> Result<(), String> {
871            self.core.browser_create_dir(name)
872        }
873        fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
874            self.core.browser_write(name, contents)
875        }
876
877        // --- this machine's real files ----------------------------------------
878        fn local_files(&self) -> Option<&dyn ModelStore> {
879            Some(&self.files)
880        }
881        fn list_external_files(&self, extensions: &[&str]) -> Vec<String> {
882            self.files.list_external_files(extensions)
883        }
884        fn read_external_file(&self, name: &str) -> Option<Vec<u8>> {
885            self.files.read_external_file(name)
886        }
887        fn export_file_named(&self, file_name: &str, contents: &str) -> Result<(), String> {
888            self.files.export_file_named(file_name, contents)
889        }
890        fn export_file_named_bytes(&self, file_name: &str, contents: &[u8]) -> Result<(), String> {
891            self.files.export_file_named_bytes(file_name, contents)
892        }
893    }
894
895}
896
897
898
899
900
901
902// --- Document identity -----------------------------------------------------------
903//
904// A document's IDENTITY is the raw string the app keys it by: the name handed
905// to `read` / `write`, kept on the open tab, and copied into everything that
906// has to find the document again. On the file backends it is a path (native),
907// a `/models/...` path (browser) or a bare name (the models folder). On a PLM
908// store it is `part/<part id>/rev/<revision id>` — always a concrete revision,
909// since the CAD app has no floating occurrences (D3).
910//
911// Every place the app keys a document by identity (plm-cad-integration-todo
912// S0, enumerated 2026-09-25 by grep, not recalled):
913//
914// MINTED (a new identity comes into being):
915//  1. `BrowserEntry.identity` — `browser_entries` rows; `browser_write`'s
916//     return value (Save As, the STEP-to-part lane `panels/step_parts.rs`).
917//  2. `Document::name` (`document.rs`) — the open tab's identity, set by Open
918//     / Save / Save As (`panels/file.rs` `save_to`, `save_as`, the open path)
919//     and by recovery. The ROOT: items 3 and 5 are copies of it.
920//  3. The recovery blob's per-entry `name` (`recovery.rs` `RecoveryEntry`),
921//     copied from `Document::name`, read back by `already_saved` and restored
922//     onto the tab.
923//  4. `sibling_identity` — a document beside another: a family's members
924//     (`family_table.rs` `member_identity`, `generate_family`'s
925//     `file_name_of`) and a spun-out part (`panels/file.rs`
926//     `spin_out_target`). There are no folders on a PLM store: S7 / S9
927//     replace these with a new part.
928//  5. The ACOMP occurrence chain's `sourceKey` — `partsLibrary.<part>.sourceKey`
929//     in an assembly document, written at insert (`panels/file.rs`
930//     `insert_component_document`, `panels/step_parts.rs`) from the part's
931//     identity; RESOLVED by `store.read(sourceKey)` in
932//     `panels/update_components.rs`, `panels/parts_library.rs`
933//     (`write_through`), `panels/component_actions.rs` (`part_source_key`,
934//     Edit Part), `panels/bom.rs`, `panels/ecad_parts.rs` (open requests) and
935//     `app.rs`. S13 rewrites file-path sourceKeys to revision keys.
936//
937// DERIVED (read off an identity's shape):
938//  6. `model_display_name` (tab title, name field, duplicate-open check in
939//     `panels/file.rs` `name_is_free`), `file_name_of`, and
940//     `DocumentClass::of_name` (a document's class from its EXTENSION) — a
941//     revision key has neither a file name nor an extension; on a PLM store
942//     the class comes from the index (P3 / S7).
943//
944// NOT identities, though the plan's first draft listed them:
945//  * `@pinned` holds explorer FOLDER locations, never documents — in PLM mode
946//    it pins workspace folders (S14) and stays a Preferences key.
947//  * There is no recent-files list in the app (no `recent`, `mru` or
948//    `last_open` anywhere in `BREP_app/src`).
949//
950// The seam: `ModelStore::identity`, `ModelStore::display_name` and
951// `ModelStore::sibling_of`, whose defaults are exactly the path rules below,
952// so a file backend is unchanged and a PLM backend supplies its own.
953
954/// A document identity read for its shape — see the enumeration above.
955#[derive(Clone, Debug, PartialEq, Eq)]
956pub enum DocumentIdentity {
957    /// A native path, a browser `/models/...` path, or a bare name.
958    Path(String),
959    /// A PLM document key, `part/<part id>/rev/<revision id>`.
960    Revision { part: String, revision: String },
961}
962
963impl DocumentIdentity {
964    /// Read `key` as a PLM revision key — `part/<id>/rev/<id>`, both halves
965    /// non-empty and slash-free, the same split the server's store routes make.
966    ///
967    /// Only a PLM-backed store may call this on its identities: the SHAPE is
968    /// not proof, since a browser document at `models/part/7/rev/3.nbrep`
969    /// lists as `part/7/rev/3`. That is why the file backends never parse.
970    pub fn parse_revision_key(key: &str) -> Option<Self> {
971        match key.split('/').collect::<Vec<_>>().as_slice() {
972            ["part", part, "rev", revision] if !part.is_empty() && !revision.is_empty() => {
973                Some(DocumentIdentity::Revision {
974                    part: part.to_string(),
975                    revision: revision.to_string(),
976                })
977            }
978            _ => None,
979        }
980    }
981
982    /// The raw string this identity is keyed by.
983    pub fn key(&self) -> String {
984        match self {
985            DocumentIdentity::Path(path) => path.clone(),
986            DocumentIdentity::Revision { part, revision } => format!("part/{part}/rev/{revision}"),
987        }
988    }
989}
990
991/// The identity of the file `file_name` in the same folder as the document
992/// `identity`: a native path, a browser `/models/...` path, or — for a
993/// document known only by a bare name — the bare `file_name`, which both
994/// stores resolve into their models folder.
995pub fn sibling_identity(identity: &str, file_name: &str) -> String {
996    match identity.rfind(['/', '\\']) {
997        Some(cut) => format!("{}{file_name}", &identity[..=cut]),
998        None => file_name.to_string(),
999    }
1000}
1001
1002/// The last path component of a document identity (`/a/b/bolt.fbrep` ->
1003/// `bolt.fbrep`), extension kept.
1004pub fn file_name_of(identity: &str) -> String {
1005    identity.rsplit(['/', '\\']).next().unwrap_or(identity).to_string()
1006}
1007
1008/// Strip the model extension (and any directory) from a filename to get the
1009/// bare display name — shared by both platform impls (and the file panel, which
1010/// shows the bare name while keeping the raw identity for re-saves).
1011pub(crate) fn model_display_name(file_name: &str) -> String {
1012    let base = file_name
1013        .rsplit(['/', '\\'])
1014        .next()
1015        .unwrap_or(file_name);
1016    // Only the NORMAL extension is implied: a family or template keeps its
1017    // extension in its name, which is what carries its class.
1018    match crate::document_class::DocumentClass::of_name(base) {
1019        Some(crate::document_class::DocumentClass::Normal) => {
1020            crate::document_class::strip_class_extension(base).to_string()
1021        }
1022        _ => base.to_string(),
1023    }
1024}
1025
1026// --- Desktop: application models/files directory -------------------------------
1027#[cfg(not(target_arch = "wasm32"))]
1028mod native_model {
1029    use super::{
1030        model_display_name, BrowserEntry, BrowserPlace, ModelStore, PlaceKind, DOCK_LAYOUT_KEY,
1031        FEATURE_PALETTE_DISPLAY_KEY, KICAD_LIBRARY_KEY, PINNED_KEY, PLM_IMPORT_KEY, RECOVERY_KEY,
1032        SETTINGS_KEY,
1033    };
1034    use std::cell::{Cell, RefCell};
1035    use std::path::{Path, PathBuf};
1036
1037    /// Persist models as `<config>/brep-app/models/<name>.nbrep` and reserved
1038    /// application state as files under `<config>/brep-app`. Enumerable (for the
1039    /// Open list) and unit-testable without a display.
1040    ///
1041    /// Open, Save As, import, and export are all driven by the application's
1042    /// common egui explorer; no OS-native dialog is involved.
1043    /// The PLM a config root names (D5's files only: what this machine keeps),
1044    /// as "not connected, reason unknown". A file that cannot be read is
1045    /// reported the same way, with the reason.
1046    fn configured_plm(root: &std::path::Path) -> Option<crate::plm::connection::Session> {
1047        use crate::plm::connection::{Keep, Session, Status};
1048        let status = match crate::plm::config::resolve(root, &|_| None, &Default::default()) {
1049            Ok(None) => return None,
1050            Ok(Some(config)) => Status::NotConnected { url: config.url, reason: None },
1051            Err(e) if root.join("plm.json").exists() => Status::NotConnected { url: String::new(), reason: Some(e) },
1052            Err(_) => return None,
1053        };
1054        Some(Session { status, keep: Keep::Files(root.to_path_buf()) })
1055    }
1056
1057    pub struct FileModelStore {
1058        app_dir: PathBuf,
1059        dir: PathBuf,
1060        browser_dir: RefCell<PathBuf>,
1061        /// See [`ModelStore::mutation_generation`]. A plain `Cell` — this store
1062        /// is built once and held behind one `Box`, never cloned.
1063        mutations: Cell<u64>,
1064        /// Said once, on the first frame: why a session that asked for a PLM is
1065        /// on the file stores instead. Empty in every file-only session.
1066        notices: RefCell<Vec<String>>,
1067        /// The PLM configured in `app_dir` (read once, when the store is
1068        /// built) and why this session is not on it: the PLM tab's status.
1069        plm: RefCell<Option<crate::plm::connection::Session>>,
1070    }
1071
1072    impl FileModelStore {
1073        pub fn new() -> Self {
1074            // The one config-directory rule, shared with the PLM's two files.
1075            let app_dir = crate::plm::config::app_config_dir();
1076            let plm = configured_plm(&app_dir);
1077            Self {
1078                dir: app_dir.join("models"),
1079                browser_dir: RefCell::new(app_dir.join("models")),
1080                app_dir,
1081                mutations: Cell::new(0),
1082                notices: RefCell::new(Vec::new()),
1083                plm: RefCell::new(plm),
1084            }
1085        }
1086
1087        /// A store rooted at an explicit directory — used by the round-trip test.
1088        /// The tests stay headless and exercise the same egui explorer path.
1089        ///
1090        /// COLLAPSES the two directories [`Self::new`] keeps apart: models and
1091        /// reserved state land together. That is what a unit test wants and
1092        /// what a session must not have — see [`Self::rooted_at`].
1093        #[cfg_attr(not(test), allow(dead_code))]
1094        pub fn with_dir(dir: PathBuf) -> Self {
1095            Self {
1096                app_dir: dir.clone(),
1097                browser_dir: RefCell::new(dir.clone()),
1098                dir,
1099                mutations: Cell::new(0),
1100                notices: RefCell::new(Vec::new()),
1101                plm: RefCell::new(None),
1102            }
1103        }
1104
1105        /// A store rooted at `root` with the SAME split [`Self::new`] makes
1106        /// under the user's config directory: models in `<root>/models`,
1107        /// reserved application state in `<root>`. The models directory is
1108        /// created now, so the explorer opens on a real location rather than on
1109        /// a path that does not exist yet.
1110        pub fn rooted_at(root: PathBuf) -> Self {
1111            let models = root.join("models");
1112            let _ = std::fs::create_dir_all(&models);
1113            let plm = configured_plm(&root);
1114            Self {
1115                app_dir: root,
1116                browser_dir: RefCell::new(models.clone()),
1117                dir: models,
1118                mutations: Cell::new(0),
1119                notices: RefCell::new(Vec::new()),
1120                plm: RefCell::new(plm),
1121            }
1122        }
1123
1124        /// Queue `notice` for the first frame's notice drain (see
1125        /// [`ModelStore::take_persistence_errors`]).
1126        pub fn with_notice(self, notice: String) -> Self {
1127            self.notices.borrow_mut().push(notice);
1128            self
1129        }
1130
1131        /// Keep why this session is not on its configured PLM, for the PLM
1132        /// tab (the notice above is drained on the first frame).
1133        pub fn with_plm_reason(self, reason: String) -> Self {
1134            if let Some(session) = self.plm.borrow_mut().as_mut() {
1135                if let crate::plm::connection::Status::NotConnected { url, reason: kept } = &mut session.status {
1136                    // The boot's sentence names the server first; the tab
1137                    // already does.
1138                    let own = format!("PLM at {url}: ");
1139                    *kept = Some(reason.strip_prefix(&own).map(str::to_string).unwrap_or(reason));
1140                }
1141            }
1142            self
1143        }
1144
1145        /// The root D5's files live in.
1146        pub(crate) fn root(&self) -> &std::path::Path {
1147            &self.app_dir
1148        }
1149
1150        /// Record that a write or a delete LANDED (see
1151        /// [`ModelStore::mutation_generation`]).
1152        fn bump(&self) {
1153            self.mutations.set(self.mutations.get().wrapping_add(1));
1154        }
1155
1156        /// Resolve a name to a path. Explicit paths remain supported for existing
1157        /// callers; a bare name maps to `<dir>/<sanitized>.nbrep`.
1158        fn resolve(&self, name: &str) -> PathBuf {
1159            match name {
1160                SETTINGS_KEY => return self.app_dir.join("settings.json"),
1161                FEATURE_PALETTE_DISPLAY_KEY => return self.app_dir.join("feature_palette_display.json"),
1162                DOCK_LAYOUT_KEY => return self.app_dir.join("dock_layout.json"),
1163                PINNED_KEY => return self.app_dir.join("pinned.json"),
1164                RECOVERY_KEY => return self.app_dir.join("recovery.json"),
1165                KICAD_LIBRARY_KEY => return self.app_dir.join("kicad_library.json"),
1166                PLM_IMPORT_KEY => return self.app_dir.join("plm_import.json"),
1167                _ => {}
1168            }
1169            if name.contains('/') || name.contains('\\') {
1170                return PathBuf::from(name);
1171            }
1172            let class = crate::document_class::DocumentClass::of_name(name).unwrap_or_default();
1173            let safe: String = crate::document_class::strip_class_extension(name)
1174                .chars()
1175                .map(|c| {
1176                    if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
1177                        c
1178                    } else {
1179                        '_'
1180                    }
1181                })
1182                .collect();
1183            self.dir.join(class.file_name(&safe))
1184        }
1185
1186        fn home_dir() -> PathBuf {
1187            std::env::var_os("HOME")
1188                .map(PathBuf::from)
1189                .or_else(|| std::env::current_dir().ok())
1190                .unwrap_or_else(|| PathBuf::from("."))
1191        }
1192
1193        fn matches_extension(path: &Path, extensions: &[&str]) -> bool {
1194            extensions.is_empty()
1195                || extensions.iter().any(|extension| {
1196                    let wanted = extension.trim_start_matches('.').to_ascii_lowercase();
1197                    let name = path
1198                        .file_name()
1199                        .unwrap_or_default()
1200                        .to_string_lossy()
1201                        .to_ascii_lowercase();
1202                    name.ends_with(&format!(".{wanted}"))
1203                })
1204        }
1205    }
1206
1207    impl ModelStore for FileModelStore {
1208        fn backend_label(&self) -> String {
1209            format!("filesystem: {}", self.dir.display())
1210        }
1211
1212        fn local_files(&self) -> Option<&dyn ModelStore> {
1213            Some(self)
1214        }
1215
1216        // Writes are synchronous here, so nothing fails after `write` returned;
1217        // the only thing this lane ever carries is a boot notice.
1218        fn take_persistence_errors(&self) -> Vec<String> {
1219            std::mem::take(&mut *self.notices.borrow_mut())
1220        }
1221
1222        fn plm_session(&self) -> Option<crate::plm::connection::Session> {
1223            self.plm.borrow().clone()
1224        }
1225
1226        fn list(&self) -> Vec<String> {
1227            let mut names: Vec<String> = std::fs::read_dir(&self.dir)
1228                .into_iter()
1229                .flatten()
1230                .flatten()
1231                .filter_map(|entry| {
1232                    let name = entry.file_name().to_string_lossy().into_owned();
1233                    crate::document_class::DocumentClass::of_name(&name)
1234                        .is_some()
1235                        .then(|| model_display_name(&name))
1236                })
1237                .collect();
1238            names.sort();
1239            names
1240        }
1241
1242        fn read(&self, name: &str) -> Option<String> {
1243            std::fs::read_to_string(self.resolve(name)).ok()
1244        }
1245
1246        fn write(&self, name: &str, contents: &str) -> Result<(), String> {
1247            let path = self.resolve(name);
1248            if let Some(parent) = path.parent() {
1249                std::fs::create_dir_all(parent).map_err(|e| format!("create models dir: {e}"))?;
1250            }
1251            std::fs::write(&path, contents).map_err(|e| format!("write {}: {e}", path.display()))?;
1252            self.bump();
1253            Ok(())
1254        }
1255
1256        fn mutation_generation(&self) -> u64 {
1257            self.mutations.get()
1258        }
1259
1260        fn remove(&self, name: &str) -> Result<(), String> {
1261            match std::fs::remove_file(self.resolve(name)) {
1262                // A delete that found nothing changed nothing, so it does not
1263                // count: the generation tracks CONTENT, not calls.
1264                Ok(()) => {
1265                    self.bump();
1266                    Ok(())
1267                }
1268                Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
1269                Err(e) => Err(format!("remove: {e}")),
1270            }
1271        }
1272
1273        fn canonical_identity(&self, name: &str) -> String {
1274            // `resolve` is where every read and write goes, so it IS the
1275            // document a name means; the explorer spells paths the same way.
1276            self.resolve(name).to_string_lossy().into_owned()
1277        }
1278
1279        fn browser_location(&self) -> String {
1280            self.browser_dir.borrow().display().to_string()
1281        }
1282
1283        fn browser_entries(&self, extensions: &[&str]) -> Vec<BrowserEntry> {
1284            let mut entries: Vec<BrowserEntry> = std::fs::read_dir(&*self.browser_dir.borrow())
1285                .into_iter()
1286                .flatten()
1287                .flatten()
1288                .filter_map(|entry| {
1289                    let path = entry.path();
1290                    let is_dir = path.is_dir();
1291                    if !(is_dir || (path.is_file() && Self::matches_extension(&path, extensions))) {
1292                        return None;
1293                    }
1294                    let meta = entry.metadata().ok();
1295                    let size = meta.as_ref().filter(|m| m.is_file()).map(|m| m.len());
1296                    let modified = meta
1297                        .as_ref()
1298                        .and_then(|m| m.modified().ok())
1299                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
1300                        .map(|d| d.as_secs_f64());
1301                    Some(BrowserEntry {
1302                        name: entry.file_name().to_string_lossy().into_owned(),
1303                        identity: path.to_string_lossy().into_owned(),
1304                        is_dir,
1305                        size,
1306                        modified,
1307                    })
1308                })
1309                .collect();
1310            entries.sort_by(|a, b| {
1311                b.is_dir
1312                    .cmp(&a.is_dir)
1313                    .then_with(|| a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase()))
1314            });
1315            entries
1316        }
1317
1318        fn browser_enter(&self, identity: &str) -> Result<(), String> {
1319            let path = PathBuf::from(identity);
1320            if !path.is_dir() {
1321                return Err(format!("not a directory: {}", path.display()));
1322            }
1323            *self.browser_dir.borrow_mut() = path;
1324            Ok(())
1325        }
1326
1327        fn browser_up(&self) -> Result<(), String> {
1328            let parent = self.browser_dir.borrow().parent().map(Path::to_path_buf);
1329            if let Some(parent) = parent {
1330                *self.browser_dir.borrow_mut() = parent;
1331            }
1332            Ok(())
1333        }
1334
1335        fn browser_home(&self) -> Result<(), String> {
1336            *self.browser_dir.borrow_mut() = Self::home_dir();
1337            Ok(())
1338        }
1339
1340        fn browser_root(&self) -> Result<(), String> {
1341            let current = self.browser_dir.borrow().clone();
1342            let root = current
1343                .ancestors()
1344                .last()
1345                .map(Path::to_path_buf)
1346                .unwrap_or_else(|| PathBuf::from(std::path::MAIN_SEPARATOR.to_string()));
1347            *self.browser_dir.borrow_mut() = root;
1348            Ok(())
1349        }
1350
1351        fn browser_navigate(&self, location: &str) -> Result<(), String> {
1352            let path = PathBuf::from(location);
1353            if path.is_dir() {
1354                *self.browser_dir.borrow_mut() = path;
1355                Ok(())
1356            } else {
1357                Err(format!("not a directory: {location}"))
1358            }
1359        }
1360
1361        fn browser_places(&self) -> Vec<BrowserPlace> {
1362            let home = Self::home_dir();
1363            let mut places = vec![BrowserPlace {
1364                label: "Home".into(),
1365                location: home.display().to_string(),
1366                kind: PlaceKind::Home,
1367            }];
1368            for (label, sub, kind) in [
1369                ("Documents", "Documents", PlaceKind::Documents),
1370                ("Downloads", "Downloads", PlaceKind::Downloads),
1371            ] {
1372                let path = home.join(sub);
1373                if path.is_dir() {
1374                    places.push(BrowserPlace {
1375                        label: label.into(),
1376                        location: path.display().to_string(),
1377                        kind,
1378                    });
1379                }
1380            }
1381            places.push(BrowserPlace {
1382                label: "Models".into(),
1383                location: self.dir.display().to_string(),
1384                kind: PlaceKind::Models,
1385            });
1386            places.push(BrowserPlace {
1387                label: "/".into(),
1388                location: "/".into(),
1389                kind: PlaceKind::Root,
1390            });
1391            places
1392        }
1393
1394        fn browser_create_dir(&self, name: &str) -> Result<(), String> {
1395            let name = PathBuf::from(name);
1396            if name.components().count() != 1 {
1397                return Err("folder name must be one path component".into());
1398            }
1399            let path = self.browser_dir.borrow().join(name);
1400            std::fs::create_dir(&path)
1401                .map_err(|e| format!("create folder {}: {e}", path.display()))
1402        }
1403
1404        fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
1405            let file_name = PathBuf::from(name)
1406                .file_name()
1407                .ok_or("invalid file name")?
1408                .to_string_lossy()
1409                .into_owned();
1410            let file_name = super::model_file_name(&file_name);
1411            let path = self.browser_dir.borrow().join(file_name);
1412            std::fs::write(&path, contents)
1413                .map_err(|e| format!("write {}: {e}", path.display()))?;
1414            // This door writes the file ITSELF rather than going through
1415            // `write`, so it has to move the generation itself.
1416            self.bump();
1417            Ok(path.to_string_lossy().into_owned())
1418        }
1419
1420        fn list_external_files(&self, extensions: &[&str]) -> Vec<String> {
1421            let wanted: Vec<String> = extensions
1422                .iter()
1423                .map(|extension| extension.trim_start_matches('.').to_ascii_lowercase())
1424                .collect();
1425            let mut names: Vec<String> = std::fs::read_dir(&self.dir)
1426                .into_iter()
1427                .flatten()
1428                .flatten()
1429                .filter_map(|entry| {
1430                    let path = entry.path();
1431                    path.is_file().then_some(path)
1432                })
1433                .filter_map(|path| {
1434                    let extension = path.extension()?.to_string_lossy().to_ascii_lowercase();
1435                    wanted.contains(&extension).then(|| {
1436                        path.file_name()
1437                            .unwrap_or_default()
1438                            .to_string_lossy()
1439                            .into_owned()
1440                    })
1441                })
1442                .collect();
1443            names.sort();
1444            names
1445        }
1446
1447        fn read_external_file(&self, name: &str) -> Option<Vec<u8>> {
1448            let path = PathBuf::from(name);
1449            let path = if path.is_absolute() || path.components().count() > 1 {
1450                path
1451            } else {
1452                self.browser_dir.borrow().join(path)
1453            };
1454            std::fs::read(path).ok()
1455        }
1456
1457        fn export_file_named(&self, file_name: &str, contents: &str) -> Result<(), String> {
1458            self.export_file_named_bytes(file_name, contents.as_bytes())
1459        }
1460
1461        fn export_file_named_bytes(&self, file_name: &str, contents: &[u8]) -> Result<(), String> {
1462            std::fs::create_dir_all(&self.dir)
1463                .map_err(|e| format!("create models dir: {e}"))?;
1464            let name = PathBuf::from(file_name)
1465                .file_name()
1466                .ok_or("invalid export file name")?
1467                .to_owned();
1468            let path = self.dir.join(name);
1469            std::fs::write(&path, contents)
1470                .map_err(|e| format!("write {}: {e}", path.display()))
1471        }
1472    }
1473}
1474
1475// --- The mirrored store: a synchronous facade over an ASYNC backend ------------
1476//
1477// The browser has no synchronous storage big enough for this application. The
1478// measured numbers that forced this module into existence: one imported STEP
1479// part serialises to a ~35 MB native BREP payload, and an imported assembly
1480// document runs 1.0x-12.8x its source STEP text — against `localStorage`'s
1481// ~5-10 MB per-origin quota. Every backend with room (IndexedDB today, a remote
1482// server tomorrow) is ASYNC, while [`ModelStore`] is synchronous and the egui
1483// frame loop that calls it is immediate-mode.
1484//
1485// The reconciliation is this module: an in-memory `BTreeMap` MIRROR of the whole
1486// key space, hydrated from the backend inside the already-async wasm entry
1487// point (`lib.rs::start`) BEFORE the app is constructed. Because hydration
1488// completes before the first `read` can happen, a synchronous read never has a
1489// "not loaded yet" state to represent, and none of the ~55 call sites change.
1490// Writes mutate the mirror synchronously (so the very next `read` sees them) and
1491// are pushed to the backend WRITE-BEHIND; a push that fails afterwards surfaces
1492// through [`ModelStore::take_persistence_errors`].
1493//
1494// Hydration is the only WHOLE-key-space read, not the only read. It cannot be:
1495// the backend is shared with every other tab of the origin, and a mirror that
1496// only ever loaded at boot goes stale the moment someone else saves — which is
1497// what made a part saved in one tab un-insertable in another until the user hit
1498// reload. [`MirrorStore::refresh_key`] re-reads ONE key through
1499// [`StoreBackend::get`] and folds it in, so the correction costs a single record
1500// rather than the corpus; what drives it is platform business, and lives in
1501// [`web_model`](super::web_model).
1502pub(crate) mod mirror_store {
1503    use super::{
1504        model_display_name, BrowserEntry, BrowserPlace, KeySpace, ModelStore, PlaceKind,
1505        Residency, DOCK_LAYOUT_KEY, FEATURE_PALETTE_DISPLAY_KEY, KICAD_LIBRARY_KEY, PINNED_KEY,
1506        PLM_IMPORT_KEY, RECOVERY_KEY, SETTINGS_KEY,
1507    };
1508    use std::cell::{Cell, RefCell};
1509    use std::collections::{BTreeMap, BTreeSet, VecDeque};
1510    use std::future::Future;
1511    use std::pin::Pin;
1512    use std::rc::Rc;
1513
1514    /// The persisted key scheme, VERBATIM from the `localStorage` era so an
1515    /// existing origin's keys keep their meaning under any backend: model
1516    /// documents live at `brep-app:model:<relative name>`, explorer folders at
1517    /// `brep-app:dir:<relative path>/`, and the three reserved application blobs
1518    /// at `brep-app:settings` / `:dock_layout` / `:pinned` (see [`MirrorStore::key`]).
1519    pub(crate) const PREFIX: &str = "brep-app:model:";
1520    pub(crate) const DIR_PREFIX: &str = "brep-app:dir:";
1521
1522    /// How many distinct persistence failures the error log keeps before the
1523    /// oldest is dropped. The log is a user-facing notice channel, not telemetry.
1524    const MAX_ERRORS: usize = 64;
1525
1526    /// The future a [`StoreBackend`] hands back. Boxed (not `async fn` in the
1527    /// trait) because the store is used as `dyn StoreBackend`, and deliberately
1528    /// NOT `Send`: every host is single-threaded (the browser main thread; the
1529    /// native test executor below).
1530    pub(crate) type BackendFuture<T> = Pin<Box<dyn Future<Output = Result<T, String>>>>;
1531
1532    /// One hydrated key's value in the mirror: the bytes themselves, or only
1533    /// the fact that they exist and how many there are.
1534    ///
1535    /// `OnDisk` is what makes an index-only hydrate possible: `list`,
1536    /// `browser_entries` and sizes answer from it with no bytes in memory, and
1537    /// the bytes arrive later — on the first `read`, or from
1538    /// [`MirrorStore::prefetch`] in the background.
1539    #[derive(Clone, Debug, PartialEq, Eq)]
1540    // `OnDisk` is built only by an index-only backend: the tests' today, the
1541    // PLM's in S1.
1542    #[cfg_attr(not(test), allow(dead_code))]
1543    pub(crate) enum Entry {
1544        Resident(String),
1545        OnDisk { bytes: u64 },
1546    }
1547
1548    impl Entry {
1549        /// The value's size in bytes, whether or not it is loaded.
1550        pub(crate) fn len(&self) -> u64 {
1551            match self {
1552                Entry::Resident(value) => value.len() as u64,
1553                Entry::OnDisk { bytes } => *bytes,
1554            }
1555        }
1556    }
1557
1558    /// The key space a BACKEND key belongs to (see [`KeySpace`]), or `None` for
1559    /// an explorer directory row (`brep-app:dir:`), which only the file backends
1560    /// hold — a PLM user's folders are the workspace (S14), not store keys.
1561    /// A key this module did not mint counts as a document.
1562    #[cfg_attr(not(test), allow(dead_code))] // routed by S1's PLM session
1563    pub(crate) fn key_space(key: &str) -> Option<KeySpace> {
1564        if key.starts_with(DIR_PREFIX) {
1565            return None;
1566        }
1567        for name in super::PREFERENCE_KEYS.iter().chain(super::LOCAL_KEYS) {
1568            if key == MirrorStore::key(name) {
1569                return Some(KeySpace::of(name));
1570            }
1571        }
1572        Some(KeySpace::Documents)
1573    }
1574
1575    /// Where a [`MirrorStore`]'s bytes actually live — the swappable persistence
1576    /// seam.
1577    ///
1578    /// The contract is deliberately narrow and platform-free: **string keys,
1579    /// string values, `String` errors, async everywhere**. No `JsValue`, no
1580    /// `web_sys` type, no IndexedDB concept crosses it, and the mirror above it
1581    /// knows nothing about how a key is stored. [`IdbBackend`](super::web_model)
1582    /// is the only implementation today; the intended SECOND one is a remote
1583    /// HTTP/AJAX backend that pushes and pulls the same key space to a central
1584    /// server, and it must be able to land without touching this module.
1585    ///
1586    /// The obligations an implementation carries:
1587    ///
1588    /// * **Per-key FIFO — the mirror's, unless the backend says it is its own.**
1589    ///   Two `put`s of the same key must land in call order, whatever order
1590    ///   their futures are polled in. IndexedDB gets this for free (a
1591    ///   `readwrite` transaction commits in creation order, and
1592    ///   [`IdbBackend`](super::web_model) creates the transaction and issues the
1593    ///   request synchronously inside `put`), and says so through
1594    ///   [`Self::orders_writes_per_key`]. Every other backend — an HTTP one has
1595    ///   no such guarantee — gets the mirror's per-key send queue: a key's next
1596    ///   `put`/`delete` is not even ISSUED until its previous one has settled.
1597    /// * **`load_index` or `load_all` hydrates the whole key space** — every key
1598    ///   is present in the answer; `load_index` may leave values
1599    ///   [`Entry::OnDisk`].
1600    /// * **`get` sees other clients' writes.** The mirror calls it to correct
1601    ///   itself after someone else wrote the key, and to load an `OnDisk`
1602    ///   entry, so it must read THROUGH any per-connection cache of the
1603    ///   implementation's own.
1604    ///
1605    /// ## Index hydration
1606    ///
1607    /// A full hydrate holds EVERY saved document resident for the session: a
1608    /// non-issue for a browser origin (IndexedDB is local, and still hydrates
1609    /// whole through the `load_index` default), untenable for a remote catalog
1610    /// of thousands of parts. So the mirror's value is an [`Entry`], a backend
1611    /// may answer [`Self::load_index`] with sizes instead of bytes, and the
1612    /// bytes arrive on demand ([`ModelStore::read`] asks for them) or in the
1613    /// background ([`MirrorStore::prefetch`]). All of it sits behind THIS trait
1614    /// and behind [`ModelStore`], so no call site moved.
1615    pub(crate) trait StoreBackend {
1616        /// A short human label for the storage panel header (see
1617        /// [`ModelStore::backend_label`]).
1618        fn label(&self) -> String;
1619
1620        /// The signed-in client of a backend that talks to a PLM (see
1621        /// [`ModelStore::plm_client`]). `None` for every local backend.
1622        fn plm_client(&self) -> Option<std::rc::Rc<crate::plm::client::PlmClient>> {
1623            None
1624        }
1625
1626        /// See [`ModelStore::plm_revision`]. `None` for every local backend.
1627        fn plm_revision(&self, key: &str) -> Option<crate::plm::client::IndexEntry> {
1628            let _ = key;
1629            None
1630        }
1631
1632        /// See [`ModelStore::refresh_plm_index`].
1633        fn refresh_plm_index(&self, keys: &[String], stale: bool) {
1634            let _ = (keys, stale);
1635        }
1636
1637        /// Pull the entire key space, values included.
1638        fn load_all(&self) -> BackendFuture<Vec<(String, String)>>;
1639
1640        /// Pull the entire key space as METADATA: every key, with its bytes
1641        /// where they are cheap and only its size ([`Entry::OnDisk`]) where they
1642        /// are not. Called ONCE, during the async boot, before any
1643        /// [`MirrorStore`] exists.
1644        ///
1645        /// The default is [`Self::load_all`] with every value resident — exactly
1646        /// the hydrate a local backend has always done. A remote backend
1647        /// overrides it (the PLM's `GET /api/store/index` IS this call).
1648        fn load_index(&self) -> BackendFuture<Vec<(String, Entry)>> {
1649            let all = self.load_all();
1650            Box::pin(async move {
1651                Ok(all
1652                    .await?
1653                    .into_iter()
1654                    .map(|(key, value)| (key, Entry::Resident(value)))
1655                    .collect())
1656            })
1657        }
1658
1659        /// `true` when this backend ALREADY lands two writes of one key in call
1660        /// order without help (IndexedDB), so the mirror issues every write
1661        /// the moment it is made. The default, `false`, puts the backend behind
1662        /// the mirror's per-key send queue — the safe answer for a backend that
1663        /// has not thought about it.
1664        fn orders_writes_per_key(&self) -> bool {
1665            false
1666        }
1667
1668        /// Read ONE key's CURRENT value straight from the backend — `None` when
1669        /// it is absent.
1670        ///
1671        /// This is the LIVE read, and it exists because the mirror is not the
1672        /// only writer of its key space: a SECOND TAB of the same origin holds
1673        /// its own mirror over the same IndexedDB (and a remote backend would
1674        /// have other clients still). Hydration alone therefore goes stale the
1675        /// moment someone else saves — the user's "I saved a part in one tab and
1676        /// the other tab cannot insert it until I reload". [`MirrorStore::refresh_key`]
1677        /// answers that by re-reading exactly the key that changed. It is also
1678        /// how an [`Entry::OnDisk`] value is loaded.
1679        ///
1680        /// REQUIRED rather than defaulted, for the reason
1681        /// [`ModelStore::mutation_generation`] is: a `None`-returning default
1682        /// would let a new backend compile while silently making every external
1683        /// change look like a deletion.
1684        fn get(&self, key: &str) -> BackendFuture<Option<String>>;
1685
1686        /// Persist `value` under `key`, creating or overwriting.
1687        fn put(&self, key: &str, value: &str) -> BackendFuture<()>;
1688
1689        /// Delete `key`. Succeeding on an absent key is correct.
1690        fn delete(&self, key: &str) -> BackendFuture<()>;
1691    }
1692
1693    /// A [`StoreBackend`] that sends each [`KeySpace`] to its own backend — the
1694    /// shape D1 gives a PLM session: documents to the store routes, preferences
1695    /// to the per-user store (P5), `@recovery` to the machine. Explorer
1696    /// directory rows travel with the documents, since only a file backend
1697    /// holds them; a documents backend with no folders refuses them itself.
1698    ///
1699    /// Hydration is the union of the three indexes. A key one backend returns
1700    /// that belongs to ANOTHER space is dropped rather than trusted: a backend
1701    /// is authoritative for its own space only.
1702    #[cfg_attr(not(test), allow(dead_code))] // composed by S1's PLM session
1703    pub(crate) struct KeySpaceRouter {
1704        pub(crate) documents: Rc<dyn StoreBackend>,
1705        pub(crate) preferences: Rc<dyn StoreBackend>,
1706        pub(crate) local: Rc<dyn StoreBackend>,
1707    }
1708
1709    #[cfg_attr(not(test), allow(dead_code))]
1710    impl KeySpaceRouter {
1711        fn route(&self, key: &str) -> &Rc<dyn StoreBackend> {
1712            match key_space(key) {
1713                Some(KeySpace::Preferences) => &self.preferences,
1714                Some(KeySpace::Local) => &self.local,
1715                Some(KeySpace::Documents) | None => &self.documents,
1716            }
1717        }
1718
1719        fn serves(space: KeySpace, key: &str) -> bool {
1720            match key_space(key) {
1721                Some(found) => found == space,
1722                None => space == KeySpace::Documents,
1723            }
1724        }
1725
1726        fn spaces(&self) -> [(KeySpace, &Rc<dyn StoreBackend>); 3] {
1727            [
1728                (KeySpace::Documents, &self.documents),
1729                (KeySpace::Preferences, &self.preferences),
1730                (KeySpace::Local, &self.local),
1731            ]
1732        }
1733    }
1734
1735    impl StoreBackend for KeySpaceRouter {
1736        fn label(&self) -> String {
1737            self.documents.label()
1738        }
1739
1740        fn plm_client(&self) -> Option<std::rc::Rc<crate::plm::client::PlmClient>> {
1741            self.documents.plm_client()
1742        }
1743
1744        fn plm_revision(&self, key: &str) -> Option<crate::plm::client::IndexEntry> {
1745            self.documents.plm_revision(key)
1746        }
1747
1748        fn refresh_plm_index(&self, keys: &[String], stale: bool) {
1749            self.documents.refresh_plm_index(keys, stale)
1750        }
1751
1752        fn load_all(&self) -> BackendFuture<Vec<(String, String)>> {
1753            let requests: Vec<_> = self
1754                .spaces()
1755                .into_iter()
1756                .map(|(space, backend)| (space, backend.load_all()))
1757                .collect();
1758            Box::pin(async move {
1759                let mut all = Vec::new();
1760                for (space, request) in requests {
1761                    all.extend(
1762                        request
1763                            .await?
1764                            .into_iter()
1765                            .filter(|(key, _)| Self::serves(space, key)),
1766                    );
1767                }
1768                Ok(all)
1769            })
1770        }
1771
1772        fn load_index(&self) -> BackendFuture<Vec<(String, Entry)>> {
1773            let requests: Vec<_> = self
1774                .spaces()
1775                .into_iter()
1776                .map(|(space, backend)| (space, backend.load_index()))
1777                .collect();
1778            Box::pin(async move {
1779                let mut all = Vec::new();
1780                for (space, request) in requests {
1781                    all.extend(
1782                        request
1783                            .await?
1784                            .into_iter()
1785                            .filter(|(key, _)| Self::serves(space, key)),
1786                    );
1787                }
1788                Ok(all)
1789            })
1790        }
1791
1792        fn orders_writes_per_key(&self) -> bool {
1793            self.spaces()
1794                .iter()
1795                .all(|(_, backend)| backend.orders_writes_per_key())
1796        }
1797
1798        fn get(&self, key: &str) -> BackendFuture<Option<String>> {
1799            self.route(key).get(key)
1800        }
1801
1802        fn put(&self, key: &str, value: &str) -> BackendFuture<()> {
1803            self.route(key).put(key, value)
1804        }
1805
1806        fn delete(&self, key: &str) -> BackendFuture<()> {
1807            self.route(key).delete(key)
1808        }
1809    }
1810
1811    /// The backend installed when persistence could NOT be brought up (IndexedDB
1812    /// blocked in a private window, storage denied, the boot hydrate never ran).
1813    ///
1814    /// There is deliberately no quiet fallback to `localStorage`: a 5 MB backend
1815    /// silently standing in for a hundreds-of-megabytes one is the failure mode
1816    /// this whole change exists to end. Instead the session stays fully usable
1817    /// IN MEMORY — the mirror still holds everything written this session — while
1818    /// every write reports, loudly and repeatedly, that nothing is being saved.
1819    // Built by the browser's boot today; a native session falls back to the file stores instead.
1820    #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
1821    pub(crate) struct UnavailableBackend {
1822        reason: String,
1823    }
1824
1825    // See `UnavailableBackend`.
1826    #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
1827    impl UnavailableBackend {
1828        pub(crate) fn new(reason: impl Into<String>) -> Self {
1829            Self {
1830                reason: reason.into(),
1831            }
1832        }
1833    }
1834
1835    impl StoreBackend for UnavailableBackend {
1836        fn label(&self) -> String {
1837            format!("NOT SAVING — {} · use Download to keep your work", self.reason)
1838        }
1839
1840        fn load_all(&self) -> BackendFuture<Vec<(String, String)>> {
1841            let reason = self.reason.clone();
1842            Box::pin(async move { Err(reason) })
1843        }
1844
1845        fn get(&self, _key: &str) -> BackendFuture<Option<String>> {
1846            let reason = self.reason.clone();
1847            Box::pin(async move { Err(reason) })
1848        }
1849
1850        fn put(&self, _key: &str, _value: &str) -> BackendFuture<()> {
1851            let reason = self.reason.clone();
1852            Box::pin(async move { Err(reason) })
1853        }
1854
1855        fn delete(&self, _key: &str) -> BackendFuture<()> {
1856            let reason = self.reason.clone();
1857            Box::pin(async move { Err(reason) })
1858        }
1859    }
1860
1861    /// Persistence failures that happened AFTER the synchronous `write` returned
1862    /// `Ok` — the price of write-behind. Nothing is lost mid-session (the mirror
1863    /// holds it), but the user MUST learn that it did not persist, so the log is
1864    /// drained into the toast overlay every frame.
1865    ///
1866    /// The cursor (rather than a `Vec::drain`) keeps the full history addressable
1867    /// for the `__brepStoreErrors` verification hook while still handing the UI
1868    /// each message exactly once.
1869    pub(crate) struct ErrorLog {
1870        log: RefCell<Vec<String>>,
1871        drained: Cell<usize>,
1872        /// Wakes the reactive frame loop so a failure recorded from an async
1873        /// callback is toasted THIS frame instead of waiting for stray input.
1874        wake: Option<Rc<dyn Fn()>>,
1875    }
1876
1877    impl ErrorLog {
1878        fn new(wake: Option<Rc<dyn Fn()>>) -> Self {
1879            Self {
1880                log: RefCell::new(Vec::new()),
1881                drained: Cell::new(0),
1882                wake,
1883            }
1884        }
1885
1886        /// Record one failure. An identical message that is still UNDRAINED is
1887        /// collapsed (a burst of failing writes shows one toast, not six); once
1888        /// the UI has shown it, the same message can be recorded again — every
1889        /// failed save is reported.
1890        pub(crate) fn record(&self, message: String) {
1891            {
1892                let mut log = self.log.borrow_mut();
1893                let undrained = log.len() > self.drained.get();
1894                if undrained && log.last().map(|last| *last == message).unwrap_or(false) {
1895                    return;
1896                }
1897                log.push(message);
1898                if log.len() > MAX_ERRORS {
1899                    log.remove(0);
1900                    self.drained.set(self.drained.get().saturating_sub(1));
1901                }
1902            }
1903            if let Some(wake) = &self.wake {
1904                wake();
1905            }
1906        }
1907
1908        /// Messages recorded since the last drain (what the UI has not shown yet).
1909        fn drain(&self) -> Vec<String> {
1910            let log = self.log.borrow();
1911            let from = self.drained.get().min(log.len());
1912            self.drained.set(log.len());
1913            log[from..].to_vec()
1914        }
1915
1916        /// Every message recorded this session (verification hook).
1917        // The browser's `__brepStoreErrors` verification hook.
1918        #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
1919        fn all(&self) -> Vec<String> {
1920            self.log.borrow().clone()
1921        }
1922    }
1923
1924    /// Drive one write-behind push to completion.
1925    ///
1926    /// wasm: hand it to the browser's microtask queue, which is the whole point —
1927    /// the caller's `write` already returned. Native: the frame-driven executor
1928    /// ([`crate::plm::native`]), the same shape — polled once at once (so a
1929    /// backend whose futures are already resolved finishes inside `write`, as
1930    /// every test that does not care about timing assumes), parked if still
1931    /// pending, and polled again once its waker fires and the frame loop (or a
1932    /// test's [`test_executor::drive`]) runs the queue. Keeping the shape
1933    /// identical means the native tests exercise the REAL write-behind path
1934    /// (spawn, await, record the error) rather than a synchronous stand-in — and
1935    /// the parking is what lets a test hold one write open and watch what the
1936    /// per-key queue does with the next.
1937    #[cfg(target_arch = "wasm32")]
1938    fn spawn(task: impl Future<Output = ()> + 'static) {
1939        wasm_bindgen_futures::spawn_local(task);
1940    }
1941
1942    #[cfg(not(target_arch = "wasm32"))]
1943    fn spawn(task: impl Future<Output = ()> + 'static) {
1944        crate::plm::native::spawn(task);
1945    }
1946
1947
1948    /// A synchronous [`ModelStore`] over an asynchronous [`StoreBackend`]: the
1949    /// hydrated mirror plus write-behind. Cheap to clone — every field is shared,
1950    /// so a clone is another handle on the SAME session state (used by the
1951    /// verification hooks).
1952    #[derive(Clone)]
1953    pub(crate) struct MirrorStore {
1954        /// The whole key space, keyed EXACTLY as the backend keys it. A value
1955        /// is [`Entry::OnDisk`] until something asks for its bytes.
1956        entries: Rc<RefCell<BTreeMap<String, Entry>>>,
1957        /// The explorer's current virtual directory (`/` or `/models/...`).
1958        browser_dir: Rc<RefCell<String>>,
1959        backend: Rc<dyn StoreBackend>,
1960        errors: Rc<ErrorLog>,
1961        /// Pushes issued but not yet settled. Zero means everything written so
1962        /// far is durable — which is what makes a "save, reload, still there"
1963        /// check non-racy (see the `__brepStorePending` hook).
1964        pending: Rc<Cell<usize>>,
1965        /// See [`ModelStore::mutation_generation`]. SHARED like every other
1966        /// field: a clone is another handle on the same session state, and the
1967        /// `__brepStoreWrite` verification hook holds one — a write through
1968        /// that hook has to move the generation the app reads.
1969        mutations: Rc<Cell<u64>>,
1970        /// How many of our OWN pushes are still in flight, PER KEY. Used by
1971        /// [`Self::refresh_key`] to leave a key alone while this session is
1972        /// mid-write on it: pulling another tab's value in on top of a save we
1973        /// have already returned `Ok` for would show the user someone else's
1974        /// bytes under their own file name.
1975        in_flight: Rc<RefCell<BTreeMap<String, usize>>>,
1976        /// Repaint the reactive frame loop. The `ErrorLog` holds this for write
1977        /// failures; the store needs its own handle because an EXTERNAL change
1978        /// arrives on an async callback too, and a list that updates only when
1979        /// the user happens to move the mouse is the stale list all over again.
1980        wake: Option<Rc<dyn Fn()>>,
1981        /// `OnDisk` keys whose bytes have been asked of the backend and not yet
1982        /// answered, so a second `read` of one does not ask again.
1983        loading: Rc<RefCell<BTreeSet<String>>>,
1984        /// The per-key send queue (see [`StoreBackend::orders_writes_per_key`]):
1985        /// for each key with a write in flight, the writes made after it, not
1986        /// yet issued. A key is present exactly while its drain task runs.
1987        queues: Rc<RefCell<BTreeMap<String, VecDeque<QueuedWrite>>>>,
1988    }
1989
1990    /// A write the per-key queue holds back: the document name its failure is
1991    /// reported under, and the backend call, made only when its turn comes.
1992    type QueuedWrite = (String, Box<dyn FnOnce() -> BackendFuture<()>>);
1993
1994    impl MirrorStore {
1995        /// Build the session store from an already-hydrated key space.
1996        /// `wake` (`None` off-browser) is called when a write-behind failure is
1997        /// recorded, to repaint the reactive frame loop.
1998        // The browser's boot and the tests; the native boot hydrates from an index.
1999        #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
2000        pub(crate) fn new(
2001            backend: Rc<dyn StoreBackend>,
2002            entries: Vec<(String, String)>,
2003            wake: Option<Rc<dyn Fn()>>,
2004        ) -> Self {
2005            Self::from_index(
2006                backend,
2007                entries
2008                    .into_iter()
2009                    .map(|(key, value)| (key, Entry::Resident(value)))
2010                    .collect(),
2011                wake,
2012            )
2013        }
2014
2015        /// Build the session store from an INDEX ([`StoreBackend::load_index`]):
2016        /// every key present, some values only [`Entry::OnDisk`].
2017        pub(crate) fn from_index(
2018            backend: Rc<dyn StoreBackend>,
2019            entries: Vec<(String, Entry)>,
2020            wake: Option<Rc<dyn Fn()>>,
2021        ) -> Self {
2022            Self {
2023                entries: Rc::new(RefCell::new(entries.into_iter().collect())),
2024                browser_dir: Rc::new(RefCell::new("/models".into())),
2025                backend,
2026                errors: Rc::new(ErrorLog::new(wake.clone())),
2027                pending: Rc::new(Cell::new(0)),
2028                mutations: Rc::new(Cell::new(0)),
2029                in_flight: Rc::new(RefCell::new(BTreeMap::new())),
2030                wake,
2031                loading: Rc::new(RefCell::new(BTreeSet::new())),
2032                queues: Rc::new(RefCell::new(BTreeMap::new())),
2033            }
2034        }
2035
2036        /// The persisted key for a document/reserved name. UNCHANGED from the
2037        /// `localStorage` implementation this replaced, so an existing origin's
2038        /// data keeps its identity.
2039        pub(crate) fn key(name: &str) -> String {
2040            match name {
2041                SETTINGS_KEY => "brep-app:settings".into(),
2042                FEATURE_PALETTE_DISPLAY_KEY => "brep-app:feature_palette_display".into(),
2043                DOCK_LAYOUT_KEY => "brep-app:dock_layout".into(),
2044                PINNED_KEY => "brep-app:pinned".into(),
2045                RECOVERY_KEY => "brep-app:recovery".into(),
2046                // Desktop-only today (the web build cannot read a KiCad
2047                // library), but a reserved name that fell through to the
2048                // document arm would key as `brep-app:model:@kicad_library` and
2049                // LIST as a document the day anything wrote it here.
2050                KICAD_LIBRARY_KEY => "brep-app:kicad_library".into(),
2051                PLM_IMPORT_KEY => "brep-app:plm_import".into(),
2052                _ => format!("{PREFIX}{}", Self::model_relative(name)),
2053            }
2054        }
2055
2056        /// A document name reduced to its store-relative form: no leading `/`, no
2057        /// `/models` root, no model extension.
2058        fn model_relative(name: &str) -> String {
2059            let name = name
2060                .trim_start_matches('/')
2061                .strip_prefix("models/")
2062                .unwrap_or_else(|| name.trim_start_matches('/'));
2063            // The key drops ONLY the normal extension (an existing origin's
2064            // keys keep their identity); a family or template keeps its own,
2065            // lowercased, so `bolt` the part and `bolt.fbrep` the family are
2066            // two keys.
2067            let name = name.trim_matches('/');
2068            match crate::document_class::DocumentClass::of_name(name) {
2069                Some(crate::document_class::DocumentClass::Normal) => {
2070                    crate::document_class::strip_class_extension(name).to_string()
2071                }
2072                Some(_) => super::model_file_name(name),
2073                None => name.to_string(),
2074            }
2075        }
2076
2077        fn virtual_model_path(relative: &str) -> String {
2078            format!("/models/{}", super::model_file_name(relative.trim_matches('/')))
2079        }
2080
2081        fn child_path(parent: &str, child: &str) -> String {
2082            if parent == "/" {
2083                format!("/{child}")
2084            } else {
2085                format!("{}/{child}", parent.trim_end_matches('/'))
2086            }
2087        }
2088
2089        /// Hand one backend write to the executor, counting it in `pending` (and
2090        /// under its KEY in `in_flight`) and routing its eventual failure to the
2091        /// error log.
2092        ///
2093        /// `request` is the backend call itself, not its future: behind the
2094        /// per-key send queue it is made only once every earlier write of the
2095        /// same key has SETTLED, which is the FIFO an HTTP backend cannot give
2096        /// itself. A backend that orders its own writes
2097        /// ([`StoreBackend::orders_writes_per_key`]) has it called at once, so
2098        /// IndexedDB creates its transaction inside `write` exactly as before.
2099        fn push(
2100            &self,
2101            name: &str,
2102            key: &str,
2103            request: impl FnOnce() -> BackendFuture<()> + 'static,
2104        ) {
2105            self.pending.set(self.pending.get() + 1);
2106            *self.in_flight.borrow_mut().entry(key.to_string()).or_insert(0) += 1;
2107            if self.backend.orders_writes_per_key() {
2108                let settle = self.settler(key);
2109                let name = name.to_string();
2110                let request = request();
2111                spawn(async move { settle(&name, request.await) });
2112                return;
2113            }
2114            {
2115                let mut queues = self.queues.borrow_mut();
2116                if let Some(queue) = queues.get_mut(key) {
2117                    // A write of this key is still open: this one waits its turn.
2118                    queue.push_back((name.to_string(), Box::new(request)));
2119                    return;
2120                }
2121                queues.insert(key.to_string(), VecDeque::new());
2122            }
2123            // Nothing ahead of it: issue now, then drain whatever queues behind.
2124            let settle = self.settler(key);
2125            let queues = self.queues.clone();
2126            let key = key.to_string();
2127            let first = (name.to_string(), request());
2128            spawn(async move {
2129                let (name, request) = first;
2130                settle(&name, request.await);
2131                loop {
2132                    let next = {
2133                        let mut queues = queues.borrow_mut();
2134                        let next = queues.get_mut(&key).and_then(VecDeque::pop_front);
2135                        if next.is_none() {
2136                            queues.remove(&key);
2137                        }
2138                        next
2139                    };
2140                    let Some((name, request)) = next else {
2141                        break;
2142                    };
2143                    // A failed write does not cancel the next: each is its own
2144                    // save, and each failure is reported under its own name.
2145                    settle(&name, request().await);
2146                }
2147            });
2148        }
2149
2150        /// What one settled write does to the session's books: out of
2151        /// `pending` and `in_flight`, and into the error log if it failed.
2152        fn settler(&self, key: &str) -> impl Fn(&str, Result<(), String>) + 'static {
2153            let pending = self.pending.clone();
2154            let in_flight = self.in_flight.clone();
2155            let errors = self.errors.clone();
2156            let key = key.to_string();
2157            move |name, outcome| {
2158                pending.set(pending.get().saturating_sub(1));
2159                {
2160                    let mut in_flight = in_flight.borrow_mut();
2161                    if let Some(count) = in_flight.get_mut(&key) {
2162                        *count -= 1;
2163                        if *count == 0 {
2164                            in_flight.remove(&key);
2165                        }
2166                    }
2167                }
2168                if let Err(message) = outcome {
2169                    errors.record(format!("'{name}' was NOT saved: {message}"));
2170                }
2171            }
2172        }
2173
2174        /// Fold ONE externally-changed key into the mirror: `Some` is the value
2175        /// the backend now holds, `None` means it is gone.
2176        ///
2177        /// A change that is not a change (the value we already had) stops here —
2178        /// no generation bump, no repaint — so an echo costs nothing. A real one
2179        /// moves [`ModelStore::mutation_generation`], which is exactly what that
2180        /// counter promises ("every mutation that landed, WHOEVER made it") and
2181        /// what relights the outdated-components badge on an assembly whose part
2182        /// was just re-saved in another tab.
2183        // The browser's other-tab channel today; natively, the PLM change feed (S1).
2184        #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
2185        pub(crate) fn apply_external(&self, key: &str, value: Option<String>) {
2186            let changed = {
2187                let mut entries = self.entries.borrow_mut();
2188                match value {
2189                    Some(value) => {
2190                        let same = matches!(entries.get(key), Some(Entry::Resident(held)) if *held == value);
2191                        if same {
2192                            false
2193                        } else {
2194                            entries.insert(key.to_string(), Entry::Resident(value));
2195                            true
2196                        }
2197                    }
2198                    None => entries.remove(key).is_some(),
2199                }
2200            };
2201            if !changed {
2202                return;
2203            }
2204            self.mutations.set(self.mutations.get().wrapping_add(1));
2205            if let Some(wake) = &self.wake {
2206                wake();
2207            }
2208        }
2209
2210        /// Re-read `key` from the backend and fold the result in — the LIVE read
2211        /// behind the mirror, driven by whatever tells this session that someone
2212        /// else wrote (on the web, the `brep-app:store` broadcast in
2213        /// [`web_model`](super::web_model)).
2214        ///
2215        /// A key this session is itself mid-write on is left ALONE: our own
2216        /// `write` already returned `Ok` against the mirror, and racing another
2217        /// tab's older bytes in on top of it would replace the user's save with a
2218        /// stranger's under the same name. Skipping costs no correctness. Which
2219        /// of two simultaneous saves of one file wins is settled by the backend's
2220        /// own ordering (IndexedDB commits same-store transactions in creation
2221        /// order), and it settles the same way for the mirror: if our put was
2222        /// created last it lands last, so the mirror already holds what the
2223        /// backend ends up holding — and the OTHER tab hears our commit and
2224        /// corrects itself.
2225        // See `apply_external`.
2226        #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
2227        pub(crate) fn refresh_key(&self, key: String) {
2228            if self.in_flight.borrow().contains_key(&key) {
2229                return;
2230            }
2231            let store = self.clone();
2232            let request = self.backend.get(&key);
2233            spawn(async move {
2234                match request.await {
2235                    Ok(value) => store.apply_external(&key, value),
2236                    // A failed re-read leaves the mirror as it was. It is NOT a
2237                    // persistence failure — nothing of the user's was lost — so it
2238                    // does not go to the toast channel; the stale row is the cost,
2239                    // and the next write to that key notifies again.
2240                    Err(_) => {}
2241                }
2242            });
2243        }
2244
2245        /// Seed the log with a boot-time failure so the very first frame toasts it.
2246        // The browser's boot notice; the native boot reports through the file store it falls back to.
2247        #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
2248        pub(crate) fn report(&self, message: String) {
2249            self.errors.record(message);
2250        }
2251
2252        /// Backend pushes issued but not yet settled (verification hook).
2253        pub(crate) fn pending(&self) -> usize {
2254            self.pending.get()
2255        }
2256
2257        /// Every persistence failure this session (verification hook).
2258        // Browser verification hooks.
2259        #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
2260        pub(crate) fn error_history(&self) -> Vec<String> {
2261            self.errors.all()
2262        }
2263
2264        /// Byte length of a stored document, or `None` if absent (verification
2265        /// hook — a multi-megabyte payload is not worth marshalling into JS just
2266        /// to measure it).
2267        // Browser verification hooks.
2268        #[cfg_attr(not(target_arch = "wasm32"), allow(dead_code))]
2269        pub(crate) fn len_of(&self, name: &str) -> Option<usize> {
2270            self.entries.borrow().get(&Self::key(name)).map(|v| v.len() as usize)
2271        }
2272
2273        /// Ask the backend for ONE `OnDisk` key's bytes, unless that is already
2274        /// asked. The answer lands through [`Self::resolve`].
2275        fn load(&self, key: &str) {
2276            if !self.loading.borrow_mut().insert(key.to_string()) {
2277                return;
2278            }
2279            let store = self.clone();
2280            let key = key.to_string();
2281            let request = self.backend.get(&key);
2282            spawn(async move {
2283                let outcome = request.await;
2284                store.loading.borrow_mut().remove(&key);
2285                store.resolve(&key, outcome);
2286            });
2287        }
2288
2289        /// Fold a loaded `OnDisk` value in — ONLY if the entry is still
2290        /// `OnDisk`. A write or a remove that happened while the bytes were on
2291        /// their way is newer than them, and wins.
2292        ///
2293        /// A failed load leaves the entry `OnDisk`, so the next `read` asks
2294        /// again. Like a failed refresh it does not toast: nothing of the
2295        /// user's was lost (S1 owns how a transport failure is shown).
2296        fn resolve(&self, key: &str, outcome: Result<Option<String>, String>) {
2297            let Ok(value) = outcome else {
2298                return;
2299            };
2300            {
2301                let mut entries = self.entries.borrow_mut();
2302                if !matches!(entries.get(key), Some(Entry::OnDisk { .. })) {
2303                    return;
2304                }
2305                match value {
2306                    Some(value) => entries.insert(key.to_string(), Entry::Resident(value)),
2307                    // Listed, then gone before we read it: the backend is
2308                    // authoritative, the listing was stale.
2309                    None => entries.remove(key),
2310                };
2311            }
2312            self.mutations.set(self.mutations.get().wrapping_add(1));
2313            if let Some(wake) = &self.wake {
2314                wake();
2315            }
2316        }
2317
2318        /// Load every `OnDisk` entry in the background, ONE at a time, so a
2319        /// catalog of thousands of parts streams in behind the first frame
2320        /// instead of in front of it (or all at once in front of the user's own
2321        /// saves). A key a `read` already asked for is not asked twice, and a
2322        /// key written meanwhile is skipped.
2323        pub(crate) fn prefetch(&self) {
2324            let keys: Vec<String> = self
2325                .entries
2326                .borrow()
2327                .iter()
2328                .filter(|(_, entry)| matches!(entry, Entry::OnDisk { .. }))
2329                .map(|(key, _)| key.clone())
2330                .collect();
2331            if keys.is_empty() {
2332                return;
2333            }
2334            let store = self.clone();
2335            spawn(async move {
2336                for key in keys {
2337                    let wanted = matches!(store.entries.borrow().get(&key), Some(Entry::OnDisk { .. }));
2338                    if !wanted || !store.loading.borrow_mut().insert(key.clone()) {
2339                        continue;
2340                    }
2341                    let outcome = store.backend.get(&key).await;
2342                    store.loading.borrow_mut().remove(&key);
2343                    store.resolve(&key, outcome);
2344                }
2345            });
2346        }
2347    }
2348
2349    impl ModelStore for MirrorStore {
2350        fn backend_label(&self) -> String {
2351            self.backend.label()
2352        }
2353
2354        fn plm_client(&self) -> Option<std::rc::Rc<crate::plm::client::PlmClient>> {
2355            self.backend.plm_client()
2356        }
2357
2358        fn plm_revision(&self, key: &str) -> Option<crate::plm::client::IndexEntry> {
2359            self.backend.plm_revision(key)
2360        }
2361
2362        fn refresh_plm_index(&self, keys: &[String], stale: bool) {
2363            self.backend.refresh_plm_index(keys, stale)
2364        }
2365
2366        fn list(&self) -> Vec<String> {
2367            // `BTreeMap` iterates in key order, so the names come out sorted.
2368            self.entries
2369                .borrow()
2370                .keys()
2371                .filter_map(|key| key.strip_prefix(PREFIX).map(str::to_string))
2372                .collect()
2373        }
2374
2375        fn read(&self, name: &str) -> Option<String> {
2376            let key = Self::key(name);
2377            let held = self.entries.borrow().get(&key).cloned();
2378            match held {
2379                Some(Entry::Resident(value)) => Some(value),
2380                // Listed but not loaded: ask for it and answer `None` NOW — the
2381                // API is synchronous. The bytes land through `resolve`, which
2382                // moves the generation and wakes the frame, so a caller keyed on
2383                // the generation reads again. `residency` tells the two `None`s
2384                // apart.
2385                Some(Entry::OnDisk { .. }) => {
2386                    self.load(&key);
2387                    None
2388                }
2389                None => None,
2390            }
2391        }
2392
2393        fn canonical_identity(&self, name: &str) -> String {
2394            // The explorer's spelling of a document key (`browser_entries`);
2395            // a reserved name has no other spelling.
2396            match Self::key(name).strip_prefix(PREFIX) {
2397                Some(relative) => Self::virtual_model_path(relative),
2398                None => name.to_string(),
2399            }
2400        }
2401
2402        fn residency(&self, name: &str) -> Residency {
2403            match self.entries.borrow().get(&Self::key(name)) {
2404                Some(Entry::Resident(_)) => Residency::Resident,
2405                Some(Entry::OnDisk { .. }) => Residency::OnDisk,
2406                None => Residency::Absent,
2407            }
2408        }
2409
2410        fn write(&self, name: &str, contents: &str) -> Result<(), String> {
2411            let key = Self::key(name);
2412            self.entries
2413                .borrow_mut()
2414                .insert(key.clone(), Entry::Resident(contents.to_string()));
2415            // The mirror is authoritative for this session, so `Ok` is honest:
2416            // every subsequent read sees the new bytes. Durability is the
2417            // backend's job and its failure arrives via `take_persistence_errors`.
2418            let backend = self.backend.clone();
2419            let (put_key, contents) = (key.clone(), contents.to_string());
2420            self.push(name, &key, move || backend.put(&put_key, &contents));
2421            self.mutations.set(self.mutations.get().wrapping_add(1));
2422            Ok(())
2423        }
2424
2425        fn remove(&self, name: &str) -> Result<(), String> {
2426            let key = Self::key(name);
2427            // The backend delete is pushed either way (the mirror may be
2428            // ahead of it), but only a delete that found something COUNTS —
2429            // the same rule `FileModelStore::remove` keeps.
2430            let existed = self.entries.borrow_mut().remove(&key).is_some();
2431            let backend = self.backend.clone();
2432            let delete_key = key.clone();
2433            self.push(name, &key, move || backend.delete(&delete_key));
2434            if existed {
2435                self.mutations.set(self.mutations.get().wrapping_add(1));
2436            }
2437            Ok(())
2438        }
2439
2440        // `browser_write` needs no bump of its own: it routes through `write`.
2441        fn mutation_generation(&self) -> u64 {
2442            self.mutations.get()
2443        }
2444
2445        fn pending_writes(&self) -> usize {
2446            self.pending()
2447        }
2448
2449        fn take_persistence_errors(&self) -> Vec<String> {
2450            self.errors.drain()
2451        }
2452
2453        fn browser_location(&self) -> String {
2454            // The RAW navigable path (breadcrumb / back-forward / path-edit rely on
2455            // this being feed-able straight back to `browser_navigate`); the human
2456            // "virtual filesystem" context lives in `backend_label`.
2457            self.browser_dir.borrow().clone()
2458        }
2459
2460        fn browser_entries(&self, extensions: &[&str]) -> Vec<BrowserEntry> {
2461            let current = self.browser_dir.borrow().clone();
2462            if current == "/" {
2463                return vec![BrowserEntry {
2464                    name: "models".into(),
2465                    identity: "/models".into(),
2466                    is_dir: true,
2467                    size: None,
2468                    modified: None,
2469                }];
2470            }
2471            let relative_dir = current
2472                .strip_prefix("/models")
2473                .unwrap_or("")
2474                .trim_matches('/');
2475            let prefix = if relative_dir.is_empty() {
2476                String::new()
2477            } else {
2478                format!("{relative_dir}/")
2479            };
2480            let wanted: Vec<String> = extensions
2481                .iter()
2482                .map(|extension| extension.trim_start_matches('.').to_ascii_lowercase())
2483                .collect();
2484            let mut entries: BTreeMap<String, BrowserEntry> = BTreeMap::new();
2485            for (key, value) in self.entries.borrow().iter() {
2486                let (item, is_model) = if let Some(item) = key.strip_prefix(PREFIX) {
2487                    (item, true)
2488                } else if let Some(item) = key.strip_prefix(DIR_PREFIX) {
2489                    (item.trim_end_matches('/'), false)
2490                } else {
2491                    continue;
2492                };
2493                let Some(rest) = item.strip_prefix(&prefix) else {
2494                    continue;
2495                };
2496                if rest.is_empty() {
2497                    continue;
2498                }
2499                if let Some((child, _)) = rest.split_once('/') {
2500                    entries
2501                        .entry(child.to_string())
2502                        .or_insert_with(|| BrowserEntry {
2503                            name: child.to_string(),
2504                            identity: Self::child_path(&current, child),
2505                            is_dir: true,
2506                            size: None,
2507                            modified: None,
2508                        });
2509                } else if is_model {
2510                    let name = super::model_file_name(rest);
2511                    let lower = name.to_ascii_lowercase();
2512                    if wanted.is_empty()
2513                        || wanted
2514                            .iter()
2515                            .any(|extension| lower.ends_with(&format!(".{extension}")))
2516                    {
2517                        // Size = the mirrored JSON's byte length. `modified` stays
2518                        // None: the key space carries values, not timestamps.
2519                        entries.insert(
2520                            name.clone(),
2521                            BrowserEntry {
2522                                name,
2523                                identity: Self::virtual_model_path(item),
2524                                is_dir: false,
2525                                size: Some(value.len()),
2526                                modified: None,
2527                            },
2528                        );
2529                    }
2530                } else {
2531                    entries
2532                        .entry(rest.to_string())
2533                        .or_insert_with(|| BrowserEntry {
2534                            name: rest.to_string(),
2535                            identity: Self::child_path(&current, rest),
2536                            is_dir: true,
2537                            size: None,
2538                            modified: None,
2539                        });
2540                }
2541            }
2542            let mut entries: Vec<_> = entries.into_values().collect();
2543            entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name)));
2544            entries
2545        }
2546
2547        fn browser_enter(&self, identity: &str) -> Result<(), String> {
2548            if identity == "/models" || identity.starts_with("/models/") {
2549                *self.browser_dir.borrow_mut() = identity.trim_end_matches('/').to_string();
2550                Ok(())
2551            } else {
2552                Err("the browser virtual filesystem is rooted at /models".into())
2553            }
2554        }
2555
2556        fn browser_up(&self) -> Result<(), String> {
2557            let current = self.browser_dir.borrow().clone();
2558            if current == "/" {
2559                return Ok(());
2560            }
2561            let parent = current
2562                .rsplit_once('/')
2563                .map(|(parent, _)| parent)
2564                .unwrap_or("");
2565            *self.browser_dir.borrow_mut() = if parent.is_empty() {
2566                "/".into()
2567            } else {
2568                parent.into()
2569            };
2570            Ok(())
2571        }
2572
2573        fn browser_home(&self) -> Result<(), String> {
2574            *self.browser_dir.borrow_mut() = "/models".into();
2575            Ok(())
2576        }
2577
2578        fn browser_root(&self) -> Result<(), String> {
2579            *self.browser_dir.borrow_mut() = "/".into();
2580            Ok(())
2581        }
2582
2583        fn browser_navigate(&self, location: &str) -> Result<(), String> {
2584            let loc = location.trim_end_matches('/');
2585            let loc = if loc.is_empty() { "/" } else { loc };
2586            if loc == "/" || loc == "/models" || loc.starts_with("/models/") {
2587                *self.browser_dir.borrow_mut() = loc.to_string();
2588                Ok(())
2589            } else {
2590                Err("the browser virtual filesystem is rooted at /models".into())
2591            }
2592        }
2593
2594        fn browser_places(&self) -> Vec<BrowserPlace> {
2595            vec![
2596                BrowserPlace {
2597                    label: "Models".into(),
2598                    location: "/models".into(),
2599                    kind: PlaceKind::Models,
2600                },
2601                BrowserPlace {
2602                    label: "/".into(),
2603                    location: "/".into(),
2604                    kind: PlaceKind::Root,
2605                },
2606            ]
2607        }
2608
2609        fn browser_create_dir(&self, name: &str) -> Result<(), String> {
2610            if name.is_empty()
2611                || name == "."
2612                || name == ".."
2613                || name.contains('/')
2614                || name.contains('\\')
2615            {
2616                return Err("folder name must be one path component".into());
2617            }
2618            let current = self.browser_dir.borrow().clone();
2619            if !current.starts_with("/models") {
2620                return Err("folders can only be created under /models".into());
2621            }
2622            let relative = Self::child_path(&current, name)
2623                .trim_start_matches("/models/")
2624                .to_string();
2625            // A folder is a zero-length marker key; the explorer derives the tree
2626            // from the key prefixes alone.
2627            let key = format!("{DIR_PREFIX}{relative}/");
2628            self.entries
2629                .borrow_mut()
2630                .insert(key.clone(), Entry::Resident(String::new()));
2631            let backend = self.backend.clone();
2632            let put_key = key.clone();
2633            self.push(name, &key, move || backend.put(&put_key, ""));
2634            Ok(())
2635        }
2636
2637        fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
2638            let file = model_display_name(name);
2639            if file.is_empty() {
2640                return Err("invalid file name".into());
2641            }
2642            let current = self.browser_dir.borrow().clone();
2643            if !current.starts_with("/models") {
2644                return Err("select a folder under /models".into());
2645            }
2646            let identity = Self::child_path(&current, &super::model_file_name(&file));
2647            self.write(&identity, contents)?;
2648            Ok(identity)
2649        }
2650    }
2651}
2652
2653// --- Browser: IndexedDB documents + download / upload interchange --------------
2654#[cfg(target_arch = "wasm32")]
2655mod web_model {
2656    use super::mirror_store::{
2657        BackendFuture, MirrorStore, StoreBackend, UnavailableBackend, DIR_PREFIX, PREFIX,
2658    };
2659    use super::{
2660        model_display_name, BrowserEntry, BrowserPlace, ImportedFile, ModelStore,
2661    };
2662    use std::cell::RefCell;
2663    use std::future::Future;
2664    use std::pin::Pin;
2665    use std::rc::Rc;
2666    use std::task::{Context, Poll, Waker};
2667    use wasm_bindgen::prelude::*;
2668    use wasm_bindgen::JsCast;
2669
2670    // The origin-private persistent store on the web is **IndexedDB**. It replaced
2671    // `localStorage`, whose ~5-10 MB per-origin quota cannot hold a single native
2672    // BREP payload (a measured STEP import: ~35 MB), and it is the only browser
2673    // store that is both large and enumerable without a permission gesture (unlike
2674    // the File System Access API, which fails headless). Its API is ASYNC, hence
2675    // the mirror in [`mirror_store`](super::mirror_store); the key scheme is the
2676    // localStorage one, byte for byte. **Download + upload** below still bridge to
2677    // the user's REAL filesystem for portability.
2678    const DB_NAME: &str = "brep-app";
2679    const DB_VERSION: u32 = 1;
2680    /// The single key/value object store; keys are the `brep-app:*` strings.
2681    const STORE_NAME: &str = "kv";
2682    /// The cross-tab change channel. IndexedDB is shared by every tab of the
2683    /// origin but says NOTHING when another connection writes it, so each tab's
2684    /// hydrated mirror would sit at its boot-time contents until reloaded — save a
2685    /// part in one tab, and the tab holding the assembly cannot insert it. Every
2686    /// tab posts the key it just committed here and re-reads the keys it is told
2687    /// about; a [`BroadcastChannel`](web_sys::BroadcastChannel) never echoes to
2688    /// the object that sent the message, so a tab hears only the others.
2689    const CHANNEL_NAME: &str = "brep-app:store";
2690
2691    thread_local! {
2692        /// The single hidden `<input type=file>`, created once and reused.
2693        static IMPORT_INPUT: RefCell<Option<web_sys::HtmlInputElement>> = const { RefCell::new(None) };
2694        /// The most recent completed upload, awaiting the panel's poll.
2695        static IMPORTED: RefCell<Option<ImportedFile>> = const { RefCell::new(None) };
2696        /// The file chooser's own input and slot ([`ModelStore::begin_pick_file`]):
2697        /// a picked attachment must never be taken by the model-import lane.
2698        static PICK_INPUT: RefCell<Option<web_sys::HtmlInputElement>> = const { RefCell::new(None) };
2699        static PICKED: RefCell<Option<ImportedFile>> = const { RefCell::new(None) };
2700        /// The egui context, so an ASYNC callback (a `FileReader` load, a failed
2701        /// IndexedDB write) can wake the reactive eframe loop (the app only
2702        /// repaints on input events + explicit requests). Without it a completed
2703        /// upload — or a "your model did not save" notice — sits unshown until the
2704        /// user happens to move the mouse. Seeded once at boot via [`set_repaint_ctx`].
2705        static REPAINT_CTX: RefCell<Option<eframe::egui::Context>> = const { RefCell::new(None) };
2706        /// The store built by [`hydrate`] inside the async wasm entry point,
2707        /// waiting for `BrepApp::new` to pick it up through
2708        /// [`default_model_store`](super::default_model_store). The hand-off is a
2709        /// thread-local rather than a closure capture so the app constructor keeps
2710        /// its platform-free signature.
2711        static BOOT_STORE: RefCell<Option<IdbModelStore>> = const { RefCell::new(None) };
2712        /// Why a page the PLM serves is on IndexedDB instead (the PLM refused
2713        /// its session), shown once on the first frame.
2714        static BOOT_NOTICE: RefCell<Option<String>> = const { RefCell::new(None) };
2715        /// The same reason, kept for the PLM tab after the notice is drained.
2716        static BOOT_REASON: RefCell<Option<String>> = const { RefCell::new(None) };
2717    }
2718
2719    fn set_boot_notice(notice: String) {
2720        BOOT_REASON.with(|r| *r.borrow_mut() = Some(notice.clone()));
2721        BOOT_NOTICE.with(|n| *n.borrow_mut() = Some(format!("{notice} — this page uses the browser's storage")));
2722    }
2723
2724    /// Register the egui context used to wake the frame loop when an async browser
2725    /// upload — or a write-behind persistence failure — completes. Called once from
2726    /// the app shell at construction.
2727    pub fn set_repaint_ctx(ctx: eframe::egui::Context) {
2728        REPAINT_CTX.with(|c| *c.borrow_mut() = Some(ctx));
2729    }
2730
2731    /// Ask the reactive frame loop for a repaint. Reads `REPAINT_CTX` at CALL time,
2732    /// so it does not matter that the store is built (during boot) before the app
2733    /// shell registers the context.
2734    fn wake_frame_loop() {
2735        REPAINT_CTX.with(|c| {
2736            if let Some(ctx) = c.borrow().as_ref() {
2737                ctx.request_repaint();
2738            }
2739        });
2740    }
2741
2742    // --- IndexedDB request/transaction -> Future ----------------------------------
2743    // Hand-rolled rather than pulling `indexed_db_futures`: this crate deliberately
2744    // keeps its wasm dependency tree thin (see the `ehttp` note in Cargo.toml about
2745    // the `getrandom` dep-tree problem), and the whole adapter is the ~60 lines
2746    // below. It owns its `Closure`s — dropping the future clears the handlers — so
2747    // a session of saves does not leak a pair of JS closures per write, which a
2748    // `Closure::forget()` sketch would.
2749
2750    #[derive(Default)]
2751    struct Settled {
2752        outcome: Option<Result<JsValue, String>>,
2753        waker: Option<Waker>,
2754    }
2755
2756    /// Which DOM event pair the future is listening to. Kept so `Drop` can detach
2757    /// the handlers (and with them the Rust closures' reference back to the target).
2758    enum EventSource {
2759        Request(web_sys::IdbRequest),
2760        Transaction(web_sys::IdbTransaction),
2761    }
2762
2763    /// One IndexedDB completion, as a `Future`.
2764    struct IdbFuture {
2765        source: EventSource,
2766        settled: Rc<RefCell<Settled>>,
2767        /// Owned so the closures live exactly as long as the future.
2768        _handlers: Vec<Closure<dyn FnMut(web_sys::Event)>>,
2769    }
2770
2771    fn settle(settled: &Rc<RefCell<Settled>>, outcome: Result<JsValue, String>) {
2772        let waker = {
2773            let mut settled = settled.borrow_mut();
2774            if settled.outcome.is_none() {
2775                settled.outcome = Some(outcome);
2776            }
2777            settled.waker.take()
2778        };
2779        if let Some(waker) = waker {
2780            waker.wake();
2781        }
2782    }
2783
2784    fn request_error(request: &web_sys::IdbRequest) -> String {
2785        request
2786            .error()
2787            .ok()
2788            .flatten()
2789            .map(|error| format!("{}: {}", error.name(), error.message()))
2790            .unwrap_or_else(|| "IndexedDB request failed".into())
2791    }
2792
2793    fn js_error(value: &JsValue) -> String {
2794        value
2795            .as_string()
2796            .or_else(|| js_sys::Reflect::get(value, &JsValue::from_str("message")).ok()?.as_string())
2797            .unwrap_or_else(|| format!("{value:?}"))
2798    }
2799
2800    /// Resolve when `request` succeeds, with its `result`.
2801    fn on_request(request: web_sys::IdbRequest) -> IdbFuture {
2802        let settled = Rc::new(RefCell::new(Settled::default()));
2803        let success = {
2804            let settled = settled.clone();
2805            let request = request.clone();
2806            Closure::wrap(Box::new(move |_event: web_sys::Event| {
2807                let value = request.result().unwrap_or(JsValue::UNDEFINED);
2808                settle(&settled, Ok(value));
2809            }) as Box<dyn FnMut(web_sys::Event)>)
2810        };
2811        let failure = {
2812            let settled = settled.clone();
2813            let request = request.clone();
2814            Closure::wrap(Box::new(move |_event: web_sys::Event| {
2815                settle(&settled, Err(request_error(&request)));
2816            }) as Box<dyn FnMut(web_sys::Event)>)
2817        };
2818        request.set_onsuccess(Some(success.as_ref().unchecked_ref()));
2819        request.set_onerror(Some(failure.as_ref().unchecked_ref()));
2820        IdbFuture {
2821            source: EventSource::Request(request),
2822            settled,
2823            _handlers: vec![success, failure],
2824        }
2825    }
2826
2827    /// Resolve when `transaction` COMMITS.
2828    ///
2829    /// A write must be awaited here, not on the `put` request's `onsuccess`: the
2830    /// request succeeds before the transaction commits, and a page reload can abort
2831    /// an uncommitted `readwrite` transaction. For a multi-megabyte payload that
2832    /// window is real, and "the save is durable" is exactly the claim the pending
2833    /// counter and the reload test rest on.
2834    fn on_transaction(transaction: web_sys::IdbTransaction) -> IdbFuture {
2835        let settled = Rc::new(RefCell::new(Settled::default()));
2836        let complete = {
2837            let settled = settled.clone();
2838            Closure::wrap(Box::new(move |_event: web_sys::Event| {
2839                settle(&settled, Ok(JsValue::UNDEFINED));
2840            }) as Box<dyn FnMut(web_sys::Event)>)
2841        };
2842        let describe = {
2843            let transaction = transaction.clone();
2844            move |fallback: &str| {
2845                transaction
2846                    .error()
2847                    .map(|error| format!("{}: {}", error.name(), error.message()))
2848                    .unwrap_or_else(|| fallback.to_string())
2849            }
2850        };
2851        let failure = {
2852            let settled = settled.clone();
2853            let describe = describe.clone();
2854            Closure::wrap(Box::new(move |_event: web_sys::Event| {
2855                settle(&settled, Err(describe("IndexedDB transaction failed")));
2856            }) as Box<dyn FnMut(web_sys::Event)>)
2857        };
2858        let abort = {
2859            let settled = settled.clone();
2860            Closure::wrap(Box::new(move |_event: web_sys::Event| {
2861                settle(
2862                    &settled,
2863                    Err(describe("IndexedDB transaction aborted (quota?)")),
2864                );
2865            }) as Box<dyn FnMut(web_sys::Event)>)
2866        };
2867        transaction.set_oncomplete(Some(complete.as_ref().unchecked_ref()));
2868        transaction.set_onerror(Some(failure.as_ref().unchecked_ref()));
2869        transaction.set_onabort(Some(abort.as_ref().unchecked_ref()));
2870        IdbFuture {
2871            source: EventSource::Transaction(transaction),
2872            settled,
2873            _handlers: vec![complete, failure, abort],
2874        }
2875    }
2876
2877    impl Future for IdbFuture {
2878        type Output = Result<JsValue, String>;
2879
2880        fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
2881            let mut settled = self.settled.borrow_mut();
2882            if let Some(outcome) = settled.outcome.take() {
2883                return Poll::Ready(outcome);
2884            }
2885            settled.waker = Some(cx.waker().clone());
2886            Poll::Pending
2887        }
2888    }
2889
2890    impl Drop for IdbFuture {
2891        fn drop(&mut self) {
2892            // Detach the handlers, breaking the target -> closure -> target cycle
2893            // so the closures we own are actually freed.
2894            match &self.source {
2895                EventSource::Request(request) => {
2896                    request.set_onsuccess(None);
2897                    request.set_onerror(None);
2898                }
2899                EventSource::Transaction(transaction) => {
2900                    transaction.set_oncomplete(None);
2901                    transaction.set_onerror(None);
2902                    transaction.set_onabort(None);
2903                }
2904            }
2905        }
2906    }
2907
2908    // --- the IndexedDB backend -----------------------------------------------------
2909
2910    /// [`StoreBackend`] over one IndexedDB object store. The ONLY place in the
2911    /// crate that knows what IndexedDB is.
2912    pub(super) struct IdbBackend {
2913        db: web_sys::IdbDatabase,
2914        /// The cross-tab notification channel (see [`CHANNEL_NAME`]), or `None`
2915        /// where the browser does not offer one — then this tab still saves and
2916        /// still reads, it just will not hear about another tab's saves.
2917        channel: Option<web_sys::BroadcastChannel>,
2918    }
2919
2920    impl IdbBackend {
2921        /// Open (creating on first use) the application database. `Err` on any
2922        /// browser that blocks storage — private windows, "block all cookies"
2923        /// setups — which the caller turns into a loud, non-persisting session.
2924        ///
2925        /// `channel` is the tab's ONE [`BroadcastChannel`](web_sys::BroadcastChannel)
2926        /// handle, shared with [`hydrate`]'s listener: posting and listening on the
2927        /// same object is what keeps a tab from hearing its own writes back (the
2928        /// channel never delivers a message to the object that sent it).
2929        async fn open(channel: Option<web_sys::BroadcastChannel>) -> Result<Self, String> {
2930            let factory = web_sys::window()
2931                .ok_or("no window")?
2932                .indexed_db()
2933                .map_err(|e| js_error(&e))?
2934                .ok_or("no indexedDB on this window")?;
2935            let request = factory
2936                .open_with_u32(DB_NAME, DB_VERSION)
2937                .map_err(|e| js_error(&e))?;
2938
2939            // Create the object store on first open / version bump. The request is
2940            // captured directly (rather than read off the event target) so no
2941            // `EventTarget` binding is needed.
2942            let upgrading = request.clone();
2943            let upgrade = Closure::wrap(Box::new(move |_event: web_sys::Event| {
2944                if let Ok(value) = upgrading.result() {
2945                    if let Ok(db) = value.dyn_into::<web_sys::IdbDatabase>() {
2946                        // Errors here mean the store already exists — harmless.
2947                        let _ = db.create_object_store(STORE_NAME);
2948                    }
2949                }
2950            }) as Box<dyn FnMut(web_sys::Event)>);
2951            request.set_onupgradeneeded(Some(upgrade.as_ref().unchecked_ref()));
2952
2953            let opened = on_request(request.clone().unchecked_into::<web_sys::IdbRequest>()).await;
2954            request.set_onupgradeneeded(None);
2955            drop(upgrade);
2956
2957            let db = opened?
2958                .dyn_into::<web_sys::IdbDatabase>()
2959                .map_err(|_| "IndexedDB open returned no database".to_string())?;
2960            Ok(Self { db, channel })
2961        }
2962
2963        /// Tell the other tabs of this origin that `key` changed, so their mirrors
2964        /// can re-read it. Only the FILESYSTEM key space is announced: `@settings`,
2965        /// `@dock_layout`, `@recovery` and friends are per-tab working state, and
2966        /// syncing them live would have two windows fight over one layout.
2967        fn announce(channel: &Option<web_sys::BroadcastChannel>, key: &str) {
2968            if !key.starts_with(PREFIX) && !key.starts_with(DIR_PREFIX) {
2969                return;
2970            }
2971            if let Some(channel) = channel {
2972                // A failed post costs this tab nothing it can act on — the write
2973                // itself landed — so it is dropped rather than toasted.
2974                let _ = channel.post_message(&JsValue::from_str(key));
2975            }
2976        }
2977
2978        /// Start a transaction and reach its object store. The transaction handle
2979        /// is returned so a write can await its COMMIT.
2980        fn transact(
2981            &self,
2982            mode: web_sys::IdbTransactionMode,
2983        ) -> Result<(web_sys::IdbTransaction, web_sys::IdbObjectStore), String> {
2984            let transaction = self
2985                .db
2986                .transaction_with_str_and_mode(STORE_NAME, mode)
2987                .map_err(|e| js_error(&e))?;
2988            let store = transaction.object_store(STORE_NAME).map_err(|e| js_error(&e))?;
2989            Ok((transaction, store))
2990        }
2991    }
2992
2993    impl StoreBackend for IdbBackend {
2994        fn label(&self) -> String {
2995            "browser storage (IndexedDB) · download/upload for files".into()
2996        }
2997
2998        /// IndexedDB commits `readwrite` transactions in creation order, and
2999        /// `put` / `delete` create theirs synchronously — so the mirror issues
3000        /// every write at once, exactly as it did before the send queue existed.
3001        fn orders_writes_per_key(&self) -> bool {
3002            true
3003        }
3004
3005        fn load_all(&self) -> BackendFuture<Vec<(String, String)>> {
3006            // Two whole-store requests in ONE readonly transaction rather than a
3007            // cursor: `getAllKeys` and `getAll` both come back in key order, so
3008            // zipping them reconstructs the key space in one round trip each.
3009            let started = (|| -> Result<(IdbFuture, IdbFuture), String> {
3010                let (_transaction, store) = self.transact(web_sys::IdbTransactionMode::Readonly)?;
3011                let keys = store.get_all_keys().map_err(|e| js_error(&e))?;
3012                let values = store.get_all().map_err(|e| js_error(&e))?;
3013                Ok((on_request(keys), on_request(values)))
3014            })();
3015            Box::pin(async move {
3016                let (keys, values) = started?;
3017                let keys = js_sys::Array::from(&keys.await?);
3018                let values = js_sys::Array::from(&values.await?);
3019                if keys.length() != values.length() {
3020                    return Err("IndexedDB returned mismatched keys and values".into());
3021                }
3022                let mut entries = Vec::with_capacity(keys.length() as usize);
3023                for i in 0..keys.length() {
3024                    let (Some(key), Some(value)) =
3025                        (keys.get(i).as_string(), values.get(i).as_string())
3026                    else {
3027                        // A non-string entry is not ours; skipping it is safer than
3028                        // failing the whole hydrate.
3029                        continue;
3030                    };
3031                    entries.push((key, value));
3032                }
3033                Ok(entries)
3034            })
3035        }
3036
3037        fn get(&self, key: &str) -> BackendFuture<Option<String>> {
3038            let started = (|| -> Result<IdbFuture, String> {
3039                let (_transaction, store) = self.transact(web_sys::IdbTransactionMode::Readonly)?;
3040                let request = store.get(&JsValue::from_str(key)).map_err(|e| js_error(&e))?;
3041                Ok(on_request(request))
3042            })();
3043            Box::pin(async move {
3044                // An absent key resolves to `undefined`, which is the `None` the
3045                // mirror turns into a removal.
3046                Ok(started?.await?.as_string())
3047            })
3048        }
3049
3050        fn put(&self, key: &str, value: &str) -> BackendFuture<()> {
3051            // The transaction is created and the request issued SYNCHRONOUSLY, so
3052            // two writes of the same key commit in call order (IndexedDB commits
3053            // `readwrite` transactions in creation order) no matter how the futures
3054            // are later polled — the per-key FIFO obligation in `StoreBackend`.
3055            let started = (|| -> Result<IdbFuture, String> {
3056                let (transaction, store) =
3057                    self.transact(web_sys::IdbTransactionMode::Readwrite)?;
3058                store
3059                    .put_with_key(&JsValue::from_str(value), &JsValue::from_str(key))
3060                    .map_err(|e| js_error(&e))?;
3061                Ok(on_transaction(transaction))
3062            })();
3063            let channel = self.channel.clone();
3064            let key = key.to_string();
3065            Box::pin(async move {
3066                started?.await?;
3067                // AFTER the commit, never before: a tab told about the key reads
3068                // it back immediately, and must not be able to read the old bytes.
3069                Self::announce(&channel, &key);
3070                Ok(())
3071            })
3072        }
3073
3074        fn delete(&self, key: &str) -> BackendFuture<()> {
3075            let started = (|| -> Result<IdbFuture, String> {
3076                let (transaction, store) =
3077                    self.transact(web_sys::IdbTransactionMode::Readwrite)?;
3078                store
3079                    .delete(&JsValue::from_str(key))
3080                    .map_err(|e| js_error(&e))?;
3081                Ok(on_transaction(transaction))
3082            })();
3083            let channel = self.channel.clone();
3084            let key = key.to_string();
3085            Box::pin(async move {
3086                started?.await?;
3087                Self::announce(&channel, &key);
3088                Ok(())
3089            })
3090        }
3091    }
3092
3093    // --- boot ----------------------------------------------------------------------
3094
3095    /// Bring up browser persistence and hydrate the whole key space into memory.
3096    ///
3097    /// Called from the async wasm entry point BEFORE `eframe::WebRunner::start`, so
3098    /// by the time any synchronous [`ModelStore::read`] can run, the mirror is
3099    /// complete — that ordering is the entire reason the trait stays synchronous.
3100    ///
3101    /// On failure there is NO quiet fallback: the session gets an
3102    /// [`UnavailableBackend`], which keeps the app usable in memory while saying so
3103    /// in the panel header and toasting every write that does not persist.
3104    pub(super) async fn hydrate() {
3105        let wake: Rc<dyn Fn()> = Rc::new(wake_frame_loop);
3106        let channel = web_sys::BroadcastChannel::new(CHANNEL_NAME).ok();
3107        let core = match IdbBackend::open(channel.clone()).await {
3108            Ok(backend) => {
3109                let mut backend: Rc<dyn StoreBackend> = Rc::new(backend);
3110                // A page the PLM serves (D6, `/cad/app/`) keeps its documents
3111                // and preferences there, with `@recovery` in IndexedDB first.
3112                // A refusal keeps the page on IndexedDB and says why; a page
3113                // served from anywhere else never asks.
3114                match crate::plm::backend::open_web_session(backend.clone()).await {
3115                    Ok(Some(plm)) => backend = plm,
3116                    Ok(None) => {}
3117                    Err(sentence) => set_boot_notice(format!("PLM: {sentence}")),
3118                }
3119                // `load_index`'s default IS `load_all` with every value
3120                // resident, so a browser origin still hydrates whole; the
3121                // prefetch below then has nothing to do. An index-only backend
3122                // (the PLM) goes through this same door.
3123                match backend.load_index().await {
3124                    Ok(entries) => {
3125                        let core = MirrorStore::from_index(backend, entries, Some(wake));
3126                        core.prefetch();
3127                        // Hydration is the LAST all-at-once read. From here the
3128                        // mirror keeps up with the other tabs one key at a time,
3129                        // so a part saved next door is insertable here without a
3130                        // reload.
3131                        listen_for_other_tabs(&core, channel);
3132                        core
3133                    }
3134                    // The database opened but would not read. Writing against a
3135                    // half-known key space could overwrite documents the mirror
3136                    // never saw, so treat it as unavailable rather than risk that.
3137                    Err(message) => unavailable(
3138                        format!("IndexedDB could not be read ({message})"),
3139                        Some(wake),
3140                    ),
3141                }
3142            }
3143            Err(message) => unavailable(format!("IndexedDB unavailable ({message})"), Some(wake)),
3144        };
3145        install_verification_hooks(&core);
3146        BOOT_STORE.with(|c| *c.borrow_mut() = Some(IdbModelStore { core }));
3147    }
3148
3149    /// Subscribe this tab's mirror to the other tabs' commits: each message is a
3150    /// key one of them just wrote, and the mirror re-reads exactly that key.
3151    ///
3152    /// The handler is `forget()`-ed — one per tab, alive for the session, the same
3153    /// pattern the verification hooks use. It holds a `MirrorStore` clone, which is
3154    /// another handle on the SAME session state, not a copy of it.
3155    fn listen_for_other_tabs(core: &MirrorStore, channel: Option<web_sys::BroadcastChannel>) {
3156        let Some(channel) = channel else {
3157            return;
3158        };
3159        let core = core.clone();
3160        let on_message = Closure::wrap(Box::new(move |event: web_sys::MessageEvent| {
3161            if let Some(key) = event.data().as_string() {
3162                core.refresh_key(key);
3163            }
3164        }) as Box<dyn FnMut(web_sys::MessageEvent)>);
3165        channel.set_onmessage(Some(on_message.as_ref().unchecked_ref()));
3166        on_message.forget();
3167        // The channel object itself must outlive this call — a dropped
3168        // `BroadcastChannel` stops delivering — and it does: `IdbBackend` holds the
3169        // same handle for the life of the session.
3170    }
3171
3172    /// A session with no persistence: empty, in-memory, and loud about it.
3173    fn unavailable(reason: String, wake: Option<Rc<dyn Fn()>>) -> MirrorStore {
3174        let core = MirrorStore::new(
3175            Rc::new(UnavailableBackend::new(reason.clone())),
3176            Vec::new(),
3177            wake,
3178        );
3179        // Seed the notice channel so the FIRST frame tells the user, before they
3180        // have saved anything and discovered it the hard way.
3181        core.report(format!(
3182            "{reason} — this session will not be saved; use Download to keep your work"
3183        ));
3184        core
3185    }
3186
3187    /// Hand the hydrated store to `BrepApp::new`. If boot never ran (no code path
3188    /// does that today), the app still starts — non-persisting and saying so.
3189    pub(super) fn take_boot_store() -> Box<dyn ModelStore> {
3190        let store = BOOT_STORE.with(|c| c.borrow_mut().take()).unwrap_or_else(|| {
3191            let wake: Rc<dyn Fn()> = Rc::new(wake_frame_loop);
3192            IdbModelStore {
3193                core: unavailable("storage was never initialised".into(), Some(wake)),
3194            }
3195        });
3196        Box::new(store)
3197    }
3198
3199    // --- verification hooks ---------------------------------------------------------
3200
3201    /// Publish `window.__brepStore*` handles onto the LIVE store, in the same
3202    /// spirit as the app shell's `__brep*` state globals: the headed verifier needs
3203    /// to write a payload far larger than any UI gesture can type, then prove it
3204    /// survived a reload. `__brepStorePending` is what makes that check non-racy —
3205    /// it reaches zero only once the backing transaction has COMMITTED.
3206    /// `__brepStoreRead` is the read side of the same seam: a script that saves
3207    /// through the UI has to be able to look at what landed.
3208    fn install_verification_hooks(core: &MirrorStore) {
3209        let Some(window) = web_sys::window() else {
3210            return;
3211        };
3212        let publish = |name: &str, value: &JsValue| {
3213            let _ = js_sys::Reflect::set(&window, &JsValue::from_str(name), value);
3214        };
3215
3216        let store = core.clone();
3217        let write = Closure::wrap(Box::new(move |name: String, contents: String| -> JsValue {
3218            match store.write(&name, &contents) {
3219                Ok(()) => JsValue::NULL,
3220                Err(message) => JsValue::from_str(&message),
3221            }
3222        }) as Box<dyn FnMut(String, String) -> JsValue>);
3223        publish("__brepStoreWrite", write.as_ref());
3224        write.forget();
3225
3226        let store = core.clone();
3227        let len = Closure::wrap(Box::new(move |name: String| -> f64 {
3228            store.len_of(&name).map(|n| n as f64).unwrap_or(-1.0)
3229        }) as Box<dyn FnMut(String) -> f64>);
3230        publish("__brepStoreLen", len.as_ref());
3231        len.forget();
3232
3233        // The READ sibling of `__brepStoreWrite`. Without it a headed script can
3234        // only prove a save happened, not WHAT was saved: the assemblies sweep
3235        // used to read the document straight out of `localStorage`, which the
3236        // IndexedDB migration silently emptied.
3237        let store = core.clone();
3238        let read = Closure::wrap(Box::new(move |name: String| -> JsValue {
3239            match store.read(&name) {
3240                Some(contents) => JsValue::from_str(&contents),
3241                None => JsValue::NULL,
3242            }
3243        }) as Box<dyn FnMut(String) -> JsValue>);
3244        publish("__brepStoreRead", read.as_ref());
3245        read.forget();
3246
3247        let store = core.clone();
3248        let list = Closure::wrap(Box::new(move || -> JsValue {
3249            JsValue::from_str(&serde_json::to_string(&store.list()).unwrap_or_default())
3250        }) as Box<dyn FnMut() -> JsValue>);
3251        publish("__brepStoreList", list.as_ref());
3252        list.forget();
3253
3254        let store = core.clone();
3255        let errors = Closure::wrap(Box::new(move || -> JsValue {
3256            JsValue::from_str(&serde_json::to_string(&store.error_history()).unwrap_or_default())
3257        }) as Box<dyn FnMut() -> JsValue>);
3258        publish("__brepStoreErrors", errors.as_ref());
3259        errors.forget();
3260
3261        // The DELETE sibling of `__brepStoreWrite`, so the two-tab check in
3262        // `web/verify_store.mjs` can prove the second half of live storage: a
3263        // document removed in one tab leaves the other tab's list as well.
3264        let store = core.clone();
3265        let remove = Closure::wrap(Box::new(move |name: String| -> JsValue {
3266            match store.remove(&name) {
3267                Ok(()) => JsValue::NULL,
3268                Err(message) => JsValue::from_str(&message),
3269            }
3270        }) as Box<dyn FnMut(String) -> JsValue>);
3271        publish("__brepStoreRemove", remove.as_ref());
3272        remove.forget();
3273
3274        let store = core.clone();
3275        let pending = Closure::wrap(Box::new(move || -> f64 { store.pending() as f64 })
3276            as Box<dyn FnMut() -> f64>);
3277        publish("__brepStorePending", pending.as_ref());
3278        pending.forget();
3279    }
3280
3281    // --- real-file interchange (download / upload) ----------------------------------
3282    // Free functions, not methods: they are pure browser plumbing with no store
3283    // state, and keeping them out of the store type leaves `IdbModelStore` as a
3284    // thin seam between the mirror and this lane.
3285
3286    /// Lazily create the reusable hidden file input, wiring its `change` handler
3287    /// (which reads the chosen file and stashes it for `take_import`). `accept` is
3288    /// (re)applied every call so the picker's filter matches the current lane (the
3289    /// `.nbrep` model lane vs. a `.step` import lane).
3290    /// The file chooser's hidden input, created once: any file, its own name
3291    /// kept whole, its bytes into [`PICKED`].
3292    fn pick_input() -> Option<web_sys::HtmlInputElement> {
3293        if let Some(existing) = PICK_INPUT.with(|c| c.borrow().clone()) {
3294            return Some(existing);
3295        }
3296        let document = web_sys::window()?.document()?;
3297        let input: web_sys::HtmlInputElement = document.create_element("input").ok()?.dyn_into().ok()?;
3298        input.set_type("file");
3299        input.set_hidden(true);
3300        let input_for_cb = input.clone();
3301        let onchange = Closure::wrap(Box::new(move |_e: web_sys::Event| {
3302            let Some(file) = input_for_cb.files().and_then(|files| files.get(0)) else { return };
3303            let name = file.name();
3304            let Ok(reader) = web_sys::FileReader::new() else { return };
3305            let reader_for_load = reader.clone();
3306            let onload = Closure::wrap(Box::new(move |_e: web_sys::Event| {
3307                if let Ok(value) = reader_for_load.result() {
3308                    let bytes = js_sys::Uint8Array::new(&value).to_vec();
3309                    PICKED.with(|c| *c.borrow_mut() = Some(ImportedFile { name: name.clone(), bytes }));
3310                    wake_frame_loop();
3311                }
3312            }) as Box<dyn FnMut(web_sys::Event)>);
3313            reader.set_onload(Some(onload.as_ref().unchecked_ref()));
3314            onload.forget();
3315            let _ = reader.read_as_array_buffer(&file);
3316        }) as Box<dyn FnMut(web_sys::Event)>);
3317        input.set_onchange(Some(onchange.as_ref().unchecked_ref()));
3318        onchange.forget();
3319        if let Some(body) = document.body() {
3320            let _ = body.append_child(&input);
3321        }
3322        PICK_INPUT.with(|c| *c.borrow_mut() = Some(input.clone()));
3323        Some(input)
3324    }
3325
3326    fn ensure_input(accept: &str) -> Option<web_sys::HtmlInputElement> {
3327        if let Some(existing) = IMPORT_INPUT.with(|c| c.borrow().clone()) {
3328            existing.set_accept(accept);
3329            return Some(existing);
3330        }
3331        let document = web_sys::window()?.document()?;
3332        let input: web_sys::HtmlInputElement =
3333            document.create_element("input").ok()?.dyn_into().ok()?;
3334        input.set_type("file");
3335        input.set_accept(accept);
3336        input.set_hidden(true);
3337
3338        // Read bytes so binary STL is not corrupted at the browser boundary.
3339        let input_for_cb = input.clone();
3340        let onchange = Closure::wrap(Box::new(move |_e: web_sys::Event| {
3341            let Some(files) = input_for_cb.files() else { return };
3342            let Some(file) = files.get(0) else { return };
3343            let name = file.name();
3344            let Ok(reader) = web_sys::FileReader::new() else { return };
3345            let reader_for_load = reader.clone();
3346            let onload = Closure::wrap(Box::new(move |_e: web_sys::Event| {
3347                if let Ok(value) = reader_for_load.result() {
3348                    let bytes = js_sys::Uint8Array::new(&value).to_vec();
3349                    IMPORTED.with(|c| *c.borrow_mut() = Some(ImportedFile {
3350                        name: model_display_name(&name),
3351                        bytes,
3352                    }));
3353                    // Wake the reactive frame loop so the file panel polls
3354                    // `take_import` THIS frame, not on the next stray input event.
3355                    wake_frame_loop();
3356                }
3357            }) as Box<dyn FnMut(web_sys::Event)>);
3358            reader.set_onload(Some(onload.as_ref().unchecked_ref()));
3359            // One small per-import leak (the app runs for the page lifetime).
3360            onload.forget();
3361            let _ = reader.read_as_array_buffer(&file);
3362        }) as Box<dyn FnMut(web_sys::Event)>);
3363        input.set_onchange(Some(onchange.as_ref().unchecked_ref()));
3364        onchange.forget(); // created once — leak is bounded
3365
3366        if let Some(body) = document.body() {
3367            let _ = body.append_child(&input);
3368        }
3369        IMPORT_INPUT.with(|c| *c.borrow_mut() = Some(input.clone()));
3370        Some(input)
3371    }
3372
3373    /// Offer `contents` to the user as a download named EXACTLY `file_name`, via a
3374    /// Blob object-URL + a synthetic anchor click.
3375    fn download(file_name: &str, mime: &str, contents: &str) -> Result<(), String> {
3376        let parts = js_sys::Array::of1(&JsValue::from_str(contents));
3377        let options = web_sys::BlobPropertyBag::new();
3378        options.set_type(mime);
3379        let blob = web_sys::Blob::new_with_str_sequence_and_options(&parts, &options)
3380            .map_err(|_| "blob create failed".to_string())?;
3381        click_download(file_name, &blob)
3382    }
3383
3384    /// [`download`] for BINARY content: the bytes are handed to the Blob as a
3385    /// `Uint8Array`, so nothing on the way out reinterprets them as UTF-8.
3386    fn download_bytes(file_name: &str, mime: &str, contents: &[u8]) -> Result<(), String> {
3387        let array = js_sys::Uint8Array::new_with_length(contents.len() as u32);
3388        array.copy_from(contents);
3389        let parts = js_sys::Array::of1(&array);
3390        let options = web_sys::BlobPropertyBag::new();
3391        options.set_type(mime);
3392        let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &options)
3393            .map_err(|_| "blob create failed".to_string())?;
3394        click_download(file_name, &blob)
3395    }
3396
3397    /// The half both downloads share: an object URL for `blob`, a synthetic
3398    /// anchor click carrying EXACTLY `file_name`, then the URL released.
3399    fn click_download(file_name: &str, blob: &web_sys::Blob) -> Result<(), String> {
3400        let document = web_sys::window()
3401            .and_then(|w| w.document())
3402            .ok_or("no document")?;
3403        let url = web_sys::Url::create_object_url_with_blob(blob)
3404            .map_err(|_| "object url failed".to_string())?;
3405        let anchor: web_sys::HtmlAnchorElement = document
3406            .create_element("a")
3407            .map_err(|_| "anchor create failed".to_string())?
3408            .dyn_into()
3409            .map_err(|_| "anchor cast failed".to_string())?;
3410        anchor.set_href(&url);
3411        anchor.set_download(file_name);
3412        anchor.click();
3413        let _ = web_sys::Url::revoke_object_url(&url);
3414        Ok(())
3415    }
3416
3417    /// The browser model store: the mirrored key space (which owns every CRUD and
3418    /// explorer method) plus this platform's real-file interchange lane.
3419    pub(super) struct IdbModelStore {
3420        core: MirrorStore,
3421    }
3422
3423    impl ModelStore for IdbModelStore {
3424        // --- delegated to the mirror ------------------------------------------
3425        fn backend_label(&self) -> String {
3426            self.core.backend_label()
3427        }
3428        fn plm_client(&self) -> Option<std::rc::Rc<crate::plm::client::PlmClient>> {
3429            self.core.plm_client()
3430        }
3431        fn plm_revision(&self, key: &str) -> Option<crate::plm::client::IndexEntry> {
3432            self.core.plm_revision(key)
3433        }
3434        fn refresh_plm_index(&self, keys: &[String], stale: bool) {
3435            self.core.refresh_plm_index(keys, stale)
3436        }
3437        fn list(&self) -> Vec<String> {
3438            self.core.list()
3439        }
3440        fn read(&self, name: &str) -> Option<String> {
3441            self.core.read(name)
3442        }
3443        fn residency(&self, name: &str) -> super::Residency {
3444            self.core.residency(name)
3445        }
3446        fn canonical_identity(&self, name: &str) -> String {
3447            self.core.canonical_identity(name)
3448        }
3449        fn write(&self, name: &str, contents: &str) -> Result<(), String> {
3450            self.core.write(name, contents)
3451        }
3452        fn remove(&self, name: &str) -> Result<(), String> {
3453            self.core.remove(name)
3454        }
3455        fn mutation_generation(&self) -> u64 {
3456            self.core.mutation_generation()
3457        }
3458        fn take_persistence_errors(&self) -> Vec<String> {
3459            let mut errors: Vec<String> = BOOT_NOTICE.with(|n| n.borrow_mut().take()).into_iter().collect();
3460            errors.extend(self.core.take_persistence_errors());
3461            errors
3462        }
3463        fn plm_session(&self) -> Option<crate::plm::connection::Session> {
3464            use crate::plm::connection::{parse_label, Keep, Session, Status};
3465            if let Some((url, username)) = parse_label(&self.core.backend_label()) {
3466                return Some(Session { status: Status::Connected { url, username }, keep: Keep::BrowserSession });
3467            }
3468            let location = web_sys::window()?.location();
3469            if !crate::plm::backend::served_by_plm(&location.pathname().ok()?) {
3470                return None;
3471            }
3472            let reason = BOOT_REASON.with(|r| r.borrow().clone());
3473            Some(Session { status: Status::NotConnected { url: location.origin().ok()?, reason }, keep: Keep::BrowserSession })
3474        }
3475        fn pending_writes(&self) -> usize {
3476            self.core.pending_writes()
3477        }
3478        fn browser_location(&self) -> String {
3479            self.core.browser_location()
3480        }
3481        fn browser_entries(&self, extensions: &[&str]) -> Vec<BrowserEntry> {
3482            self.core.browser_entries(extensions)
3483        }
3484        fn browser_enter(&self, identity: &str) -> Result<(), String> {
3485            self.core.browser_enter(identity)
3486        }
3487        fn browser_up(&self) -> Result<(), String> {
3488            self.core.browser_up()
3489        }
3490        fn browser_home(&self) -> Result<(), String> {
3491            self.core.browser_home()
3492        }
3493        fn browser_root(&self) -> Result<(), String> {
3494            self.core.browser_root()
3495        }
3496        fn browser_navigate(&self, location: &str) -> Result<(), String> {
3497            self.core.browser_navigate(location)
3498        }
3499        fn browser_places(&self) -> Vec<BrowserPlace> {
3500            self.core.browser_places()
3501        }
3502        fn browser_create_dir(&self, name: &str) -> Result<(), String> {
3503            self.core.browser_create_dir(name)
3504        }
3505        fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
3506            self.core.browser_write(name, contents)
3507        }
3508
3509        // --- the browser's real-file lane -------------------------------------
3510        fn supports_file_interchange(&self) -> bool {
3511            true
3512        }
3513
3514        fn begin_pick_file(&self) -> Result<(), String> {
3515            let input = pick_input().ok_or("the browser's file chooser is unavailable")?;
3516            // Clear so choosing the same file again still fires `change`.
3517            input.set_value("");
3518            input.click();
3519            Ok(())
3520        }
3521
3522        fn take_picked_file(&self) -> Option<ImportedFile> {
3523            PICKED.with(|c| c.borrow_mut().take())
3524        }
3525
3526        /// Offer the document as a `<name>.nbrep` download. The returned
3527        /// identity is the bare name (the browser owns where the download lands).
3528        fn export_file(&self, name: &str, contents: &str) -> Result<Option<String>, String> {
3529            let name = model_display_name(name);
3530            let file_name = super::model_file_name(&name);
3531            download(&file_name, super::media_type(&file_name), contents)?;
3532            Ok(Some(name))
3533        }
3534
3535        fn begin_import(&self) -> Result<(), String> {
3536            let input =
3537                ensure_input(".nbrep,.fbrep,.tbrep").ok_or("file input unavailable")?;
3538            // Clear so re-selecting the same file still fires `change`.
3539            input.set_value("");
3540            input.click();
3541            Ok(())
3542        }
3543
3544        fn take_import(&self) -> Option<ImportedFile> {
3545            IMPORTED.with(|c| c.borrow_mut().take())
3546        }
3547
3548        // Format-typed interchange: the same hidden input, refiltered to the
3549        // requested extensions. The onchange handler stashes the file's real name
3550        // (its extension survives `model_display_name`, since that only strips
3551        // `.nbrep`), so the panel routes STEP imports by extension.
3552        fn begin_import_filtered(&self, filter: (&str, &[&str])) -> Result<(), String> {
3553            let accept = filter
3554                .1
3555                .iter()
3556                .map(|ext| format!(".{ext}"))
3557                .collect::<Vec<_>>()
3558                .join(",");
3559            let input = ensure_input(&accept).ok_or("file input unavailable")?;
3560            input.set_value("");
3561            input.click();
3562            Ok(())
3563        }
3564
3565        /// Offer `contents` as a download under EXACTLY `file_name` (extension
3566        /// kept) — the foreign-format sibling of [`Self::export_file`]. The
3567        /// media type is the file's own ([`super::media_type`]), so a sheet
3568        /// leaves as `image/svg+xml` and not as anonymous bytes.
3569        fn export_file_named(&self, file_name: &str, contents: &str) -> Result<(), String> {
3570            download(file_name, super::media_type(file_name), contents)
3571        }
3572
3573        /// The same, for bytes: `model/gltf-binary` for a GLB, `application/pdf`
3574        /// for a drawing sheet. It used to be glTF whatever the file was, which
3575        /// is why this lane could only ever carry one format.
3576        fn export_file_named_bytes(&self, file_name: &str, contents: &[u8]) -> Result<(), String> {
3577            download_bytes(file_name, super::media_type(file_name), contents)
3578        }
3579    }
3580}
3581
3582
3583