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