Merge pull request #237 from storytold/fix/225-226-231-web

Web: short Project panel, unreadable picked files, lost graphics device (#231, #226, #225)
This commit is contained in:
BFlat authored and GitHub committed 2026-10-08 12:46:13 +07:00
commit 5d1b818fa1
9 files changed
+283 -22

No files matched your search

+58 -8
View File
@@ -615,22 +615,72 @@ export async function browseRead(path) {
}
/// The fallback where folders can't be opened: pick files (or a whole folder, `directory`) with a
/// plain file input; resolves with [{path, bytes}] (path relative to the picked folder).
/// plain file input; resolves with [{path, bytes}] (path relative to the picked folder). A file
/// that can't be read is listed as {path, error} instead (#226); cancelling resolves with [].
export function browsePickFiles(directory) {
return new Promise((resolve, reject) => {
return new Promise((resolve) => {
const input = document.createElement("input");
input.type = "file";
input.multiple = true;
if (directory) input.webkitdirectory = true;
input.oncancel = () => resolve([]);
input.onchange = async () => {
try {
const out = [];
for (const f of input.files) {
out.push({ path: (directory && f.webkitRelativePath) || f.name, bytes: new Uint8Array(await f.arrayBuffer()) });
const out = [];
for (const f of input.files) {
const path = (directory && f.webkitRelativePath) || f.name;
try {
out.push({ path, bytes: new Uint8Array(await f.arrayBuffer()) });
} catch (e) {
out.push({ path, error: String((e && (e.message || e.name)) || e) });
}
resolve(out);
} catch (e) { reject(e); }
}
resolve(out);
};
input.click();
});
}
/// The page's WebGPU device was lost (#225): the canvas can't show anything new while the project
/// stays open and editable. Say so over the stale picture, and offer to save the project (a
/// download) and to reload; the reload first writes pending changes to browser storage, from
/// which the session comes back when it is `kept` there.
export function showDeviceLost(message, kept) {
if (document.getElementById("device-lost")) return;
const box = document.createElement("div");
box.id = "device-lost";
box.setAttribute("role", "alertdialog");
const title = document.createElement("strong");
title.textContent = "The graphics device was lost";
const text = document.createElement("p");
text.textContent =
"EffectCraft can't update the display any more. Your project is still open. " +
(kept ? "Reload to continue: the project and its unsaved changes come back." : "This session isn't kept in browser storage: save the project before you reload.");
const detail = document.createElement("small");
detail.textContent = message;
const save = document.createElement("button");
save.textContent = "Save Project";
save.onclick = () =>
self.effectcraft
.execute("file.save", {})
.catch(() => self.effectcraft.execute("file.saveAs", { path: "/Untitled Project.ecproj" }))
.catch((e) => (text.textContent = `Saving failed: ${(e && e.message) || e}`));
const reload = document.createElement("button");
reload.textContent = "Reload";
reload.onclick = async () => {
reload.disabled = true;
try {
await self.effectcraft.flush();
} catch (e) {
text.textContent = `Writing to browser storage failed (${(e && e.message) || e}). Save the project, or reload anyway and lose the changes.`;
reload.textContent = "Reload Anyway";
reload.disabled = false;
reload.onclick = () => location.reload();
return;
}
location.reload();
};
const buttons = document.createElement("div");
buttons.append(save, reload);
box.append(title, text, detail, buttons);
document.body.append(box);
}
+12 -1
View File
@@ -11,6 +11,12 @@ use effectcraft_ui_egui::EffectcraftApp;
use serde_json::json;
use wasm_bindgen::prelude::*;
#[wasm_bindgen(module = "/js/host.js")]
extern "C" {
#[wasm_bindgen(js_name = showDeviceLost)]
fn show_device_lost(message: &str, kept: bool);
}
/// Work for the UI thread with access to the app (posted by async tasks: picked files…).
type Action = Box<dyn FnOnce(&mut EffectcraftApp)>;
@@ -215,9 +221,14 @@ pub async fn start(canvas_id: String) -> Result<(), JsValue> {
log::error!("{message}");
}
}));
// The canvas can't present any more (#225): the page says so over it and
// offers a reload, which brings the session back when browser storage keeps it.
let lost = gpu_failures.clone();
let kept = !empty && persist::backend() != "memory";
rs.device.set_device_lost_callback(move |reason, message| {
lost.report(&format!("GPU device lost ({reason:?}): {message}"), true);
let message = format!("GPU device lost ({reason:?}): {message}");
lost.report(&message, true);
show_device_lost(&message, kept);
});
}
let mut session = session();
+31 -9
View File
@@ -105,7 +105,7 @@ fn spawn(p: js_sys::Promise, what: &'static str, then: impl FnOnce(&mut effectcr
crate::post(move |app| then(app, v));
}
Err(e) => {
let msg = e.as_string().or_else(|| js_sys::Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_default();
let msg = crate::persist::js_err(e);
// Cancelling a picker is not an error.
if !msg.contains("abort") && !msg.contains("AbortError") {
crate::post(move |app| app.ui.status = format!("{what}: {msg}"));
@@ -234,20 +234,42 @@ impl Browser for WebBrowser {
Ok(json!({"pending": true}))
}
"addFiles" | "uploadFolder" => {
let what = if id == "uploadFolder" { "Upload Folder…" } else { "Add Files…" };
let p = pick_files(id == "uploadFolder");
wasm_bindgen_futures::spawn_local(async move {
let Ok(list) = wasm_bindgen_futures::JsFuture::from(p).await else { return };
let mut n = 0;
for f in js_sys::Array::from(&list).iter() {
let path = js_sys::Reflect::get(&f, &"path".into()).ok().and_then(|v| v.as_string());
let bytes = js_sys::Reflect::get(&f, &"bytes".into()).ok().and_then(|b| b.dyn_into::<js_sys::Uint8Array>().ok());
if let (Some(path), Some(bytes)) = (path, bytes) {
crate::files::put(&format!("/{}", path.trim_start_matches('/')), bytes.to_vec().into());
n += 1;
let list = match wasm_bindgen_futures::JsFuture::from(p).await {
Ok(list) => js_sys::Array::from(&list),
Err(e) => {
let msg = crate::persist::js_err(e);
crate::post(move |app| app.ui.status = format!("{what}: {msg}"));
return;
}
};
// Files that can't be read are reported, not dropped silently (#226).
let (mut n, mut unread) = (0, vec![]);
for f in list.iter() {
let get = |key: &str| js_sys::Reflect::get(&f, &key.into()).ok();
let Some(path) = get("path").and_then(|v| v.as_string()) else { continue };
match get("bytes").and_then(|b| b.dyn_into::<js_sys::Uint8Array>().ok()) {
Some(bytes) => {
crate::files::put(&format!("/{}", path.trim_start_matches('/')), bytes.to_vec().into());
n += 1;
}
None => unread.push(match get("error").and_then(|e| e.as_string()).filter(|e| !e.is_empty()) {
Some(why) => format!("{path} ({why})"),
None => path,
}),
}
}
// Cancelled: nothing to report.
if n == 0 && unread.is_empty() {
return;
}
crate::post(move |app| {
app.ui.status = format!("{n} file(s) added to Browser Storage");
if !unread.is_empty() {
app.ui.status += &format!(". Could not read {}: try {what} again", unread.join(", "));
}
go(app, STORAGE);
});
});
+2 -1
View File
@@ -60,7 +60,8 @@ pub fn last_error() -> Option<String> {
LAST_ERROR.with(|e| e.borrow().clone())
}
fn js_err(e: JsValue) -> String {
/// A JavaScript error (a rejected promise's reason) as text.
pub(crate) fn js_err(e: JsValue) -> String {
e.as_string().or_else(|| js_sys::Reflect::get(&e, &"message".into()).ok().and_then(|m| m.as_string())).unwrap_or_else(|| format!("{e:?}"))
}
+129
View File
@@ -0,0 +1,129 @@
// Page-side helpers of the web app (js/host.js) under Node with a fake DOM, no browser or Wasm
// build needed:
//
// node apps/effectcraft-web/tests/page.mjs
//
// - Add Files… / Upload Folder…: a picked file that can't be read is listed with its error
// instead of failing the whole pick silently, and cancelling picks nothing (#226).
// - A lost WebGPU device: the page says so over the canvas and offers Save Project and Reload;
// the reload writes pending changes to browser storage first, and a failed write is reported
// instead of reloading (#225).
// Exits non-zero on failure.
import assert from "node:assert/strict";
import { dirname, join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
const here = dirname(fileURLToPath(import.meta.url));
const tick = () => new Promise((r) => setTimeout(r, 0));
globalThis.self = globalThis;
class FakeElement {
constructor(tag) {
this.tag = tag;
this.children = [];
this.attributes = {};
this.textContent = "";
}
setAttribute(k, v) {
this.attributes[k] = v;
}
append(...c) {
this.children.push(...c);
}
click() {
this.onclick?.();
}
*all() {
yield this;
for (const c of this.children) yield* c.all();
}
}
let picker;
globalThis.document = {
body: new FakeElement("body"),
createElement(tag) {
const el = new FakeElement(tag);
if (tag === "input") {
picker = el;
el.click = () => {};
}
return el;
},
getElementById(id) {
return [...this.body.all()].find((e) => e.id === id) ?? null;
},
};
let reloads = 0;
globalThis.location = { reload: () => reloads++ };
const host = await import(pathToFileURL(join(here, "../js/host.js")));
// #226: one readable and one unreadable file.
const file = (name, read) => ({ name, webkitRelativePath: "", arrayBuffer: read });
const picked = host.browsePickFiles(false);
picker.files = [
file("ok.png", async () => new Uint8Array([1, 2, 3]).buffer),
file("red-corner.png", async () => {
throw new DOMException("Generated unreadable selected file", "NotReadableError");
}),
];
await picker.onchange();
const out = await picked;
assert.equal(out.length, 2);
assert.deepEqual([out[0].path, [...out[0].bytes]], ["ok.png", [1, 2, 3]]);
assert.deepEqual(out[1], { path: "red-corner.png", error: "Generated unreadable selected file" });
const cancelled = host.browsePickFiles(false);
picker.oncancel();
assert.deepEqual(await cancelled, [], "cancelling picks nothing");
// #225: the device-lost notice.
const calls = [];
let flushFails = false;
self.effectcraft = {
flush: async () => {
calls.push("flush");
if (flushFails) throw new Error("quota exceeded");
},
execute: async (command, params) => {
calls.push([command, params]);
if (command === "file.save") throw new Error("no path: use file.saveAs {path}");
},
};
const notice = () => document.getElementById("device-lost");
const buttons = () => [...notice().all()].filter((e) => e.tag === "button");
const text = () => [...notice().all()].find((e) => e.tag === "p").textContent;
host.showDeviceLost("GPU device lost (Destroyed): destroyed", true);
host.showDeviceLost("again", true);
assert.equal(document.body.children.length, 1, "one notice");
assert.equal([...notice().all()].find((e) => e.tag === "small").textContent, "GPU device lost (Destroyed): destroyed");
assert.match(text(), /Reload to continue/);
assert.deepEqual(
buttons().map((b) => b.textContent),
["Save Project", "Reload"],
);
// Save Project: Save, or Save As for an untitled project (a download).
await buttons()[0].onclick();
assert.deepEqual(calls.splice(0), [
["file.save", {}],
["file.saveAs", { path: "/Untitled Project.ecproj" }],
]);
// Reload writes pending changes first.
await buttons()[1].onclick();
assert.deepEqual(calls.splice(0), ["flush"]);
assert.equal(reloads, 1);
// A failed write is reported instead of reloading; Reload Anyway reloads.
document.body.children = [];
flushFails = true;
host.showDeviceLost("GPU device lost (Unknown): gone", false);
assert.match(text(), /isn't kept in browser storage/);
await buttons()[1].onclick();
await tick();
assert.equal(reloads, 1, "no reload after a failed write");
assert.match(text(), /quota exceeded/);
assert.equal(buttons()[1].textContent, "Reload Anyway");
buttons()[1].onclick();
assert.equal(reloads, 2);
console.log("page: ok");
+17
View File
@@ -20,6 +20,23 @@
}
#loading img { width: 56px; height: 56px; }
#loading .err { color: #ff8a7a; white-space: pre-wrap; max-width: 70ch; pointer-events: auto; }
#device-lost {
position: absolute; inset: 0; display: flex; flex-direction: column; gap: 10px;
align-items: center; justify-content: center; padding: 16px; text-align: center;
background: rgb(18 18 18 / 0.85); color: #c8c8c8; font: 14px/1.4 system-ui, sans-serif;
}
#device-lost strong { color: #e3e3e3; font-size: 16px; }
#device-lost p { margin: 0; max-width: 60ch; }
#device-lost small { color: #8c8c8c; font-size: 12px; }
#device-lost div { display: flex; gap: 8px; }
#device-lost button {
padding: 6px 14px; border: 1px solid #3a3a3a; border-radius: 4px;
background: #232323; color: #e3e3e3; font: inherit; cursor: pointer;
}
#device-lost button:hover { background: #303030; }
#device-lost button:last-child { background: #2d8ceb; border-color: #2d8ceb; }
#device-lost button:last-child:hover { background: #4aa0f5; }
#device-lost button:disabled { opacity: 0.6; cursor: default; }
</style>
</head>
<body>
+8 -2
View File
@@ -174,6 +174,12 @@ fn fit(r: Rect, w: f32, h: f32) -> Rect {
Rect::from_center_size(r.center(), vec2(w * s, h * s))
}
/// A scroll bar thumb's length on a `track` long: the share of `content` in view, at least 16
/// points but never longer than the track (a panel only a few points tall still draws, #231).
fn thumb_len(track: f32, visible: f32, content: f32) -> f32 {
(track * visible / content).max(16.0).min(track).max(0.0)
}
/// An inline text edit in progress: (item, field `name`|`comment`, text).
type Editing = (u64, String, String);
fn edit_id() -> egui::Id {
@@ -340,7 +346,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
if overflow > 0.0 {
let track = Rect::from_min_max(pos2(opt_x0, list.max.y - 5.0), pos2(rect.max.x - 4.0, list.max.y - 1.0));
let tw = (track.width() * track.width() / (track.width() + overflow)).max(16.0);
let tw = thumb_len(track.width(), track.width(), track.width() + overflow);
let thumb = Rect::from_min_size(pos2(track.min.x + (track.width() - tw) * (hscroll / overflow), track.min.y), vec2(tw, track.height()));
let sresp = ui.interact(track.expand2(vec2(0.0, 2.0)), egui::Id::new("proj-hscroll"), Sense::drag());
p.rect_filled(track, 2.0, t.field_bg);
@@ -396,7 +402,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let scroll = app.ui.project_scroll;
if max_scroll > 0.0 {
let track = Rect::from_min_max(pos2(rect.max.x - 6.0, list.min.y + 1.0), pos2(rect.max.x - 2.0, list.max.y - 7.0));
let th = (track.height() * list.height() / content_h).clamp(16.0, track.height());
let th = thumb_len(track.height(), list.height(), content_h);
let thumb = Rect::from_min_size(pos2(track.min.x, track.min.y + (track.height() - th) * (scroll / max_scroll)), vec2(track.width(), th));
let vresp = ui.interact(track.expand2(vec2(2.0, 0.0)), egui::Id::new("proj-vscroll"), Sense::drag());
p.rect_filled(thumb, 2.0, if vresp.hovered() || vresp.dragged() { t.text_dim } else { t.text_faint });
+12
View File
@@ -119,3 +119,15 @@ fn dragging_in_the_empty_area_box_selects_items() {
click_at(&mut h, from);
assert!(h.state().session.state.project_selection.is_empty());
}
/// A Project panel shorter than its scroll bar's thumb draws instead of panicking: an empty
/// project in a 1280 × 800 browser window at device pixel ratio 2 (640 × 400 points) left the
/// list too short for the thumb's 16-point minimum (#231).
#[test]
fn a_short_project_panel_draws() {
for height in [400.0, 300.0] {
let mut h = Harness::builder().with_size(egui::vec2(640.0, height)).build_eframe(|_| EffectcraftApp::new(Session::default()));
h.run_steps(3);
assert!(h.state().auto.find("project.empty").is_some(), "the Project panel drew at {height} points");
}
}
+14 -1
View File
@@ -230,7 +230,8 @@ Window ▸ Media Browser browses a virtual tree (`src/browse.rs`):
- **Browser Storage**: every file in browser storage (imported media, saved projects, files added
with **Add Files…** or **Upload Folder…**, plain file inputs that work in every browser). This is
the persistent recent list where folders can't be opened.
the persistent recent list where folders can't be opened. A picked file that can't be read is
named in the status bar with the browser's reason, and the others are still added.
- **Folders**: **Open Folder…** (File System Access API, `showDirectoryPicker`, Chromium) lists a
folder from the user's disk; only the listing is read, and a file's bytes are read when it is
imported (then kept in browser storage). The folder handles are remembered in IndexedDB; after
@@ -262,6 +263,13 @@ eyedroppers read GPU frames back asynchronously (`Gpu::read_display_async`: the
frame later). On WebGL2, or without a usable adapter, everything renders on the CPU.
`effectcraft.info().gpu` reports `{compositor, viewerOnGpu}`.
If the page's WebGPU device is lost (a driver reset, the browser reclaiming the GPU), the canvas
can't show anything new, while the project stays open and commands keep working. The page then
covers the canvas with a notice (`showDeviceLost` in `js/host.js`) offering **Save Project** (a
download) and **Reload**, which first writes pending changes to browser storage
(`effectcraft.flush()`) so the session comes back after the reload; when the write fails it says
so and offers **Reload Anyway**.
**GPU effects run in the frame workers.** A browser never lets JavaScript (or wasm) wait for a GPU
readback, not even in a worker: a buffer maps only once the thread returns to its event loop.
The CPU renderer, which runs GPU effect chains and reads their results back mid-frame, therefore
@@ -418,6 +426,11 @@ node apps/effectcraft-web/tests/smoke.mjs --url http://127.0.0.1:8765/ --out tar
reused worker again even at the same size, and a worker that fails to start fails its job and is
terminated (`node apps/effectcraft-web/tests/workers.mjs`).
`apps/effectcraft-web/tests/page.mjs` checks page-side helpers of `js/host.js` the same way with
a fake DOM: a picked file that can't be read is reported while the others are added, and the
device-lost notice saves, flushes before reloading and reports a failed flush
(`node apps/effectcraft-web/tests/page.mjs`).
It also checks the M13.10 paths: viewer frames rendered in frame workers while scrubbing with the
CPU renderer (project synced as diffs, event-loop gaps under 400 ms), a `wait: true` render that
replies when done while the page keeps running, and the Media Browser over browser storage; and