diff --git a/CHANGELOG.md b/CHANGELOG.md index 6822406..f3e7825 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,17 @@ All notable changes to LLeMbas Desktop. The format follows ## [Unreleased] +### Changed + +- **Files, in the web explorer's shape, using the window's width.** Both tabs (This computer and + Server) are now where you are (crumbs, each part a way back), the folder tree, the list in + columns -- name with an icon by kind, type, size, changed -- and, on a wide window, the chosen + file's details with the start of it and Open in the viewer (a double click opens it too). With + less room the details give way first (a file then opens in the viewer), then the tree, then the + type and date columns, by the room the page has rather than the window's width; nothing is cut + off or scrolls the page sideways (checked at 1920, 1280, 1024, 800 and 390 px with a long name). + The tree and the list share one request per folder. + ### Fixed - **Files › Server showed an empty pane while it waited.** Nothing was drawn until the server's diff --git a/scripts/gen-i18n.py b/scripts/gen-i18n.py index 32816de..14c931e 100755 --- a/scripts/gen-i18n.py +++ b/scripts/gen-i18n.py @@ -546,6 +546,24 @@ KEYS = ( # 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.", + # explorer.js: the Files page as the web's explorer (the server's words, but the last) + "Name", + "Type", + "Size", + "Changed", + "folder", + "mount", + "All files", + "Home", + "Folders", + "Where you are", + "Nothing here yet. Not even a hobbit hole.", + "Contents", + "Actions", + "More for %(name)s", + "Path", + "Choose a file to see it here.", + "Details", ) # The app's own sentences. The mode names (Manual, Edit, Auto, Plan) stay English, as the @@ -828,6 +846,8 @@ OWN: dict[str, dict[str, str]] = { "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.", + "Choose a file to see it here.": "Vyberte soubor a uvidíte ho tady.", + "Details": "Podrobnosti", }, "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.", @@ -1105,6 +1125,8 @@ OWN: dict[str, dict[str, str]] = { "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.", + "Choose a file to see it here.": "Wählen Sie eine Datei, um sie hier zu sehen.", + "Details": "Details", }, "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.", @@ -1382,6 +1404,8 @@ OWN: dict[str, dict[str, str]] = { "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.", + "Choose a file to see it here.": "Elige un archivo para verlo aquí.", + "Details": "Detalles", }, "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.", @@ -1659,6 +1683,8 @@ OWN: dict[str, dict[str, str]] = { "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.", + "Choose a file to see it here.": "Choisissez un fichier pour le voir ici.", + "Details": "Détails", }, "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.", @@ -1936,6 +1962,8 @@ OWN: dict[str, dict[str, str]] = { "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ó.", + "Choose a file to see it here.": "Válasszon egy fájlt, hogy itt lássa.", + "Details": "Részletek", }, "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.", @@ -2213,6 +2241,8 @@ OWN: dict[str, dict[str, str]] = { "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.", + "Choose a file to see it here.": "Scegli un file per vederlo qui.", + "Details": "Dettagli", }, "pl": { "Sign in again: the server no longer accepts this app's sign-in.": "Zaloguj się ponownie: serwer nie przyjmuje już logowania tej aplikacji.", @@ -2490,6 +2520,8 @@ OWN: dict[str, dict[str, str]] = { "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.", + "Choose a file to see it here.": "Wybierz plik, aby zobaczyć go tutaj.", + "Details": "Szczegóły", }, "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.", @@ -2767,6 +2799,8 @@ OWN: dict[str, dict[str, str]] = { "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ť.", + "Choose a file to see it here.": "Vyberte súbor a uvidíte ho tu.", + "Details": "Podrobnosti", }, } diff --git a/tests/ui/run.mjs b/tests/ui/run.mjs index c3c5de2..6fdb537 100644 --- a/tests/ui/run.mjs +++ b/tests/ui/run.mjs @@ -1320,7 +1320,7 @@ function h8(over = {}, commands = {}) { async function serverPane(p) { await p.click("#nav-files") await p.click('#files-tabs [data-tab="server"]') - await p.waitForSelector('#server-tree li[data-path="/home/frodo/lembas.md"]') + await p.waitForSelector('#server-list .frow[data-path="/home/frodo/lembas.md"]') } test("H8: the server's files: upload, a new folder, rename and delete, each read again from the server", async () => { @@ -1332,21 +1332,21 @@ test("H8: the server's files: upload, a new folder, rename and delete, each read assert.equal((await p.evaluate(() => window.__mock.calls.find((c) => c.cmd === "library_upload").args.dir)), "/home/frodo") await p.waitForFunction(() => window.__mock.api("GET", "/files?path=%2F").length === 2) // A new folder inside a folder, from its row's menu. - await p.click('#server-tree li[data-path="/home/frodo/recipes"] > .tree__more') + await p.click('#server-list .frow[data-path="/home/frodo/recipes"] .frow__more') await p.click(".menu .menu__item >> text=New folder") await p.fill("dialog[open] .dialog__input", "Breads") await p.click("dialog[open] .btn--primary") await p.waitForFunction(() => window.__mock.api("POST", "/files/mkdir").length === 1) assert.deepEqual((await p.evaluate(() => window.__mock.api("POST", "/files/mkdir")))[0].args.body, { path: "/home/frodo/recipes", name: "Breads" }) // Rename a file. - await p.click('#server-tree li[data-path="/home/frodo/lembas.md"] > .tree__more') + await p.click('#server-list .frow[data-path="/home/frodo/lembas.md"] .frow__more') await p.click(".menu .menu__item >> text=Rename") await p.fill("dialog[open] .dialog__input", "waybread.md") await p.click("dialog[open] .btn--primary") await p.waitForFunction(() => window.__mock.api("POST", "/files/rename").length === 1) assert.deepEqual((await p.evaluate(() => window.__mock.api("POST", "/files/rename")))[0].args.body, { path: "/home/frodo/lembas.md", name: "waybread.md" }) // Delete: to the trash, after asking. - await p.click('#server-tree li[data-path="/home/frodo/lembas.md"] > .tree__more') + await p.click('#server-list .frow[data-path="/home/frodo/lembas.md"] .frow__more') await p.click(".menu .menu__item >> text=Delete") await p.click("dialog[open] .btn--danger") await p.waitForFunction(() => window.__mock.api("DELETE", "/files?path=%2Fhome%2Ffrodo%2Flembas.md").length === 1) @@ -1362,7 +1362,7 @@ test("H8: a refusal or a file the server did not take is said in the pane", asyn await p.click("#server-upload") await p.waitForSelector("#server-status.is-error") assert.equal(await p.textContent("#server-status"), "big.iso: This file is too large to send.") - await p.click('#server-tree li[data-path="/home/frodo/lembas.md"] > .tree__more') + await p.click('#server-list .frow[data-path="/home/frodo/lembas.md"] .frow__more') await p.click(".menu .menu__item >> text=Rename") await p.fill("dialog[open] .dialog__input", "x.md") await p.click("dialog[open] .btn--primary") @@ -1394,7 +1394,7 @@ test("H8: at 390 px the server's pane and the switches stay inside the window", const p = await page(h8(), { width: 390, height: 844 }) await p.evaluate(() => document.getElementById("nav-files").click()) await p.click('#files-tabs [data-tab="server"]') - await p.waitForSelector('#server-tree li[data-path="/home/frodo/lembas.md"]') + await p.waitForSelector('#server-list .frow[data-path="/home/frodo/lembas.md"]') assert.ok(await noSideways(p, 390), "nothing scrolls sideways") await shot(p, "h8-files-390") assert.deepEqual(p.errors, []) @@ -1438,22 +1438,22 @@ async function openServerTab(p) { 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)) +const serverRows = (p) => p.$$eval("#server-list .frow[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.waitForSelector("#server-list .fnote.is-loading") + assert.equal((await p.textContent("#server-list .fnote.is-loading")).trim(), "Loading…") + assert.ok(await p.isVisible("#server-list .fnote.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"]') + await p.waitForSelector('#server-list .frow[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) + assert.equal(await p.locator("#server-list .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 p.click('#server-list .frow[data-path="/home"] .frow__open') + await p.waitForSelector('#server-list .frow[data-path="/home/ada"]') + assert.ok(await p.isVisible('#server-list .frow[data-path="/home/ada"]')) await shot(p, "n8-admin-root-1280") assert.deepEqual(p.errors, []) }) @@ -1461,27 +1461,27 @@ test("N8: the administrator's / is drawn: folders, the mount and the files at th 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.waitForSelector("#server-list .fnote.is-loading") + assert.equal(await p.locator("#server-list .fnote__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.waitForSelector("#server-list .fnote.is-loading .fnote__retry") + assert.match(await p.textContent("#server-list .fnote.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) + await p.waitForSelector('#server-list .frow[data-path="/test.py"]') + assert.equal(await p.locator("#server-list .fnote").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.waitForSelector("#server-list .fnote.is-error") + assert.match(await p.textContent("#server-list .fnote.is-error"), /This token was not given the app scope\./) + assert.equal(await p.getAttribute("#server-list .fnote.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) + await p.click("#server-list .fnote__retry") + await p.waitForSelector('#server-list .frow[data-path="/home"]') + assert.equal(await p.locator("#server-list .is-error").count(), 0) assert.equal((await p.evaluate((k) => window.__mock.api("GET", "/files?path=%2F").length)), 2) assert.deepEqual(p.errors, []) }) @@ -1490,8 +1490,8 @@ 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\./) + await p.waitForSelector("#server-list .fnote.is-error") + assert.match(await p.textContent("#server-list .fnote.is-error"), /The server's answer could not be read\./) assert.deepEqual(p.errors, []) await p.context().close() } @@ -1500,11 +1500,11 @@ test("N8: an answer that is not a listing is said, never an empty pane", async ( 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.waitForSelector("#server-list .fnote.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.waitForSelector('#server-list .frow[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, []) @@ -1513,9 +1513,9 @@ test("N8: Read again while the first answer is on its way draws the listing once 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") + await p.waitForSelector("#server-list .fnote__retry") assert.ok(await noSideways(p, 390), "nothing scrolls sideways") - const box = await p.locator("#server-tree .tree__retry").boundingBox() + const box = await p.locator("#server-list .fnote__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, []) @@ -1844,6 +1844,125 @@ test("M8: no picture switch where the server cannot draw, or in an agent chat", assert.deepEqual(q.errors, []) }) +// FX: the Files page as the web's explorer -- crumbs, the folder tree, the list in columns, the +// chosen file's details and preview, using the window's width; narrower, the details and then the +// tree give way, and nothing is ever cut off or scrolls the page sideways. + +const LONG = "a-very-long-file-name-that-somebody-chose-for-the-lembas-recipe-and-its-variants.md" +function fx() { + const cfg = n8({ + "GET /files?path=%2Fhome": { path: "/home", parent: "/", entries: [{ name: "ada", path: "/home/ada", kind: "dir", size: 0, mime: "", updated_at: STAMP }, { name: LONG, path: `/home/${LONG}`, kind: "file", size: 123456, mime: "text/markdown", updated_at: STAMP }] }, + "GET /files/content": { path: "/test.py", name: "test.py", size: 210, mime: "text/x-python", kind: "code", language: "python", sha: "x", text: "print('lembas')\n" }, + }) + cfg.commands = { + local_list: { root: "/home/dev/project", path: "", more: false, entries: [{ name: "src", path: "src", kind: "dir", size: 0, modified: 1767323045 }, { name: "README.md", path: "README.md", kind: "file", size: 2048, modified: 1767323045 }] }, + local_read: { root: "/home/dev/project", path: "README.md", name: "README.md", size: 2048, text: "# Project\n", withheld: null }, + } + return cfg +} + +test("FX: wide, the server's files are a tree, a list in columns and the chosen file's details with a preview", async () => { + const p = await page(fx(), { width: 1600, height: 900 }) + await openServerTab(p) + await p.waitForSelector('#server-list .frow[data-path="/test.py"]') + // The tree: the top, then its folders; the top is where we are. + await p.waitForSelector('#server-tree li[data-path="/home"]') + assert.equal(await p.textContent("#server-tree .ftree__root"), "All files") + assert.equal(await p.getAttribute("#server-tree .ftree__root", "aria-current"), "true") + assert.deepEqual(await p.$$eval("#server-tree li[data-path]", (l) => l.map((x) => x.dataset.path)), ["/home", "/shared", "/skills"]) + // The columns, and the kinds' words. + assert.deepEqual(await p.$$eval("#server-list .frow--head [role=columnheader]", (l) => l.map((x) => x.textContent)), ["Name", "Type", "Size", "Changed", "Actions"]) + assert.equal(await p.textContent('#server-list .frow[data-path="/shared"] .frow__type'), "mount") + assert.equal(await p.textContent('#server-list .frow[data-path="/home"] .frow__type'), "folder") + assert.equal(await p.textContent('#server-list .frow[data-path="/test.py"] .frow__type'), "py") + assert.equal(await p.textContent('#server-list .frow[data-path="/test.py"] .frow__size'), "210 B") + // Every row's cells on the same tracks: the size column's left edge is one x for every row. + const xs = await p.$$eval("#server-list .frow .frow__size", (l) => [...new Set(l.map((x) => Math.round(x.getBoundingClientRect().right)))]) + assert.equal(xs.length, 1, `sizes end on one line: ${xs}`) + // A file chosen: its details and the start of it. + assert.match(await p.textContent("#server-details"), /Choose a file to see it here\./) + await p.click('#server-list .frow[data-path="/test.py"] .frow__open') + await p.waitForSelector("#server-details .fdetails__text") + assert.equal(await p.textContent("#server-details .fdetails__name"), "test.py") + assert.equal(await p.textContent("#server-details .fdetails__text"), "print('lembas')\n") + assert.ok(await p.$eval('#server-list .frow[data-path="/test.py"]', (x) => x.classList.contains("is-chosen"))) + assert.ok(await p.isHidden("#viewer"), "choosing is not opening") + await shot(p, "fx-server-1600") + // Open in the viewer from the details, and with a double click. + await p.click("#server-details .btn--primary") + await p.waitForSelector("#viewer:not([hidden])") + assert.equal(await p.textContent("#viewer-name"), "test.py") + assert.deepEqual(p.errors, []) +}) + +test("FX: a folder is listed from the list, the tree or the crumbs; the crumbs say where", async () => { + const p = await page(fx(), { width: 1280, height: 860 }) + await openServerTab(p) + await p.waitForSelector('#server-list .frow[data-path="/home"]') + await p.click('#server-list .frow[data-path="/home"] .frow__open') + await p.waitForSelector(`#server-list .frow[data-path="/home/${LONG}"]`) + assert.deepEqual(await p.$$eval("#server-crumbs > *:not(.crumbs__sep)", (l) => l.map((x) => x.textContent)), ["All files", "home"]) + assert.equal(await p.getAttribute('#server-tree .ftree__row[data-path="/home"]', "aria-current"), "true") + // A long name gives, the rest of its row stays on its tracks and inside the list. + const row = await p.$eval(`#server-list .frow[data-path="/home/${LONG}"]`, (r) => { + const list = document.getElementById("server-list").getBoundingClientRect() + const more = r.querySelector(".frow__when").getBoundingClientRect() + return { right: more.right, list: list.right, label: r.querySelector(".frow__label").scrollWidth > r.querySelector(".frow__label").clientWidth } + }) + assert.ok(row.right <= row.list && row.label, `the long name is cut with an ellipsis, the date inside: ${JSON.stringify(row)}`) + await p.click("#server-crumbs .crumbs__link") + await p.waitForSelector('#server-list .frow[data-path="/test.py"]') + await p.click('#server-tree .ftree__row[data-path="/home"]') + await p.waitForSelector(`#server-list .frow[data-path="/home/${LONG}"]`) + await p.waitForSelector('#server-tree li[data-path="/home/ada"]') + await shot(p, "fx-server-1280") + assert.deepEqual(p.errors, []) +}) + +test("FX: this computer's folder in the same shape, its dates from the system's", async () => { + const p = await page(fx(), { width: 1600, height: 900 }) + await p.evaluate(() => document.getElementById("nav-files").click()) + await p.waitForSelector('#local-list .frow[data-path="README.md"]') + assert.equal(await p.textContent("#local-tree .ftree__root"), "project") + assert.equal(await p.textContent("#local-crumbs .crumbs__here"), "project") + assert.notEqual((await p.textContent('#local-list .frow[data-path="README.md"] .frow__when')).trim(), "") + await p.click('#local-list .frow[data-path="README.md"] .frow__open') + await p.waitForSelector("#local-details .fdetails__text") + assert.equal(await p.textContent("#local-details .fdetails__text"), "# Project\n") + await shot(p, "fx-local-1600") + assert.deepEqual(p.errors, []) +}) + +test("FX: at every width nothing scrolls sideways and nothing is cut off; narrower, a file opens in the viewer", async () => { + for (const [width, height] of [[1920, 1080], [1280, 860], [1024, 768], [800, 700], [390, 844]]) { + const p = await page(fx(), { width, height }) + await openServerTab(p) + await p.waitForSelector('#server-list .frow[data-path="/home"]') + await p.click('#server-list .frow[data-path="/home"] .frow__open') + await p.waitForSelector(`#server-list .frow[data-path="/home/${LONG}"]`) + assert.ok(await noSideways(p, width), `${width}: nothing scrolls sideways`) + // Every visible control of the bar and the rows is inside the window. + const out = await p.$$eval("#pane-server button, #pane-server .frow__size", (l, w) => l.filter((b) => { const r = b.getBoundingClientRect(); return r.width > 0 && (r.left < -1 || r.right > w + 1) }).map((b) => b.className || b.id), width) + assert.deepEqual(out, [], `${width}: controls inside the window`) + // The panes give way by the room the page has (60rem, 40rem of it), not the window's width. + const room = await p.$eval("#pane-server", (x) => x.getBoundingClientRect().width) + const details = await p.isVisible("#server-details") + assert.equal(details, room > 960, `${width}: the details pane at ${room} px`) + assert.equal(await p.isVisible("#server-tree"), room > 640, `${width}: the tree at ${room} px`) + // The name's track never closes: every name shows at least a few letters. + const names = await p.$$eval("#server-list .frow__label", (l) => Math.min(...l.map((x) => x.getBoundingClientRect().width))) + assert.ok(names >= 24, `${width}: names keep room (${names} px)`) + await shot(p, `fx-server-${width}`) + await p.click("#server-crumbs .crumbs__link") + await p.waitForSelector('#server-list .frow[data-path="/test.py"]') + await p.click('#server-list .frow[data-path="/test.py"] .frow__open') + if (details) await p.waitForSelector("#server-details .fdetails__text") + else await p.waitForSelector("#viewer:not([hidden])") + assert.deepEqual(p.errors, []) + await p.context().close() + } +}) + // --- Run ---------------------------------------------------------------------------------------- const only = process.argv[2] || "" diff --git a/ui/app.css b/ui/app.css index aa18656..4adb287 100644 --- a/ui/app.css +++ b/ui/app.css @@ -1414,57 +1414,206 @@ details[open] > summary .step__chev .icon { transform: rotate(90deg); } } .tabs__tab[aria-selected="true"] { color: var(--ink); background: var(--surface-active); } -.files { display: grid; align-content: start; } -.files__pane { display: grid; gap: var(--sp-2); background: var(--surface); border: var(--border-w) solid var(--border); border-radius: var(--radius-soft); padding: var(--sp-3); min-width: 0; } -.files__head { display: grid; grid-template-columns: minmax(0, 1fr) var(--control-h-sm); gap: var(--sp-2); align-items: center; } -.files__where { min-width: 0; } -/* The server's pane: Upload and New folder before Read again. */ -#pane-server .files__head { grid-template-columns: minmax(0, 1fr) repeat(3, var(--control-h-sm)); } -#server-status { white-space: pre-line; } -.files__where select { width: 100%; height: var(--control-h-sm); } -.files__tree { min-height: 0; } -.pane__path { display: block; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; direction: rtl; text-align: left; font: var(--text-xs) var(--font-ui); color: var(--ink-muted); } -.pane__pick { width: 100%; } -.pane__note { padding: var(--sp-2) 0; font-size: var(--text-sm); color: var(--ink-muted); } -.files__tree > .btn { justify-self: start; } - -.tree, .tree ul { list-style: none; margin: 0; padding: 0; } -.tree__row { - width: 100%; - height: var(--control-h-sm); +/* --- Files: two explorers in the web's shape ------------------------------------------------ + The page's whole width under its head, like a file manager: where you are and what can be done + there, then the folder tree, the list and the chosen file's details side by side, each its own + scroller. The list is one grid with a track per column and every row a subgrid of it, so a long + name never pushes the type, size or date out of line: the name's track is the one that gives. + Narrower, the details go first (a file then opens in the viewer), then the tree (the crumbs + are the way up), then the type and date columns. */ +.page__body.files { display: grid; grid-template-rows: minmax(0, 1fr); padding: 0; overflow: hidden; } +.page__body.files > * { max-width: none; margin: 0; } +.fx { display: grid; grid-template-rows: auto auto minmax(0, 1fr); min-width: 0; min-height: 0; container: fx / inline-size; } +.fx > .fx__bar { grid-row: 1; } +.fx > .fx__status { grid-row: 2; } +.fx > .fx__panes { grid-row: 3; } +.fx[hidden] { display: none; } +.fx__bar { display: grid; - grid-template-columns: calc(var(--depth, 0) * var(--sp-4)) 1rem 1rem minmax(0, 1fr) auto; + grid-template-columns: auto minmax(0, 1fr) auto; + grid-template-areas: "where crumbs tools"; + align-items: center; + gap: var(--sp-2); + min-height: var(--header-height); + padding: var(--sp-1) var(--sp-4); + border-bottom: var(--border-w) solid var(--border); + background: var(--surface); +} +.fx__where { grid-area: where; min-width: 0; max-width: 18rem; } +.fx__bar > .crumbs { grid-area: crumbs; } +.fx__where:empty { display: none; } +.fx__tools { grid-area: tools; display: flex; align-items: center; gap: var(--sp-1); } +.fx__status { margin: 0; padding: var(--sp-2) var(--sp-4); border-bottom: var(--border-w) solid var(--border); white-space: pre-line; } +.fx__status[hidden] { display: none; } +.pane__pick { width: 100%; height: var(--control-h-sm); } + +.crumbs { + display: flex; + align-items: center; + gap: var(--sp-1); + min-width: 0; + overflow-x: auto; + white-space: nowrap; + scrollbar-width: none; + font: var(--text-sm) var(--font-mono); +} +.crumbs__sep { color: var(--ink-faint); } +.crumbs__link { + min-height: var(--control-h-sm); + padding: 0 var(--sp-1); + font: inherit; + color: var(--ink-muted); + background: transparent; + border: 0; + border-radius: var(--radius-sm); + cursor: pointer; +} +.crumbs__link:hover { color: var(--ink); background: var(--surface-hover); } +.crumbs__here { padding: 0 var(--sp-1); color: var(--ink); font-weight: 600; } + +.fx__panes { display: grid; grid-template-columns: minmax(11rem, 14rem) minmax(0, 1fr) minmax(14rem, 18rem); min-width: 0; min-height: 0; } +.fx__tree, .fx__list, .fx__details { min-width: 0; min-height: 0; overflow: auto; overscroll-behavior: contain; } +.fx__tree { padding: var(--sp-2); border-right: var(--border-w) solid var(--border); background: var(--surface); } +.fx__list { padding: 0 var(--sp-4) var(--sp-8); container: fxlist / inline-size; } +.fx__details { display: grid; align-content: start; gap: var(--sp-3); padding: var(--sp-4); border-left: var(--border-w) solid var(--border); background: var(--surface); } +.fx[data-empty="true"] .fx__tree, .fx[data-empty="true"] .fx__details { display: none; } +.fx[data-empty="true"] .fx__panes { grid-template-columns: minmax(0, 1fr); } + +/* The folder tree: a chevron opens a folder, its name lists it. */ +.ftree, .ftree ul { list-style: none; margin: 0; padding: 0; } +.ftree__line { display: grid; grid-template-columns: calc(var(--depth, 0) * var(--sp-3)) 1.25rem minmax(0, 1fr); align-items: center; } +.ftree__line::before { content: ""; } +.ftree__chevron { display: grid; place-items: center; width: 1.25rem; height: var(--control-h-sm); padding: 0; color: var(--ink-faint); background: transparent; border: 0; cursor: pointer; } +.ftree__chevron .icon { width: 12px; height: 12px; transition: transform var(--dur-1) var(--ease-out); } +.ftree__chevron[aria-expanded="true"] .icon { transform: rotate(90deg); } +.ftree__row { + display: grid; + grid-template-columns: 1rem minmax(0, 1fr); gap: var(--sp-2); align-items: center; + width: 100%; + min-height: var(--control-h-sm); padding: 0 var(--sp-2); - text-align: left; font: var(--text-sm) var(--font-body); + text-align: left; + color: var(--ink-muted); + background: transparent; + border: 0; + border-radius: var(--radius-sm); + cursor: pointer; +} +.ftree__row:hover { color: var(--ink); background: var(--surface-hover); } +.ftree__row.is-current { color: var(--ink); background: var(--surface-active); font-weight: 600; } +.ftree__root { margin-bottom: var(--sp-1); } +.ftree__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } + +/* The list. */ +.frows { + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(3.5rem, auto) minmax(4.5rem, auto) minmax(6.5rem, auto) var(--control-h-sm); + column-gap: var(--sp-4); +} +.frow { + position: relative; + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + align-items: center; + min-height: var(--row-h); + margin: 0 calc(-1 * var(--sp-2)); + padding: 0 var(--sp-2); + border-bottom: var(--border-w) solid var(--border); + border-radius: var(--radius-sm); +} +.frow:not(.frow--head):hover { background: var(--surface-hover); } +.frow.is-chosen { background: var(--surface-active); } +.frow--head { + position: sticky; + top: 0; + z-index: 1; + background: var(--bg); + color: var(--ink-faint); + font: var(--text-xs) var(--font-ui); + letter-spacing: 0.06em; + text-transform: uppercase; + border-radius: 0; +} +.frow__name { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; } +.frow__open { + display: inline-flex; + align-items: center; + gap: var(--sp-3); + min-width: 0; + min-height: var(--row-h); + padding: 0; + font: var(--text-sm) var(--font-body); + text-align: left; color: var(--ink); background: transparent; border: 0; - border-radius: var(--radius-lg); cursor: pointer; } -/* The indent's track needs an item of its own, or the chevron would sit in it. */ -.tree__row::before { content: ""; } -.tree__row:hover { background: var(--surface-hover); } -.tree__row .icon { color: var(--ink-muted); } -.tree__chevron { display: grid; place-items: center; width: 1rem; } -.tree__chevron .icon { width: 12px; height: 12px; transition: transform var(--dur-1) var(--ease-out); } -.tree__row[aria-expanded="true"] .tree__chevron .icon { transform: rotate(90deg); } -.tree__name { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } -.tree__size { font: var(--text-xs) var(--font-ui); color: var(--ink-faint); } -/* A row with a more button beside it: the button on the row's own line, its folder below both. */ -.tree__item { display: grid; grid-template-columns: minmax(0, 1fr) var(--control-h-sm); align-items: center; } -.tree__item > ul { grid-column: 1 / -1; } -.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); padding-inline-start: calc(var(--sp-3) + var(--depth, 0) * var(--sp-4)); font-size: var(--text-xs); color: var(--ink-muted); } +/* The whole row opens what it names; the buttons at its end stay on top. */ +.frow__open::after { content: ""; position: absolute; inset: 0; border-radius: inherit; } +.frow__open:focus-visible { outline: none; } +.frow__open:focus-visible::after { outline: var(--outline-w) solid var(--accent); outline-offset: calc(-1 * var(--outline-w)); } +.frow__name .badge, .frow__actions { position: relative; z-index: 1; } +.frow__icon { display: inline-flex; color: var(--ink-muted); } +.frow__icon--dir, .frow__icon--mount { color: var(--accent); } +.frow__icon--code, .frow__icon--html { color: var(--leaf); } +.frow__label { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } +.frow__type { font: var(--text-xs) var(--font-mono); color: var(--ink-faint); white-space: nowrap; } +.frow__size, .frow__when { font-size: var(--text-sm); color: var(--ink-muted); white-space: nowrap; text-align: right; } +.frow--head .frow__size, .frow--head .frow__when { font-size: var(--text-xs); } +.frow__actions { display: flex; justify-content: flex-end; } +.frow__more { opacity: 0; } +.frow:hover .frow__more, .frow__more:focus-visible, .frow__more[aria-expanded="true"] { opacity: 1; } +@media (hover: none) { .frow__more { opacity: 1; } } +.badge { padding: 0 var(--sp-1); font: var(--text-xs) var(--font-ui); color: var(--ink-muted); border: var(--border-w) solid var(--border); border-radius: var(--radius-sm); white-space: nowrap; } +.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } +.fx__empty { display: grid; justify-items: center; gap: var(--sp-3); padding: var(--sp-12) var(--sp-4); text-align: center; color: var(--ink-muted); } +.fx__empty .icon { width: 24px; height: 24px; color: var(--leaf); } + +/* The chosen file. */ +.fdetails__none { display: grid; justify-items: center; gap: var(--sp-2); padding: var(--sp-8) 0; text-align: center; color: var(--ink-muted); font-size: var(--text-sm); } +.fdetails__none .icon { width: 24px; height: 24px; color: var(--ink-faint); } +.fdetails__id { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--sp-2); align-items: center; } +.fdetails__name { font: 600 var(--text-base) / var(--leading-tight) var(--font-body); overflow-wrap: anywhere; } +.fdetails__facts { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--sp-1) var(--sp-3); margin: 0; font-size: var(--text-sm); } +.fdetails__facts dt { color: var(--ink-faint); } +.fdetails__facts dd { margin: 0; min-width: 0; overflow-wrap: anywhere; } +.fdetails__do { display: flex; flex-wrap: wrap; gap: var(--sp-2); } +.fdetails__preview { min-width: 0; } +.fdetails__text { margin: 0; max-height: 24rem; overflow: auto; padding: var(--sp-3); font: var(--text-xs) / var(--leading-normal) var(--font-mono); white-space: pre; color: var(--ink); background: var(--bg); border: var(--border-w) solid var(--border); border-radius: var(--radius-sm); } +.fdetails__note { font-size: var(--text-sm); 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; } +.fnote { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-1); padding-inline-start: calc(var(--sp-1) + var(--depth, 0) * var(--sp-3)); font-size: var(--text-sm); color: var(--ink-muted); } +.ftree .fnote { font-size: var(--text-xs); } +.fnote.is-error { color: var(--warning); } +.fnote > span { min-width: 0; overflow-wrap: anywhere; } +.fnote__retry { flex: none; } + +/* By the room the page has, not the window's: the sidebar takes its share first. */ +@container fx (max-width: 60rem) { + .fx__panes { grid-template-columns: minmax(10rem, 14rem) minmax(0, 1fr); } + .fx__details { display: none; } +} +@container fx (max-width: 40rem) { + .fx__panes { grid-template-columns: minmax(0, 1fr); } + .fx__tree { display: none; } + .fx__bar { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "where where" "crumbs tools"; padding: var(--sp-1) var(--sp-3); } + .fx__where { max-width: none; } + .fx__list { padding: 0 var(--sp-3) var(--sp-8); } +} +/* A list narrower than its columns keeps the name and the size; the type and date go. */ +@container fxlist (max-width: 28rem) { + .frows { grid-template-columns: minmax(0, 1fr) auto var(--control-h-sm); column-gap: var(--sp-2); } + .frows .frow__type, .frows .frow__when { display: none; } +} +@media (max-width: 48rem) { + .frow, .frow__open { min-height: var(--tap-min); } +} /* 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 9e8a1e3..4ed37bb 100644 --- a/ui/i18n.js +++ b/ui/i18n.js @@ -47,6 +47,7 @@ const LEMBAS_I18N = { "A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Úloha bez vlastního modelu běží na prvním volném z těchto, v tomto pořadí. Žádný nezaškrtnutý: všechny.", "About": "O aplikaci", "Account": "Účet", + "Actions": "Akce", "Activity": "Aktivita", "Add": "Přidat", "Add folder…": "Přidat složku…", @@ -54,6 +55,7 @@ const LEMBAS_I18N = { "Agent": "Agent", "Agent chats can work in these folders and anything inside them — nowhere else on this computer.": "Agentní konverzace mohou pracovat v těchto složkách a ve všem uvnitř nich — nikde jinde na tomto počítači.", "Agent: %(why)s": "Agent: %(why)s", + "All files": "Všechny soubory", "Allow": "Povolit", "Always": "Vždy", "Always allow": "Vždy povolit", @@ -77,10 +79,12 @@ const LEMBAS_I18N = { "Background tasks": "Úlohy na pozadí", "Cancel": "Zrušit", "Carry it out": "Provést to", + "Changed": "Změněno", "Chat": "Konverzace", "Chats": "Konverzace", "Check for updates…": "Zkontrolovat aktualizace…", "Choose": "Vybrat", + "Choose a file to see it here.": "Vyberte soubor a uvidíte ho tady.", "Choose a folder first.": "Nejprve vyberte složku.", "Choose a folder…": "Vybrat složku…", "Choose a model to choose its effort.": "Vyberte model, abyste mohli zvolit jeho náročnost.", @@ -101,6 +105,7 @@ const LEMBAS_I18N = { "Connected.": "Připojeno.", "Connected. This shell is also open in another tab, and they share a size.": "Připojeno. Tento shell je otevřený i na jiné kartě a sdílejí velikost.", "Connecting…": "Připojuje se…", + "Contents": "Obsah", "Context": "Kontext", "Context and background tasks": "Kontext a úlohy na pozadí", "Context used": "Využitý kontext", @@ -122,6 +127,7 @@ const LEMBAS_I18N = { "Delete this chat?": "Smazat tuto konverzaci?", "Delete this scheduled task?": "Odstranit tuto naplánovanou úlohu?", "Deny": "Zamítnout", + "Details": "Podrobnosti", "Dictate a message": "Nadiktovat zprávu", "Disconnected. Close and reopen to reconnect.": "Odpojeno. Pro opětovné připojení zavřete a znovu otevřete.", "Done": "Hotovo", @@ -155,10 +161,12 @@ const LEMBAS_I18N = { "Folder name": "Název složky", "Folder on %(device)s": "Složka na zařízení %(device)s", "Folder on the computer": "Složka na počítači", + "Folders": "Složky", "Free space": "Volné místo", "Hello": "Dobrý den", "Hello, %(name)s": "Dobrý den, %(name)s", "History": "Historie", + "Home": "Domů", "How many work on it at once, the run's own agent among them, on the same model.": "Kolik jich na ní pracuje současně, včetně vlastního agenta běhu, na stejném modelu.", "In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "Vlastními slovy. Server z nich vytvoří pravidlo a pokyn a před uložením vám ukáže další spuštění.", "Install LLeMbas %(version)s…": "Nainstalovat LLeMbas %(version)s…", @@ -197,10 +205,12 @@ const LEMBAS_I18N = { "Models": "Modely", "Models for scheduled tasks": "Modely pro naplánované úlohy", "More": "Více", + "More for %(name)s": "Další pro %(name)s", "Move to folder…": "Přesunout do složky…", "Move to the trash": "Přesunout do koše", "Move “%(name)s” to the trash? It can be restored from there.": "Přesunout „%(name)s“ do koše? Odtamtud ho lze obnovit.", "Moved to the trash.": "Přesunuto do koše.", + "Name": "Název", "Never": "Nikdy", "New agent chat": "Nová agentní konverzace", "New chat": "Nová konverzace", @@ -225,6 +235,7 @@ const LEMBAS_I18N = { "Noted by a model": "Zaznamenal model", "Nothing has been run in this shell yet.": "V tomto shellu se zatím nic nespustilo.", "Nothing has been said in the room yet.": "V místnosti zatím nikdo nic neřekl.", + "Nothing here yet. Not even a hobbit hole.": "Zatím tu nic není. Ani hobití nora.", "Nothing is scheduled.": "Nic není naplánováno.", "Nothing matches.": "Nic neodpovídá.", "Nothing remembered yet.": "Zatím si nic nepamatuje.", @@ -250,6 +261,7 @@ const LEMBAS_I18N = { "Or type an answer": "Nebo napište odpověď", "Outputs": "Výstupy", "Page": "Stránka", + "Path": "Cesta", "Pause": "Pozastavit", "Pause %(title)s": "Pozastavit %(title)s", "Paused": "Pozastaveno", @@ -316,6 +328,7 @@ const LEMBAS_I18N = { "Signed in as %(who)s.": "Přihlášen jako %(who)s.", "Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Odhlášením se přihlášení aplikace na serveru zruší; propojení agenta zůstane. Chcete-li použít jiný server, odhlaste se.", "Since the last reply": "Od poslední odpovědi", + "Size": "Velikost", "Skills": "Dovednosti", "Skipped": "Vynecháno", "Something to remember…": "Něco k zapamatování…", @@ -412,6 +425,7 @@ const LEMBAS_I18N = { "Tools": "Nástroje", "Tools and skills…": "Nástroje a dovednosti…", "Try again": "Zkusit znovu", + "Type": "Typ", "Type a command or a chat's title": "Napište příkaz nebo název konverzace", "Type a folder…": "Zadat složku…", "Unpin chat": "Odepnout konverzaci", @@ -433,6 +447,7 @@ const LEMBAS_I18N = { "When": "Kdy", "When it matters": "Když na tom záleží", "Where it runs": "Kde se spouští", + "Where you are": "Kde jste", "Wide": "Široký", "Writing": "Píše", "Writing down…": "Zapisuji…", @@ -468,6 +483,7 @@ const LEMBAS_I18N = { "failed": "selhalo", "findings: %(n)s": "zjištění: %(n)s", "finished": "dokončený", + "folder": "složka", "free": "volný", "idle": "nečinný", "it serves one request at a time and is answering another": "vyřizuje jeden požadavek najednou a právě vyřizuje jiný", @@ -475,6 +491,7 @@ const LEMBAS_I18N = { "late": "se zpožděním", "light": "světlý", "messages: %(n)s": "zprávy: %(n)s", + "mount": "připojení", "not linked": "nepropojeno", "not linked now": "teď není propojený", "off": "vypnuto", @@ -534,6 +551,7 @@ const LEMBAS_I18N = { "A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Eine Aufgabe ohne eigenes Modell läuft auf dem ersten freien davon, in dieser Reihenfolge. Keines angehakt: alle.", "About": "Über", "Account": "Konto", + "Actions": "Aktionen", "Activity": "Aktivität", "Add": "Hinzufügen", "Add folder…": "Ordner hinzufügen…", @@ -541,6 +559,7 @@ const LEMBAS_I18N = { "Agent": "Agent", "Agent chats can work in these folders and anything inside them — nowhere else on this computer.": "Agenten-Chats können in diesen Ordnern und allem darin arbeiten — nirgendwo sonst auf diesem Computer.", "Agent: %(why)s": "Agent: %(why)s", + "All files": "Alle Dateien", "Allow": "Erlauben", "Always": "Immer", "Always allow": "Immer erlauben", @@ -564,10 +583,12 @@ const LEMBAS_I18N = { "Background tasks": "Hintergrundaufgaben", "Cancel": "Abbrechen", "Carry it out": "Ausführen", + "Changed": "Geändert", "Chat": "Chat", "Chats": "Chats", "Check for updates…": "Nach Updates suchen…", "Choose": "Auswählen", + "Choose a file to see it here.": "Wählen Sie eine Datei, um sie hier zu sehen.", "Choose a folder first.": "Wählen Sie zuerst einen Ordner.", "Choose a folder…": "Ordner wählen…", "Choose a model to choose its effort.": "Wählen Sie ein Modell, um seinen Aufwand zu wählen.", @@ -588,6 +609,7 @@ const LEMBAS_I18N = { "Connected.": "Verbunden.", "Connected. This shell is also open in another tab, and they share a size.": "Verbunden. Diese Shell ist auch in einem anderen Tab offen, und beide teilen sich eine Größe.", "Connecting…": "Verbinde…", + "Contents": "Inhalt", "Context": "Kontext", "Context and background tasks": "Kontext und Hintergrundaufgaben", "Context used": "Genutzter Kontext", @@ -609,6 +631,7 @@ const LEMBAS_I18N = { "Delete this chat?": "Diesen Chat löschen?", "Delete this scheduled task?": "Diese geplante Aufgabe löschen?", "Deny": "Ablehnen", + "Details": "Details", "Dictate a message": "Nachricht diktieren", "Disconnected. Close and reopen to reconnect.": "Getrennt. Schließen und erneut öffnen, um neu zu verbinden.", "Done": "Fertig", @@ -642,10 +665,12 @@ const LEMBAS_I18N = { "Folder name": "Ordnername", "Folder on %(device)s": "Ordner auf %(device)s", "Folder on the computer": "Ordner auf dem Computer", + "Folders": "Ordner", "Free space": "Freier Platz", "Hello": "Hallo", "Hello, %(name)s": "Hallo, %(name)s", "History": "Verlauf", + "Home": "Start", "How many work on it at once, the run's own agent among them, on the same model.": "Wie viele gleichzeitig daran arbeiten, der eigene Agent des Laufs mitgezählt, mit demselben Modell.", "In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "In Ihren eigenen Worten. Der Server macht daraus eine Regel und eine Anweisung und zeigt Ihnen die nächsten Läufe, bevor etwas gespeichert wird.", "Install LLeMbas %(version)s…": "LLeMbas %(version)s installieren…", @@ -684,10 +709,12 @@ const LEMBAS_I18N = { "Models": "Modelle", "Models for scheduled tasks": "Modelle für geplante Aufgaben", "More": "Mehr", + "More for %(name)s": "Mehr für %(name)s", "Move to folder…": "In Ordner verschieben…", "Move to the trash": "In den Papierkorb", "Move “%(name)s” to the trash? It can be restored from there.": "„%(name)s“ in den Papierkorb verschieben? Von dort lässt es sich wiederherstellen.", "Moved to the trash.": "In den Papierkorb verschoben.", + "Name": "Name", "Never": "Nie", "New agent chat": "Neuer Agenten-Chat", "New chat": "Neuer Chat", @@ -712,6 +739,7 @@ const LEMBAS_I18N = { "Noted by a model": "Von einem Modell notiert", "Nothing has been run in this shell yet.": "In dieser Shell wurde noch nichts ausgeführt.", "Nothing has been said in the room yet.": "Im Raum wurde noch nichts gesagt.", + "Nothing here yet. Not even a hobbit hole.": "Hier ist noch nichts. Nicht einmal eine Hobbithöhle.", "Nothing is scheduled.": "Nichts ist geplant.", "Nothing matches.": "Nichts gefunden.", "Nothing remembered yet.": "Noch nichts behalten.", @@ -737,6 +765,7 @@ const LEMBAS_I18N = { "Or type an answer": "Oder eine Antwort eingeben", "Outputs": "Ausgaben", "Page": "Seite", + "Path": "Pfad", "Pause": "Pausieren", "Pause %(title)s": "%(title)s pausieren", "Paused": "Pausiert", @@ -803,6 +832,7 @@ const LEMBAS_I18N = { "Signed in as %(who)s.": "Angemeldet als %(who)s.", "Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Beim Abmelden gibt die App ihre Anmeldung am Server auf; die Verbindung des Agenten bleibt. Melden Sie sich ab, um einen anderen Server zu verwenden.", "Since the last reply": "Seit der letzten Antwort", + "Size": "Größe", "Skills": "Fertigkeiten", "Skipped": "Ausgelassen", "Something to remember…": "Etwas, das behalten werden soll…", @@ -899,6 +929,7 @@ const LEMBAS_I18N = { "Tools": "Werkzeuge", "Tools and skills…": "Werkzeuge und Fähigkeiten…", "Try again": "Erneut versuchen", + "Type": "Typ", "Type a command or a chat's title": "Befehl oder Chat-Titel eingeben", "Type a folder…": "Ordner eingeben…", "Unpin chat": "Chat loslösen", @@ -920,6 +951,7 @@ const LEMBAS_I18N = { "When": "Wann", "When it matters": "Wenn es zählt", "Where it runs": "Wo es läuft", + "Where you are": "Wo Sie sind", "Wide": "Breit", "Writing": "Schreibe", "Writing down…": "Wird aufgeschrieben…", @@ -955,6 +987,7 @@ const LEMBAS_I18N = { "failed": "fehlgeschlagen", "findings: %(n)s": "Erkenntnisse: %(n)s", "finished": "fertig", + "folder": "Ordner", "free": "frei", "idle": "untätig", "it serves one request at a time and is answering another": "es bedient eine Anfrage auf einmal und beantwortet gerade eine andere", @@ -962,6 +995,7 @@ const LEMBAS_I18N = { "late": "verspätet", "light": "hell", "messages: %(n)s": "Nachrichten: %(n)s", + "mount": "Einhängung", "not linked": "nicht verbunden", "not linked now": "gerade nicht verbunden", "off": "aus", @@ -1021,6 +1055,7 @@ const LEMBAS_I18N = { "A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Una tarea sin modelo propio se ejecuta en el primero libre de estos, en este orden. Ninguno marcado: todos.", "About": "Acerca de", "Account": "Cuenta", + "Actions": "Acciones", "Activity": "Actividad", "Add": "Añadir", "Add folder…": "Añadir carpeta…", @@ -1028,6 +1063,7 @@ const LEMBAS_I18N = { "Agent": "Agente", "Agent chats can work in these folders and anything inside them — nowhere else on this computer.": "Los chats de agente pueden trabajar en estas carpetas y en todo lo que contienen — en ningún otro lugar de este equipo.", "Agent: %(why)s": "Agente: %(why)s", + "All files": "Todos los archivos", "Allow": "Permitir", "Always": "Siempre", "Always allow": "Permitir siempre", @@ -1051,10 +1087,12 @@ const LEMBAS_I18N = { "Background tasks": "Tareas en segundo plano", "Cancel": "Cancelar", "Carry it out": "Llevarlo a cabo", + "Changed": "Cambiado", "Chat": "Chat", "Chats": "Chats", "Check for updates…": "Buscar actualizaciones…", "Choose": "Elegir", + "Choose a file to see it here.": "Elige un archivo para verlo aquí.", "Choose a folder first.": "Elija primero una carpeta.", "Choose a folder…": "Elegir una carpeta…", "Choose a model to choose its effort.": "Elija un modelo para elegir su esfuerzo.", @@ -1075,6 +1113,7 @@ const LEMBAS_I18N = { "Connected.": "Conectado.", "Connected. This shell is also open in another tab, and they share a size.": "Conectado. Este shell también está abierto en otra pestaña, y comparten tamaño.", "Connecting…": "Conectando…", + "Contents": "Contenido", "Context": "Contexto", "Context and background tasks": "Contexto y tareas en segundo plano", "Context used": "Contexto usado", @@ -1096,6 +1135,7 @@ const LEMBAS_I18N = { "Delete this chat?": "¿Eliminar este chat?", "Delete this scheduled task?": "¿Eliminar esta tarea programada?", "Deny": "Denegar", + "Details": "Detalles", "Dictate a message": "Dictar un mensaje", "Disconnected. Close and reopen to reconnect.": "Desconectado. Cierre y vuelva a abrir para reconectar.", "Done": "Hecho", @@ -1129,10 +1169,12 @@ const LEMBAS_I18N = { "Folder name": "Nombre de la carpeta", "Folder on %(device)s": "Carpeta en %(device)s", "Folder on the computer": "Carpeta en el ordenador", + "Folders": "Carpetas", "Free space": "Espacio libre", "Hello": "Hola", "Hello, %(name)s": "Hola, %(name)s", "History": "Historial", + "Home": "Inicio", "How many work on it at once, the run's own agent among them, on the same model.": "Cuántos trabajan en ella a la vez, contando el propio agente de la ejecución, con el mismo modelo.", "In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "Con sus propias palabras. El servidor las convierte en una regla y una instrucción, y le muestra las próximas ejecuciones antes de guardar nada.", "Install LLeMbas %(version)s…": "Instalar LLeMbas %(version)s…", @@ -1171,10 +1213,12 @@ const LEMBAS_I18N = { "Models": "Modelos", "Models for scheduled tasks": "Modelos para tareas programadas", "More": "Más", + "More for %(name)s": "Más para %(name)s", "Move to folder…": "Mover a una carpeta…", "Move to the trash": "Mover a la papelera", "Move “%(name)s” to the trash? It can be restored from there.": "¿Mover «%(name)s» a la papelera? Se puede restaurar desde allí.", "Moved to the trash.": "Movido a la papelera.", + "Name": "Nombre", "Never": "Nunca", "New agent chat": "Nuevo chat de agente", "New chat": "Nuevo chat", @@ -1199,6 +1243,7 @@ const LEMBAS_I18N = { "Noted by a model": "Anotado por un modelo", "Nothing has been run in this shell yet.": "Aún no se ha ejecutado nada en este shell.", "Nothing has been said in the room yet.": "Todavía no se ha dicho nada en la sala.", + "Nothing here yet. Not even a hobbit hole.": "Aquí aún no hay nada. Ni siquiera un agujero hobbit.", "Nothing is scheduled.": "No hay nada programado.", "Nothing matches.": "No hay coincidencias.", "Nothing remembered yet.": "Todavía no se recuerda nada.", @@ -1224,6 +1269,7 @@ const LEMBAS_I18N = { "Or type an answer": "O escriba una respuesta", "Outputs": "Resultados", "Page": "Página", + "Path": "Ruta", "Pause": "Pausar", "Pause %(title)s": "Pausar %(title)s", "Paused": "En pausa", @@ -1290,6 +1336,7 @@ const LEMBAS_I18N = { "Signed in as %(who)s.": "Sesión iniciada como %(who)s.", "Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Al cerrar sesión, la aplicación renuncia a su acceso en el servidor; la vinculación del agente se mantiene. Cierre sesión para usar otro servidor.", "Since the last reply": "Desde la última respuesta", + "Size": "Tamaño", "Skills": "Habilidades", "Skipped": "Omitida", "Something to remember…": "Algo que recordar…", @@ -1386,6 +1433,7 @@ const LEMBAS_I18N = { "Tools": "Herramientas", "Tools and skills…": "Herramientas y habilidades…", "Try again": "Reintentar", + "Type": "Tipo", "Type a command or a chat's title": "Escriba un comando o el título de un chat", "Type a folder…": "Escribir una carpeta…", "Unpin chat": "Dejar de fijar el chat", @@ -1407,6 +1455,7 @@ const LEMBAS_I18N = { "When": "Cuándo", "When it matters": "Cuando importa", "Where it runs": "Dónde se ejecuta", + "Where you are": "Dónde está", "Wide": "Ancha", "Writing": "Escribiendo", "Writing down…": "Transcribiendo…", @@ -1442,6 +1491,7 @@ const LEMBAS_I18N = { "failed": "falló", "findings: %(n)s": "hallazgos: %(n)s", "finished": "terminado", + "folder": "carpeta", "free": "libre", "idle": "inactivo", "it serves one request at a time and is answering another": "atiende una petición a la vez y está respondiendo otra", @@ -1449,6 +1499,7 @@ const LEMBAS_I18N = { "late": "con retraso", "light": "claro", "messages: %(n)s": "mensajes: %(n)s", + "mount": "montaje", "not linked": "no vinculado", "not linked now": "no vinculado ahora", "off": "desactivado", @@ -1508,6 +1559,7 @@ const LEMBAS_I18N = { "A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Une tâche sans modèle à elle tourne sur le premier libre de ceux-ci, dans cet ordre. Aucun coché : tous.", "About": "À propos", "Account": "Compte", + "Actions": "Actions", "Activity": "Activité", "Add": "Ajouter", "Add folder…": "Ajouter un dossier…", @@ -1515,6 +1567,7 @@ const LEMBAS_I18N = { "Agent": "Agent", "Agent chats can work in these folders and anything inside them — nowhere else on this computer.": "Les discussions d'agent peuvent travailler dans ces dossiers et tout ce qu'ils contiennent — nulle part ailleurs sur cet ordinateur.", "Agent: %(why)s": "Agent : %(why)s", + "All files": "Tous les fichiers", "Allow": "Autoriser", "Always": "Toujours", "Always allow": "Toujours autoriser", @@ -1538,10 +1591,12 @@ const LEMBAS_I18N = { "Background tasks": "Tâches en arrière-plan", "Cancel": "Annuler", "Carry it out": "Exécuter", + "Changed": "Modifié", "Chat": "Conversation", "Chats": "Conversations", "Check for updates…": "Rechercher des mises à jour…", "Choose": "Choisir", + "Choose a file to see it here.": "Choisissez un fichier pour le voir ici.", "Choose a folder first.": "Choisissez d’abord un dossier.", "Choose a folder…": "Choisir un dossier…", "Choose a model to choose its effort.": "Choisissez un modèle pour choisir son effort.", @@ -1562,6 +1617,7 @@ const LEMBAS_I18N = { "Connected.": "Connecté.", "Connected. This shell is also open in another tab, and they share a size.": "Connecté. Ce shell est aussi ouvert dans un autre onglet, et ils partagent une taille.", "Connecting…": "Connexion…", + "Contents": "Contenu", "Context": "Contexte", "Context and background tasks": "Contexte et tâches en arrière-plan", "Context used": "Contexte utilisé", @@ -1583,6 +1639,7 @@ const LEMBAS_I18N = { "Delete this chat?": "Supprimer cette conversation ?", "Delete this scheduled task?": "Supprimer cette tâche planifiée ?", "Deny": "Refuser", + "Details": "Détails", "Dictate a message": "Dicter un message", "Disconnected. Close and reopen to reconnect.": "Déconnecté. Fermez et rouvrez pour vous reconnecter.", "Done": "Terminé", @@ -1616,10 +1673,12 @@ const LEMBAS_I18N = { "Folder name": "Nom du dossier", "Folder on %(device)s": "Dossier sur %(device)s", "Folder on the computer": "Dossier sur l’ordinateur", + "Folders": "Dossiers", "Free space": "Espace libre", "Hello": "Bonjour", "Hello, %(name)s": "Bonjour, %(name)s", "History": "Historique", + "Home": "Accueil", "How many work on it at once, the run's own agent among them, on the same model.": "Combien y travaillent à la fois, l’agent de l’exécution compris, avec le même modèle.", "In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "Avec vos propres mots. Le serveur en fait une règle et une instruction, et vous montre les prochaines exécutions avant que rien ne soit enregistré.", "Install LLeMbas %(version)s…": "Installer LLeMbas %(version)s…", @@ -1658,10 +1717,12 @@ const LEMBAS_I18N = { "Models": "Modèles", "Models for scheduled tasks": "Modèles pour les tâches planifiées", "More": "Plus", + "More for %(name)s": "Plus pour %(name)s", "Move to folder…": "Déplacer dans un dossier…", "Move to the trash": "Placer dans la corbeille", "Move “%(name)s” to the trash? It can be restored from there.": "Placer « %(name)s » dans la corbeille ? On peut l'y restaurer.", "Moved to the trash.": "Placé dans la corbeille.", + "Name": "Nom", "Never": "Jamais", "New agent chat": "Nouvelle discussion d'agent", "New chat": "Nouvelle conversation", @@ -1686,6 +1747,7 @@ const LEMBAS_I18N = { "Noted by a model": "Noté par un modèle", "Nothing has been run in this shell yet.": "Rien n'a encore été exécuté dans ce shell.", "Nothing has been said in the room yet.": "Rien n’a encore été dit dans la salle.", + "Nothing here yet. Not even a hobbit hole.": "Rien ici pour l'instant. Pas même un trou de hobbit.", "Nothing is scheduled.": "Rien n’est planifié.", "Nothing matches.": "Aucun résultat.", "Nothing remembered yet.": "Rien n’est encore retenu.", @@ -1711,6 +1773,7 @@ const LEMBAS_I18N = { "Or type an answer": "Ou saisissez une réponse", "Outputs": "Sorties", "Page": "Page", + "Path": "Chemin", "Pause": "Mettre en pause", "Pause %(title)s": "Mettre %(title)s en pause", "Paused": "En pause", @@ -1777,6 +1840,7 @@ const LEMBAS_I18N = { "Signed in as %(who)s.": "Connecté en tant que %(who)s.", "Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "La déconnexion abandonne la connexion de l'application au serveur ; la liaison de l'agent reste. Déconnectez-vous pour utiliser un autre serveur.", "Since the last reply": "Depuis la dernière réponse", + "Size": "Taille", "Skills": "Compétences", "Skipped": "Ignorée", "Something to remember…": "Quelque chose à retenir…", @@ -1873,6 +1937,7 @@ const LEMBAS_I18N = { "Tools": "Outils", "Tools and skills…": "Outils et compétences…", "Try again": "Réessayer", + "Type": "Type", "Type a command or a chat's title": "Tapez une commande ou le titre d'une conversation", "Type a folder…": "Saisir un dossier…", "Unpin chat": "Désépingler la discussion", @@ -1894,6 +1959,7 @@ const LEMBAS_I18N = { "When": "Quand", "When it matters": "Quand c'est important", "Where it runs": "Où elle s’exécute", + "Where you are": "Où vous êtes", "Wide": "Large", "Writing": "Écriture", "Writing down…": "Transcription…", @@ -1929,6 +1995,7 @@ const LEMBAS_I18N = { "failed": "échoué", "findings: %(n)s": "constats : %(n)s", "finished": "terminé", + "folder": "dossier", "free": "libre", "idle": "inactif", "it serves one request at a time and is answering another": "il traite une requête à la fois et en traite une autre", @@ -1936,6 +2003,7 @@ const LEMBAS_I18N = { "late": "en retard", "light": "clair", "messages: %(n)s": "messages : %(n)s", + "mount": "montage", "not linked": "non relié", "not linked now": "non relié pour l’instant", "off": "désactivé", @@ -1995,6 +2063,7 @@ const LEMBAS_I18N = { "A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "A saját modell nélküli feladat ezek közül az első szabadon fut, ebben a sorrendben. Ha egy sincs bejelölve: mindegyiken.", "About": "Névjegy", "Account": "Fiók", + "Actions": "Műveletek", "Activity": "Tevékenység", "Add": "Hozzáadás", "Add folder…": "Mappa hozzáadása…", @@ -2002,6 +2071,7 @@ const LEMBAS_I18N = { "Agent": "Ügynök", "Agent chats can work in these folders and anything inside them — nowhere else on this computer.": "Az ügynökcsevegések ezekben a mappákban és mindenben, ami bennük van, dolgozhatnak — sehol máshol ezen a számítógépen.", "Agent: %(why)s": "Ügynök: %(why)s", + "All files": "Minden fájl", "Allow": "Engedélyezés", "Always": "Mindig", "Always allow": "Mindig engedélyez", @@ -2025,10 +2095,12 @@ const LEMBAS_I18N = { "Background tasks": "Háttérfeladatok", "Cancel": "Mégse", "Carry it out": "Végrehajtás", + "Changed": "Módosítva", "Chat": "Csevegés", "Chats": "Csevegések", "Check for updates…": "Frissítések keresése…", "Choose": "Kiválasztás", + "Choose a file to see it here.": "Válasszon egy fájlt, hogy itt lássa.", "Choose a folder first.": "Először válasszon mappát.", "Choose a folder…": "Mappa kiválasztása…", "Choose a model to choose its effort.": "Válasszon modellt az erőfeszítés megadásához.", @@ -2049,6 +2121,7 @@ const LEMBAS_I18N = { "Connected.": "Kapcsolódva.", "Connected. This shell is also open in another tab, and they share a size.": "Kapcsolódva. Ez a shell egy másik lapon is nyitva van, és közös a méretük.", "Connecting…": "Kapcsolódás…", + "Contents": "Tartalom", "Context": "Kontextus", "Context and background tasks": "Kontextus és háttérfeladatok", "Context used": "Felhasznált kontextus", @@ -2070,6 +2143,7 @@ const LEMBAS_I18N = { "Delete this chat?": "Törli ezt a csevegést?", "Delete this scheduled task?": "Törli ezt az ütemezett feladatot?", "Deny": "Elutasítás", + "Details": "Részletek", "Dictate a message": "Üzenet diktálása", "Disconnected. Close and reopen to reconnect.": "Kapcsolat bontva. Az újrakapcsolódáshoz zárja be és nyissa meg újra.", "Done": "Kész", @@ -2103,10 +2177,12 @@ const LEMBAS_I18N = { "Folder name": "Mappa neve", "Folder on %(device)s": "Mappa ezen: %(device)s", "Folder on the computer": "Mappa a számítógépen", + "Folders": "Mappák", "Free space": "Szabad hely", "Hello": "Üdv", "Hello, %(name)s": "Üdv, %(name)s", "History": "Előzmények", + "Home": "Kezdőlap", "How many work on it at once, the run's own agent among them, on the same model.": "Hányan dolgoznak rajta egyszerre, a futás saját ügynökét is beleértve, ugyanazon a modellen.", "In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "A saját szavaival. A szerver szabályt és utasítást készít belőlük, és mentés előtt megmutatja a következő futásokat.", "Install LLeMbas %(version)s…": "LLeMbas %(version)s telepítése…", @@ -2145,10 +2221,12 @@ const LEMBAS_I18N = { "Models": "Modellek", "Models for scheduled tasks": "Modellek az ütemezett feladatokhoz", "More": "Több", + "More for %(name)s": "Továbbiak: %(name)s", "Move to folder…": "Áthelyezés mappába…", "Move to the trash": "Áthelyezés a lomtárba", "Move “%(name)s” to the trash? It can be restored from there.": "Áthelyezi a lomtárba: „%(name)s”? Onnan visszaállítható.", "Moved to the trash.": "A lomtárba került.", + "Name": "Név", "Never": "Soha", "New agent chat": "Új ügynökcsevegés", "New chat": "Új csevegés", @@ -2173,6 +2251,7 @@ const LEMBAS_I18N = { "Noted by a model": "Egy modell jegyezte fel", "Nothing has been run in this shell yet.": "Ebben a shellben még semmi sem futott.", "Nothing has been said in the room yet.": "A szobában még senki nem mondott semmit.", + "Nothing here yet. Not even a hobbit hole.": "Itt még nincs semmi. Még egy hobbitlyuk sem.", "Nothing is scheduled.": "Nincs semmi ütemezve.", "Nothing matches.": "Nincs találat.", "Nothing remembered yet.": "Még nincs megjegyezve semmi.", @@ -2198,6 +2277,7 @@ const LEMBAS_I18N = { "Or type an answer": "Vagy írjon választ", "Outputs": "Kimenetek", "Page": "Oldal", + "Path": "Útvonal", "Pause": "Szüneteltetés", "Pause %(title)s": "%(title)s szüneteltetése", "Paused": "Szüneteltetve", @@ -2264,6 +2344,7 @@ const LEMBAS_I18N = { "Signed in as %(who)s.": "Bejelentkezve: %(who)s.", "Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Kijelentkezéskor az alkalmazás lemond a szerveren lévő bejelentkezéséről; az ügynök kapcsolata megmarad. Más szerver használatához jelentkezzen ki.", "Since the last reply": "Az utolsó válasz óta", + "Size": "Méret", "Skills": "Készségek", "Skipped": "Kihagyva", "Something to remember…": "Valami, amit meg kell jegyezni…", @@ -2360,6 +2441,7 @@ const LEMBAS_I18N = { "Tools": "Eszközök", "Tools and skills…": "Eszközök és készségek…", "Try again": "Újrapróbálás", + "Type": "Típus", "Type a command or a chat's title": "Írjon be egy parancsot vagy egy csevegés címét", "Type a folder…": "Mappa megadása…", "Unpin chat": "Kitűzés megszüntetése", @@ -2381,6 +2463,7 @@ const LEMBAS_I18N = { "When": "Mikor", "When it matters": "Ha számít", "Where it runs": "Hol fut", + "Where you are": "Hol van", "Wide": "Széles", "Writing": "Írás", "Writing down…": "Leírás…", @@ -2416,6 +2499,7 @@ const LEMBAS_I18N = { "failed": "sikertelen", "findings: %(n)s": "megállapítások: %(n)s", "finished": "kész", + "folder": "mappa", "free": "szabad", "idle": "tétlen", "it serves one request at a time and is answering another": "egyszerre egy kérést szolgál ki, és éppen egy másikat válaszol meg", @@ -2423,6 +2507,7 @@ const LEMBAS_I18N = { "late": "késve", "light": "világos", "messages: %(n)s": "üzenetek: %(n)s", + "mount": "csatolás", "not linked": "nincs összekapcsolva", "not linked now": "most nincs összekapcsolva", "off": "ki", @@ -2482,6 +2567,7 @@ const LEMBAS_I18N = { "A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Un’attività senza un modello proprio gira sul primo libero di questi, in quest’ordine. Nessuno spuntato: tutti.", "About": "Informazioni", "Account": "Account", + "Actions": "Azioni", "Activity": "Attività", "Add": "Aggiungi", "Add folder…": "Aggiungi cartella…", @@ -2489,6 +2575,7 @@ const LEMBAS_I18N = { "Agent": "Agente", "Agent chats can work in these folders and anything inside them — nowhere else on this computer.": "Le chat con agente possono lavorare in queste cartelle e in tutto ciò che contengono — in nessun altro punto di questo computer.", "Agent: %(why)s": "Agente: %(why)s", + "All files": "Tutti i file", "Allow": "Consenti", "Always": "Sempre", "Always allow": "Consenti sempre", @@ -2512,10 +2599,12 @@ const LEMBAS_I18N = { "Background tasks": "Attività in background", "Cancel": "Annulla", "Carry it out": "Eseguilo", + "Changed": "Modificato", "Chat": "Chat", "Chats": "Chat", "Check for updates…": "Cerca aggiornamenti…", "Choose": "Scegli", + "Choose a file to see it here.": "Scegli un file per vederlo qui.", "Choose a folder first.": "Scegli prima una cartella.", "Choose a folder…": "Scegli una cartella…", "Choose a model to choose its effort.": "Scegli un modello per sceglierne l’impegno.", @@ -2536,6 +2625,7 @@ const LEMBAS_I18N = { "Connected.": "Connesso.", "Connected. This shell is also open in another tab, and they share a size.": "Connesso. Questa shell è aperta anche in un'altra scheda, e le due condividono la dimensione.", "Connecting…": "Connessione in corso…", + "Contents": "Contenuto", "Context": "Contesto", "Context and background tasks": "Contesto e attività in background", "Context used": "Contesto usato", @@ -2557,6 +2647,7 @@ const LEMBAS_I18N = { "Delete this chat?": "Eliminare questa chat?", "Delete this scheduled task?": "Eliminare questa attività pianificata?", "Deny": "Nega", + "Details": "Dettagli", "Dictate a message": "Detta un messaggio", "Disconnected. Close and reopen to reconnect.": "Disconnesso. Chiudi e riapri per riconnetterti.", "Done": "Fatto", @@ -2590,10 +2681,12 @@ const LEMBAS_I18N = { "Folder name": "Nome della cartella", "Folder on %(device)s": "Cartella su %(device)s", "Folder on the computer": "Cartella sul computer", + "Folders": "Cartelle", "Free space": "Spazio libero", "Hello": "Ciao", "Hello, %(name)s": "Ciao, %(name)s", "History": "Cronologia", + "Home": "Home", "How many work on it at once, the run's own agent among them, on the same model.": "Quanti ci lavorano alla volta, compreso l’agente dell’esecuzione, sullo stesso modello.", "In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "Con parole tue. Il server ne ricava una regola e un’istruzione e ti mostra le prossime esecuzioni prima di salvare.", "Install LLeMbas %(version)s…": "Installa LLeMbas %(version)s…", @@ -2632,10 +2725,12 @@ const LEMBAS_I18N = { "Models": "Modelli", "Models for scheduled tasks": "Modelli per le attività pianificate", "More": "Altro", + "More for %(name)s": "Altro per %(name)s", "Move to folder…": "Sposta in una cartella…", "Move to the trash": "Sposta nel cestino", "Move “%(name)s” to the trash? It can be restored from there.": "Spostare «%(name)s» nel cestino? Si può ripristinare da lì.", "Moved to the trash.": "Spostato nel cestino.", + "Name": "Nome", "Never": "Mai", "New agent chat": "Nuova chat con agente", "New chat": "Nuova chat", @@ -2660,6 +2755,7 @@ const LEMBAS_I18N = { "Noted by a model": "Annotato da un modello", "Nothing has been run in this shell yet.": "In questa shell non è ancora stato eseguito nulla.", "Nothing has been said in the room yet.": "Nella stanza non è ancora stato detto nulla.", + "Nothing here yet. Not even a hobbit hole.": "Ancora niente qui. Nemmeno una caverna hobbit.", "Nothing is scheduled.": "Non c’è niente di pianificato.", "Nothing matches.": "Nessun risultato.", "Nothing remembered yet.": "Ancora niente da ricordare.", @@ -2685,6 +2781,7 @@ const LEMBAS_I18N = { "Or type an answer": "Oppure scriva una risposta", "Outputs": "Risultati", "Page": "Pagina", + "Path": "Percorso", "Pause": "Metti in pausa", "Pause %(title)s": "Metti in pausa %(title)s", "Paused": "In pausa", @@ -2751,6 +2848,7 @@ const LEMBAS_I18N = { "Signed in as %(who)s.": "Accesso effettuato come %(who)s.", "Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Uscendo, l'app rinuncia al suo accesso sul server; il collegamento dell'agente resta. Esca per usare un altro server.", "Since the last reply": "Dall’ultima risposta", + "Size": "Dimensione", "Skills": "Abilità", "Skipped": "Saltata", "Something to remember…": "Qualcosa da ricordare…", @@ -2847,6 +2945,7 @@ const LEMBAS_I18N = { "Tools": "Strumenti", "Tools and skills…": "Strumenti e abilità…", "Try again": "Riprova", + "Type": "Tipo", "Type a command or a chat's title": "Digiti un comando o il titolo di una chat", "Type a folder…": "Digita una cartella…", "Unpin chat": "Togli la chat dalle fissate", @@ -2868,6 +2967,7 @@ const LEMBAS_I18N = { "When": "Quando", "When it matters": "Quando conta", "Where it runs": "Dove viene eseguito", + "Where you are": "Dove sei", "Wide": "Larga", "Writing": "Scrittura", "Writing down…": "Trascrizione…", @@ -2903,6 +3003,7 @@ const LEMBAS_I18N = { "failed": "fallito", "findings: %(n)s": "risultati: %(n)s", "finished": "finito", + "folder": "cartella", "free": "libero", "idle": "inattivo", "it serves one request at a time and is answering another": "serve una richiesta alla volta e ne sta servendo un’altra", @@ -2910,6 +3011,7 @@ const LEMBAS_I18N = { "late": "in ritardo", "light": "chiaro", "messages: %(n)s": "messaggi: %(n)s", + "mount": "montaggio", "not linked": "non collegato", "not linked now": "non collegato ora", "off": "disattivato", @@ -2969,6 +3071,7 @@ const LEMBAS_I18N = { "A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Zadanie bez własnego modelu działa na pierwszym wolnym z tych, w tej kolejności. Żaden niezaznaczony: wszystkie.", "About": "O aplikacji", "Account": "Konto", + "Actions": "Działania", "Activity": "Aktywność", "Add": "Dodaj", "Add folder…": "Dodaj folder…", @@ -2976,6 +3079,7 @@ const LEMBAS_I18N = { "Agent": "Agent", "Agent chats can work in these folders and anything inside them — nowhere else on this computer.": "Czaty z agentem mogą pracować w tych folderach i we wszystkim, co zawierają — nigdzie indziej na tym komputerze.", "Agent: %(why)s": "Agent: %(why)s", + "All files": "Wszystkie pliki", "Allow": "Pozwól", "Always": "Zawsze", "Always allow": "Zawsze zezwalaj", @@ -2999,10 +3103,12 @@ const LEMBAS_I18N = { "Background tasks": "Zadania w tle", "Cancel": "Anuluj", "Carry it out": "Wykonaj", + "Changed": "Zmieniono", "Chat": "Czat", "Chats": "Czaty", "Check for updates…": "Sprawdź aktualizacje…", "Choose": "Wybierz", + "Choose a file to see it here.": "Wybierz plik, aby zobaczyć go tutaj.", "Choose a folder first.": "Najpierw wybierz folder.", "Choose a folder…": "Wybierz folder…", "Choose a model to choose its effort.": "Wybierz model, aby wybrać jego wysiłek.", @@ -3023,6 +3129,7 @@ const LEMBAS_I18N = { "Connected.": "Połączono.", "Connected. This shell is also open in another tab, and they share a size.": "Połączono. Ta powłoka jest otwarta też w innej karcie i dzielą one rozmiar.", "Connecting…": "Łączenie…", + "Contents": "Zawartość", "Context": "Kontekst", "Context and background tasks": "Kontekst i zadania w tle", "Context used": "Użyty kontekst", @@ -3044,6 +3151,7 @@ const LEMBAS_I18N = { "Delete this chat?": "Usunąć ten czat?", "Delete this scheduled task?": "Usunąć to zaplanowane zadanie?", "Deny": "Odrzuć", + "Details": "Szczegóły", "Dictate a message": "Podyktuj wiadomość", "Disconnected. Close and reopen to reconnect.": "Rozłączono. Aby połączyć ponownie, zamknij i otwórz ponownie.", "Done": "Gotowe", @@ -3077,10 +3185,12 @@ const LEMBAS_I18N = { "Folder name": "Nazwa folderu", "Folder on %(device)s": "Folder na %(device)s", "Folder on the computer": "Folder na komputerze", + "Folders": "Foldery", "Free space": "Wolne miejsce", "Hello": "Cześć", "Hello, %(name)s": "Cześć, %(name)s", "History": "Historia", + "Home": "Start", "How many work on it at once, the run's own agent among them, on the same model.": "Ilu pracuje nad nim naraz, łącznie z własnym agentem uruchomienia, na tym samym modelu.", "In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "Własnymi słowami. Serwer zamieni je w regułę i polecenie i pokaże następne uruchomienia, zanim cokolwiek zostanie zapisane.", "Install LLeMbas %(version)s…": "Zainstaluj LLeMbas %(version)s…", @@ -3119,10 +3229,12 @@ const LEMBAS_I18N = { "Models": "Modele", "Models for scheduled tasks": "Modele dla zaplanowanych zadań", "More": "Więcej", + "More for %(name)s": "Więcej dla %(name)s", "Move to folder…": "Przenieś do folderu…", "Move to the trash": "Przenieś do kosza", "Move “%(name)s” to the trash? It can be restored from there.": "Przenieść „%(name)s” do kosza? Można go stamtąd przywrócić.", "Moved to the trash.": "Przeniesiono do kosza.", + "Name": "Nazwa", "Never": "Nigdy", "New agent chat": "Nowy czat z agentem", "New chat": "Nowy czat", @@ -3147,6 +3259,7 @@ const LEMBAS_I18N = { "Noted by a model": "Zanotowane przez model", "Nothing has been run in this shell yet.": "W tej powłoce nic jeszcze nie uruchomiono.", "Nothing has been said in the room yet.": "W pokoju nikt jeszcze nic nie powiedział.", + "Nothing here yet. Not even a hobbit hole.": "Jeszcze nic tu nie ma. Nawet hobbiciej norki.", "Nothing is scheduled.": "Nic nie jest zaplanowane.", "Nothing matches.": "Nic nie pasuje.", "Nothing remembered yet.": "Na razie nic nie zapamiętano.", @@ -3172,6 +3285,7 @@ const LEMBAS_I18N = { "Or type an answer": "Albo wpisz odpowiedź", "Outputs": "Wyniki", "Page": "Strona", + "Path": "Ścieżka", "Pause": "Wstrzymaj", "Pause %(title)s": "Wstrzymaj %(title)s", "Paused": "Wstrzymany", @@ -3238,6 +3352,7 @@ const LEMBAS_I18N = { "Signed in as %(who)s.": "Zalogowano jako %(who)s.", "Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Wylogowanie zrzeka się logowania aplikacji na serwerze; połączenie agenta zostaje. Wyloguj się, aby użyć innego serwera.", "Since the last reply": "Od ostatniej odpowiedzi", + "Size": "Rozmiar", "Skills": "Umiejętności", "Skipped": "Pominięty", "Something to remember…": "Coś do zapamiętania…", @@ -3334,6 +3449,7 @@ const LEMBAS_I18N = { "Tools": "Narzędzia", "Tools and skills…": "Narzędzia i umiejętności…", "Try again": "Spróbuj ponownie", + "Type": "Typ", "Type a command or a chat's title": "Wpisz polecenie lub tytuł czatu", "Type a folder…": "Wpisz folder…", "Unpin chat": "Odepnij czat", @@ -3355,6 +3471,7 @@ const LEMBAS_I18N = { "When": "Kiedy", "When it matters": "Gdy to ważne", "Where it runs": "Gdzie działa", + "Where you are": "Gdzie jesteś", "Wide": "Szeroki", "Writing": "Pisanie", "Writing down…": "Zapisywanie…", @@ -3390,6 +3507,7 @@ const LEMBAS_I18N = { "failed": "nie powiodło się", "findings: %(n)s": "ustalenia: %(n)s", "finished": "zakończony", + "folder": "folder", "free": "wolny", "idle": "bezczynny", "it serves one request at a time and is answering another": "obsługuje jedno żądanie naraz i właśnie obsługuje inne", @@ -3397,6 +3515,7 @@ const LEMBAS_I18N = { "late": "z opóźnieniem", "light": "jasny", "messages: %(n)s": "wiadomości: %(n)s", + "mount": "montowanie", "not linked": "niepołączony", "not linked now": "teraz niepołączony", "off": "wył.", @@ -3456,6 +3575,7 @@ const LEMBAS_I18N = { "A task without a model of its own runs on the first free one of these, in this order. None ticked: all of them.": "Úloha bez vlastného modelu beží na prvom voľnom z týchto, v tomto poradí. Žiadny nezaškrtnutý: všetky.", "About": "O aplikácii", "Account": "Účet", + "Actions": "Akcie", "Activity": "Aktivita", "Add": "Pridať", "Add folder…": "Pridať priečinok…", @@ -3463,6 +3583,7 @@ const LEMBAS_I18N = { "Agent": "Agent", "Agent chats can work in these folders and anything inside them — nowhere else on this computer.": "Agentské konverzácie môžu pracovať v týchto priečinkoch a vo všetkom, čo obsahujú — nikde inde na tomto počítači.", "Agent: %(why)s": "Agent: %(why)s", + "All files": "Všetky súbory", "Allow": "Povoliť", "Always": "Vždy", "Always allow": "Vždy povoliť", @@ -3486,10 +3607,12 @@ const LEMBAS_I18N = { "Background tasks": "Úlohy na pozadí", "Cancel": "Zrušiť", "Carry it out": "Vykonať to", + "Changed": "Zmenené", "Chat": "Konverzácia", "Chats": "Konverzácie", "Check for updates…": "Skontrolovať aktualizácie…", "Choose": "Vybrať", + "Choose a file to see it here.": "Vyberte súbor a uvidíte ho tu.", "Choose a folder first.": "Najprv vyberte priečinok.", "Choose a folder…": "Vybrať priečinok…", "Choose a model to choose its effort.": "Vyberte model, aby ste mohli zvoliť jeho náročnosť.", @@ -3510,6 +3633,7 @@ const LEMBAS_I18N = { "Connected.": "Pripojené.", "Connected. This shell is also open in another tab, and they share a size.": "Pripojené. Tento shell je otvorený aj v inej karte a zdieľajú veľkosť.", "Connecting…": "Pripája sa…", + "Contents": "Obsah", "Context": "Kontext", "Context and background tasks": "Kontext a úlohy na pozadí", "Context used": "Využitý kontext", @@ -3531,6 +3655,7 @@ const LEMBAS_I18N = { "Delete this chat?": "Zmazať túto konverzáciu?", "Delete this scheduled task?": "Odstrániť túto naplánovanú úlohu?", "Deny": "Odmietnuť", + "Details": "Podrobnosti", "Dictate a message": "Nadiktovať správu", "Disconnected. Close and reopen to reconnect.": "Odpojené. Zavrite a znova otvorte, aby sa pripojilo.", "Done": "Hotovo", @@ -3564,10 +3689,12 @@ const LEMBAS_I18N = { "Folder name": "Názov zložky", "Folder on %(device)s": "Priečinok na zariadení %(device)s", "Folder on the computer": "Priečinok na počítači", + "Folders": "Priečinky", "Free space": "Voľné miesto", "Hello": "Dobrý deň", "Hello, %(name)s": "Dobrý deň, %(name)s", "History": "História", + "Home": "Domov", "How many work on it at once, the run's own agent among them, on the same model.": "Koľko na nej pracuje naraz, vrátane vlastného agenta behu, na rovnakom modeli.", "In your own words. The server reads them into a rule and an instruction, and shows you the next runs before anything is saved.": "Vlastnými slovami. Server z nich vytvorí pravidlo a pokyn a pred uložením vám ukáže ďalšie spustenia.", "Install LLeMbas %(version)s…": "Nainštalovať LLeMbas %(version)s…", @@ -3606,10 +3733,12 @@ const LEMBAS_I18N = { "Models": "Modely", "Models for scheduled tasks": "Modely pre naplánované úlohy", "More": "Viac", + "More for %(name)s": "Ďalšie pre %(name)s", "Move to folder…": "Presunúť do priečinka…", "Move to the trash": "Presunúť do koša", "Move “%(name)s” to the trash? It can be restored from there.": "Presunúť „%(name)s“ do koša? Odtiaľ sa dá obnoviť.", "Moved to the trash.": "Presunuté do koša.", + "Name": "Názov", "Never": "Nikdy", "New agent chat": "Nová agentská konverzácia", "New chat": "Nová konverzácia", @@ -3634,6 +3763,7 @@ const LEMBAS_I18N = { "Noted by a model": "Zaznamenal model", "Nothing has been run in this shell yet.": "V tomto shelli sa zatiaľ nič nespustilo.", "Nothing has been said in the room yet.": "V miestnosti zatiaľ nikto nič nepovedal.", + "Nothing here yet. Not even a hobbit hole.": "Zatiaľ tu nič nie je. Ani hobitia nora.", "Nothing is scheduled.": "Nič nie je naplánované.", "Nothing matches.": "Nič nezodpovedá.", "Nothing remembered yet.": "Zatiaľ si nič nepamätá.", @@ -3659,6 +3789,7 @@ const LEMBAS_I18N = { "Or type an answer": "Alebo napíšte odpoveď", "Outputs": "Výstupy", "Page": "Stránka", + "Path": "Cesta", "Pause": "Pozastaviť", "Pause %(title)s": "Pozastaviť %(title)s", "Paused": "Pozastavené", @@ -3725,6 +3856,7 @@ const LEMBAS_I18N = { "Signed in as %(who)s.": "Prihlásený ako %(who)s.", "Signing out gives the app's sign-in up on the server; the agent's link stays. Sign out to use another server.": "Odhlásením sa prihlásenie aplikácie na serveri zruší; prepojenie agenta zostane. Ak chcete použiť iný server, odhláste sa.", "Since the last reply": "Od poslednej odpovede", + "Size": "Veľkosť", "Skills": "Schopnosti", "Skipped": "Vynechané", "Something to remember…": "Niečo na zapamätanie…", @@ -3821,6 +3953,7 @@ const LEMBAS_I18N = { "Tools": "Nástroje", "Tools and skills…": "Nástroje a schopnosti…", "Try again": "Skúsiť znova", + "Type": "Typ", "Type a command or a chat's title": "Napíšte príkaz alebo názov konverzácie", "Type a folder…": "Zadať priečinok…", "Unpin chat": "Odopnúť konverzáciu", @@ -3842,6 +3975,7 @@ const LEMBAS_I18N = { "When": "Kedy", "When it matters": "Keď na tom záleží", "Where it runs": "Kde sa spúšťa", + "Where you are": "Kde ste", "Wide": "Široký", "Writing": "Píše", "Writing down…": "Zapisujem…", @@ -3877,6 +4011,7 @@ const LEMBAS_I18N = { "failed": "zlyhalo", "findings: %(n)s": "zistenia: %(n)s", "finished": "dokončený", + "folder": "priečinok", "free": "voľný", "idle": "nečinný", "it serves one request at a time and is answering another": "vybavuje jednu požiadavku naraz a práve vybavuje inú", @@ -3884,6 +4019,7 @@ const LEMBAS_I18N = { "late": "s oneskorením", "light": "svetlá", "messages: %(n)s": "správy: %(n)s", + "mount": "pripojenie", "not linked": "neprepojené", "not linked now": "teraz nie je prepojený", "off": "vypnuté", diff --git a/ui/index.html b/ui/index.html index 43e3f54..c8eafac 100644 --- a/ui/index.html +++ b/ui/index.html @@ -100,22 +100,36 @@
-
-
-
- +
+
+
+ +
+ +
+
+
+ +
+
-
- diff --git a/ui/js/dom.js b/ui/js/dom.js index c78185c..6363808 100644 --- a/ui/js/dom.js +++ b/ui/js/dom.js @@ -104,6 +104,9 @@ export const ICONS = { mic: ["M8 1.5a2.5 2.5 0 012.5 2.5v4a2.5 2.5 0 01-5 0V4A2.5 2.5 0 018 1.5zM8 3a1 1 0 00-1 1v4a1 1 0 002 0V4a1 1 0 00-1-1z", "M3.5 7.5H5a3 3 0 006 0h1.5a4.5 4.5 0 01-3.75 4.44V14h-1.5v-2.06A4.5 4.5 0 013.5 7.5z"], speaker: ["M2 6h2.5L8 3v10L4.5 10H2z", "M10.2 5.2a4 4 0 010 5.6l-1.06-1.06a2.5 2.5 0 000-3.48z", "M12.3 3.1a7 7 0 010 9.8l-1.06-1.06a5.5 5.5 0 000-7.68z"], picture: ["M1.5 2.5h13v11h-13zM3 4v6.4l2.75-2.75 2.5 2.5 1.75-1.75L13 11.4V4z", "M10.5 5a1.25 1.25 0 110 2.5 1.25 1.25 0 010-2.5z"], + fileText: ["M3 1.5h6.5L13 5v9.5H3zM4.5 3v10h7V6H8.5V3z", "M6 8h4v1H6zM6 10h4v1H6z"], + fileCode: ["M3 1.5h6.5L13 5v9.5H3zM4.5 3v10h7V6H8.5V3z", "M6.7 7.6l.7.7-1.2 1.2 1.2 1.2-.7.7-1.9-1.9z", "M9.3 7.6l1.9 1.9-1.9 1.9-.7-.7 1.2-1.2-1.2-1.2z"], + drive: ["M1.5 4h13v8h-13zM3 5.5v5h10v-5z", "M10.5 7.25h1.5v1.5h-1.5z"], info: ["M8 1.5a6.5 6.5 0 110 13 6.5 6.5 0 010-13zM8 3a5 5 0 100 10A5 5 0 008 3z", "M7.25 7h1.5v4.5h-1.5zM7.25 4.5h1.5V6h-1.5z"], } diff --git a/ui/js/explorer.js b/ui/js/explorer.js index 215fb89..72153e8 100644 --- a/ui/js/explorer.js +++ b/ui/js/explorer.js @@ -1,141 +1,376 @@ -// The two explorers of the Files page, kept apart as two tabs: this computer's -- the agent's -// folders, read through a command that reaches only them (src-tauri/src/files.rs) -- and the -// server's tree, for the chats, read through /api/v2. A file opens in the viewer either way. +// The Files page: two explorers, kept apart as two tabs -- this computer's (the agent's folders, +// read through a command that reaches only them, src-tauri/src/files.rs) and the server's (the +// person's files, as the chats see them, read through /api/v2). Each is the web explorer's shape, +// using the window's width: where you are (crumbs) and what can be done there, the folder tree, +// the list (name, type, size, changed), and the chosen file's details and preview. A file opens in +// the viewer either way. +// +// Nothing here is ever an empty box: a directory being read says Loading…, past SLOW_MS it says +// the server has not answered (with Try again, the late answer still drawn), and a refusal, an +// answer that is not a listing or a failure to draw it is said in its place with Try again. import { $, h, clear, icon, ICONS, bytes } from "./dom.js" import { invoke, get, post, del, why } from "./bridge.js" import { menu, ask } from "./popup.js" -import { openViewer } from "./viewer.js" +import { openViewer, showKind, withheldWords } from "./viewer.js" // The server's tree as the app's door gives it, read as the person (their own access, no chat): // GET /files?path=/a -> { path, parent, entries: [{ name, path, kind: "dir"|"file"|"mount", size, mime, updated_at }] } // GET /files/content?path= -> { path, name, size, mime, kind, language, sha, text, withheld?: { reason, message } } -// A path the person cannot read is the server's 404, in its words. +// A path the person cannot read is the server's 404, in its words. `/` answers with where the +// person's explorer opens (`fs.start`): `/` itself for an organisation's owner or administrator, +// the person's home for anyone else -- its `path` says which. export const SERVER_FILES = { list: (path) => `/files?path=${encodeURIComponent(path || "/")}`, read: (path) => `/files/content?path=${encodeURIComponent(path)}`, } -/** 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) - const ul = h("ul", { class: "tree", role: "tree" }) - 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) { - clearTimeout(slow) - if (!stale()) loading.replaceWith(said(why(err))) - return - } finally { - clearTimeout(slow) - } - 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))) - } +/** How long an answer is shared between the tree and the list after it came. */ +const SHARED_MS = 1500 + +/** The most of a file the details pane previews: the viewer has the rest. */ +const PREVIEW_CHARS = 6000 + +/** A directory in either tree: the server's mounts open like its folders. */ +const isDir = (e) => e.kind === "dir" || e.kind === "mount" + +const IMAGE = /\.(png|jpe?g|gif|webp|bmp|ico|avif|svg)$/i + +/** An entry's icon, by what the viewer would make of it (the web's `file_icon`). */ +function iconOf(e) { + if (e.kind === "mount") return ICONS.drive + if (isDir(e)) return ICONS.folder + if ((e.mime || "").startsWith("image/") || IMAGE.test(e.name)) return ICONS.picture + const { kind } = showKind(e.path || e.name) + return kind === "code" || kind === "html" ? ICONS.fileCode : kind === "markdown" || kind === "text" ? ICONS.fileText : ICONS.file +} + +/** The Type column: a folder, a mount, or the extension (the web's `type_label`). */ +function typeOf(e) { + if (e.kind === "mount") return t("mount") + if (isDir(e)) return t("folder") + const i = e.name.lastIndexOf(".") + return i > 0 ? e.name.slice(i + 1).toLowerCase() : "" +} + +/** A stamp as a date the person reads, and the whole of it for a title. */ +function dateOf(stamp) { + if (!stamp) return { short: "", full: "" } + const d = new Date(stamp) + if (Number.isNaN(d.getTime())) return { short: "", full: "" } + const lang = document.documentElement.lang || undefined + return { + short: new Intl.DateTimeFormat(lang, { day: "numeric", month: "short", year: "numeric" }).format(d), + full: new Intl.DateTimeFormat(lang, { dateStyle: "long", timeStyle: "short" }).format(d), } - const item = (e, depth) => { +} + +/** Is the details pane on the screen (wide windows): a file is chosen there, else opened. */ +const detailsShown = (el) => !!el && getComputedStyle(el).display !== "none" + +/** + * Read something into `holder` and draw it, never leaving it empty: Loading… at once, the slow + * note and Try again past SLOW_MS, a refusal or a failure to draw in its place. `tag` is the + * element the notes are (an `li` inside a list). Only the latest reading of a holder is drawn: + * one that comes after the holder was read again, or left the page, is dropped. Returns a promise + * that settles once the reading is drawn or said. + */ +function reading(holder, { tag = "div", depth = 0, load, draw }) { + const mine = (readings.get(holder) || 0) + 1 + readings.set(holder, mine) + const stale = () => readings.get(holder) !== mine || !holder.isConnected + const again = () => reading(holder, { tag, depth, load, draw }) + const retry = () => h("button", { type: "button", class: "btn btn--sm fnote__retry", text: t("Try again"), onclick: again }) + const note = (cls, role, text) => { + const n = h(tag, { class: `fnote ${cls}`, role }, h("span", { text })) + n.style.setProperty("--depth", String(depth)) + return n + } + clear(holder) + const loading = note("is-loading", "status", t("Loading…")) + loading.setAttribute("aria-live", "polite") + holder.append(loading) + const slow = setTimeout(() => { + if (!stale()) 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 n = note("is-error", "alert", text) + n.append(retry()) + clear(holder).append(n) + } + return Promise.resolve() + .then(load) + .then( + (answer) => { + clearTimeout(slow) + if (stale()) return + try { + clear(holder) + draw(answer) + } catch (err) { + said(err instanceof Error ? err.message : why(err)) + } + }, + (err) => { + clearTimeout(slow) + if (!stale()) said(why(err)) + }, + ) +} +const readings = new WeakMap() // holder -> the reading it shows now (a number) + +/** + * One explorer, inside the pane's elements (`crumbs`, `tree`, `list`, `details`). `source`: + * key "local" | "server" (the rows' data, the tests' handle) + * list(path) -> { path, entries } (`path` as answered: the server's `/` says where it is) + * top() the directory the explorer opens at (the server's: as `/` answered) + * ask(path) the path to ask for a directory (the server's top is asked as `/`) + * crumbs(path) -> [[name, path], ...] from the top down + * rootName() the tree's first line + * open(entry) in the viewer + * preview(entry) -> { text, withheld, size } + * actions(entry) (optional) -> menu items + */ +function explorer(el, source) { + let current = null // the directory listed now (as answered) + let chosen = null // the file whose details show + // One request per directory for the tree and the list together: an answer is shared while it + // is on its way and for a moment after (the tree drawn again once the top is known); a refusal + // is never kept, so Try again asks afresh. Read again and a change forget them all. + const pending = new Map() // asked path -> promise + const fetchDir = (path) => { + const asked = source.ask(path) + if (!pending.has(asked)) { + const p = Promise.resolve().then(() => source.list(asked)) + pending.set(asked, p) + const forget = () => pending.get(asked) === p && pending.delete(asked) + p.then(() => setTimeout(forget, SHARED_MS), forget) + } + return pending.get(asked) + } + + // --- The folder tree: folders only, opened lazily; a name lists its folder. + const treeBox = el.tree + const opened = new Set() + const branches = new Map() // path -> { ul, depth } + const drawTree = () => { + clear(treeBox) + const rootRow = h( + "button", + { type: "button", class: "ftree__row ftree__root", dataset: { path: source.top() }, onclick: () => go(source.top()) }, + icon(ICONS.folder), + h("span", { class: "ftree__name", text: source.rootName() }), + ) + const ul = h("ul", { class: "ftree", role: "tree", "aria-label": t("Folders") }) + treeBox.append(rootRow, ul) + branch(ul, source.top(), 0) + mark() + } + const branch = (ul, path, depth) => { + branches.set(path, { ul, depth }) + return reading(ul, { + tag: "li", + depth, + load: () => fetchDir(path), + draw: (answer) => { + const dirs = (answer.entries || []).filter(isDir) + for (const e of dirs) ul.append(twig(e, depth)) + mark() + }, + }) + } + const twig = (e, depth) => { + const chevron = h("button", { type: "button", class: "ftree__chevron", "aria-label": e.name, "aria-expanded": "false", tabindex: "-1" }, icon(ICONS.chevron)) 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) }), + { type: "button", class: "ftree__row", title: e.path, dataset: { path: e.path } }, + icon(iconOf(e)), + h("span", { class: "ftree__name", text: e.name }), ) - 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?.remove() - kids = h("ul", { role: "group" }) - li.append(kids) - await level(kids, e.path, depth + 1) + const line = h("div", { class: "ftree__line" }, chevron, row) + line.style.setProperty("--depth", String(depth)) + const li = h("li", { role: "treeitem", dataset: { path: e.path } }, line) + let kids = null + const expand = (opening) => { + chevron.setAttribute("aria-expanded", String(opening)) + li.setAttribute("aria-expanded", String(opening)) + kids?.remove() + kids = null + if (!opening) { + opened.delete(e.path) + branches.delete(e.path) + return } - row.addEventListener("click", () => expand(row.getAttribute("aria-expanded") !== "true")) - if (opened.has(e.path)) expand(true) - } else row.addEventListener("click", () => open(e)) + opened.add(e.path) + kids = h("ul", { role: "group" }) + li.append(kids) + branch(kids, e.path, depth + 1) + } + chevron.addEventListener("click", () => expand(chevron.getAttribute("aria-expanded") !== "true")) + row.addEventListener("click", () => { + go(e.path) + if (chevron.getAttribute("aria-expanded") !== "true") expand(true) + }) + if (opened.has(e.path)) expand(true) return li } - const done = level(ul, rootPath, 0) - const reload = (path) => { - const at = levels.get(path) || levels.get(rootPath) - if (!at) return Promise.resolve() - clear(at.ul) - return level(at.ul, levels.has(path) ? path : rootPath, at.depth) + /** The tree's line for the folder listed now. */ + const mark = () => { + for (const r of treeBox.querySelectorAll(".ftree__row")) { + const here = r.dataset.path === current || (r.classList.contains("ftree__root") && current === source.top()) + r.classList.toggle("is-current", here) + if (here) r.setAttribute("aria-current", "true") + else r.removeAttribute("aria-current") + } } - return { done, reload } + + // --- Where you are. + const drawCrumbs = () => { + const parts = source.crumbs(current ?? source.top()) + clear(el.crumbs).append( + ...parts.flatMap(([name, at], i) => { + const last = i === parts.length - 1 + const part = last + ? h("span", { class: "crumbs__here", "aria-current": "page", title: at, text: name }) + : h("button", { type: "button", class: "crumbs__link", title: at, text: name, onclick: () => go(at) }) + return i ? [h("span", { class: "crumbs__sep", "aria-hidden": "true", text: "/" }), part] : [part] + }), + ) + el.crumbs.scrollLeft = el.crumbs.scrollWidth + } + + // --- The list. + const go = (path) => { + current = path + chosen = null + drawDetails() + drawCrumbs() + mark() + return reading(el.list, { + load: () => fetchDir(path), + draw: (answer) => { + if (!answer || !Array.isArray(answer.entries)) throw { detail: t("The server's answer could not be read.") } + if (answer.path !== undefined && answer.path !== path && source.ask(path) === source.ask(answer.path)) current = answer.path + drawCrumbs() + mark() + drawRows(answer.entries) + }, + }) + } + const drawRows = (entries) => { + if (!entries.length) { + el.list.append(h("div", { class: "fx__empty" }, icon(ICONS.folder), h("p", { text: t("Nothing here yet. Not even a hobbit hole.") }))) + return + } + const head = h( + "div", + { class: "frow frow--head", role: "row" }, + h("span", { role: "columnheader", text: t("Name") }), + h("span", { role: "columnheader", class: "frow__type", text: t("Type") }), + h("span", { role: "columnheader", class: "frow__size", text: t("Size") }), + h("span", { role: "columnheader", class: "frow__when", text: t("Changed") }), + h("span", { role: "columnheader" }, h("span", { class: "visually-hidden", text: t("Actions") })), + ) + el.list.append(h("div", { class: "frows", role: "table", "aria-label": t("Contents") }, head, entries.map(row))) + } + const row = (e) => { + const date = dateOf(e.updated_at) + const items = source.actions?.(e) || [] + const openRow = h( + "button", + { type: "button", class: "frow__open", title: e.path }, + h("span", { class: `frow__icon frow__icon--${e.kind === "mount" ? "mount" : isDir(e) ? "dir" : showKind(e.name).kind}` }, icon(iconOf(e))), + h("span", { class: "frow__label", text: e.name }), + ) + const r = h( + "div", + { class: "frow", role: "row", dataset: { path: e.path, kind: e.kind } }, + h("span", { role: "cell", class: "frow__name" }, openRow, e.kind === "mount" ? h("span", { class: "badge", text: t("mount") }) : null), + h("span", { role: "cell", class: "frow__type", text: typeOf(e) }), + h("span", { role: "cell", class: "frow__size", text: isDir(e) ? "" : bytes(e.size) }), + h("span", { role: "cell", class: "frow__when", title: date.full, text: date.short }), + h( + "span", + { role: "cell", class: "frow__actions" }, + items.length + ? h("button", { type: "button", class: "tool frow__more", "aria-label": t("More for %(name)s", { name: e.name }), title: t("More for %(name)s", { name: e.name }), onclick: (ev) => menu(ev.currentTarget, items) }, icon(ICONS.more)) + : null, + ), + ) + openRow.addEventListener("click", () => { + if (isDir(e)) go(e.path) + else if (detailsShown(el.details)) choose(e) + else source.open(e) + }) + openRow.addEventListener("dblclick", () => { + if (!isDir(e)) source.open(e) + }) + return r + } + + // --- The chosen file's details and preview. + const choose = (e) => { + chosen = e + for (const r of el.list.querySelectorAll(".frow[data-path]")) r.classList.toggle("is-chosen", r.dataset.path === e.path) + drawDetails() + } + const drawDetails = () => { + const box = el.details + if (!box) return + for (const r of el.list.querySelectorAll(".frow.is-chosen")) if (!chosen || r.dataset.path !== chosen.path) r.classList.remove("is-chosen") + clear(box) + if (!chosen) { + box.append(h("div", { class: "fdetails__none" }, icon(ICONS.file), h("p", { text: t("Choose a file to see it here.") }))) + return + } + const e = chosen + const date = dateOf(e.updated_at) + const fact = (label, value, title) => (value ? [h("dt", { text: label }), h("dd", { title: title || value, text: value })] : []) + const preview = h("div", { class: "fdetails__preview", "aria-label": t("Preview") }) + box.append( + h("div", { class: "fdetails__id" }, h("span", { class: `frow__icon frow__icon--${showKind(e.name).kind}` }, icon(iconOf(e))), h("h2", { class: "fdetails__name", title: e.name, text: e.name })), + h("dl", { class: "fdetails__facts" }, fact(t("Type"), typeOf(e)), fact(t("Size"), bytes(e.size)), fact(t("Changed"), date.short, date.full), fact(t("Path"), e.path)), + h("div", { class: "fdetails__do" }, h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Open in the viewer"), onclick: () => source.open(e) })), + preview, + ) + reading(preview, { + load: () => source.preview(e), + draw: (f) => { + if (chosen !== e) return + if (f.withheld) preview.append(h("p", { class: "fdetails__note", text: withheldWords(f.withheld, f.size ?? e.size) })) + else { + const text = String(f.text ?? "") + preview.append(h("pre", { class: "fdetails__text", text: text.length > PREVIEW_CHARS ? `${text.slice(0, PREVIEW_CHARS)}\n…` : text })) + } + }, + }) + } + + /** Read again: the list, the tree as it is open, the file chosen. */ + const refresh = () => { + pending.clear() + const at = current ?? source.top() + drawTree() + return go(at) + } + /** A directory changed (an upload, a new folder, a rename, a delete): draw what is there now. */ + const changed = (dir) => { + pending.clear() + const b = branches.get(dir) + if (b) branch(b.ul, dir, b.depth) + if (dir === current || source.ask(dir) === source.ask(current ?? source.top())) return go(current ?? source.top()) + return Promise.resolve() + } + return { go, refresh, changed, drawTree, current: () => current ?? source.top() } } // --- This computer ------------------------------------------------------------------------------ let local = { root: "", roots: [], fixed: false } +let localPane = null /** * Point this computer's pane at a folder: `fixed` (an agent chat's working directory, which is @@ -147,32 +382,63 @@ export function showLocal({ roots, root, fixed }) { if (changed) renderLocal() } +const baseName = (p) => String(p).replace(/\/+$/, "").split("/").pop() || p + function renderLocal() { const head = clear($("local-where")) - const box = $("local-tree") + const els = { crumbs: $("local-crumbs"), tree: $("local-tree"), list: $("local-list"), details: $("local-details") } + $("pane-local").dataset.empty = String(!local.roots.length) if (!local.roots.length) { - clear(box).append( - h("p", { class: "pane__note", text: t("No project folder yet: the agent can open nothing on this computer.") }), - h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Add folder…"), onclick: () => invoke("remote_add") }), + clear(els.crumbs) + clear(els.tree) + clear(els.details) + clear(els.list).append( + h( + "div", + { class: "fx__empty" }, + icon(ICONS.folder), + h("p", { text: t("No project folder yet: the agent can open nothing on this computer.") }), + h("button", { type: "button", class: "btn btn--sm btn--primary", text: t("Add folder…"), onclick: () => invoke("remote_add") }), + ), ) + localPane = null return } - if (local.fixed) { - head.append(h("span", { class: "pane__path", title: local.root }, h("bdi", { text: local.root }))) - } else { - const pick = h( - "select", - { class: "pane__pick", "aria-label": t("Folder"), onchange: (e) => showLocal({ roots: local.roots, root: e.target.value, fixed: false }) }, - local.roots.map((r) => h("option", { value: r, text: r, selected: r === local.root })), + if (!local.fixed && local.roots.length > 1) { + head.append( + h( + "select", + { class: "pane__pick", "aria-label": t("Folder"), title: local.root, onchange: (e) => showLocal({ roots: local.roots, root: e.target.value, fixed: false }) }, + local.roots.map((r) => h("option", { value: r, text: r, selected: r === local.root })), + ), ) - head.append(pick) } const root = local.root - return tree(box, { - rootPath: "", - list: async (path) => (await invoke("local_list", { root, path })).entries, + const where = t("This computer") + localPane = explorer(els, { + key: "local", + top: () => "", + ask: (path) => path || "", + rootName: () => baseName(root), + crumbs: (path) => { + const out = [[baseName(root), ""]] + let at = "" + for (const part of String(path).split("/").filter(Boolean)) { + at = at ? `${at}/${part}` : part + out.push([part, at]) + } + return out + }, + list: async (path) => { + const r = await invoke("local_list", { root, path }) + if (!r || !Array.isArray(r.entries)) throw { detail: t("The server's answer could not be read.") } + return { + path: r.path ?? path, + entries: r.entries.map((e) => ({ ...e, updated_at: e.modified ? new Date(e.modified * 1000).toISOString() : null })), + } + }, + preview: (e) => invoke("local_read", { root, path: e.path }), open: async (e) => { - const where = t("This computer") try { const f = await invoke("local_read", { root, path: e.path }) openViewer({ name: f.name, path: `${f.root}/${f.path}`, where, text: f.text, withheld: f.withheld, size: f.size }) @@ -181,6 +447,8 @@ function renderLocal() { } }, }) + localPane.drawTree() + localPane.go("") } /** Read a file the agent showed in a chat on this computer: `path` from the chat's folder. */ @@ -201,11 +469,11 @@ export async function readServer(path) { // POST /files/rename { path, name } // DELETE /files?path= to the trash, as on the web -let server = null // the tree drawn now: { reload(path) } -let serverRoot = "/" // the directory the server answered `/` with: the person's home +let server = null // the explorer drawn now +let serverRoot = "/" // the directory the server answered `/` with: `/` or the person's home const parentOf = (path) => path.replace(/\/[^/]*$/, "") || "/" -const reloadServer = (dir) => server?.reload(dir === serverRoot ? "/" : dir) +const reloadServer = (dir) => server?.changed(dir) function serverSaid(text, error = false) { const line = $("server-status") @@ -268,24 +536,48 @@ async function deleteEntry(e) { function serverActions(e) { const items = [] if (isDir(e)) items.push({ label: t("Upload"), run: () => uploadInto(e.path) }, { label: t("New folder"), run: () => makeFolder(e.path) }) - if (e.kind !== "mount") items.push(...(items.length ? [null] : []), { label: t("Rename"), run: () => renameEntry(e) }, { label: t("Delete"), danger: true, run: () => deleteEntry(e) }) + else items.push({ label: t("Open in the viewer"), run: () => openServerFile(e.path, e.name) }) + if (e.kind !== "mount") items.push(null, { label: t("Rename"), run: () => renameEntry(e) }, { label: t("Delete"), danger: true, run: () => deleteEntry(e) }) return items } export function renderServer() { - const box = $("server-tree") serverSaid("") - server = tree(box, { - rootPath: "/", + const els = { crumbs: $("server-crumbs"), tree: $("server-tree"), list: $("server-list"), details: $("server-details") } + let first = true + server = explorer(els, { + key: "server", + top: () => serverRoot, + // The top is asked as `/`, which is what the server opens a person at. + ask: (path) => (!path || path === serverRoot ? "/" : path), + rootName: () => (serverRoot === "/" ? t("All files") : t("Home")), + crumbs: (path) => { + const out = [[serverRoot === "/" ? t("All files") : t("Home"), serverRoot]] + const rest = path.startsWith(serverRoot) ? path.slice(serverRoot.length) : path + let at = serverRoot + for (const part of rest.split("/").filter(Boolean)) { + at = `${at.replace(/\/$/, "")}/${part}` + out.push([part, at]) + } + return out + }, 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 + if (path === "/" && r.path && r.path !== serverRoot) { + serverRoot = r.path + // The top is known now: the tree and the crumbs start there. + if (first) queueMicrotask(() => server?.drawTree()) + } + first = false + return r }, + preview: (e) => readServer(e.path), open: (e) => openServerFile(e.path, e.name), actions: serverActions, }) + server.drawTree() + return server.go(serverRoot) } /** A file in the server's tree, in the viewer: from the explorer and from a chat's outputs. */ @@ -312,7 +604,7 @@ export async function openServerFile(path, name) { let tab = "local" let serverRead = false -/** Show one of the two trees: `local` (this computer) or `server`. The server's is read once. */ +/** Show one of the two explorers: `local` (this computer) or `server`. The server's is read once. */ export function showFilesTab(which) { tab = which === "server" ? "server" : "local" for (const b of $("files-tabs").querySelectorAll("button")) b.setAttribute("aria-selected", String(b.dataset.tab === tab)) @@ -328,13 +620,15 @@ export function initExplorer() { const tabBtn = (which, ico, label) => h("button", { type: "button", role: "tab", class: "tabs__tab", dataset: { tab: which }, "aria-selected": String(which === tab), onclick: () => showFilesTab(which) }, icon(ico), h("span", { text: label })) $("files-tabs").replaceChildren(tabBtn("local", ICONS.folder, t("This computer")), tabBtn("server", ICONS.file, t("Server"))) - $("server-where").textContent = t("Your files on the server, as the chats see them.") $("server-refresh").replaceChildren(icon(ICONS.refresh)) - $("server-refresh").addEventListener("click", renderServer) + $("server-refresh").addEventListener("click", () => { + serverSaid("") + return server ? server.refresh() : renderServer() + }) $("server-upload").replaceChildren(icon(ICONS.send)) - $("server-upload").addEventListener("click", () => uploadInto(serverRoot)) + $("server-upload").addEventListener("click", () => uploadInto(server?.current() ?? serverRoot)) $("server-mkdir").replaceChildren(icon(ICONS.folder)) - $("server-mkdir").addEventListener("click", () => makeFolder(serverRoot)) + $("server-mkdir").addEventListener("click", () => makeFolder(server?.current() ?? serverRoot)) $("local-refresh").replaceChildren(icon(ICONS.refresh)) - $("local-refresh").addEventListener("click", renderLocal) + $("local-refresh").addEventListener("click", () => (localPane ? localPane.refresh() : renderLocal())) } diff --git a/ui/js/viewer.js b/ui/js/viewer.js index 449e76c..c4ccd03 100644 --- a/ui/js/viewer.js +++ b/ui/js/viewer.js @@ -52,7 +52,7 @@ export function closeViewer() { * server's is { reason: "not_text" | "too_large", message } with the message in the person's * language already. */ -function withheldWords(withheld, size) { +export function withheldWords(withheld, size) { if (typeof withheld === "object" && withheld.message) return String(withheld.message) const reason = typeof withheld === "object" ? withheld.reason : withheld return reason === "too_big" || reason === "too_large"