Files: never an empty pane -- Loading…, a slow answer and a refusal said in the tree
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:
7 files changed
+284
-44
No files matched your search
@@ -4,6 +4,19 @@ All notable changes to LLeMbas Desktop. The format follows
|
||||
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and the project uses
|
||||
[Semantic Versioning](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Fixed
|
||||
|
||||
- **Files › Server showed an empty pane while it waited.** Nothing was drawn until the server's
|
||||
listing came back -- no word that it was being read -- and a refusal or a broken answer was said
|
||||
only once Rust's read timeout (60 s) had passed; to the person it looked as if there were no
|
||||
files at all. Every folder in both trees now says Loading… while it is read; after 8 s it says
|
||||
the server has not answered yet and offers Try again (the late answer is still drawn); a
|
||||
refusal, an answer that is not a listing, or anything that breaks drawing it is said in its
|
||||
place with Try again. Read again while an answer was on its way no longer draws the listing
|
||||
twice.
|
||||
|
||||
## [1.0.0-beta.7] — 2026-11-10
|
||||
|
||||
LLeMbas CLI 2.0.0-beta.7 inside, the look and words of LLeMbas 2.0.0-beta.9.
|
||||
|
||||
@@ -543,6 +543,9 @@ KEYS = (
|
||||
"approvals wait %(n)s min",
|
||||
# chat.js: a picture as the next reply
|
||||
"The reply to the next message is a picture drawn from it.",
|
||||
# explorer.js: a directory still being read, or an answer that is not a listing
|
||||
"The server has not answered in %(n)s seconds.",
|
||||
"The server's answer could not be read.",
|
||||
)
|
||||
|
||||
# The app's own sentences. The mode names (Manual, Edit, Auto, Plan) stay English, as the
|
||||
@@ -823,6 +826,8 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"Waiting for the computer": "Čeká na počítač",
|
||||
"approvals wait %(n)s min": "schválení čeká %(n)s min",
|
||||
"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 server has not answered in %(n)s seconds.": "Server neodpověděl do %(n)s sekund.",
|
||||
"The server's answer could not be read.": "Odpověď serveru nešlo přečíst.",
|
||||
},
|
||||
"de": {
|
||||
"Sign in again: the server no longer accepts this app's sign-in.": "Melden Sie sich erneut an: Der Server nimmt die Anmeldung dieser App nicht mehr an.",
|
||||
@@ -1098,6 +1103,8 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"Waiting for the computer": "Wartet auf den Computer",
|
||||
"approvals wait %(n)s min": "Freigaben warten %(n)s Min.",
|
||||
"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 server has not answered in %(n)s seconds.": "Der Server hat seit %(n)s Sekunden nicht geantwortet.",
|
||||
"The server's answer could not be read.": "Die Antwort des Servers konnte nicht gelesen werden.",
|
||||
},
|
||||
"es": {
|
||||
"Sign in again: the server no longer accepts this app's sign-in.": "Vuelva a iniciar sesión: el servidor ya no acepta el inicio de sesión de esta aplicación.",
|
||||
@@ -1373,6 +1380,8 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"Waiting for the computer": "Esperando al ordenador",
|
||||
"approvals wait %(n)s min": "las aprobaciones esperan %(n)s min",
|
||||
"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 server has not answered in %(n)s seconds.": "El servidor no ha respondido en %(n)s segundos.",
|
||||
"The server's answer could not be read.": "No se pudo leer la respuesta del servidor.",
|
||||
},
|
||||
"fr": {
|
||||
"Sign in again: the server no longer accepts this app's sign-in.": "Reconnectez-vous : le serveur n’accepte plus la connexion de cette application.",
|
||||
@@ -1648,6 +1657,8 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"Waiting for the computer": "En attente de l’ordinateur",
|
||||
"approvals wait %(n)s min": "les approbations attendent %(n)s min",
|
||||
"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 server has not answered in %(n)s seconds.": "Le serveur n’a pas répondu depuis %(n)s secondes.",
|
||||
"The server's answer could not be read.": "La réponse du serveur n’a pas pu être lue.",
|
||||
},
|
||||
"hu": {
|
||||
"Sign in again: the server no longer accepts this app's sign-in.": "Jelentkezzen be újra: a szerver már nem fogadja el ennek az alkalmazásnak a bejelentkezését.",
|
||||
@@ -1923,6 +1934,8 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"Waiting for the computer": "A számítógépre vár",
|
||||
"approvals wait %(n)s min": "a jóváhagyások %(n)s percig várnak",
|
||||
"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 server has not answered in %(n)s seconds.": "A szerver %(n)s másodperce nem válaszolt.",
|
||||
"The server's answer could not be read.": "A szerver válasza nem olvasható.",
|
||||
},
|
||||
"it": {
|
||||
"Sign in again: the server no longer accepts this app's sign-in.": "Accedi di nuovo: il server non accetta più l’accesso di questa app.",
|
||||
@@ -2198,6 +2211,8 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"Waiting for the computer": "In attesa del computer",
|
||||
"approvals wait %(n)s min": "le approvazioni attendono %(n)s min",
|
||||
"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 server has not answered in %(n)s seconds.": "Il server non ha risposto in %(n)s secondi.",
|
||||
"The server's answer could not be read.": "Non è stato possibile leggere la risposta del server.",
|
||||
},
|
||||
"pl": {
|
||||
"Sign in again: the server no longer accepts this app's sign-in.": "Zaloguj się ponownie: serwer nie przyjmuje już logowania tej aplikacji.",
|
||||
@@ -2473,6 +2488,8 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"Waiting for the computer": "Czeka na komputer",
|
||||
"approvals wait %(n)s min": "zatwierdzenia czekają %(n)s min",
|
||||
"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 server has not answered in %(n)s seconds.": "Serwer nie odpowiedział od %(n)s sekund.",
|
||||
"The server's answer could not be read.": "Nie udało się odczytać odpowiedzi serwera.",
|
||||
},
|
||||
"sk": {
|
||||
"Sign in again: the server no longer accepts this app's sign-in.": "Prihláste sa znova: server už neprijíma prihlásenie tejto aplikácie.",
|
||||
@@ -2748,6 +2765,8 @@ OWN: dict[str, dict[str, str]] = {
|
||||
"Waiting for the computer": "Čaká na počítač",
|
||||
"approvals wait %(n)s min": "schválenia čakajú %(n)s min",
|
||||
"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 server has not answered in %(n)s seconds.": "Server neodpovedal do %(n)s sekúnd.",
|
||||
"The server's answer could not be read.": "Odpoveď servera sa nedala prečítať.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
// /api/v2 as the page reaches it through `api`. A path is looked up with its query,
|
||||
// then without it. A value `{ __error: { status, detail } }` is a refusal. Anything
|
||||
// not in the table is a 404, as an older server would answer.
|
||||
// hold ["GET /files?path=%2F", ...] -- answers that wait (a slow server) until the test
|
||||
// lets them go with `__mock.release(key)`; the table is read when they are let go.
|
||||
// streams { "/chats/c1/messages/m1/stream": [[event, data], ...] } -- the frames a followed
|
||||
// reply sends as soon as it is opened. More can be sent later with `__mock.frame()`.
|
||||
// terminal [[kind, data], ...] -- what every terminal's socket sends as soon as it is opened
|
||||
@@ -29,7 +31,16 @@
|
||||
for (const cb of listeners.get(name) || []) cb({ event: name, payload })
|
||||
}
|
||||
|
||||
const held = [] // [{ key, go }] answers waiting for `release`
|
||||
|
||||
function route(method, path) {
|
||||
if ((config.hold || []).includes(`${method} ${path}`)) {
|
||||
return new Promise((resolve) => held.push({ key: `${method} ${path}`, go: resolve })).then(() => answer(method, path))
|
||||
}
|
||||
return answer(method, path)
|
||||
}
|
||||
|
||||
function answer(method, path) {
|
||||
const routes = config.routes || {}
|
||||
const bare = path.split("?")[0]
|
||||
for (const key of [`${method} ${path}`, `${method} ${bare}`]) {
|
||||
@@ -130,6 +141,17 @@
|
||||
|
||||
window.__mock = {
|
||||
calls,
|
||||
/** Let the held answers for `key` go, in the order they were asked; stop holding new ones. */
|
||||
release(key) {
|
||||
config.hold = (config.hold || []).filter((k) => k !== key)
|
||||
const going = held.filter((x) => x.key === key)
|
||||
for (const x of going) held.splice(held.indexOf(x), 1)
|
||||
for (const x of going) x.go()
|
||||
},
|
||||
/** Change the server's answer for a route from now on. */
|
||||
answer(key, value) {
|
||||
config.routes[key] = value
|
||||
},
|
||||
frame,
|
||||
emit: dispatch,
|
||||
/** The calls of one command, or of `api` with one method and path prefix. */
|
||||
|
||||
+124
-1
@@ -189,9 +189,11 @@ function baseRoutes(over = {}) {
|
||||
|
||||
let browser
|
||||
|
||||
async function page(config, { width = 1280, height = 860 } = {}) {
|
||||
async function page(config, { width = 1280, height = 860, clock = false } = {}) {
|
||||
const context = await browser.newContext({ viewport: { width, height } })
|
||||
const p = await context.newPage()
|
||||
// The page's own timers, moved on by the test (`p.clock.fastForward`) rather than waited for.
|
||||
if (clock) await p.clock.install()
|
||||
const errors = []
|
||||
p.on("pageerror", (e) => errors.push(String(e)))
|
||||
await context.route(`${ORIGIN}/**`, (route) => {
|
||||
@@ -1398,6 +1400,127 @@ test("H8: at 390 px the server's pane and the switches stay inside the window",
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
// N8: Files › Server for an administrator (owner, on 1.0.0-beta.7: "shows nothing", then it loaded
|
||||
// late). The pane drew nothing at all while the listing was on its way -- no word that it was being
|
||||
// read, and a refusal or a broken answer only as late as Rust's 60 s read timeout. An answer that
|
||||
// came after Read again was appended beside the new one. The answer below is what the server's own
|
||||
// test client gives an administrator for `GET /api/v2/files?path=/` (2.0.0-beta.9: `fs.start` is `/`
|
||||
// for an organisation's owner), with a mount and files at the root as on a lived-in instance.
|
||||
|
||||
const ADMIN_ROOT = {
|
||||
path: "/",
|
||||
parent: null,
|
||||
entries: [
|
||||
{ name: "home", path: "/home", kind: "dir", size: 0, mime: "", updated_at: STAMP },
|
||||
{ name: "shared", path: "/shared", kind: "mount", size: 0, mime: "", updated_at: STAMP },
|
||||
{ name: "skills", path: "/skills", kind: "dir", size: 0, mime: "", updated_at: STAMP },
|
||||
{ name: "retry_util.py", path: "/retry_util.py", kind: "file", size: 1917, mime: "text/x-python", updated_at: STAMP },
|
||||
{ name: "test.py", path: "/test.py", kind: "file", size: 210, mime: "text/x-python", updated_at: STAMP },
|
||||
],
|
||||
}
|
||||
const ROOT_KEY = "GET /files?path=%2F"
|
||||
|
||||
function n8(over = {}, hold = []) {
|
||||
return {
|
||||
status: status(),
|
||||
routes: baseRoutes({
|
||||
[ROOT_KEY]: ADMIN_ROOT,
|
||||
"GET /files?path=%2Fhome": { path: "/home", parent: "/", entries: [{ name: "ada", path: "/home/ada", kind: "dir", size: 0, mime: "", updated_at: STAMP }] },
|
||||
...over,
|
||||
}),
|
||||
hold,
|
||||
streams: {},
|
||||
}
|
||||
}
|
||||
|
||||
async function openServerTab(p) {
|
||||
await p.evaluate(() => document.getElementById("nav-files").click())
|
||||
await p.click('#files-tabs [data-tab="server"]')
|
||||
}
|
||||
|
||||
const serverRows = (p) => p.$$eval("#server-tree li[data-path]", (l) => l.map((x) => x.dataset.path))
|
||||
|
||||
test("N8: the administrator's / is drawn: folders, the mount and the files at the root; Loading… while it is read", async () => {
|
||||
const p = await page(n8({}, [ROOT_KEY]))
|
||||
await openServerTab(p)
|
||||
await p.waitForSelector("#server-tree .tree__note.is-loading")
|
||||
assert.equal((await p.textContent("#server-tree .tree__note.is-loading")).trim(), "Loading…")
|
||||
assert.ok(await p.isVisible("#server-tree .tree__note.is-loading"), "the pane is not empty while it waits")
|
||||
await p.evaluate((k) => window.__mock.release(k), ROOT_KEY)
|
||||
await p.waitForSelector('#server-tree li[data-path="/test.py"]')
|
||||
assert.deepEqual(await serverRows(p), ["/home", "/shared", "/skills", "/retry_util.py", "/test.py"])
|
||||
assert.equal(await p.locator("#server-tree .is-loading").count(), 0)
|
||||
// The mount opens like a folder; a folder's children come under it.
|
||||
await p.click('#server-tree li[data-path="/home"] > .tree__row')
|
||||
await p.waitForSelector('#server-tree li[data-path="/home/ada"]')
|
||||
assert.ok(await p.isVisible('#server-tree li[data-path="/home/ada"]'))
|
||||
await shot(p, "n8-admin-root-1280")
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("N8: an answer that has not come in 8 s says so and offers Try again; the late answer is still drawn", async () => {
|
||||
const p = await page(n8({}, [ROOT_KEY]), { clock: true })
|
||||
await openServerTab(p)
|
||||
await p.waitForSelector("#server-tree .tree__note.is-loading")
|
||||
assert.equal(await p.locator("#server-tree .tree__retry").count(), 0)
|
||||
await p.clock.fastForward(9000)
|
||||
await p.waitForSelector("#server-tree .tree__note.is-loading .tree__retry")
|
||||
assert.match(await p.textContent("#server-tree .tree__note.is-loading"), /The server has not answered in 8 seconds\./)
|
||||
await p.evaluate((k) => window.__mock.release(k), ROOT_KEY)
|
||||
await p.waitForSelector('#server-tree li[data-path="/test.py"]')
|
||||
assert.equal(await p.locator("#server-tree .tree__note").count(), 0)
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("N8: a refusal is said in the pane with Try again, which reads the folder afresh", async () => {
|
||||
const p = await page(n8({ [ROOT_KEY]: { __error: { status: 403, detail: "This token was not given the app scope." } } }))
|
||||
await openServerTab(p)
|
||||
await p.waitForSelector("#server-tree .tree__note.is-error")
|
||||
assert.match(await p.textContent("#server-tree .tree__note.is-error"), /This token was not given the app scope\./)
|
||||
assert.equal(await p.getAttribute("#server-tree .tree__note.is-error", "role"), "alert")
|
||||
await p.evaluate(([k, v]) => window.__mock.answer(k, v), [ROOT_KEY, ADMIN_ROOT])
|
||||
await p.click("#server-tree .tree__retry")
|
||||
await p.waitForSelector('#server-tree li[data-path="/home"]')
|
||||
assert.equal(await p.locator("#server-tree .is-error").count(), 0)
|
||||
assert.equal((await p.evaluate((k) => window.__mock.api("GET", "/files?path=%2F").length)), 2)
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("N8: an answer that is not a listing is said, never an empty pane", async () => {
|
||||
for (const broken of [null, { path: "/" }, { detail: "Not Found" }]) {
|
||||
const p = await page(n8({ [ROOT_KEY]: broken }))
|
||||
await openServerTab(p)
|
||||
await p.waitForSelector("#server-tree .tree__note.is-error")
|
||||
assert.match(await p.textContent("#server-tree .tree__note.is-error"), /The server's answer could not be read\./)
|
||||
assert.deepEqual(p.errors, [])
|
||||
await p.context().close()
|
||||
}
|
||||
})
|
||||
|
||||
test("N8: Read again while the first answer is on its way draws the listing once", async () => {
|
||||
const p = await page(n8({}, [ROOT_KEY]))
|
||||
await openServerTab(p)
|
||||
await p.waitForSelector("#server-tree .tree__note.is-loading")
|
||||
await p.click("#server-refresh")
|
||||
await p.waitForFunction((k) => window.__mock.api("GET", "/files?path=%2F").length === 2)
|
||||
await p.evaluate((k) => window.__mock.release(k), ROOT_KEY)
|
||||
await p.waitForSelector('#server-tree li[data-path="/test.py"]')
|
||||
await p.waitForTimeout(50)
|
||||
assert.deepEqual(await serverRows(p), ["/home", "/shared", "/skills", "/retry_util.py", "/test.py"])
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
test("N8: at 390 px the waiting and the refusal stay inside the window, Try again reachable", async () => {
|
||||
const p = await page(n8({ [ROOT_KEY]: { __error: { status: 0, detail: "Could not reach the server: connection timed out after a long wait at the very far end of the network" } } }), { width: 390, height: 844 })
|
||||
await openServerTab(p)
|
||||
await p.waitForSelector("#server-tree .tree__retry")
|
||||
assert.ok(await noSideways(p, 390), "nothing scrolls sideways")
|
||||
const box = await p.locator("#server-tree .tree__retry").boundingBox()
|
||||
assert.ok(box && box.x >= 0 && box.x + box.width <= 390 && box.height >= 28, `Try again is on the screen: ${JSON.stringify(box)}`)
|
||||
await shot(p, "n8-refused-390")
|
||||
assert.deepEqual(p.errors, [])
|
||||
})
|
||||
|
||||
// I8: voice (/v1/audio, through Rust) and drawing a picture (/v1/images, through Rust).
|
||||
|
||||
const PNG = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
|
||||
+5
-1
@@ -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
@@ -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
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user