Files
LLeMbas-Desktop/ui/js/explorer.js
T
HomerandClaude Opus 5.5 ab0c1434c4 One approval for app and agent; /api/v2 files and show; app_api 2
- The app's device login asks for `app models` and, where discovery says
  login.device.agent_scope, `agent_scope=models memory link` in the same
  approval. The agent's token goes to the bundled CLI on stdin
  (`lembas login --token-stdin --server <url> --json`) and is never kept
  here; if the CLI does not take it, it is given up on the server. A
  computer linked already keeps its link; a server without agent_scope
  gets the old two steps.
- The server pane reads GET /api/v2/files and /files/content (mounts open
  like folders; `withheld` shown in the server's words). The "not offered
  yet" state is gone.
- A show card's file comes from GET /api/v2/chats/{chat}/show?node=&version=
  or ?shown= as plain text (new `api_text` command, 1 MB cap); args.path is
  read only for agent chats on this computer, locally.
- The gate is link protocol 3 plus app_api 2 (APP_API = 2), not the
  server's version number, which says 1.7.2 on dev until its first beta.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 14:48:00 +00:00

171 lines
6.7 KiB
JavaScript

// The two explorers, kept apart: this computer's -- the agent's working directory, read through
// a command that reaches only the agent's folders (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.
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
import { invoke, get, why } from "./bridge.js"
import { openViewer } 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.
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"
/** A lazily opened tree in `box`. `list(path)` -> entries; `open(entry)` for a file. */
function tree(box, { list, open, rootPath }) {
clear(box)
const ul = h("ul", { class: "tree", role: "tree" })
box.append(ul)
const level = async (parent, path, depth) => {
let entries
try {
entries = await list(path)
} catch (err) {
parent.append(h("li", { class: "tree__note is-error", text: why(err) }))
return
}
if (!entries.length) parent.append(h("li", { class: "tree__note", text: t("Empty folder") }))
for (const e of entries) {
const row = h(
"button",
{ type: "button", class: "tree__row", title: e.path, "aria-expanded": isDir(e) ? "false" : null },
h("span", { class: "tree__chevron" }, isDir(e) ? icon(ICONS.chevron) : null),
icon(isDir(e) ? ICONS.folder : ICONS.file),
h("span", { class: "tree__name", text: e.name }),
h("span", { class: "tree__size", text: isDir(e) ? "" : bytes(e.size) }),
)
row.style.setProperty("--depth", String(depth))
const li = h("li", { role: "treeitem" }, row)
if (isDir(e)) {
let kids = null
row.addEventListener("click", async () => {
const opening = row.getAttribute("aria-expanded") !== "true"
row.setAttribute("aria-expanded", String(opening))
if (!opening) {
kids?.remove()
kids = null
return
}
kids = h("ul", { role: "group" })
li.append(kids)
await level(kids, e.path, depth + 1)
})
} else row.addEventListener("click", () => open(e))
parent.append(li)
}
}
return level(ul, rootPath, 0)
}
// --- This computer ------------------------------------------------------------------------------
let local = { root: "", roots: [], fixed: false }
/**
* 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()
}
function renderLocal() {
const head = clear($("local-where"))
const box = $("local-tree")
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", text: t("Add folder…"), onclick: () => invoke("remote_add") }),
)
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 })),
)
head.append(pick)
}
const root = local.root
tree(box, {
rootPath: "",
list: async (path) => (await invoke("local_list", { root, path })).entries,
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 })
} catch (err) {
openViewer({ name: e.name, path: e.path, where, error: why(err) })
}
},
})
}
/** 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))
}
export function renderServer() {
const box = $("server-tree")
tree(box, {
rootPath: "/",
list: async (path) => (await get(SERVER_FILES.list(path))).entries || [],
open: async (e) => {
const where = t("Server")
try {
const f = await readServer(e.path)
openViewer({
name: f.name || e.name,
path: f.path || e.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: e.name, path: e.path, where, error: why(err) })
}
},
})
}
export function initExplorer() {
for (const pane of document.querySelectorAll(".pane")) {
const toggle = pane.querySelector(".pane__toggle")
toggle.prepend(h("span", { class: "tree__chevron" }, icon(ICONS.chevron)))
toggle.addEventListener("click", () => {
const open = pane.dataset.open !== "true"
pane.dataset.open = String(open)
toggle.setAttribute("aria-expanded", String(open))
$("side").dataset[pane.id === "pane-local" ? "local" : "server"] = String(open)
})
}
$("server-refresh").replaceChildren(icon(ICONS.refresh))
$("server-refresh").addEventListener("click", renderServer)
$("local-refresh").replaceChildren(icon(ICONS.refresh))
$("local-refresh").addEventListener("click", renderLocal)
}