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