pub const SETTINGS_KEY: &str = "@settings";
pub const DOCK_LAYOUT_KEY: &str = "@dock_layout";
pub const PINNED_KEY: &str = "@pinned";
pub struct ImportedFile {
pub name: String,
pub bytes: Vec<u8>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct BrowserEntry {
pub name: String,
pub identity: String,
pub is_dir: bool,
pub size: Option<u64>,
pub modified: Option<f64>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct BrowserPlace {
pub label: String,
pub location: String,
pub kind: PlaceKind,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum PlaceKind {
Home,
Documents,
Downloads,
Models,
Root,
}
pub trait ModelStore {
fn backend_label(&self) -> String {
"persistent storage".into()
}
fn list(&self) -> Vec<String> {
Vec::new()
}
fn read(&self, name: &str) -> Option<String>;
fn write(&self, name: &str, contents: &str) -> Result<(), String>;
fn remove(&self, _name: &str) -> Result<(), String> {
Ok(())
}
fn take_persistence_errors(&self) -> Vec<String> {
Vec::new()
}
fn browser_location(&self) -> String {
self.backend_label()
}
fn browser_entries(&self, _extensions: &[&str]) -> Vec<BrowserEntry> {
self.list()
.into_iter()
.map(|name| BrowserEntry {
identity: name.clone(),
name,
is_dir: false,
size: None,
modified: None,
})
.collect()
}
fn browser_enter(&self, _identity: &str) -> Result<(), String> {
Err("this storage backend has no directories".into())
}
fn browser_up(&self) -> Result<(), String> {
Ok(())
}
fn browser_home(&self) -> Result<(), String> {
Ok(())
}
fn browser_root(&self) -> Result<(), String> {
Ok(())
}
fn browser_navigate(&self, _location: &str) -> Result<(), String> {
Err("this storage backend cannot navigate to a path".into())
}
fn browser_places(&self) -> Vec<BrowserPlace> {
Vec::new()
}
fn browser_create_dir(&self, _name: &str) -> Result<(), String> {
Err("this storage backend cannot create directories".into())
}
fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
self.write(name, contents)?;
Ok(name.to_string())
}
fn list_external_files(&self, _extensions: &[&str]) -> Vec<String> {
Vec::new()
}
fn read_external_file(&self, _name: &str) -> Option<Vec<u8>> {
None
}
fn supports_file_interchange(&self) -> bool {
false
}
fn export_file(&self, name: &str, contents: &str) -> Result<Option<String>, String> {
let _ = (name, contents);
Ok(None)
}
fn begin_import(&self) -> Result<(), String> {
Ok(())
}
fn take_import(&self) -> Option<ImportedFile> {
None
}
fn begin_import_filtered(&self, _filter: (&str, &[&str])) -> Result<(), String> {
self.begin_import()
}
fn export_file_named(&self, _file_name: &str, _contents: &str) -> Result<(), String> {
Ok(())
}
}
pub fn default_model_store() -> Box<dyn ModelStore> {
#[cfg(not(target_arch = "wasm32"))]
{
Box::new(native_model::FileModelStore::new())
}
#[cfg(target_arch = "wasm32")]
{
web_model::take_boot_store()
}
}
#[cfg(target_arch = "wasm32")]
pub async fn hydrate_web_store() {
web_model::hydrate().await;
}
#[cfg(target_arch = "wasm32")]
pub use web_model::set_repaint_ctx;
pub const MODEL_EXT: &str = ".BREP.json";
#[cfg(all(test, not(target_arch = "wasm32")))]
pub fn native_test_store(dir: std::path::PathBuf) -> Box<dyn ModelStore> {
Box::new(native_model::FileModelStore::with_dir(dir))
}
#[cfg(all(test, not(target_arch = "wasm32")))]
pub(crate) struct MemModelStore {
docs: std::cell::RefCell<std::collections::BTreeMap<String, String>>,
reads: std::cell::Cell<usize>,
}
#[cfg(all(test, not(target_arch = "wasm32")))]
impl MemModelStore {
pub fn new() -> Self {
Self {
docs: std::cell::RefCell::new(std::collections::BTreeMap::new()),
reads: std::cell::Cell::new(0),
}
}
pub fn put(&self, name: &str, contents: &str) {
self.docs
.borrow_mut()
.insert(name.to_string(), contents.to_string());
}
pub fn reads(&self) -> usize {
self.reads.get()
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
impl ModelStore for MemModelStore {
fn backend_label(&self) -> String {
"in-memory test store".into()
}
fn list(&self) -> Vec<String> {
self.docs.borrow().keys().cloned().collect()
}
fn read(&self, name: &str) -> Option<String> {
self.reads.set(self.reads.get() + 1);
self.docs.borrow().get(name).cloned()
}
fn write(&self, name: &str, contents: &str) -> Result<(), String> {
self.put(name, contents);
Ok(())
}
fn remove(&self, name: &str) -> Result<(), String> {
self.docs.borrow_mut().remove(name);
Ok(())
}
}
pub(crate) fn model_display_name(file_name: &str) -> String {
let base = file_name
.rsplit(['/', '\\'])
.next()
.unwrap_or(file_name);
base.strip_suffix(MODEL_EXT)
.or_else(|| base.strip_suffix(".json"))
.unwrap_or(base)
.to_string()
}
#[cfg(not(target_arch = "wasm32"))]
mod native_model {
use super::{
model_display_name, BrowserEntry, BrowserPlace, ModelStore, PlaceKind, DOCK_LAYOUT_KEY,
MODEL_EXT, PINNED_KEY, SETTINGS_KEY,
};
use std::cell::RefCell;
use std::path::{Path, PathBuf};
pub struct FileModelStore {
app_dir: PathBuf,
dir: PathBuf,
browser_dir: RefCell<PathBuf>,
}
impl FileModelStore {
pub fn new() -> Self {
let base = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
.unwrap_or_else(|| PathBuf::from("."));
let app_dir = base.join("brep-app");
Self {
dir: app_dir.join("models"),
browser_dir: RefCell::new(app_dir.join("models")),
app_dir,
}
}
#[cfg_attr(not(test), allow(dead_code))]
pub fn with_dir(dir: PathBuf) -> Self {
Self {
app_dir: dir.clone(),
browser_dir: RefCell::new(dir.clone()),
dir,
}
}
fn resolve(&self, name: &str) -> PathBuf {
match name {
SETTINGS_KEY => return self.app_dir.join("settings.json"),
DOCK_LAYOUT_KEY => return self.app_dir.join("dock_layout.json"),
PINNED_KEY => return self.app_dir.join("pinned.json"),
_ => {}
}
if name.contains('/') || name.contains('\\') {
return PathBuf::from(name);
}
let safe: String = name
.strip_suffix(MODEL_EXT)
.unwrap_or(name)
.chars()
.map(|c| {
if c.is_ascii_alphanumeric() || c == '-' || c == '_' || c == '.' {
c
} else {
'_'
}
})
.collect();
self.dir.join(format!("{safe}{MODEL_EXT}"))
}
fn home_dir() -> PathBuf {
std::env::var_os("HOME")
.map(PathBuf::from)
.or_else(|| std::env::current_dir().ok())
.unwrap_or_else(|| PathBuf::from("."))
}
fn matches_extension(path: &Path, extensions: &[&str]) -> bool {
extensions.is_empty()
|| extensions.iter().any(|extension| {
let wanted = extension.trim_start_matches('.').to_ascii_lowercase();
let name = path
.file_name()
.unwrap_or_default()
.to_string_lossy()
.to_ascii_lowercase();
name.ends_with(&format!(".{wanted}"))
})
}
}
impl ModelStore for FileModelStore {
fn backend_label(&self) -> String {
format!("filesystem: {}", self.dir.display())
}
fn list(&self) -> Vec<String> {
let mut names: Vec<String> = std::fs::read_dir(&self.dir)
.into_iter()
.flatten()
.flatten()
.filter_map(|entry| {
let name = entry.file_name().to_string_lossy().into_owned();
name.ends_with(MODEL_EXT).then(|| model_display_name(&name))
})
.collect();
names.sort();
names
}
fn read(&self, name: &str) -> Option<String> {
std::fs::read_to_string(self.resolve(name)).ok()
}
fn write(&self, name: &str, contents: &str) -> Result<(), String> {
let path = self.resolve(name);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent).map_err(|e| format!("create models dir: {e}"))?;
}
std::fs::write(&path, contents).map_err(|e| format!("write {}: {e}", path.display()))
}
fn remove(&self, name: &str) -> Result<(), String> {
match std::fs::remove_file(self.resolve(name)) {
Ok(()) => Ok(()),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(e) => Err(format!("remove: {e}")),
}
}
fn browser_location(&self) -> String {
self.browser_dir.borrow().display().to_string()
}
fn browser_entries(&self, extensions: &[&str]) -> Vec<BrowserEntry> {
let mut entries: Vec<BrowserEntry> = std::fs::read_dir(&*self.browser_dir.borrow())
.into_iter()
.flatten()
.flatten()
.filter_map(|entry| {
let path = entry.path();
let is_dir = path.is_dir();
if !(is_dir || (path.is_file() && Self::matches_extension(&path, extensions))) {
return None;
}
let meta = entry.metadata().ok();
let size = meta.as_ref().filter(|m| m.is_file()).map(|m| m.len());
let modified = meta
.as_ref()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs_f64());
Some(BrowserEntry {
name: entry.file_name().to_string_lossy().into_owned(),
identity: path.to_string_lossy().into_owned(),
is_dir,
size,
modified,
})
})
.collect();
entries.sort_by(|a, b| {
b.is_dir
.cmp(&a.is_dir)
.then_with(|| a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase()))
});
entries
}
fn browser_enter(&self, identity: &str) -> Result<(), String> {
let path = PathBuf::from(identity);
if !path.is_dir() {
return Err(format!("not a directory: {}", path.display()));
}
*self.browser_dir.borrow_mut() = path;
Ok(())
}
fn browser_up(&self) -> Result<(), String> {
let parent = self.browser_dir.borrow().parent().map(Path::to_path_buf);
if let Some(parent) = parent {
*self.browser_dir.borrow_mut() = parent;
}
Ok(())
}
fn browser_home(&self) -> Result<(), String> {
*self.browser_dir.borrow_mut() = Self::home_dir();
Ok(())
}
fn browser_root(&self) -> Result<(), String> {
let current = self.browser_dir.borrow().clone();
let root = current
.ancestors()
.last()
.map(Path::to_path_buf)
.unwrap_or_else(|| PathBuf::from(std::path::MAIN_SEPARATOR.to_string()));
*self.browser_dir.borrow_mut() = root;
Ok(())
}
fn browser_navigate(&self, location: &str) -> Result<(), String> {
let path = PathBuf::from(location);
if path.is_dir() {
*self.browser_dir.borrow_mut() = path;
Ok(())
} else {
Err(format!("not a directory: {location}"))
}
}
fn browser_places(&self) -> Vec<BrowserPlace> {
let home = Self::home_dir();
let mut places = vec![BrowserPlace {
label: "Home".into(),
location: home.display().to_string(),
kind: PlaceKind::Home,
}];
for (label, sub, kind) in [
("Documents", "Documents", PlaceKind::Documents),
("Downloads", "Downloads", PlaceKind::Downloads),
] {
let path = home.join(sub);
if path.is_dir() {
places.push(BrowserPlace {
label: label.into(),
location: path.display().to_string(),
kind,
});
}
}
places.push(BrowserPlace {
label: "Models".into(),
location: self.dir.display().to_string(),
kind: PlaceKind::Models,
});
places.push(BrowserPlace {
label: "/".into(),
location: "/".into(),
kind: PlaceKind::Root,
});
places
}
fn browser_create_dir(&self, name: &str) -> Result<(), String> {
let name = PathBuf::from(name);
if name.components().count() != 1 {
return Err("folder name must be one path component".into());
}
let path = self.browser_dir.borrow().join(name);
std::fs::create_dir(&path)
.map_err(|e| format!("create folder {}: {e}", path.display()))
}
fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
let mut file_name = PathBuf::from(name)
.file_name()
.ok_or("invalid file name")?
.to_os_string();
if !name.to_ascii_lowercase().ends_with(".json") {
file_name.push(MODEL_EXT);
}
let path = self.browser_dir.borrow().join(file_name);
std::fs::write(&path, contents)
.map_err(|e| format!("write {}: {e}", path.display()))?;
Ok(path.to_string_lossy().into_owned())
}
fn list_external_files(&self, extensions: &[&str]) -> Vec<String> {
let wanted: Vec<String> = extensions
.iter()
.map(|extension| extension.trim_start_matches('.').to_ascii_lowercase())
.collect();
let mut names: Vec<String> = std::fs::read_dir(&self.dir)
.into_iter()
.flatten()
.flatten()
.filter_map(|entry| {
let path = entry.path();
path.is_file().then_some(path)
})
.filter_map(|path| {
let extension = path.extension()?.to_string_lossy().to_ascii_lowercase();
wanted.contains(&extension).then(|| {
path.file_name()
.unwrap_or_default()
.to_string_lossy()
.into_owned()
})
})
.collect();
names.sort();
names
}
fn read_external_file(&self, name: &str) -> Option<Vec<u8>> {
let path = PathBuf::from(name);
let path = if path.is_absolute() || path.components().count() > 1 {
path
} else {
self.browser_dir.borrow().join(path)
};
std::fs::read(path).ok()
}
fn export_file_named(&self, file_name: &str, contents: &str) -> Result<(), String> {
std::fs::create_dir_all(&self.dir)
.map_err(|e| format!("create models dir: {e}"))?;
let name = PathBuf::from(file_name)
.file_name()
.ok_or("invalid export file name")?
.to_owned();
let path = self.dir.join(name);
std::fs::write(&path, contents)
.map_err(|e| format!("write {}: {e}", path.display()))
}
}
}
#[cfg(any(target_arch = "wasm32", test))]
pub(crate) mod mirror_store {
use super::{
model_display_name, BrowserEntry, BrowserPlace, ModelStore, PlaceKind, DOCK_LAYOUT_KEY,
MODEL_EXT, PINNED_KEY, SETTINGS_KEY,
};
use std::cell::{Cell, RefCell};
use std::collections::BTreeMap;
use std::future::Future;
use std::pin::Pin;
use std::rc::Rc;
pub(crate) const PREFIX: &str = "brep-app:model:";
pub(crate) const DIR_PREFIX: &str = "brep-app:dir:";
const MAX_ERRORS: usize = 64;
pub(crate) type BackendFuture<T> = Pin<Box<dyn Future<Output = Result<T, String>>>>;
pub(crate) trait StoreBackend {
fn label(&self) -> String;
fn load_all(&self) -> BackendFuture<Vec<(String, String)>>;
fn put(&self, key: &str, value: &str) -> BackendFuture<()>;
fn delete(&self, key: &str) -> BackendFuture<()>;
}
pub(crate) struct UnavailableBackend {
reason: String,
}
impl UnavailableBackend {
pub(crate) fn new(reason: impl Into<String>) -> Self {
Self {
reason: reason.into(),
}
}
}
impl StoreBackend for UnavailableBackend {
fn label(&self) -> String {
format!("NOT SAVING — {} · use Download to keep your work", self.reason)
}
fn load_all(&self) -> BackendFuture<Vec<(String, String)>> {
let reason = self.reason.clone();
Box::pin(async move { Err(reason) })
}
fn put(&self, _key: &str, _value: &str) -> BackendFuture<()> {
let reason = self.reason.clone();
Box::pin(async move { Err(reason) })
}
fn delete(&self, _key: &str) -> BackendFuture<()> {
let reason = self.reason.clone();
Box::pin(async move { Err(reason) })
}
}
pub(crate) struct ErrorLog {
log: RefCell<Vec<String>>,
drained: Cell<usize>,
wake: Option<Rc<dyn Fn()>>,
}
impl ErrorLog {
fn new(wake: Option<Rc<dyn Fn()>>) -> Self {
Self {
log: RefCell::new(Vec::new()),
drained: Cell::new(0),
wake,
}
}
pub(crate) fn record(&self, message: String) {
{
let mut log = self.log.borrow_mut();
let undrained = log.len() > self.drained.get();
if undrained && log.last().map(|last| *last == message).unwrap_or(false) {
return;
}
log.push(message);
if log.len() > MAX_ERRORS {
log.remove(0);
self.drained.set(self.drained.get().saturating_sub(1));
}
}
if let Some(wake) = &self.wake {
wake();
}
}
fn drain(&self) -> Vec<String> {
let log = self.log.borrow();
let from = self.drained.get().min(log.len());
self.drained.set(log.len());
log[from..].to_vec()
}
fn all(&self) -> Vec<String> {
self.log.borrow().clone()
}
}
#[cfg(target_arch = "wasm32")]
fn spawn(task: impl Future<Output = ()> + 'static) {
wasm_bindgen_futures::spawn_local(task);
}
#[cfg(not(target_arch = "wasm32"))]
fn spawn(task: impl Future<Output = ()> + 'static) {
let mut task = Box::pin(task);
let mut cx = std::task::Context::from_waker(std::task::Waker::noop());
let _ = task.as_mut().poll(&mut cx);
}
#[derive(Clone)]
pub(crate) struct MirrorStore {
entries: Rc<RefCell<BTreeMap<String, String>>>,
browser_dir: Rc<RefCell<String>>,
backend: Rc<dyn StoreBackend>,
errors: Rc<ErrorLog>,
pending: Rc<Cell<usize>>,
}
impl MirrorStore {
pub(crate) fn new(
backend: Rc<dyn StoreBackend>,
entries: Vec<(String, String)>,
wake: Option<Rc<dyn Fn()>>,
) -> Self {
Self {
entries: Rc::new(RefCell::new(entries.into_iter().collect())),
browser_dir: Rc::new(RefCell::new("/models".into())),
backend,
errors: Rc::new(ErrorLog::new(wake)),
pending: Rc::new(Cell::new(0)),
}
}
pub(crate) fn key(name: &str) -> String {
match name {
SETTINGS_KEY => "brep-app:settings".into(),
DOCK_LAYOUT_KEY => "brep-app:dock_layout".into(),
PINNED_KEY => "brep-app:pinned".into(),
_ => format!("{PREFIX}{}", Self::model_relative(name)),
}
}
fn model_relative(name: &str) -> String {
let name = name
.trim_start_matches('/')
.strip_prefix("models/")
.unwrap_or_else(|| name.trim_start_matches('/'));
name.strip_suffix(MODEL_EXT)
.or_else(|| name.strip_suffix(".json"))
.unwrap_or(name)
.trim_matches('/')
.to_string()
}
fn virtual_model_path(relative: &str) -> String {
format!("/models/{}{MODEL_EXT}", relative.trim_matches('/'))
}
fn child_path(parent: &str, child: &str) -> String {
if parent == "/" {
format!("/{child}")
} else {
format!("{}/{child}", parent.trim_end_matches('/'))
}
}
fn push(&self, name: &str, request: BackendFuture<()>) {
self.pending.set(self.pending.get() + 1);
let pending = self.pending.clone();
let errors = self.errors.clone();
let name = name.to_string();
spawn(async move {
let outcome = request.await;
pending.set(pending.get().saturating_sub(1));
if let Err(message) = outcome {
errors.record(format!("'{name}' was NOT saved: {message}"));
}
});
}
pub(crate) fn report(&self, message: String) {
self.errors.record(message);
}
pub(crate) fn pending(&self) -> usize {
self.pending.get()
}
pub(crate) fn error_history(&self) -> Vec<String> {
self.errors.all()
}
pub(crate) fn len_of(&self, name: &str) -> Option<usize> {
self.entries.borrow().get(&Self::key(name)).map(|v| v.len())
}
}
impl ModelStore for MirrorStore {
fn backend_label(&self) -> String {
self.backend.label()
}
fn list(&self) -> Vec<String> {
self.entries
.borrow()
.keys()
.filter_map(|key| key.strip_prefix(PREFIX).map(str::to_string))
.collect()
}
fn read(&self, name: &str) -> Option<String> {
self.entries.borrow().get(&Self::key(name)).cloned()
}
fn write(&self, name: &str, contents: &str) -> Result<(), String> {
let key = Self::key(name);
self.entries
.borrow_mut()
.insert(key.clone(), contents.to_string());
self.push(name, self.backend.put(&key, contents));
Ok(())
}
fn remove(&self, name: &str) -> Result<(), String> {
let key = Self::key(name);
self.entries.borrow_mut().remove(&key);
self.push(name, self.backend.delete(&key));
Ok(())
}
fn take_persistence_errors(&self) -> Vec<String> {
self.errors.drain()
}
fn browser_location(&self) -> String {
self.browser_dir.borrow().clone()
}
fn browser_entries(&self, extensions: &[&str]) -> Vec<BrowserEntry> {
let current = self.browser_dir.borrow().clone();
if current == "/" {
return vec![BrowserEntry {
name: "models".into(),
identity: "/models".into(),
is_dir: true,
size: None,
modified: None,
}];
}
let relative_dir = current
.strip_prefix("/models")
.unwrap_or("")
.trim_matches('/');
let prefix = if relative_dir.is_empty() {
String::new()
} else {
format!("{relative_dir}/")
};
let wanted: Vec<String> = extensions
.iter()
.map(|extension| extension.trim_start_matches('.').to_ascii_lowercase())
.collect();
let mut entries: BTreeMap<String, BrowserEntry> = BTreeMap::new();
for (key, value) in self.entries.borrow().iter() {
let (item, is_model) = if let Some(item) = key.strip_prefix(PREFIX) {
(item, true)
} else if let Some(item) = key.strip_prefix(DIR_PREFIX) {
(item.trim_end_matches('/'), false)
} else {
continue;
};
let Some(rest) = item.strip_prefix(&prefix) else {
continue;
};
if rest.is_empty() {
continue;
}
if let Some((child, _)) = rest.split_once('/') {
entries
.entry(child.to_string())
.or_insert_with(|| BrowserEntry {
name: child.to_string(),
identity: Self::child_path(¤t, child),
is_dir: true,
size: None,
modified: None,
});
} else if is_model {
let name = format!("{rest}{MODEL_EXT}");
let lower = name.to_ascii_lowercase();
if wanted.is_empty()
|| wanted
.iter()
.any(|extension| lower.ends_with(&format!(".{extension}")))
{
entries.insert(
name.clone(),
BrowserEntry {
name,
identity: Self::virtual_model_path(item),
is_dir: false,
size: Some(value.len() as u64),
modified: None,
},
);
}
} else {
entries
.entry(rest.to_string())
.or_insert_with(|| BrowserEntry {
name: rest.to_string(),
identity: Self::child_path(¤t, rest),
is_dir: true,
size: None,
modified: None,
});
}
}
let mut entries: Vec<_> = entries.into_values().collect();
entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.name.cmp(&b.name)));
entries
}
fn browser_enter(&self, identity: &str) -> Result<(), String> {
if identity == "/models" || identity.starts_with("/models/") {
*self.browser_dir.borrow_mut() = identity.trim_end_matches('/').to_string();
Ok(())
} else {
Err("the browser virtual filesystem is rooted at /models".into())
}
}
fn browser_up(&self) -> Result<(), String> {
let current = self.browser_dir.borrow().clone();
if current == "/" {
return Ok(());
}
let parent = current
.rsplit_once('/')
.map(|(parent, _)| parent)
.unwrap_or("");
*self.browser_dir.borrow_mut() = if parent.is_empty() {
"/".into()
} else {
parent.into()
};
Ok(())
}
fn browser_home(&self) -> Result<(), String> {
*self.browser_dir.borrow_mut() = "/models".into();
Ok(())
}
fn browser_root(&self) -> Result<(), String> {
*self.browser_dir.borrow_mut() = "/".into();
Ok(())
}
fn browser_navigate(&self, location: &str) -> Result<(), String> {
let loc = location.trim_end_matches('/');
let loc = if loc.is_empty() { "/" } else { loc };
if loc == "/" || loc == "/models" || loc.starts_with("/models/") {
*self.browser_dir.borrow_mut() = loc.to_string();
Ok(())
} else {
Err("the browser virtual filesystem is rooted at /models".into())
}
}
fn browser_places(&self) -> Vec<BrowserPlace> {
vec![
BrowserPlace {
label: "Models".into(),
location: "/models".into(),
kind: PlaceKind::Models,
},
BrowserPlace {
label: "/".into(),
location: "/".into(),
kind: PlaceKind::Root,
},
]
}
fn browser_create_dir(&self, name: &str) -> Result<(), String> {
if name.is_empty()
|| name == "."
|| name == ".."
|| name.contains('/')
|| name.contains('\\')
{
return Err("folder name must be one path component".into());
}
let current = self.browser_dir.borrow().clone();
if !current.starts_with("/models") {
return Err("folders can only be created under /models".into());
}
let relative = Self::child_path(¤t, name)
.trim_start_matches("/models/")
.to_string();
let key = format!("{DIR_PREFIX}{relative}/");
self.entries.borrow_mut().insert(key.clone(), String::new());
self.push(name, self.backend.put(&key, ""));
Ok(())
}
fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
let file = model_display_name(name);
if file.is_empty() {
return Err("invalid file name".into());
}
let current = self.browser_dir.borrow().clone();
if !current.starts_with("/models") {
return Err("select a folder under /models".into());
}
let identity = format!("{}{MODEL_EXT}", Self::child_path(¤t, &file));
self.write(&identity, contents)?;
Ok(identity)
}
}
}
#[cfg(target_arch = "wasm32")]
mod web_model {
use super::mirror_store::{BackendFuture, MirrorStore, StoreBackend, UnavailableBackend};
use super::{
model_display_name, BrowserEntry, BrowserPlace, ImportedFile, ModelStore, MODEL_EXT,
};
use std::cell::RefCell;
use std::future::Future;
use std::pin::Pin;
use std::rc::Rc;
use std::task::{Context, Poll, Waker};
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
const DB_NAME: &str = "brep-app";
const DB_VERSION: u32 = 1;
const STORE_NAME: &str = "kv";
thread_local! {
static IMPORT_INPUT: RefCell<Option<web_sys::HtmlInputElement>> = const { RefCell::new(None) };
static IMPORTED: RefCell<Option<ImportedFile>> = const { RefCell::new(None) };
static REPAINT_CTX: RefCell<Option<eframe::egui::Context>> = const { RefCell::new(None) };
static BOOT_STORE: RefCell<Option<IdbModelStore>> = const { RefCell::new(None) };
}
pub fn set_repaint_ctx(ctx: eframe::egui::Context) {
REPAINT_CTX.with(|c| *c.borrow_mut() = Some(ctx));
}
fn wake_frame_loop() {
REPAINT_CTX.with(|c| {
if let Some(ctx) = c.borrow().as_ref() {
ctx.request_repaint();
}
});
}
#[derive(Default)]
struct Settled {
outcome: Option<Result<JsValue, String>>,
waker: Option<Waker>,
}
enum EventSource {
Request(web_sys::IdbRequest),
Transaction(web_sys::IdbTransaction),
}
struct IdbFuture {
source: EventSource,
settled: Rc<RefCell<Settled>>,
_handlers: Vec<Closure<dyn FnMut(web_sys::Event)>>,
}
fn settle(settled: &Rc<RefCell<Settled>>, outcome: Result<JsValue, String>) {
let waker = {
let mut settled = settled.borrow_mut();
if settled.outcome.is_none() {
settled.outcome = Some(outcome);
}
settled.waker.take()
};
if let Some(waker) = waker {
waker.wake();
}
}
fn request_error(request: &web_sys::IdbRequest) -> String {
request
.error()
.ok()
.flatten()
.map(|error| format!("{}: {}", error.name(), error.message()))
.unwrap_or_else(|| "IndexedDB request failed".into())
}
fn js_error(value: &JsValue) -> String {
value
.as_string()
.or_else(|| js_sys::Reflect::get(value, &JsValue::from_str("message")).ok()?.as_string())
.unwrap_or_else(|| format!("{value:?}"))
}
fn on_request(request: web_sys::IdbRequest) -> IdbFuture {
let settled = Rc::new(RefCell::new(Settled::default()));
let success = {
let settled = settled.clone();
let request = request.clone();
Closure::wrap(Box::new(move |_event: web_sys::Event| {
let value = request.result().unwrap_or(JsValue::UNDEFINED);
settle(&settled, Ok(value));
}) as Box<dyn FnMut(web_sys::Event)>)
};
let failure = {
let settled = settled.clone();
let request = request.clone();
Closure::wrap(Box::new(move |_event: web_sys::Event| {
settle(&settled, Err(request_error(&request)));
}) as Box<dyn FnMut(web_sys::Event)>)
};
request.set_onsuccess(Some(success.as_ref().unchecked_ref()));
request.set_onerror(Some(failure.as_ref().unchecked_ref()));
IdbFuture {
source: EventSource::Request(request),
settled,
_handlers: vec![success, failure],
}
}
fn on_transaction(transaction: web_sys::IdbTransaction) -> IdbFuture {
let settled = Rc::new(RefCell::new(Settled::default()));
let complete = {
let settled = settled.clone();
Closure::wrap(Box::new(move |_event: web_sys::Event| {
settle(&settled, Ok(JsValue::UNDEFINED));
}) as Box<dyn FnMut(web_sys::Event)>)
};
let describe = {
let transaction = transaction.clone();
move |fallback: &str| {
transaction
.error()
.map(|error| format!("{}: {}", error.name(), error.message()))
.unwrap_or_else(|| fallback.to_string())
}
};
let failure = {
let settled = settled.clone();
let describe = describe.clone();
Closure::wrap(Box::new(move |_event: web_sys::Event| {
settle(&settled, Err(describe("IndexedDB transaction failed")));
}) as Box<dyn FnMut(web_sys::Event)>)
};
let abort = {
let settled = settled.clone();
Closure::wrap(Box::new(move |_event: web_sys::Event| {
settle(
&settled,
Err(describe("IndexedDB transaction aborted (quota?)")),
);
}) as Box<dyn FnMut(web_sys::Event)>)
};
transaction.set_oncomplete(Some(complete.as_ref().unchecked_ref()));
transaction.set_onerror(Some(failure.as_ref().unchecked_ref()));
transaction.set_onabort(Some(abort.as_ref().unchecked_ref()));
IdbFuture {
source: EventSource::Transaction(transaction),
settled,
_handlers: vec![complete, failure, abort],
}
}
impl Future for IdbFuture {
type Output = Result<JsValue, String>;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output> {
let mut settled = self.settled.borrow_mut();
if let Some(outcome) = settled.outcome.take() {
return Poll::Ready(outcome);
}
settled.waker = Some(cx.waker().clone());
Poll::Pending
}
}
impl Drop for IdbFuture {
fn drop(&mut self) {
match &self.source {
EventSource::Request(request) => {
request.set_onsuccess(None);
request.set_onerror(None);
}
EventSource::Transaction(transaction) => {
transaction.set_oncomplete(None);
transaction.set_onerror(None);
transaction.set_onabort(None);
}
}
}
}
pub(super) struct IdbBackend {
db: web_sys::IdbDatabase,
}
impl IdbBackend {
async fn open() -> Result<Self, String> {
let factory = web_sys::window()
.ok_or("no window")?
.indexed_db()
.map_err(|e| js_error(&e))?
.ok_or("no indexedDB on this window")?;
let request = factory
.open_with_u32(DB_NAME, DB_VERSION)
.map_err(|e| js_error(&e))?;
let upgrading = request.clone();
let upgrade = Closure::wrap(Box::new(move |_event: web_sys::Event| {
if let Ok(value) = upgrading.result() {
if let Ok(db) = value.dyn_into::<web_sys::IdbDatabase>() {
let _ = db.create_object_store(STORE_NAME);
}
}
}) as Box<dyn FnMut(web_sys::Event)>);
request.set_onupgradeneeded(Some(upgrade.as_ref().unchecked_ref()));
let opened = on_request(request.clone().unchecked_into::<web_sys::IdbRequest>()).await;
request.set_onupgradeneeded(None);
drop(upgrade);
let db = opened?
.dyn_into::<web_sys::IdbDatabase>()
.map_err(|_| "IndexedDB open returned no database".to_string())?;
Ok(Self { db })
}
fn transact(
&self,
mode: web_sys::IdbTransactionMode,
) -> Result<(web_sys::IdbTransaction, web_sys::IdbObjectStore), String> {
let transaction = self
.db
.transaction_with_str_and_mode(STORE_NAME, mode)
.map_err(|e| js_error(&e))?;
let store = transaction.object_store(STORE_NAME).map_err(|e| js_error(&e))?;
Ok((transaction, store))
}
}
impl StoreBackend for IdbBackend {
fn label(&self) -> String {
"browser storage (IndexedDB) · download/upload for files".into()
}
fn load_all(&self) -> BackendFuture<Vec<(String, String)>> {
let started = (|| -> Result<(IdbFuture, IdbFuture), String> {
let (_transaction, store) = self.transact(web_sys::IdbTransactionMode::Readonly)?;
let keys = store.get_all_keys().map_err(|e| js_error(&e))?;
let values = store.get_all().map_err(|e| js_error(&e))?;
Ok((on_request(keys), on_request(values)))
})();
Box::pin(async move {
let (keys, values) = started?;
let keys = js_sys::Array::from(&keys.await?);
let values = js_sys::Array::from(&values.await?);
if keys.length() != values.length() {
return Err("IndexedDB returned mismatched keys and values".into());
}
let mut entries = Vec::with_capacity(keys.length() as usize);
for i in 0..keys.length() {
let (Some(key), Some(value)) =
(keys.get(i).as_string(), values.get(i).as_string())
else {
continue;
};
entries.push((key, value));
}
Ok(entries)
})
}
fn put(&self, key: &str, value: &str) -> BackendFuture<()> {
let started = (|| -> Result<IdbFuture, String> {
let (transaction, store) =
self.transact(web_sys::IdbTransactionMode::Readwrite)?;
store
.put_with_key(&JsValue::from_str(value), &JsValue::from_str(key))
.map_err(|e| js_error(&e))?;
Ok(on_transaction(transaction))
})();
Box::pin(async move {
started?.await?;
Ok(())
})
}
fn delete(&self, key: &str) -> BackendFuture<()> {
let started = (|| -> Result<IdbFuture, String> {
let (transaction, store) =
self.transact(web_sys::IdbTransactionMode::Readwrite)?;
store
.delete(&JsValue::from_str(key))
.map_err(|e| js_error(&e))?;
Ok(on_transaction(transaction))
})();
Box::pin(async move {
started?.await?;
Ok(())
})
}
}
pub(super) async fn hydrate() {
let wake: Rc<dyn Fn()> = Rc::new(wake_frame_loop);
let core = match IdbBackend::open().await {
Ok(backend) => {
let backend: Rc<dyn StoreBackend> = Rc::new(backend);
match backend.load_all().await {
Ok(entries) => MirrorStore::new(backend, entries, Some(wake)),
Err(message) => unavailable(
format!("IndexedDB could not be read ({message})"),
Some(wake),
),
}
}
Err(message) => unavailable(format!("IndexedDB unavailable ({message})"), Some(wake)),
};
install_verification_hooks(&core);
BOOT_STORE.with(|c| *c.borrow_mut() = Some(IdbModelStore { core }));
}
fn unavailable(reason: String, wake: Option<Rc<dyn Fn()>>) -> MirrorStore {
let core = MirrorStore::new(
Rc::new(UnavailableBackend::new(reason.clone())),
Vec::new(),
wake,
);
core.report(format!(
"{reason} — this session will not be saved; use Download to keep your work"
));
core
}
pub(super) fn take_boot_store() -> Box<dyn ModelStore> {
let store = BOOT_STORE.with(|c| c.borrow_mut().take()).unwrap_or_else(|| {
let wake: Rc<dyn Fn()> = Rc::new(wake_frame_loop);
IdbModelStore {
core: unavailable("storage was never initialised".into(), Some(wake)),
}
});
Box::new(store)
}
fn install_verification_hooks(core: &MirrorStore) {
let Some(window) = web_sys::window() else {
return;
};
let publish = |name: &str, value: &JsValue| {
let _ = js_sys::Reflect::set(&window, &JsValue::from_str(name), value);
};
let store = core.clone();
let write = Closure::wrap(Box::new(move |name: String, contents: String| -> JsValue {
match store.write(&name, &contents) {
Ok(()) => JsValue::NULL,
Err(message) => JsValue::from_str(&message),
}
}) as Box<dyn FnMut(String, String) -> JsValue>);
publish("__brepStoreWrite", write.as_ref());
write.forget();
let store = core.clone();
let len = Closure::wrap(Box::new(move |name: String| -> f64 {
store.len_of(&name).map(|n| n as f64).unwrap_or(-1.0)
}) as Box<dyn FnMut(String) -> f64>);
publish("__brepStoreLen", len.as_ref());
len.forget();
let store = core.clone();
let read = Closure::wrap(Box::new(move |name: String| -> JsValue {
match store.read(&name) {
Some(contents) => JsValue::from_str(&contents),
None => JsValue::NULL,
}
}) as Box<dyn FnMut(String) -> JsValue>);
publish("__brepStoreRead", read.as_ref());
read.forget();
let store = core.clone();
let list = Closure::wrap(Box::new(move || -> JsValue {
JsValue::from_str(&serde_json::to_string(&store.list()).unwrap_or_default())
}) as Box<dyn FnMut() -> JsValue>);
publish("__brepStoreList", list.as_ref());
list.forget();
let store = core.clone();
let errors = Closure::wrap(Box::new(move || -> JsValue {
JsValue::from_str(&serde_json::to_string(&store.error_history()).unwrap_or_default())
}) as Box<dyn FnMut() -> JsValue>);
publish("__brepStoreErrors", errors.as_ref());
errors.forget();
let store = core.clone();
let pending = Closure::wrap(Box::new(move || -> f64 { store.pending() as f64 })
as Box<dyn FnMut() -> f64>);
publish("__brepStorePending", pending.as_ref());
pending.forget();
}
fn ensure_input(accept: &str) -> Option<web_sys::HtmlInputElement> {
if let Some(existing) = IMPORT_INPUT.with(|c| c.borrow().clone()) {
existing.set_accept(accept);
return Some(existing);
}
let document = web_sys::window()?.document()?;
let input: web_sys::HtmlInputElement =
document.create_element("input").ok()?.dyn_into().ok()?;
input.set_type("file");
input.set_accept(accept);
input.set_hidden(true);
let input_for_cb = input.clone();
let onchange = Closure::wrap(Box::new(move |_e: web_sys::Event| {
let Some(files) = input_for_cb.files() else { return };
let Some(file) = files.get(0) else { return };
let name = file.name();
let Ok(reader) = web_sys::FileReader::new() else { return };
let reader_for_load = reader.clone();
let onload = Closure::wrap(Box::new(move |_e: web_sys::Event| {
if let Ok(value) = reader_for_load.result() {
let bytes = js_sys::Uint8Array::new(&value).to_vec();
IMPORTED.with(|c| *c.borrow_mut() = Some(ImportedFile {
name: model_display_name(&name),
bytes,
}));
wake_frame_loop();
}
}) as Box<dyn FnMut(web_sys::Event)>);
reader.set_onload(Some(onload.as_ref().unchecked_ref()));
onload.forget();
let _ = reader.read_as_array_buffer(&file);
}) as Box<dyn FnMut(web_sys::Event)>);
input.set_onchange(Some(onchange.as_ref().unchecked_ref()));
onchange.forget();
if let Some(body) = document.body() {
let _ = body.append_child(&input);
}
IMPORT_INPUT.with(|c| *c.borrow_mut() = Some(input.clone()));
Some(input)
}
fn download(file_name: &str, mime: &str, contents: &str) -> Result<(), String> {
let document = web_sys::window()
.and_then(|w| w.document())
.ok_or("no document")?;
let parts = js_sys::Array::of1(&JsValue::from_str(contents));
let options = web_sys::BlobPropertyBag::new();
options.set_type(mime);
let blob = web_sys::Blob::new_with_str_sequence_and_options(&parts, &options)
.map_err(|_| "blob create failed".to_string())?;
let url = web_sys::Url::create_object_url_with_blob(&blob)
.map_err(|_| "object url failed".to_string())?;
let anchor: web_sys::HtmlAnchorElement = document
.create_element("a")
.map_err(|_| "anchor create failed".to_string())?
.dyn_into()
.map_err(|_| "anchor cast failed".to_string())?;
anchor.set_href(&url);
anchor.set_download(file_name);
anchor.click();
let _ = web_sys::Url::revoke_object_url(&url);
Ok(())
}
pub(super) struct IdbModelStore {
core: MirrorStore,
}
impl ModelStore for IdbModelStore {
fn backend_label(&self) -> String {
self.core.backend_label()
}
fn list(&self) -> Vec<String> {
self.core.list()
}
fn read(&self, name: &str) -> Option<String> {
self.core.read(name)
}
fn write(&self, name: &str, contents: &str) -> Result<(), String> {
self.core.write(name, contents)
}
fn remove(&self, name: &str) -> Result<(), String> {
self.core.remove(name)
}
fn take_persistence_errors(&self) -> Vec<String> {
self.core.take_persistence_errors()
}
fn browser_location(&self) -> String {
self.core.browser_location()
}
fn browser_entries(&self, extensions: &[&str]) -> Vec<BrowserEntry> {
self.core.browser_entries(extensions)
}
fn browser_enter(&self, identity: &str) -> Result<(), String> {
self.core.browser_enter(identity)
}
fn browser_up(&self) -> Result<(), String> {
self.core.browser_up()
}
fn browser_home(&self) -> Result<(), String> {
self.core.browser_home()
}
fn browser_root(&self) -> Result<(), String> {
self.core.browser_root()
}
fn browser_navigate(&self, location: &str) -> Result<(), String> {
self.core.browser_navigate(location)
}
fn browser_places(&self) -> Vec<BrowserPlace> {
self.core.browser_places()
}
fn browser_create_dir(&self, name: &str) -> Result<(), String> {
self.core.browser_create_dir(name)
}
fn browser_write(&self, name: &str, contents: &str) -> Result<String, String> {
self.core.browser_write(name, contents)
}
fn supports_file_interchange(&self) -> bool {
true
}
fn export_file(&self, name: &str, contents: &str) -> Result<Option<String>, String> {
let name = model_display_name(name);
download(&format!("{name}{MODEL_EXT}"), "application/json", contents)?;
Ok(Some(name))
}
fn begin_import(&self) -> Result<(), String> {
let input =
ensure_input(".json,.BREP.json,application/json").ok_or("file input unavailable")?;
input.set_value("");
input.click();
Ok(())
}
fn take_import(&self) -> Option<ImportedFile> {
IMPORTED.with(|c| c.borrow_mut().take())
}
fn begin_import_filtered(&self, filter: (&str, &[&str])) -> Result<(), String> {
let accept = filter
.1
.iter()
.map(|ext| format!(".{ext}"))
.collect::<Vec<_>>()
.join(",");
let input = ensure_input(&accept).ok_or("file input unavailable")?;
input.set_value("");
input.click();
Ok(())
}
fn export_file_named(&self, file_name: &str, contents: &str) -> Result<(), String> {
download(file_name, "application/octet-stream", contents)
}
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod tests {
use super::native_model::FileModelStore;
use super::{ModelStore, DOCK_LAYOUT_KEY, SETTINGS_KEY};
#[test]
fn native_model_store_round_trips_named_documents() {
let dir = std::env::temp_dir().join(format!("brep-app-models-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
let store = FileModelStore::with_dir(dir.clone());
assert!(store.list().is_empty());
assert_eq!(store.read("missing"), None);
let doc_a = r#"{"features":[{"type":"P.CU"}]}"#;
let doc_b = r#"{"features":[]}"#;
store.write("alpha", doc_a).unwrap();
store.write("beta", doc_b).unwrap();
assert_eq!(store.read("alpha").as_deref(), Some(doc_a));
assert_eq!(store.read("beta").as_deref(), Some(doc_b));
assert_eq!(store.list(), vec!["alpha".to_string(), "beta".to_string()]);
assert_eq!(store.read("alpha.BREP.json").as_deref(), Some(doc_a));
store.write("alpha", doc_b).unwrap();
assert_eq!(store.read("alpha").as_deref(), Some(doc_b));
store.remove("alpha").unwrap();
assert_eq!(store.read("alpha"), None);
assert_eq!(store.list(), vec!["beta".to_string()]);
store.remove("alpha").unwrap();
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn native_store_uses_the_same_read_write_api_for_app_state_and_models() {
let dir = std::env::temp_dir().join(format!(
"brep-app-unified-store-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
let store = FileModelStore::with_dir(dir.clone());
store.write(SETTINGS_KEY, r#"{"theme":"dark"}"#).unwrap();
store.write(DOCK_LAYOUT_KEY, r#"{"tiles":[]}"#).unwrap();
store.write("part", r#"{"features":[]}"#).unwrap();
assert_eq!(
store.read(SETTINGS_KEY).as_deref(),
Some(r#"{"theme":"dark"}"#)
);
assert_eq!(
store.read(DOCK_LAYOUT_KEY).as_deref(),
Some(r#"{"tiles":[]}"#)
);
assert_eq!(store.list(), vec!["part".to_string()]);
assert!(dir.join("settings.json").is_file());
assert!(dir.join("dock_layout.json").is_file());
assert!(dir.join("part.BREP.json").is_file());
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn native_browser_navigates_and_saves_outside_the_model_directory() {
let root = std::env::temp_dir().join(format!(
"brep-app-browser-navigation-{}",
std::process::id()
));
let models = root.join("models");
let sibling = root.join("projects");
let _ = std::fs::remove_dir_all(&root);
std::fs::create_dir_all(&models).unwrap();
std::fs::create_dir_all(&sibling).unwrap();
let store = FileModelStore::with_dir(models.clone());
store.browser_up().unwrap();
let projects = store
.browser_entries(&["BREP.json"])
.into_iter()
.find(|entry| entry.name == "projects")
.expect("sibling directory should be visible");
assert!(projects.is_dir);
store.browser_enter(&projects.identity).unwrap();
let identity = store
.browser_write("assembly", r#"{"features":[]}"#)
.unwrap();
assert_eq!(std::path::PathBuf::from(&identity), sibling.join("assembly.BREP.json"));
assert_eq!(store.read(&identity).as_deref(), Some(r#"{"features":[]}"#));
assert_eq!(store.browser_location(), sibling.display().to_string());
let _ = std::fs::remove_dir_all(&root);
}
#[test]
fn native_store_exposes_foreign_files_to_the_common_explorer() {
let dir = std::env::temp_dir().join(format!(
"brep-app-explorer-store-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
let store = FileModelStore::with_dir(dir.clone());
store.export_file_named("bracket.step", "STEP DATA").unwrap();
store.export_file_named("preview.stl", "solid preview").unwrap();
assert_eq!(
store.list_external_files(&["step", "stp"]),
vec!["bracket.step".to_string()]
);
assert_eq!(
store.read_external_file("bracket.step").as_deref(),
Some(b"STEP DATA".as_slice())
);
assert!(dir.join("bracket.step").is_file());
let _ = std::fs::remove_dir_all(&dir);
}
}
#[cfg(all(test, not(target_arch = "wasm32")))]
mod mirror_tests {
use super::mirror_store::{BackendFuture, MirrorStore, StoreBackend, UnavailableBackend};
use super::{ModelStore, DOCK_LAYOUT_KEY, PINNED_KEY, SETTINGS_KEY};
use std::cell::RefCell;
use std::rc::Rc;
#[derive(Default)]
struct RecordingBackend {
puts: RefCell<Vec<(String, String)>>,
deletes: RefCell<Vec<String>>,
fail_with: RefCell<Option<String>>,
}
impl RecordingBackend {
fn shared() -> Rc<Self> {
Rc::new(Self::default())
}
fn put_keys(&self) -> Vec<String> {
self.puts.borrow().iter().map(|(k, _)| k.clone()).collect()
}
fn outcome(&self) -> BackendFuture<()> {
let failure = self.fail_with.borrow().clone();
Box::pin(async move {
match failure {
Some(message) => Err(message),
None => Ok(()),
}
})
}
}
impl StoreBackend for RecordingBackend {
fn label(&self) -> String {
"recording test backend".into()
}
fn load_all(&self) -> BackendFuture<Vec<(String, String)>> {
Box::pin(async move { Ok(Vec::new()) })
}
fn put(&self, key: &str, value: &str) -> BackendFuture<()> {
self.puts
.borrow_mut()
.push((key.to_string(), value.to_string()));
self.outcome()
}
fn delete(&self, key: &str) -> BackendFuture<()> {
self.deletes.borrow_mut().push(key.to_string());
self.outcome()
}
}
fn store(backend: &Rc<RecordingBackend>) -> MirrorStore {
MirrorStore::new(backend.clone(), Vec::new(), None)
}
#[test]
fn mirror_serves_reads_synchronously_and_pushes_writes_behind() {
let backend = RecordingBackend::shared();
let store = store(&backend);
assert!(store.list().is_empty());
assert_eq!(store.read("alpha"), None);
let doc = r#"{"features":[{"type":"P.CU"}]}"#;
store.write("alpha", doc).unwrap();
assert_eq!(store.read("alpha").as_deref(), Some(doc));
assert_eq!(store.list(), vec!["alpha".to_string()]);
assert_eq!(
backend.puts.borrow().as_slice(),
[("brep-app:model:alpha".to_string(), doc.to_string())]
);
assert_eq!(store.pending(), 0);
assert!(store.take_persistence_errors().is_empty());
assert_eq!(store.read("alpha.BREP.json").as_deref(), Some(doc));
store.remove("alpha").unwrap();
assert_eq!(store.read("alpha"), None);
assert!(store.list().is_empty());
assert_eq!(
backend.deletes.borrow().as_slice(),
["brep-app:model:alpha".to_string()]
);
}
#[test]
fn key_scheme_is_verbatim_from_the_local_storage_era() {
assert_eq!(MirrorStore::key(SETTINGS_KEY), "brep-app:settings");
assert_eq!(MirrorStore::key(DOCK_LAYOUT_KEY), "brep-app:dock_layout");
assert_eq!(MirrorStore::key(PINNED_KEY), "brep-app:pinned");
assert_eq!(MirrorStore::key("part"), "brep-app:model:part");
assert_eq!(MirrorStore::key("/models/part"), "brep-app:model:part");
assert_eq!(
MirrorStore::key("/models/sub/part.BREP.json"),
"brep-app:model:sub/part"
);
assert_eq!(MirrorStore::key("part.json"), "brep-app:model:part");
}
#[test]
fn reserved_application_blobs_share_the_document_api_and_never_list() {
let backend = RecordingBackend::shared();
let store = store(&backend);
store.write(SETTINGS_KEY, r#"{"theme":"dark"}"#).unwrap();
store.write(DOCK_LAYOUT_KEY, r#"{"tiles":[]}"#).unwrap();
store.write("part", r#"{"features":[]}"#).unwrap();
assert_eq!(
store.read(SETTINGS_KEY).as_deref(),
Some(r#"{"theme":"dark"}"#)
);
assert_eq!(store.list(), vec!["part".to_string()]);
assert_eq!(
backend.put_keys(),
vec![
"brep-app:settings".to_string(),
"brep-app:dock_layout".to_string(),
"brep-app:model:part".to_string(),
]
);
}
#[test]
fn hydrated_entries_are_visible_to_the_first_synchronous_read() {
let store = MirrorStore::new(
RecordingBackend::shared(),
vec![
("brep-app:model:alpha".into(), "A".into()),
("brep-app:model:sub/beta".into(), "B".into()),
("brep-app:settings".into(), r#"{"theme":"dark"}"#.into()),
],
None,
);
assert_eq!(
store.list(),
vec!["alpha".to_string(), "sub/beta".to_string()]
);
assert_eq!(store.read("alpha").as_deref(), Some("A"));
assert_eq!(store.read("/models/sub/beta").as_deref(), Some("B"));
assert_eq!(
store.read(SETTINGS_KEY).as_deref(),
Some(r#"{"theme":"dark"}"#)
);
}
#[test]
fn explorer_walks_the_virtual_tree_the_key_prefixes_describe() {
let backend = RecordingBackend::shared();
let store = store(&backend);
store.write("alpha", "AAAA").unwrap();
store.browser_create_dir("sub").unwrap();
store.write("/models/sub/beta", "BB").unwrap();
assert!(backend.put_keys().contains(&"brep-app:dir:sub/".to_string()));
let entries = store.browser_entries(&["BREP.json"]);
let names: Vec<_> = entries.iter().map(|e| e.name.clone()).collect();
assert_eq!(names, vec!["sub".to_string(), "alpha.BREP.json".to_string()]);
assert!(entries[0].is_dir);
assert_eq!(entries[1].size, Some(4));
assert_eq!(entries[1].identity, "/models/alpha.BREP.json");
store.browser_enter("/models/sub").unwrap();
assert_eq!(store.browser_location(), "/models/sub");
let entries = store.browser_entries(&["BREP.json"]);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].name, "beta.BREP.json");
assert_eq!(entries[0].identity, "/models/sub/beta.BREP.json");
let identity = store.browser_write("gamma", "G").unwrap();
assert_eq!(identity, "/models/sub/gamma.BREP.json");
assert_eq!(store.read(&identity).as_deref(), Some("G"));
store.browser_root().unwrap();
assert_eq!(
store
.browser_entries(&["BREP.json"])
.into_iter()
.map(|e| e.identity)
.collect::<Vec<_>>(),
vec!["/models".to_string()]
);
assert!(store.browser_navigate("/etc").is_err());
}
#[test]
fn a_write_behind_failure_reaches_the_user_exactly_once() {
let backend = RecordingBackend::shared();
*backend.fail_with.borrow_mut() = Some("QuotaExceededError: out of room".into());
let store = store(&backend);
store.write("alpha", "A").unwrap();
assert_eq!(store.read("alpha").as_deref(), Some("A"));
let drained = store.take_persistence_errors();
assert_eq!(drained.len(), 1);
assert!(drained[0].contains("alpha"), "{drained:?}");
assert!(drained[0].contains("QuotaExceededError"), "{drained:?}");
assert!(store.take_persistence_errors().is_empty());
store.write("alpha", "AA").unwrap();
assert_eq!(store.take_persistence_errors().len(), 1);
assert_eq!(store.error_history().len(), 2);
}
#[test]
fn an_unavailable_backend_is_usable_in_memory_and_loud_about_not_saving() {
let backend = Rc::new(UnavailableBackend::new("IndexedDB unavailable (blocked)"));
let store = MirrorStore::new(backend, Vec::new(), None);
assert!(store.backend_label().contains("NOT SAVING"));
assert!(store.backend_label().contains("blocked"));
store.write("alpha", "A").unwrap();
assert_eq!(store.read("alpha").as_deref(), Some("A"));
assert_eq!(store.len_of("alpha"), Some(1));
let drained = store.take_persistence_errors();
assert_eq!(drained.len(), 1);
assert!(drained[0].contains("alpha"), "{drained:?}");
}
#[test]
fn recording_a_failure_wakes_the_frame_loop() {
let woken = Rc::new(std::cell::Cell::new(0usize));
let wake = {
let woken = woken.clone();
Rc::new(move || woken.set(woken.get() + 1)) as Rc<dyn Fn()>
};
let backend = RecordingBackend::shared();
*backend.fail_with.borrow_mut() = Some("boom".into());
let store = MirrorStore::new(backend, Vec::new(), Some(wake));
store.write("alpha", "A").unwrap();
assert_eq!(woken.get(), 1);
}
}