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