Files
LLeMbas-Desktop/ui/js/explorer.js
T
HomerandClaude Opus 5.5 fe4bf064f5
ci / ui (push) Waiting to run
ci / check (push) Failing after 3s
ci / start (push) Failing after 3s
Files in the web explorer's shape: crumbs, folder tree, list in columns, details and preview
Both tabs use the window's width now, as the web's explorer does: where you are (crumbs), the
folder tree (a chevron opens, a name lists), the list on one grid with every row a subgrid --
name with an icon by kind, type, size, changed, a menu -- and on a wide window the chosen file's
details, the start of it and Open in the viewer (a double click opens it). The panes give way by
the room the page has (container queries, so the sidebar's share counts): the details at 60rem,
the tree at 40rem, the type and date columns when the list is under 28rem; a file then opens in
the viewer on a click. The tree and the list share one request per folder; every reading keeps
the never-empty states of the previous commit.

Tests (tests/ui FX): the administrator's / wide (tree, columns on one track, details, preview,
viewer), navigating by list, tree and crumbs with a long name, this computer's folder, and every
width 1920/1280/1024/800/390 with nothing sideways, every control inside the window and the
name's track never closed. H8/N8 follow the new list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 16:18:05 +00:00

635 lines
26 KiB
JavaScript

// 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, 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. `/` 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)}`,
}
/** How long a directory may take before the pane says it is still waiting, and offers to ask again. */
export const SLOW_MS = 8000
/** 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),
}
}
/** 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: "ftree__row", title: e.path, dataset: { path: e.path } },
icon(iconOf(e)),
h("span", { class: "ftree__name", text: e.name }),
)
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
}
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
}
/** 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")
}
}
// --- 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
* what the pane shows while that chat is open) or one of the agent's folders, chosen in the pane.
*/
export function showLocal({ roots, root, fixed }) {
const changed = root !== local.root || fixed !== local.fixed || roots.join("\n") !== local.roots.join("\n")
local = { roots, root: root || (roots.includes(local.root) ? local.root : roots[0] || ""), fixed: !!fixed }
if (changed) renderLocal()
}
const baseName = (p) => String(p).replace(/\/+$/, "").split("/").pop() || p
function renderLocal() {
const head = clear($("local-where"))
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(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 && 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 })),
),
)
}
const root = local.root
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) => {
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 })
} catch (err) {
openViewer({ name: e.name, path: e.path, where, error: why(err) })
}
},
})
localPane.drawTree()
localPane.go("")
}
/** Read a file the agent showed in a chat on this computer: `path` from the chat's folder. */
export async function readLocal(root, path) {
return invoke("local_read", { root, path })
}
// --- The server ---------------------------------------------------------------------------------
export async function readServer(path) {
return get(SERVER_FILES.read(path))
}
// The library's writes, as the person (each answers with the directory's listing, or a refusal
// in the server's words):
// POST /files/upload?path= multipart `files` (sent by Rust: `library_upload`, from the dialog)
// POST /files/mkdir { path, name }
// POST /files/rename { path, name }
// DELETE /files?path= to the trash, as on the web
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?.changed(dir)
function serverSaid(text, error = false) {
const line = $("server-status")
line.hidden = !text
line.textContent = text || ""
line.className = `status${error ? " is-error" : ""}`
line.setAttribute("role", error ? "alert" : "status")
}
/** Into `dir`: files chosen in the system's dialog. */
export async function uploadInto(dir) {
serverSaid("")
try {
const r = await invoke("library_upload", { dir, title: t("Upload") })
if (r.failed?.length) serverSaid(r.failed.map((f) => `${f.name}: ${f.error}`).join("\n"), true)
if (r.sent) await reloadServer(dir)
} catch (err) {
serverSaid(why(err), true)
}
}
async function makeFolder(dir) {
const name = await ask({ title: t("New folder"), value: "", ok: t("Create"), cancel: t("Cancel") })
if (!name) return
try {
await post("/files/mkdir", { path: dir, name })
serverSaid("")
await reloadServer(dir)
} catch (err) {
serverSaid(why(err), true)
}
}
async function renameEntry(e) {
const name = await ask({ title: t("Rename"), value: e.name, ok: t("Rename"), cancel: t("Cancel") })
if (!name || name === e.name) return
try {
await post("/files/rename", { path: e.path, name })
serverSaid("")
await reloadServer(parentOf(e.path))
} catch (err) {
serverSaid(why(err), true)
}
}
async function deleteEntry(e) {
const yes = await ask({ title: t("Move to the trash"), text: t("Move “%(name)s” to the trash? It can be restored from there.", { name: e.name }), ok: t("Move to the trash"), cancel: t("Cancel"), danger: true })
if (!yes) return
try {
await del(`/files?path=${encodeURIComponent(e.path)}`)
serverSaid(t("Moved to the trash."))
await reloadServer(parentOf(e.path))
} catch (err) {
serverSaid(why(err), true)
}
}
/** What can be done to an entry of the server's tree: a mount (another store) is filled, never
* renamed or deleted from here. A read-only place is refused by the server, in its words. */
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) })
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() {
serverSaid("")
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 && 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. */
export async function openServerFile(path, name) {
const where = t("Server")
try {
const f = await readServer(path)
openViewer({
name: f.name || name,
path: f.path || path,
where,
text: f.text,
// The server's own kind and language: its table and the viewer's are the same one.
kind: ["markdown", "code", "text", "html"].includes(f.kind) ? f.kind : undefined,
language: f.language || undefined,
withheld: f.withheld,
size: f.size,
})
} catch (err) {
openViewer({ name, path, where, error: why(err) })
}
}
let tab = "local"
let serverRead = false
/** 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))
$("pane-local").hidden = tab !== "local"
$("pane-server").hidden = tab !== "server"
if (tab === "server" && !serverRead) {
serverRead = true
renderServer()
}
}
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-refresh").replaceChildren(icon(ICONS.refresh))
$("server-refresh").addEventListener("click", () => {
serverSaid("")
return server ? server.refresh() : renderServer()
})
$("server-upload").replaceChildren(icon(ICONS.send))
$("server-upload").addEventListener("click", () => uploadInto(server?.current() ?? serverRoot))
$("server-mkdir").replaceChildren(icon(ICONS.folder))
$("server-mkdir").addEventListener("click", () => makeFolder(server?.current() ?? serverRoot))
$("local-refresh").replaceChildren(icon(ICONS.refresh))
$("local-refresh").addEventListener("click", () => (localPane ? localPane.refresh() : renderLocal()))
}