Web: Media Browser reports a picked file it can't read (#226)

Add Files... and Upload Folder... read every picked file in one go and
rejected on the first file the browser couldn't read; the Media
Browser dropped that rejection, so nothing was added and nothing said
why. Each file is now read on its own: the readable ones are added and
the status bar names the unreadable ones with the browser's reason and
suggests picking them again. Cancelling the picker is not reported.

Test: apps/effectcraft-web/tests/page.mjs (Node, fake DOM).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
bflatasticandClaude Opus 5.5 committed 2026-10-08 12:38:11 +07:00
1 parent 7612b220ef
commit 82a1862fa1
5 files changed
+122 -19

No files matched your search

+13 -8
View File
@@ -615,21 +615,26 @@ 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();
});
+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:?}"))
}
+70
View File
@@ -0,0 +1,70 @@
// 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).
// 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));
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);
}
*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;
},
};
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");
console.log("page: ok");