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