diff --git a/Cargo.lock b/Cargo.lock
index 19b46405..7f0b7069 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -3260,7 +3260,9 @@ version = "0.1.0"
dependencies = [
"eframe",
"egui",
+ "js-sys",
"log",
+ "serde_json",
"solvecraft-engine",
"solvecraft-ui-egui",
"wasm-bindgen",
diff --git a/apps/solvecraft-web/Cargo.toml b/apps/solvecraft-web/Cargo.toml
index 0a093c3c..e29ae198 100644
--- a/apps/solvecraft-web/Cargo.toml
+++ b/apps/solvecraft-web/Cargo.toml
@@ -23,4 +23,10 @@ log = { workspace = true }
eframe = { workspace = true, features = ["default_fonts", "wgpu", "accesskit"] }
wasm-bindgen = "0.2"
wasm-bindgen-futures = "0.4"
-web-sys = { version = "0.3", features = ["Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location"] }
+js-sys = "0.3"
+serde_json = { workspace = true }
+web-sys = { version = "0.3", features = [
+ "Window", "Document", "Element", "HtmlElement", "HtmlCanvasElement", "Location", "Navigator", "StorageManager",
+ "FileSystemHandle", "FileSystemDirectoryHandle", "FileSystemFileHandle", "FileSystemGetFileOptions", "FileSystemWritableFileStream",
+ "WritableStream", "File", "FileList", "Blob", "BlobPropertyBag", "Url", "HtmlAnchorElement", "HtmlInputElement", "Storage",
+] }
diff --git a/apps/solvecraft-web/src/files.rs b/apps/solvecraft-web/src/files.rs
new file mode 100644
index 00000000..8f20438e
--- /dev/null
+++ b/apps/solvecraft-web/src/files.rs
@@ -0,0 +1,476 @@
+//! Files in the browser. Designs are kept in the origin private file system (OPFS): read into
+//! the engine's in-memory files (`solvecraft_engine::io::vfs`) at start, written back whenever a
+//! command writes one. Files come in from the computer through a file picker (the File System
+//! Access API where the browser has it, an ` ` otherwise) or a drop on the page,
+//! and go out as downloads (exports, and "Download design"). An autosave of unsaved work is kept
+//! in OPFS too and offered again after a reload or a crash.
+
+use std::cell::{Cell, RefCell};
+use std::rc::Rc;
+
+use js_sys::{Array, Function, Object, Reflect, Uint8Array};
+use solvecraft_engine::io::vfs;
+use wasm_bindgen::{JsCast as _, JsValue, closure::Closure};
+use wasm_bindgen_futures::{JsFuture, spawn_local};
+use web_sys::{FileSystemDirectoryHandle, FileSystemFileHandle, FileSystemGetFileOptions, FileSystemWritableFileStream};
+
+/// Where OPFS files appear to the engine.
+pub const OPFS: &str = "/opfs/";
+/// Paths written here are handed to the user as downloads.
+pub const DOWNLOADS: &str = "/downloads/";
+/// Files brought in from the computer.
+pub const UPLOAD: &str = "/upload/";
+/// The autosave of unsaved work (in OPFS: `recovery.solvecraft` and `recovery.json`).
+const RECOVERY: &str = "recovery.solvecraft";
+const RECOVERY_META: &str = "recovery.json";
+
+/// What to do with a file brought in from the computer.
+#[derive(Clone, Copy, Debug, PartialEq)]
+pub enum Intent {
+ /// Open it (a design, or STEP/mesh as a new design).
+ Open,
+ /// Add its bodies to the open design (STEP, 3MF, STL).
+ Insert,
+}
+
+/// A stored design, as the file panel lists it.
+#[derive(Clone, Debug)]
+pub struct Entry {
+ pub name: String,
+ pub size: u64,
+ /// Last written (ms since 1970; 0 when unknown).
+ pub modified: f64,
+}
+
+/// State shared between the async browser calls and the app's frames.
+#[derive(Default)]
+pub struct Files {
+ pub entries: RefCell>,
+ /// Files read from the computer: (name, bytes, what to do).
+ pub incoming: RefCell, Intent)>>,
+ /// Messages for the status line (errors and confirmations).
+ pub messages: RefCell>,
+ /// The stored designs are loaded (the app may open them).
+ pub ready: Cell,
+ /// The file panel is open.
+ pub panel: Cell,
+ /// An autosave left by an earlier session: its design's name and file.
+ pub recovery: RefCell)>>,
+ /// The app's context, to wake it when a browser call finishes.
+ pub ctx: RefCell >,
+}
+
+impl Files {
+ fn say(&self, text: impl Into, error: bool) {
+ self.messages.borrow_mut().push((text.into(), error));
+ self.wake();
+ }
+
+ /// A file arrived from the computer.
+ fn arrive(&self, name: String, bytes: Vec, intent: Intent) {
+ self.incoming.borrow_mut().push((name, bytes, intent));
+ self.wake();
+ }
+
+ /// Run a frame (the app sleeps until something happens).
+ fn wake(&self) {
+ if let Some(c) = self.ctx.borrow().as_ref() {
+ c.request_repaint();
+ }
+ }
+}
+
+thread_local! {
+ /// The private file system isn't there (an insecure page: http on a network address, or an
+ /// old browser): designs are kept in local storage instead.
+ static LOCAL: Cell = const { Cell::new(false) };
+}
+
+/// Local storage keys of stored files.
+const LOCAL_PREFIX: &str = "solvecraft.file.";
+
+fn local() -> Result {
+ web_sys::window().and_then(|w| w.local_storage().ok().flatten()).ok_or_else(|| "this browser keeps no local storage".to_string())
+}
+
+/// Text as is, anything else as hex (local storage holds strings).
+fn encode(bytes: &[u8]) -> String {
+ match std::str::from_utf8(bytes) {
+ Ok(t) => format!("t{t}"),
+ Err(_) => {
+ let mut s = String::with_capacity(bytes.len() * 2 + 1);
+ s.push('x');
+ for b in bytes {
+ s.push_str(&format!("{b:02x}"));
+ }
+ s
+ }
+ }
+}
+
+fn decode(s: &str) -> Vec {
+ if let Some(t) = s.strip_prefix('t') {
+ return t.as_bytes().to_vec();
+ }
+ let h = s.strip_prefix('x').unwrap_or("");
+ (0..h.len() / 2).filter_map(|i| h.get(2 * i..2 * i + 2).and_then(|x| u8::from_str_radix(x, 16).ok())).collect()
+}
+
+/// Is the browser's storage local storage (no OPFS here)?
+pub fn local_only() -> bool {
+ LOCAL.with(Cell::get)
+}
+
+fn err(e: JsValue) -> String {
+ e.as_string().or_else(|| Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_else(|| format!("{e:?}"))
+}
+
+/// The browser's storage manager: missing on insecure pages (http on a network address) and in
+/// old browsers, where calling into it would throw.
+fn storage_manager() -> Option {
+ let nav = web_sys::window()?.navigator();
+ let sm = Reflect::get(&nav, &"storage".into()).ok().filter(|v| v.is_object())?;
+ Some(sm.unchecked_into())
+}
+
+/// Does the storage manager have `method`?
+fn has(sm: &web_sys::StorageManager, method: &str) -> bool {
+ Reflect::get(sm, &method.into()).is_ok_and(|f| f.is_function())
+}
+
+async fn root() -> Result {
+ let sm = storage_manager().filter(|sm| has(sm, "getDirectory")).ok_or("this page has no private file storage (it needs https or localhost)")?;
+ let dir = JsFuture::from(sm.get_directory()).await.map_err(err)?;
+ dir.dyn_into::().map_err(|_| "this browser has no private file storage (OPFS)".to_string())
+}
+
+async fn read_handle(h: &FileSystemFileHandle) -> Result, String> {
+ let file: web_sys::File = JsFuture::from(h.get_file()).await.map_err(err)?.dyn_into().map_err(|_| "not a file".to_string())?;
+ blob_bytes(&file).await
+}
+
+async fn blob_bytes(b: &web_sys::Blob) -> Result, String> {
+ let buf = JsFuture::from(b.array_buffer()).await.map_err(err)?;
+ Ok(Uint8Array::new(&buf).to_vec())
+}
+
+async fn file_handle(dir: &FileSystemDirectoryHandle, name: &str, create: bool) -> Result {
+ let opts = FileSystemGetFileOptions::new();
+ opts.set_create(create);
+ JsFuture::from(dir.get_file_handle_with_options(name, &opts)).await.map_err(err)?.dyn_into().map_err(|_| "not a file".to_string())
+}
+
+async fn write_file(name: &str, bytes: &[u8]) -> Result<(), String> {
+ if local_only() {
+ return local()?
+ .set_item(&format!("{LOCAL_PREFIX}{name}"), &encode(bytes))
+ .map_err(|e| format!("local storage is full or blocked: {}", err(e)));
+ }
+ let dir = root().await?;
+ let h = file_handle(&dir, name, true).await?;
+ let w: FileSystemWritableFileStream =
+ JsFuture::from(h.create_writable()).await.map_err(err)?.dyn_into().map_err(|_| "no writable stream".to_string())?;
+ JsFuture::from(w.write_with_u8_array(bytes).map_err(err)?).await.map_err(err)?;
+ JsFuture::from(w.close()).await.map_err(err)?;
+ Ok(())
+}
+
+async fn remove_file(name: &str) -> Result<(), String> {
+ if local_only() {
+ return local()?.remove_item(&format!("{LOCAL_PREFIX}{name}")).map_err(err);
+ }
+ let dir = root().await?;
+ JsFuture::from(dir.remove_entry(name)).await.map_err(err)?;
+ Ok(())
+}
+
+/// Every stored file in local storage.
+fn read_local() -> Result, f64)>, String> {
+ let st = local()?;
+ let mut out = Vec::new();
+ for i in 0..st.length().unwrap_or(0).min(10_000) {
+ let Some(k) = st.key(i).ok().flatten() else { continue };
+ let Some(name) = k.strip_prefix(LOCAL_PREFIX) else { continue };
+ if let Some(v) = st.get_item(&k).ok().flatten() {
+ out.push((name.to_string(), decode(&v), 0.0));
+ }
+ }
+ Ok(out)
+}
+
+/// Every stored file: (name, bytes). Without OPFS, local storage takes over from here on.
+async fn read_all() -> Result, f64)>, String> {
+ let dir = match root().await {
+ Ok(d) => d,
+ Err(e) => {
+ log::warn!("no OPFS ({e}): designs go to local storage");
+ LOCAL.with(|l| l.set(true));
+ return read_local();
+ }
+ };
+ let iter: JsValue = dir.values().into();
+ let next: Function = Reflect::get(&iter, &"next".into()).map_err(err)?.dyn_into().map_err(|_| "no iterator".to_string())?;
+ let mut out = Vec::new();
+ for _ in 0..10_000 {
+ let step = JsFuture::from(js_sys::Promise::resolve(&next.call0(&iter).map_err(err)?)).await.map_err(err)?;
+ if Reflect::get(&step, &"done".into()).ok().and_then(|d| d.as_bool()).unwrap_or(true) {
+ break;
+ }
+ let Ok(h) = Reflect::get(&step, &"value".into()).map_err(err)?.dyn_into::() else { continue };
+ let file =
+ match JsFuture::from(h.get_file()).await.map_err(err).and_then(|f| f.dyn_into::().map_err(|_| "not a file".to_string())) {
+ Ok(f) => f,
+ Err(e) => {
+ log::warn!("{}: {e}", h.name());
+ continue;
+ }
+ };
+ match blob_bytes(&file).await {
+ Ok(b) => out.push((h.name(), b, file.last_modified())),
+ Err(e) => log::warn!("{}: {e}", h.name()),
+ }
+ }
+ Ok(out)
+}
+
+/// Ask the browser to keep the storage (not evict it under pressure), then load every stored
+/// file into the engine's files.
+pub fn start(files: &Rc) {
+ if let Some(sm) = storage_manager().filter(|sm| has(sm, "persist"))
+ && let Ok(p) = sm.persist()
+ {
+ spawn_local(async move {
+ let _ = JsFuture::from(p).await;
+ });
+ }
+ let files = files.clone();
+ spawn_local(async move {
+ match read_all().await {
+ Ok(all) => {
+ if local_only() {
+ files.say("This page can't use the browser's file storage (it needs https or localhost): designs are kept in local storage, a few MB at most.", false);
+ }
+ let mut entries = Vec::new();
+ for (name, bytes, modified) in all {
+ if name == RECOVERY_META {
+ let meta: serde_json::Value = serde_json::from_slice(&bytes).unwrap_or_default();
+ let n = meta["name"].as_str().unwrap_or("Untitled").to_string();
+ *files.recovery.borrow_mut() = Some((n, meta["path"].as_str().map(str::to_string)));
+ continue;
+ }
+ if name != RECOVERY {
+ entries.push(Entry { name: name.clone(), size: bytes.len() as u64, modified });
+ }
+ vfs::insert(&format!("{OPFS}{name}"), bytes);
+ }
+ entries.sort_by_key(|a| a.name.to_lowercase());
+ *files.entries.borrow_mut() = entries;
+ // An autosave without its design file is nothing to recover.
+ if !vfs::exists(&format!("{OPFS}{RECOVERY}")) {
+ *files.recovery.borrow_mut() = None;
+ }
+ }
+ Err(e) => files.say(format!("browser storage: {e}"), true),
+ }
+ files.ready.set(true);
+ files.wake();
+ });
+}
+
+/// Store what commands wrote: OPFS paths into OPFS, download paths as downloads.
+pub fn flush(files: &Rc) {
+ for (path, bytes) in vfs::take_writes() {
+ if let Some(name) = path.strip_prefix(OPFS) {
+ let name = name.to_string();
+ {
+ let mut e = files.entries.borrow_mut();
+ e.retain(|x| x.name != name);
+ if name != RECOVERY && name != RECOVERY_META {
+ e.push(Entry { name: name.clone(), size: bytes.len() as u64, modified: js_sys::Date::now() });
+ e.sort_by_key(|a| a.name.to_lowercase());
+ }
+ }
+ let files = files.clone();
+ spawn_local(async move {
+ if let Err(e) = write_file(&name, &bytes).await {
+ files.say(format!("{name}: {e}"), true);
+ }
+ });
+ } else if let Some(name) = path.strip_prefix(DOWNLOADS)
+ && let Err(e) = download(name, &bytes)
+ {
+ files.say(format!("{name}: {e}"), true);
+ }
+ }
+}
+
+/// Hand `bytes` to the user as a download named `name`.
+pub fn download(name: &str, bytes: &[u8]) -> Result<(), String> {
+ let doc = web_sys::window().and_then(|w| w.document()).ok_or("no document")?;
+ let parts = Array::new();
+ parts.push(&Uint8Array::from(bytes));
+ let opts = web_sys::BlobPropertyBag::new();
+ opts.set_type("application/octet-stream");
+ let blob = web_sys::Blob::new_with_u8_array_sequence_and_options(&parts, &opts).map_err(err)?;
+ let url = web_sys::Url::create_object_url_with_blob(&blob).map_err(err)?;
+ let a: web_sys::HtmlAnchorElement = doc.create_element("a").map_err(err)?.dyn_into().map_err(|_| "no anchor".to_string())?;
+ a.set_href(&url);
+ a.set_download(name);
+ a.click();
+ // Let the browser start the download before the URL goes.
+ let revoke = Closure::once_into_js(move || {
+ let _ = web_sys::Url::revoke_object_url(&url);
+ });
+ if let Some(w) = web_sys::window() {
+ let _ = w.set_timeout_with_callback_and_timeout_and_arguments_0(revoke.unchecked_ref(), 10_000);
+ }
+ Ok(())
+}
+
+/// Delete a stored design.
+pub fn delete(files: &Rc, name: &str) {
+ let _ = vfs::remove(&format!("{OPFS}{name}"));
+ files.entries.borrow_mut().retain(|e| e.name != name);
+ let (files, name) = (files.clone(), name.to_string());
+ spawn_local(async move {
+ if let Err(e) = remove_file(&name).await {
+ files.say(format!("{name}: {e}"), true);
+ }
+ });
+}
+
+/// Rename a stored design (a copy under the new name, then the old one goes: not every browser
+/// can move OPFS files).
+pub fn rename(files: &Rc, from: &str, to: &str) -> Result<(), String> {
+ let to = to.trim();
+ if to.is_empty() || to.contains('/') || to.contains('\\') || to.len() > 200 {
+ return Err("give a file name without slashes".into());
+ }
+ if files.entries.borrow().iter().any(|e| e.name == to) {
+ return Err(format!("there is already a file named {to}"));
+ }
+ let bytes = vfs::read(&format!("{OPFS}{from}")).map_err(|e| e.to_string())?;
+ vfs::write(&format!("{OPFS}{to}"), &bytes).map_err(|e| e.to_string())?;
+ delete(files, from);
+ flush(files);
+ Ok(())
+}
+
+/// Keep (or drop) the autosave of the open design.
+pub fn autosave(files: &Rc, doc: Option<(&solvecraft_engine::doc::Document, Option<&str>)>) {
+ let files = files.clone();
+ match doc {
+ Some((d, path)) => {
+ let bytes = solvecraft_engine::io::write_design(d);
+ let meta = serde_json::json!({"name": d.name, "path": path, "saved_at": js_sys::Date::now()}).to_string();
+ spawn_local(async move {
+ let r = async {
+ write_file(RECOVERY, &bytes).await?;
+ write_file(RECOVERY_META, meta.as_bytes()).await
+ };
+ if let Err(e) = r.await {
+ files.say(format!("autosave: {e}"), true);
+ }
+ });
+ }
+ None => spawn_local(async move {
+ let _ = remove_file(RECOVERY).await;
+ let _ = remove_file(RECOVERY_META).await;
+ }),
+ }
+}
+
+/// A stored path for `name` that no stored file has: `name`, else `name (2)`, `name (3)`…
+pub fn free_name(name: &str) -> String {
+ let (stem, ext) = name.rsplit_once('.').map(|(s, e)| (s.to_string(), format!(".{e}"))).unwrap_or((name.to_string(), String::new()));
+ for i in 1..1000 {
+ let n = if i == 1 { name.to_string() } else { format!("{stem} ({i}){ext}") };
+ let p = format!("{OPFS}{n}");
+ if !vfs::exists(&p) {
+ return p;
+ }
+ }
+ format!("{OPFS}{stem} ({}){ext}", js_sys::Date::now() as u64)
+}
+
+/// The autosaved design's path (in the engine's files) for opening it.
+pub fn recovery_path() -> String {
+ format!("{OPFS}{RECOVERY}")
+}
+
+/// Pick files on the computer: the File System Access picker where there is one, else an
+/// ` `. Read files arrive in `files.incoming`.
+pub fn pick_from_disk(files: &Rc, intent: Intent) {
+ let Some(window) = web_sys::window() else { return };
+ let picker = Reflect::get(&window, &"showOpenFilePicker".into()).ok().and_then(|f| f.dyn_into::().ok());
+ match picker {
+ Some(f) => {
+ let opts = Object::new();
+ let _ = Reflect::set(&opts, &"multiple".into(), &JsValue::FALSE);
+ let promise = f.call1(&window, &opts);
+ let files = files.clone();
+ spawn_local(async move {
+ let r = async {
+ let p: js_sys::Promise = promise.map_err(err)?.dyn_into().map_err(|_| "no promise".to_string())?;
+ let handles: Array = JsFuture::from(p).await.map_err(err)?.dyn_into().map_err(|_| "no files".to_string())?;
+ for h in handles.iter() {
+ let h: FileSystemFileHandle = h.dyn_into().map_err(|_| "not a file".to_string())?;
+ let bytes = read_handle(&h).await?;
+ files.arrive(h.name(), bytes, intent);
+ }
+ Ok::<(), String>(())
+ };
+ match r.await {
+ Ok(()) => {}
+ // Closing the picker is no error.
+ Err(e) if e.contains("abort") || e.contains("Abort") => {}
+ Err(e) => files.say(format!("open: {e}"), true),
+ }
+ });
+ }
+ None => input_picker(files, intent),
+ }
+}
+
+fn input_picker(files: &Rc, intent: Intent) {
+ let Some(doc) = web_sys::window().and_then(|w| w.document()) else { return };
+ let Ok(input) = doc.create_element("input").map(|e| e.unchecked_into::()) else { return };
+ input.set_type("file");
+ input.set_accept(".solvecraft,.step,.stp,.3mf,.stl");
+ // Attached (hidden) while it is open: some browsers ignore a click on a detached input.
+ let _ = input.set_attribute("style", "display:none");
+ if let Some(body) = doc.body() {
+ let _ = body.append_child(&input);
+ }
+ let (files, el) = (files.clone(), input.clone());
+ let on_change = Closure::once_into_js(move || {
+ el.remove();
+ let Some(list) = el.files() else { return };
+ for i in 0..list.length() {
+ let Some(f) = list.get(i) else { continue };
+ let files = files.clone();
+ spawn_local(async move {
+ match blob_bytes(&f).await {
+ Ok(b) => files.arrive(f.name(), b, intent),
+ Err(e) => files.say(format!("{}: {e}", f.name()), true),
+ }
+ });
+ }
+ });
+ input.set_onchange(Some(on_change.unchecked_ref()));
+ input.click();
+}
+
+/// Read files dropped on the page.
+pub fn take_drops(files: &Rc, raw: &mut egui::RawInput) {
+ for f in std::mem::take(&mut raw.dropped_files) {
+ let files = files.clone();
+ spawn_local(async move {
+ let name = f.path().file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "dropped".into());
+ match f.bytes_async().await {
+ Ok(b) => files.arrive(name, b, Intent::Insert),
+ Err(e) => files.say(format!("{name}: {e}"), true),
+ }
+ });
+ }
+}
diff --git a/apps/solvecraft-web/src/main.rs b/apps/solvecraft-web/src/main.rs
index b64f779d..4dde3a15 100644
--- a/apps/solvecraft-web/src/main.rs
+++ b/apps/solvecraft-web/src/main.rs
@@ -6,30 +6,304 @@
//! sample part.
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
+#[cfg(target_arch = "wasm32")]
+mod files;
+
#[cfg(target_arch = "wasm32")]
mod web {
+ use std::rc::Rc;
+
use solvecraft_engine::Session;
+ use solvecraft_engine::io::vfs;
use solvecraft_ui_egui::{Services, SolveApp};
use wasm_bindgen::JsCast as _;
+ use crate::files::{self, Files, Intent};
+
const CANVAS_ID: &str = "solvecraft_canvas";
const LOADING_ID: &str = "solvecraft_loading";
const DEPTH_BITS: u8 = 24;
- struct Shell(SolveApp);
+ /// Seconds between autosaves of unsaved work.
+ const AUTOSAVE_S: f64 = 30.0;
+
+ struct Shell {
+ app: SolveApp,
+ files: Rc,
+ /// When the last autosave was (seconds), and the revision it holds (none stored: `None`).
+ autosaved_at: f64,
+ autosaved: Option,
+ /// A stored design being renamed: (its name, the new name).
+ renaming: Option<(String, String)>,
+ /// The rename field takes the keyboard when it appears.
+ rename_focus: bool,
+ /// The start page's recent designs were filled from the stored ones.
+ recents_synced: bool,
+ }
+
+ impl Shell {
+ /// Files from the computer, writes to store, messages and the autosave.
+ fn files_tick(&mut self, ctx: &egui::Context) {
+ if self.files.ctx.borrow().is_none() {
+ *self.files.ctx.borrow_mut() = Some(ctx.clone());
+ }
+ files::flush(&self.files);
+ for (text, error) in self.files.messages.borrow_mut().drain(..) {
+ self.app.set_status(text, error);
+ }
+ let incoming: Vec<_> = self.files.incoming.borrow_mut().drain(..).collect();
+ for (name, bytes, intent) in incoming {
+ let design = name.ends_with(&format!(".{}", solvecraft_engine::io::DESIGN_EXT));
+ let path = if design && intent == Intent::Open {
+ // A design from the computer is kept in the browser (a copy), so Save works.
+ let path = files::free_name(&name);
+ if let Err(e) = vfs::write(&path, &bytes) {
+ self.app.set_status(e.to_string(), true);
+ }
+ files::flush(&self.files);
+ path
+ } else {
+ let path = format!("{}{name}", files::UPLOAD);
+ vfs::insert(&path, bytes);
+ path
+ };
+ match intent {
+ Intent::Open => self.app.open_path(&path),
+ Intent::Insert => self.app.drop_path(&path),
+ }
+ self.files.panel.set(false);
+ }
+ if self.files.ready.get() && !self.recents_synced {
+ self.recents_synced = true;
+ self.sync_recents();
+ }
+ let now = ctx.input(|i| i.time);
+ // Unsaved work is kept (at most every AUTOSAVE_S); saved work needs no autosave.
+ if self.files.ready.get() && self.files.recovery.borrow().is_none() {
+ let s = &self.app.session;
+ let changed = s.is_dirty() && self.autosaved != Some(s.revision);
+ let wait = AUTOSAVE_S - (now - self.autosaved_at);
+ if changed && wait <= 0.0 {
+ files::autosave(&self.files, Some((&s.doc, s.path.as_deref())));
+ self.autosaved = Some(s.revision);
+ self.autosaved_at = now;
+ } else if changed {
+ // Come back for it even when nothing else happens.
+ ctx.request_repaint_after(std::time::Duration::from_secs_f64(wait));
+ } else if !s.is_dirty() && self.autosaved.is_some() {
+ files::autosave(&self.files, None);
+ self.autosaved = None;
+ }
+ }
+ if !self.files.ready.get() || !self.files.incoming.borrow().is_empty() {
+ ctx.request_repaint_after(std::time::Duration::from_millis(100));
+ }
+ }
+
+ /// The start page's recent designs are the stored ones: the remembered order first (those
+ /// still stored), then the others, newest first.
+ fn sync_recents(&mut self) {
+ let mut entries = self.files.entries.borrow().clone();
+ entries.sort_by(|a, b| b.modified.total_cmp(&a.modified));
+ let recent = &mut self.app.home.recent;
+ recent.retain(|p| vfs::exists(p));
+ for e in entries.iter().filter(|e| e.name.ends_with(&format!(".{}", solvecraft_engine::io::DESIGN_EXT))) {
+ let p = format!("{}{}", files::OPFS, e.name);
+ if !recent.contains(&p) {
+ recent.push(p);
+ }
+ }
+ recent.truncate(solvecraft_ui_egui::home::MAX_RECENT);
+ }
+
+ /// The stored designs: open, insert, rename, delete, download; open from the computer.
+ fn file_panel(&mut self, ctx: &egui::Context) {
+ if !self.files.panel.get() {
+ return;
+ }
+ let mut open = true;
+ let mut act: Option<(&'static str, String)> = None;
+ egui::Window::new("Files in this browser")
+ .open(&mut open)
+ .collapsible(false)
+ .resizable(true)
+ .default_width(520.0)
+ .pivot(egui::Align2::CENTER_CENTER)
+ .default_pos(ctx.content_rect().center())
+ .show(ctx, |ui| {
+ ui.horizontal(|ui| {
+ if ui.button("Open from computer…").clicked() {
+ files::pick_from_disk(&self.files, Intent::Open);
+ }
+ if ui.button("Insert from computer…").on_hover_text("STEP, 3MF or STL into the open design").clicked() {
+ files::pick_from_disk(&self.files, Intent::Insert);
+ }
+ if ui.button("Download open design").clicked() {
+ act = Some(("download_open", String::new()));
+ }
+ });
+ ui.separator();
+ if !self.files.ready.get() {
+ ui.horizontal(|ui| {
+ ui.spinner();
+ ui.label("Reading the browser's storage…");
+ });
+ return;
+ }
+ let entries = self.files.entries.borrow().clone();
+ if entries.is_empty() {
+ ui.label("Designs you save appear here. They stay in this browser.");
+ }
+ egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| {
+ egui::Grid::new("opfs_files").num_columns(3).spacing([12.0, 6.0]).show(ui, |ui| {
+ for e in &entries {
+ match &mut self.renaming {
+ Some((from, to)) if *from == e.name => {
+ let r = ui.add(egui::TextEdit::singleline(to).desired_width(200.0));
+ if std::mem::take(&mut self.rename_focus) {
+ r.request_focus();
+ }
+ if r.lost_focus() {
+ act = Some(if ui.input(|i| i.key_pressed(egui::Key::Enter)) {
+ ("rename", to.clone())
+ } else {
+ ("rename_cancel", String::new())
+ });
+ }
+ }
+ _ => {
+ ui.label(&e.name);
+ }
+ }
+ ui.label(format!("{:.1} KB", e.size as f64 / 1024.0));
+ ui.horizontal(|ui| {
+ if ui.small_button("Open").clicked() {
+ act = Some(("open", e.name.clone()));
+ }
+ let design = e.name.ends_with(&format!(".{}", solvecraft_engine::io::DESIGN_EXT));
+ if !design && ui.small_button("Insert").on_hover_text("Into the open design (STEP, 3MF, STL)").clicked() {
+ act = Some(("insert", e.name.clone()));
+ }
+ if ui.small_button("Rename").clicked() {
+ self.renaming = Some((e.name.clone(), e.name.clone()));
+ self.rename_focus = true;
+ }
+ if ui.small_button("Download").clicked() {
+ act = Some(("download", e.name.clone()));
+ }
+ if ui.small_button("Delete").clicked() {
+ act = Some(("delete", e.name.clone()));
+ }
+ });
+ ui.end_row();
+ }
+ });
+ });
+ });
+ if !open {
+ self.files.panel.set(false);
+ self.renaming = None;
+ }
+ let Some((what, name)) = act else { return };
+ let path = format!("{}{name}", files::OPFS);
+ match what {
+ "open" => {
+ self.app.open_path(&path);
+ self.files.panel.set(false);
+ }
+ "insert" => {
+ self.app.insert_path(&path);
+ self.files.panel.set(false);
+ }
+ "download" => match vfs::read(&path) {
+ Ok(b) => {
+ if let Err(e) = files::download(&name, &b) {
+ self.app.set_status(e, true);
+ }
+ }
+ Err(e) => self.app.set_status(e.to_string(), true),
+ },
+ "download_open" => {
+ let d = &self.app.session.doc;
+ let bytes = solvecraft_engine::io::write_design(d);
+ if let Err(e) = files::download(&format!("{}.{}", d.name, solvecraft_engine::io::DESIGN_EXT), &bytes) {
+ self.app.set_status(e, true);
+ }
+ }
+ "delete" => {
+ files::delete(&self.files, &name);
+ self.app.home.recent.retain(|r| *r != path);
+ }
+ "rename_cancel" => self.renaming = None,
+ "rename" => {
+ if let Some((from, _)) = self.renaming.take() {
+ match files::rename(&self.files, &from, &name) {
+ Ok(()) => {
+ // Recent designs follow the new name.
+ let (old, new) = (format!("{}{from}", files::OPFS), format!("{}{name}", files::OPFS));
+ for r in &mut self.app.home.recent {
+ if *r == old {
+ *r = new.clone();
+ }
+ }
+ }
+ Err(e) => self.app.set_status(e, true),
+ }
+ }
+ }
+ _ => {}
+ }
+ }
+
+ /// Offer the autosave an earlier session left.
+ fn recovery_prompt(&mut self, ctx: &egui::Context) {
+ let Some((name, path)) = self.files.recovery.borrow().clone() else { return };
+ let mut answer = None;
+ egui::Window::new("Recover unsaved work?").collapsible(false).resizable(false).anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]).show(
+ ctx,
+ |ui| {
+ ui.label(format!("“{name}” had unsaved changes when this page last closed."));
+ ui.horizontal(|ui| {
+ if ui.button("Recover").clicked() {
+ answer = Some(true);
+ }
+ if ui.button("Discard").clicked() {
+ answer = Some(false);
+ }
+ });
+ },
+ );
+ let Some(recover) = answer else { return };
+ *self.files.recovery.borrow_mut() = None;
+ if recover {
+ self.app.open_path(&files::recovery_path());
+ // It belongs to its own file again (saving goes there); it is unsaved.
+ self.app.session.path = path;
+ self.app.session.doc_mut().name = name;
+ self.app.session.mark_unsaved();
+ self.app.home.open = false;
+ }
+ files::autosave(&self.files, None);
+ }
+ }
impl eframe::App for Shell {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
- self.0.logic(ctx);
+ self.app.logic(ctx);
+ self.files_tick(ctx);
}
fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) {
- self.0.raw_input_hook(raw);
+ files::take_drops(&self.files, raw);
+ self.app.raw_input_hook(raw);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
- self.0.ui(ui);
+ self.app.ui(ui);
+ let ctx = ui.ctx().clone();
+ self.file_panel(&ctx);
+ self.recovery_prompt(&ctx);
}
fn save(&mut self, storage: &mut dyn eframe::Storage) {
- storage.set_string(PREFS_KEY, self.0.prefs());
+ storage.set_string(PREFS_KEY, self.app.prefs());
}
}
@@ -59,7 +333,23 @@ mod web {
canvas,
options,
Box::new(move |cc| {
- let mut app = SolveApp::new(Session::default(), Services::default());
+ let files = Rc::new(Files::default());
+ files::start(&files);
+ let panel = files.clone();
+ let services = Services {
+ // Open shows the browser's files (and the computer's, from there).
+ pick_open: Some(Box::new(move || {
+ panel.panel.set(true);
+ None
+ })),
+ // Designs are kept in the browser; everything else is downloaded.
+ pick_save: Some(Box::new(|name: &str, exts: &[&str]| {
+ let design = exts.contains(&solvecraft_engine::io::DESIGN_EXT);
+ Some(format!("{}{name}", if design { files::OPFS } else { files::DOWNLOADS }))
+ })),
+ ..Default::default()
+ };
+ let mut app = SolveApp::new(Session::default(), services);
if let Some(p) = cc.storage.and_then(|s| s.get_string(PREFS_KEY)) {
app.load_prefs(&p);
}
@@ -73,7 +363,19 @@ mod web {
app.session.doc_mut().name = "Sample Plate".into();
app.session.mark_saved();
}
- Ok(Box::new(Shell(app)))
+ if !query().contains("sample") {
+ let rev = app.session.revision;
+ app.home.show(rev);
+ }
+ Ok(Box::new(Shell {
+ app,
+ files,
+ autosaved_at: f64::NEG_INFINITY,
+ autosaved: None,
+ renaming: None,
+ rename_focus: false,
+ recents_synced: false,
+ }))
}),
)
.await;
diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs
index d991a280..db754d77 100644
--- a/crates/engine/src/cmd/file.rs
+++ b/crates/engine/src/cmd/file.rs
@@ -51,11 +51,11 @@ fn path_arg<'a>(p: &'a Value, cmd: &str) -> Result<&'a str> {
/// Read a STEP file as an Import feature.
fn read_step(path: &str) -> Result {
- let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
- if meta.len() > solvecraft_io::MAX_STEP_BYTES as u64 {
+ let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
+ if meta > solvecraft_io::MAX_STEP_BYTES as u64 {
return Err(EngineError::Other(format!("{path}: file too large")));
}
- let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
+ let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
solvecraft_io::step_import_feature(&bytes, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))
}
@@ -80,11 +80,11 @@ fn add_step(s: &mut Session, path: &str, name: Option<&str>) -> Result {
/// Add a MeshImport feature for a 3MF or STL file.
fn add_mesh(s: &mut Session, path: &str, name: Option<&str>) -> Result {
- let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
- if meta.len() > solvecraft_io::MAX_3MF_BYTES as u64 {
+ let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
+ if meta > solvecraft_io::MAX_3MF_BYTES as u64 {
return Err(EngineError::Other(format!("{path}: file too large")));
}
- let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
+ let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let mf = solvecraft_io::mesh_import_feature(&bytes, path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let fname = name.filter(|n| !n.trim().is_empty()).unwrap_or(&mf.name).to_string();
let id = s.doc_mut().add_feature(mf.kind, Some(&fname))?;
@@ -132,11 +132,11 @@ fn open(s: &mut Session, p: &Value) -> Result {
let errors = s.model.results.iter().filter(|r| r.error.is_some()).count();
return Ok(json!({"path": path, "features": s.doc.features.len(), "errors": errors, "bodies": r["bodies"], "warnings": r["warnings"]}));
}
- let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
- if meta.len() as usize > solvecraft_io::MAX_DESIGN_BYTES {
+ let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
+ if meta as usize > solvecraft_io::MAX_DESIGN_BYTES {
return Err(EngineError::Other(format!("{path}: file too large")));
}
- let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
+ let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
let doc = solvecraft_io::read_design(&bytes)?;
*s = Session::new(doc);
s.path = Some(path.to_string());
@@ -175,7 +175,7 @@ fn export_to(s: &Session, path: &str, format: Format, bodies: &[String]) -> Resu
// Bodies carry the colour they are shown in (appearance, material or imported).
solvecraft_io::export(&s.doc.painted(&s.world_state()), bodies, format, &name)?
};
- std::fs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
+ solvecraft_io::vfs::write(path, &bytes).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(json!({"path": path, "bytes": bytes.len(), "format": format!("{format:?}")}))
}
diff --git a/crates/engine/src/cmd/parameters.rs b/crates/engine/src/cmd/parameters.rs
index 1ca10f9e..282a4463 100644
--- a/crates/engine/src/cmd/parameters.rs
+++ b/crates/engine/src/cmd/parameters.rs
@@ -219,7 +219,7 @@ fn export(s: &mut Session, p: &Value) -> Result {
other => return Err(bad(cmd, format!("unknown format `{other}` (csv or json)"))),
};
if let Some(path) = path {
- std::fs::write(path, &text).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
+ solvecraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
}
Ok(json!({"format": format, "path": path, "text": text}))
}
@@ -229,11 +229,11 @@ fn import(s: &mut Session, p: &Value) -> Result {
let text = match (str_(p, "text"), str_(p, "path")) {
(Some(t), _) => t.to_string(),
(None, Some(path)) => {
- let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
- if meta.len() > 16 << 20 {
+ let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
+ if meta > 16 << 20 {
return Err(bad(cmd, "file too large"));
}
- std::fs::read_to_string(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
+ solvecraft_io::vfs::read_to_string(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
}
_ => return Err(bad(cmd, "give `text` or `path`")),
};
diff --git a/crates/engine/src/cmd/sheet.rs b/crates/engine/src/cmd/sheet.rs
index 0a8228ee..9f2807c5 100644
--- a/crates/engine/src/cmd/sheet.rs
+++ b/crates/engine/src/cmd/sheet.rs
@@ -369,7 +369,7 @@ fn export_dxf(s: &mut Session, p: &Value) -> Result {
let sh = sheet_of(s, p, cmd)?;
let text = flat_dxf(&sh)?;
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
- std::fs::write(path, &text).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
+ solvecraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
return Ok(json!({"path": path, "bends": sh.bends().len()}));
}
Ok(json!({"dxf": text, "bends": sh.bends().len()}))
diff --git a/crates/engine/src/cmd/sketch_canvas.rs b/crates/engine/src/cmd/sketch_canvas.rs
index 9c973aef..731664f0 100644
--- a/crates/engine/src/cmd/sketch_canvas.rs
+++ b/crates/engine/src/cmd/sketch_canvas.rs
@@ -31,11 +31,11 @@ fn image_bytes(p: &Value, cmd: &str) -> Result> {
base64_decode(d).ok_or_else(|| bad(cmd, "`data` is not base64"))?
} else {
let path = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "give `path` or `data`"))?;
- let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
- if meta.len() as usize > MAX_CANVAS_BYTES {
+ let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
+ if meta as usize > MAX_CANVAS_BYTES {
return Err(bad(cmd, "the image is too large"));
}
- std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
+ solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?
};
if b.len() > MAX_CANVAS_BYTES {
return Err(bad(cmd, "the image is too large"));
diff --git a/crates/engine/src/cmd/sketch_import.rs b/crates/engine/src/cmd/sketch_import.rs
index 58c4362f..fd405b0d 100644
--- a/crates/engine/src/cmd/sketch_import.rs
+++ b/crates/engine/src/cmd/sketch_import.rs
@@ -34,11 +34,11 @@ fn source_text(p: &Value, cmd: &str) -> Result {
return Ok(t.to_string());
}
let path = str_(p, "path").filter(|x| !x.trim().is_empty() && x.len() < 4096).ok_or_else(|| bad(cmd, "give `path` or `text`"))?;
- let meta = std::fs::metadata(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
- if meta.len() as usize > MAX_DRAWING_BYTES {
+ let meta = solvecraft_io::vfs::len(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
+ if meta as usize > MAX_DRAWING_BYTES {
return Err(EngineError::Other(format!("{path}: file too large")));
}
- let bytes = std::fs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
+ let bytes = solvecraft_io::vfs::read(path).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
Ok(String::from_utf8_lossy(&bytes).to_string())
}
@@ -186,7 +186,7 @@ fn export_dxf(s: &mut Session, p: &Value) -> Result {
}
let text = solvecraft_io::write_dxf(&geom);
if let Some(path) = str_(p, "path").filter(|x| !x.trim().is_empty()) {
- std::fs::write(path, &text).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
+ solvecraft_io::vfs::write(path, text.as_bytes()).map_err(|e| EngineError::Other(format!("{path}: {e}")))?;
return Ok(json!({"path": path, "entities": geom.len()}));
}
Ok(json!({"dxf": text, "entities": geom.len()}))
diff --git a/crates/io/src/lib.rs b/crates/io/src/lib.rs
index 77c63f20..f1155f1c 100644
--- a/crates/io/src/lib.rs
+++ b/crates/io/src/lib.rs
@@ -10,6 +10,7 @@ pub mod safe_write;
mod sketch2d;
mod svg;
mod threemf;
+pub mod vfs;
pub use dxf::{read_dxf, write_dxf};
pub use safe_write::{backup_path, write_atomic};
diff --git a/crates/io/src/safe_write.rs b/crates/io/src/safe_write.rs
index 65109953..2f6fb41e 100644
--- a/crates/io/src/safe_write.rs
+++ b/crates/io/src/safe_write.rs
@@ -87,6 +87,11 @@ fn write_steps(path: &Path, bytes: &[u8], backup: bool, stop: Option) -> s
/// Write `bytes` to `path` atomically; with `backup`, the file being replaced is kept as
/// `.bak` first.
pub fn write_atomic(path: &Path, bytes: &[u8], backup: bool) -> std::io::Result<()> {
+ // In the browser the host stores files (one write is already atomic there).
+ if cfg!(target_arch = "wasm32") {
+ let _ = backup;
+ return crate::vfs::write(&path.to_string_lossy(), bytes);
+ }
write_steps(path, bytes, backup, None)
}
diff --git a/crates/io/src/vfs.rs b/crates/io/src/vfs.rs
new file mode 100644
index 00000000..839a246d
--- /dev/null
+++ b/crates/io/src/vfs.rs
@@ -0,0 +1,102 @@
+//! File access for the engine. Natively it is the disk. In the browser (wasm32) there is no
+//! file system to call synchronously, so files live in memory here: the web host loads them
+//! (from the browser's private storage, a file picker or a drop) before a command reads them,
+//! and takes what commands wrote ([`take_writes`]) to store it or hand it out as a download.
+
+use std::io::{Error, ErrorKind, Result};
+
+#[cfg(not(target_arch = "wasm32"))]
+mod imp {
+ use super::*;
+
+ pub fn read(path: &str) -> Result> {
+ std::fs::read(path)
+ }
+ pub fn write(path: &str, bytes: &[u8]) -> Result<()> {
+ std::fs::write(path, bytes)
+ }
+ pub fn len(path: &str) -> Result {
+ std::fs::metadata(path).map(|m| m.len())
+ }
+ pub fn exists(path: &str) -> bool {
+ std::path::Path::new(path).exists()
+ }
+ pub fn remove(path: &str) -> Result<()> {
+ std::fs::remove_file(path)
+ }
+}
+
+#[cfg(target_arch = "wasm32")]
+mod imp {
+ use std::cell::RefCell;
+ use std::collections::BTreeMap;
+
+ use super::*;
+
+ thread_local! {
+ static FILES: RefCell>> = const { RefCell::new(BTreeMap::new()) };
+ static WRITES: RefCell> = const { RefCell::new(Vec::new()) };
+ }
+
+ fn missing(path: &str) -> Error {
+ Error::new(ErrorKind::NotFound, format!("{path}: no such file in this browser"))
+ }
+
+ pub fn read(path: &str) -> Result> {
+ FILES.with(|f| f.borrow().get(path).cloned()).ok_or_else(|| missing(path))
+ }
+ pub fn write(path: &str, bytes: &[u8]) -> Result<()> {
+ FILES.with(|f| f.borrow_mut().insert(path.to_string(), bytes.to_vec()));
+ WRITES.with(|w| {
+ let mut w = w.borrow_mut();
+ w.retain(|p| p != path);
+ w.push(path.to_string());
+ });
+ Ok(())
+ }
+ pub fn len(path: &str) -> Result {
+ FILES.with(|f| f.borrow().get(path).map(|b| b.len() as u64)).ok_or_else(|| missing(path))
+ }
+ pub fn exists(path: &str) -> bool {
+ FILES.with(|f| f.borrow().contains_key(path))
+ }
+ pub fn remove(path: &str) -> Result<()> {
+ FILES.with(|f| f.borrow_mut().remove(path)).map(|_| ()).ok_or_else(|| missing(path))
+ }
+ pub fn insert(path: &str, bytes: Vec) {
+ FILES.with(|f| f.borrow_mut().insert(path.to_string(), bytes));
+ }
+ pub fn take_writes() -> Vec<(String, Vec)> {
+ let paths = WRITES.with(|w| std::mem::take(&mut *w.borrow_mut()));
+ paths.into_iter().filter_map(|p| FILES.with(|f| f.borrow().get(&p).cloned()).map(|b| (p, b))).collect()
+ }
+ pub fn list(prefix: &str) -> Vec<(String, u64)> {
+ FILES.with(|f| f.borrow().iter().filter(|(p, _)| p.starts_with(prefix)).map(|(p, b)| (p.clone(), b.len() as u64)).collect())
+ }
+}
+
+pub use imp::*;
+
+/// A file's text.
+pub fn read_to_string(path: &str) -> Result {
+ String::from_utf8(read(path)?).map_err(|e| Error::new(ErrorKind::InvalidData, e))
+}
+
+#[cfg(test)]
+mod tests {
+ #[test]
+ fn files_round_trip() {
+ let dir = std::env::temp_dir().join(format!("solvecraft-vfs-{}", std::process::id()));
+ let _ = std::fs::create_dir_all(&dir);
+ let p = dir.join("a.txt").to_string_lossy().to_string();
+ assert!(!super::exists(&p));
+ assert!(super::read(&p).is_err());
+ super::write(&p, b"hello").unwrap();
+ assert!(super::exists(&p));
+ assert_eq!(super::len(&p).unwrap(), 5);
+ assert_eq!(super::read_to_string(&p).unwrap(), "hello");
+ super::remove(&p).unwrap();
+ assert!(!super::exists(&p));
+ let _ = std::fs::remove_dir_all(&dir);
+ }
+}
diff --git a/crates/ui-egui/src/home.rs b/crates/ui-egui/src/home.rs
index 5fa2768f..24feb9f9 100644
--- a/crates/ui-egui/src/home.rs
+++ b/crates/ui-egui/src/home.rs
@@ -180,7 +180,7 @@ pub fn open_sample(app: &mut SolveApp, i: usize) {
}
fn open_recent(app: &mut SolveApp, path: &str) {
- if !std::path::Path::new(path).exists() {
+ if !solvecraft_engine::io::vfs::exists(path) {
app.home.recent.retain(|p| p != path);
app.set_status(format!("{path} is no longer there"), true);
return;
@@ -198,11 +198,15 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
poll(app, ui.ctx());
let t = Tokens::get();
let samples = solvecraft_engine::sample::samples();
- for i in 0..samples.len() {
- want(&mut app.home, &format!("sample:{i}"));
+ // In the browser a picture blocks the page while it builds (no threads) and a kernel
+ // failure there can't be caught: samples are built when they are opened.
+ if !cfg!(target_arch = "wasm32") {
+ for i in 0..samples.len() {
+ want(&mut app.home, &format!("sample:{i}"));
+ }
}
let recent = app.home.recent.clone();
- for p in recent.iter().filter(|p| std::path::Path::new(p.as_str()).exists()) {
+ for p in recent.iter().filter(|p| solvecraft_engine::io::vfs::exists(p)) {
want(&mut app.home, p);
}
let mut action: Option> = None;
@@ -266,9 +270,19 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let path = std::path::Path::new(p);
let name = path.file_stem().map(|x| x.to_string_lossy().to_string()).unwrap_or_else(|| p.clone());
let folder = path.parent().map(|x| x.to_string_lossy().to_string()).unwrap_or_default();
- let missing = !path.exists();
- let sub = if missing { "missing".to_string() } else { folder };
- if card(ui, app.home.thumbs.get(p.as_str()).and_then(Option::as_ref), &name, &sub, missing).on_hover_text(p.as_str()).clicked() {
+ let missing = !solvecraft_engine::io::vfs::exists(p);
+ let sub = if missing {
+ "missing".to_string()
+ } else if p.starts_with("/opfs/") {
+ // The web build keeps designs in the browser's own storage.
+ "in this browser".to_string()
+ } else {
+ folder
+ };
+ if card(ui, Pic::Ready(app.home.thumbs.get(p.as_str()).and_then(Option::as_ref)), &name, &sub, missing)
+ .on_hover_text(p.as_str())
+ .clicked()
+ {
let p = p.clone();
action = Some(Box::new(move |app: &mut SolveApp| open_recent(app, &p)));
}
@@ -281,7 +295,11 @@ pub fn show(app: &mut SolveApp, ui: &mut egui::Ui) {
let key = format!("sample:{i}");
let busy = app.home.wanted == Some(i);
let sub = if busy { "opening…" } else { s.about };
- if card(ui, app.home.thumbs.get(&key).and_then(Option::as_ref), s.name, sub, false).on_hover_text(s.about).clicked() {
+ let tex = match app.home.thumbs.get(&key) {
+ Some(t) => Pic::Ready(t.as_ref()),
+ None => Pic::Icon("box"),
+ };
+ if card(ui, tex, s.name, sub, false).on_hover_text(s.about).clicked() {
action = Some(Box::new(move |app: &mut SolveApp| open_sample(app, i)));
}
});
@@ -313,8 +331,14 @@ fn grid(ui: &mut egui::Ui, n: usize, mut each: impl FnMut(&mut egui::Ui, usize))
}
}
+/// A card's picture: rendered (`None` while it renders), or an icon until it is asked for.
+enum Pic<'a> {
+ Ready(Option<&'a egui::TextureHandle>),
+ Icon(&'static str),
+}
+
/// A design card: its picture (a spinner while it renders), name and a line under it.
-fn card(ui: &mut egui::Ui, tex: Option<&egui::TextureHandle>, name: &str, sub: &str, dim: bool) -> egui::Response {
+fn card(ui: &mut egui::Ui, pic: Pic, name: &str, sub: &str, dim: bool) -> egui::Response {
let t = Tokens::get();
let size = vec2(THUMB[0] as f32 + 16.0, THUMB[1] as f32 + 62.0);
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
@@ -327,12 +351,16 @@ fn card(ui: &mut egui::Ui, tex: Option<&egui::TextureHandle>, name: &str, sub: &
egui::StrokeKind::Inside,
);
let img = egui::Rect::from_min_size(r.min + vec2(8.0, 8.0), vec2(THUMB[0] as f32, THUMB[1] as f32));
- match tex {
- Some(tx) => {
+ match pic {
+ Pic::Icon(icon) => {
+ ui.painter().rect_filled(img, CornerRadius::same(4), t.panel_header);
+ crate::icons::paint(ui.painter(), egui::Rect::from_center_size(img.center(), vec2(56.0, 56.0)), icon, t.icon, t.icon_fill, t.icon);
+ }
+ Pic::Ready(Some(tx)) => {
let tint = if dim { Color32::from_white_alpha(90) } else { Color32::WHITE };
ui.painter().image(tx.id(), img, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), tint);
}
- None => {
+ Pic::Ready(None) => {
ui.painter().rect_filled(img, CornerRadius::same(4), t.panel_header);
if !dim {
egui::Spinner::new().size(18.0).paint_at(ui, egui::Rect::from_center_size(img.center(), vec2(18.0, 18.0)));