Files: never an empty pane -- Loading…, a slow answer and a refusal said in the tree
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s

Files › Server drew nothing while GET /files was on its way: no word that it was being read, and a
refusal or a broken answer only after Rust's 60 s read timeout. To the administrator it looked as
if the server had no files, until the listing arrived. Every folder of both trees now says
Loading… while it is read, says after 8 s that the server has not answered (with Try again, the
late answer still drawn), and says a refusal, an answer that is not a listing, or a failure to
draw it in its place with Try again. An answer that comes after Read again, or after the folder
was closed, is dropped instead of appended beside the new one.

Tests (tests/ui N8): the administrator's / as the server's test client answers it (folders, a
mount, files at the root), held to show Loading…; the 8 s note under a fake clock; a 403 and Try
again; three answers that are not a listing; Read again while an answer is pending; 390 px. The
mock can hold an answer (`hold`, `__mock.release`) and change one (`__mock.answer`).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
HomerandClaude Opus 5.5 committed 2026-11-10 16:06:44 +00:00
1 parent 189f706c65
commit 6874dd46cd
7 files changed
+284 -44

No files matched your search

+5 -1
View File
@@ -1460,7 +1460,11 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); }
.tree__more { opacity: 0; }
.tree__item:hover > .tree__more, .tree__more:focus-visible, .tree__more[aria-expanded="true"] { opacity: 1; }
@media (hover: none) { .tree__more { opacity: 1; } }
.tree__note { padding: var(--sp-1) var(--sp-3); font-size: var(--text-xs); color: var(--ink-muted); }
.tree__note { padding: var(--sp-1) var(--sp-3); padding-inline-start: calc(var(--sp-3) + var(--depth, 0) * var(--sp-4)); font-size: var(--text-xs); color: var(--ink-muted); }
/* Loading, still waiting, refused: the words, then Try again on the same line when it fits. */
.tree__note.is-loading, .tree__note.is-error { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); }
.tree__note > span { min-width: 0; overflow-wrap: anywhere; }
.tree__retry { flex: none; }
/* An empty page: an icon, a heading, a sentence, centred. */
.empty { display: grid; justify-items: center; gap: var(--sp-3); padding: var(--sp-12) var(--sp-4) var(--sp-4); text-align: center; }
+16
View File
@@ -379,7 +379,9 @@ const LEMBAS_I18N = {
"The reply is ready.": "Odpověď je hotová.",
"The reply to the next message is a picture drawn from it.": "Odpovědí na další zprávu bude obrázek nakreslený podle ní.",
"The result is no longer there.": "Výsledek už tu není.",
"The server has not answered in %(n)s seconds.": "Server neodpověděl do %(n)s sekund.",
"The server refused this computer's link: %(why)s": "Server odmítl propojení tohoto počítače: %(why)s",
"The server's answer could not be read.": "Odpověď serveru nešlo přečíst.",
"The server's answer had no picture in it.": "V odpovědi serveru nebyl žádný obrázek.",
"The server's answer was not audio.": "Odpověď serveru nebyla zvuk.",
"The terminal could not be loaded.": "Terminál se nepodařilo načíst.",
@@ -864,7 +866,9 @@ const LEMBAS_I18N = {
"The reply is ready.": "Die Antwort ist fertig.",
"The reply to the next message is a picture drawn from it.": "Die Antwort auf die nächste Nachricht ist ein danach gezeichnetes Bild.",
"The result is no longer there.": "Das Ergebnis ist nicht mehr da.",
"The server has not answered in %(n)s seconds.": "Der Server hat seit %(n)s Sekunden nicht geantwortet.",
"The server refused this computer's link: %(why)s": "Der Server hat die Verknüpfung dieses Computers abgelehnt: %(why)s",
"The server's answer could not be read.": "Die Antwort des Servers konnte nicht gelesen werden.",
"The server's answer had no picture in it.": "Die Antwort des Servers enthielt kein Bild.",
"The server's answer was not audio.": "Die Antwort des Servers war kein Audio.",
"The terminal could not be loaded.": "Das Terminal konnte nicht geladen werden.",
@@ -1349,7 +1353,9 @@ const LEMBAS_I18N = {
"The reply is ready.": "La respuesta está lista.",
"The reply to the next message is a picture drawn from it.": "La respuesta al siguiente mensaje es una imagen dibujada a partir de él.",
"The result is no longer there.": "El resultado ya no está.",
"The server has not answered in %(n)s seconds.": "El servidor no ha respondido en %(n)s segundos.",
"The server refused this computer's link: %(why)s": "El servidor rechazó la vinculación de este equipo: %(why)s",
"The server's answer could not be read.": "No se pudo leer la respuesta del servidor.",
"The server's answer had no picture in it.": "La respuesta del servidor no traía ninguna imagen.",
"The server's answer was not audio.": "La respuesta del servidor no era audio.",
"The terminal could not be loaded.": "No se ha podido cargar el terminal.",
@@ -1834,7 +1840,9 @@ const LEMBAS_I18N = {
"The reply is ready.": "La réponse est prête.",
"The reply to the next message is a picture drawn from it.": "La réponse au prochain message est une image dessinée d’après lui.",
"The result is no longer there.": "Le résultat n'est plus là.",
"The server has not answered in %(n)s seconds.": "Le serveur n’a pas répondu depuis %(n)s secondes.",
"The server refused this computer's link: %(why)s": "Le serveur a refusé la liaison de cet ordinateur : %(why)s",
"The server's answer could not be read.": "La réponse du serveur n’a pas pu être lue.",
"The server's answer had no picture in it.": "La réponse du serveur ne contenait aucune image.",
"The server's answer was not audio.": "La réponse du serveur n’était pas de l’audio.",
"The terminal could not be loaded.": "Le terminal n'a pas pu être chargé.",
@@ -2319,7 +2327,9 @@ const LEMBAS_I18N = {
"The reply is ready.": "A válasz kész.",
"The reply to the next message is a picture drawn from it.": "A következő üzenetre adott válasz egy abból rajzolt kép lesz.",
"The result is no longer there.": "Az eredmény már nincs meg.",
"The server has not answered in %(n)s seconds.": "A szerver %(n)s másodperce nem válaszolt.",
"The server refused this computer's link: %(why)s": "A szerver elutasította ennek a számítógépnek a kapcsolatát: %(why)s",
"The server's answer could not be read.": "A szerver válasza nem olvasható.",
"The server's answer had no picture in it.": "A szerver válaszában nem volt kép.",
"The server's answer was not audio.": "A szerver válasza nem hang volt.",
"The terminal could not be loaded.": "A terminált nem sikerült betölteni.",
@@ -2804,7 +2814,9 @@ const LEMBAS_I18N = {
"The reply is ready.": "La risposta è pronta.",
"The reply to the next message is a picture drawn from it.": "La risposta al prossimo messaggio è un’immagine disegnata a partire da esso.",
"The result is no longer there.": "Il risultato non c’è più.",
"The server has not answered in %(n)s seconds.": "Il server non ha risposto in %(n)s secondi.",
"The server refused this computer's link: %(why)s": "Il server ha rifiutato il collegamento di questo computer: %(why)s",
"The server's answer could not be read.": "Non è stato possibile leggere la risposta del server.",
"The server's answer had no picture in it.": "La risposta del server non conteneva immagini.",
"The server's answer was not audio.": "La risposta del server non era audio.",
"The terminal could not be loaded.": "Impossibile caricare il terminale.",
@@ -3289,7 +3301,9 @@ const LEMBAS_I18N = {
"The reply is ready.": "Odpowiedź jest gotowa.",
"The reply to the next message is a picture drawn from it.": "Odpowiedzią na następną wiadomość będzie obraz narysowany na jej podstawie.",
"The result is no longer there.": "Wyniku już nie ma.",
"The server has not answered in %(n)s seconds.": "Serwer nie odpowiedział od %(n)s sekund.",
"The server refused this computer's link: %(why)s": "Serwer odrzucił połączenie tego komputera: %(why)s",
"The server's answer could not be read.": "Nie udało się odczytać odpowiedzi serwera.",
"The server's answer had no picture in it.": "W odpowiedzi serwera nie było obrazu.",
"The server's answer was not audio.": "Odpowiedź serwera nie była dźwiękiem.",
"The terminal could not be loaded.": "Nie udało się wczytać terminala.",
@@ -3774,7 +3788,9 @@ const LEMBAS_I18N = {
"The reply is ready.": "Odpoveď je hotová.",
"The reply to the next message is a picture drawn from it.": "Odpoveďou na ďalšiu správu bude obrázok nakreslený podľa nej.",
"The result is no longer there.": "Výsledok už tu nie je.",
"The server has not answered in %(n)s seconds.": "Server neodpovedal do %(n)s sekúnd.",
"The server refused this computer's link: %(why)s": "Server odmietol prepojenie tohto počítača: %(why)s",
"The server's answer could not be read.": "Odpoveď servera sa nedala prečítať.",
"The server's answer had no picture in it.": "V odpovedi servera nebol žiadny obrázok.",
"The server's answer was not audio.": "Odpoveď servera nebola zvuk.",
"The terminal could not be loaded.": "Terminál sa nepodarilo načítať.",
+85 -42
View File
@@ -19,10 +19,18 @@ export const SERVER_FILES = {
/** A directory in either tree: the server's mounts open like its folders. */
const isDir = (e) => e.kind === "dir" || e.kind === "mount"
/** How long a directory may take before the pane says it is still waiting, and offers to ask again. */
export const SLOW_MS = 8000
/**
* A lazily opened tree in `box`. `list(path)` -> entries; `open(entry)` for a file; `actions(entry)`
* (optional) -> menu items for an entry's more button. Returns `reload(path)`, which reads a
* directory that is open again (the root by `rootPath`), the folders open inside it kept open.
*
* A directory is never an empty box: while it is read it says "Loading…" (and, past SLOW_MS, that
* the answer has not come, with Try again); a refusal, or anything that breaks drawing it, is said
* in its place with Try again. Only the latest reading of a directory is drawn: an answer that
* comes after the directory was read again, closed or redrawn is dropped, not appended.
*/
function tree(box, { list, open, rootPath, actions }) {
clear(box)
@@ -30,56 +38,90 @@ function tree(box, { list, open, rootPath, actions }) {
box.append(ul)
const levels = new Map() // path -> { ul, depth }, each directory drawn now
const opened = new Set() // the directories open now
const asked = new WeakMap() // ul -> the reading it shows now (a number)
const level = async (parent, path, depth) => {
levels.set(path, { ul: parent, depth })
const mine = (asked.get(parent) || 0) + 1
asked.set(parent, mine)
const stale = () => asked.get(parent) !== mine || !parent.isConnected
const again = () => {
clear(parent)
return level(parent, path, depth)
}
const retry = () => h("button", { type: "button", class: "btn btn--sm tree__retry", text: t("Try again"), onclick: again })
const loading = h("li", { class: "tree__note is-loading", role: "status", "aria-live": "polite" }, h("span", { text: t("Loading…") }))
loading.style.setProperty("--depth", String(depth))
parent.append(loading)
const slow = setTimeout(() => {
if (stale()) return
loading.append(h("span", { text: ` ${t("The server has not answered in %(n)s seconds.", { n: Math.round(SLOW_MS / 1000) })}` }), retry())
}, SLOW_MS)
const said = (text) => {
const note = h("li", { class: "tree__note is-error", role: "alert" }, h("span", { text }), retry())
note.style.setProperty("--depth", String(depth))
return note
}
let entries
try {
entries = await list(path)
} catch (err) {
parent.append(h("li", { class: "tree__note is-error", text: why(err) }))
clearTimeout(slow)
if (!stale()) loading.replaceWith(said(why(err)))
return
} finally {
clearTimeout(slow)
}
if (!entries.length) parent.append(h("li", { class: "tree__note", text: t("Empty folder") }))
for (const e of entries) {
const row = h(
"button",
{ type: "button", class: "tree__row", title: e.path, "aria-expanded": isDir(e) ? "false" : null },
h("span", { class: "tree__chevron" }, isDir(e) ? icon(ICONS.chevron) : null),
icon(isDir(e) ? ICONS.folder : ICONS.file),
h("span", { class: "tree__name", text: e.name }),
h("span", { class: "tree__size", text: isDir(e) ? "" : bytes(e.size) }),
)
row.style.setProperty("--depth", String(depth))
const items = actions?.(e) || []
const li = h(
"li",
{ role: "treeitem", class: items.length ? "tree__item" : null, dataset: { path: e.path } },
row,
items.length
? h("button", { type: "button", class: "tool tree__more", "aria-label": t("More"), title: t("More"), onclick: (ev) => menu(ev.currentTarget, items) }, icon(ICONS.more))
: null,
)
if (isDir(e)) {
let kids = null
const expand = async (opening) => {
row.setAttribute("aria-expanded", String(opening))
if (!opening) {
opened.delete(e.path)
levels.delete(e.path)
kids?.remove()
kids = null
return
}
opened.add(e.path)
kids = h("ul", { role: "group" })
li.append(kids)
await level(kids, e.path, depth + 1)
if (stale()) return
try {
if (!Array.isArray(entries)) throw { detail: t("The server's answer could not be read.") }
const items = []
if (!entries.length) items.push(h("li", { class: "tree__note", text: t("Empty folder") }))
for (const e of entries) items.push(item(e, depth))
loading.replaceWith(...items)
} catch (err) {
loading.replaceWith(said(err instanceof Error ? err.message : why(err)))
}
}
const item = (e, depth) => {
const row = h(
"button",
{ type: "button", class: "tree__row", title: e.path, "aria-expanded": isDir(e) ? "false" : null },
h("span", { class: "tree__chevron" }, isDir(e) ? icon(ICONS.chevron) : null),
icon(isDir(e) ? ICONS.folder : ICONS.file),
h("span", { class: "tree__name", text: e.name }),
h("span", { class: "tree__size", text: isDir(e) ? "" : bytes(e.size) }),
)
row.style.setProperty("--depth", String(depth))
const items = actions?.(e) || []
const li = h(
"li",
{ role: "treeitem", class: items.length ? "tree__item" : null, dataset: { path: e.path } },
row,
items.length
? h("button", { type: "button", class: "tool tree__more", "aria-label": t("More"), title: t("More"), onclick: (ev) => menu(ev.currentTarget, items) }, icon(ICONS.more))
: null,
)
if (isDir(e)) {
let kids = null
const expand = async (opening) => {
row.setAttribute("aria-expanded", String(opening))
if (!opening) {
opened.delete(e.path)
levels.delete(e.path)
kids?.remove()
kids = null
return
}
row.addEventListener("click", () => expand(row.getAttribute("aria-expanded") !== "true"))
if (opened.has(e.path)) expand(true)
} else row.addEventListener("click", () => open(e))
parent.append(li)
}
opened.add(e.path)
kids?.remove()
kids = h("ul", { role: "group" })
li.append(kids)
await level(kids, e.path, depth + 1)
}
row.addEventListener("click", () => expand(row.getAttribute("aria-expanded") !== "true"))
if (opened.has(e.path)) expand(true)
} else row.addEventListener("click", () => open(e))
return li
}
const done = level(ul, rootPath, 0)
const reload = (path) => {
@@ -237,8 +279,9 @@ export function renderServer() {
rootPath: "/",
list: async (path) => {
const r = await get(SERVER_FILES.list(path))
if (!r || !Array.isArray(r.entries)) throw { detail: t("The server's answer could not be read.") }
if (path === "/" && r.path) serverRoot = r.path
return r.entries || []
return r.entries
},
open: (e) => openServerFile(e.path, e.name),
actions: serverActions,