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