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