diff --git a/CHANGELOG.md b/CHANGELOG.md index a288d54..6822406 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/scripts/gen-i18n.py b/scripts/gen-i18n.py index 21314e4..32816de 100755 --- a/scripts/gen-i18n.py +++ b/scripts/gen-i18n.py @@ -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ť.", }, } diff --git a/tests/ui/mock-tauri.js b/tests/ui/mock-tauri.js index cf47222..791c345 100644 --- a/tests/ui/mock-tauri.js +++ b/tests/ui/mock-tauri.js @@ -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. */ diff --git a/tests/ui/run.mjs b/tests/ui/run.mjs index 290506d..c3c5de2 100644 --- a/tests/ui/run.mjs +++ b/tests/ui/run.mjs @@ -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==" diff --git a/ui/app.css b/ui/app.css index 3cba0be..aa18656 100644 --- a/ui/app.css +++ b/ui/app.css @@ -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; } diff --git a/ui/i18n.js b/ui/i18n.js index 3685fa4..9e8a1e3 100644 --- a/ui/i18n.js +++ b/ui/i18n.js @@ -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ť.", diff --git a/ui/js/explorer.js b/ui/js/explorer.js index b9248ec..215fb89 100644 --- a/ui/js/explorer.js +++ b/ui/js/explorer.js @@ -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,