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>
This commit is contained in:
9 files changed
+987
-227
No files matched your search
+434
-140
@@ -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()))
|
||||
}
|
||||
Reference in new issue
Block a user