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