Files
LLeMbas-Desktop/ui/js/viewer.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

125 lines
5.3 KiB
JavaScript

// The viewer beside the chat: a file from this computer, from the server, or one the agent showed
// (`show`). Markdown is drawn (`markdown.js`), code and text are CodeMirror, read-only, and HTML
// is a sealed frame (src-tauri/src/sealed.rs): its own scheme, no bridge, no network, no way
// out. What a file says is never part of the app's page.
import { $, h, clear, icon, ICONS, bytes } from "./dom.js"
import { invoke } from "./bridge.js"
import { markdown } from "./markdown.js"
import { codeView } from "./code.js"
// The viewer's kind by a file's name: the CLI's and the server's `showKind`, the same table.
const LANGUAGES = {
ts: "typescript", tsx: "tsx", js: "javascript", mjs: "javascript", cjs: "javascript", jsx: "jsx", json: "json", py: "python", rs: "rust", go: "go",
rb: "ruby", php: "php", java: "java", kt: "kotlin", swift: "swift", c: "c", h: "c", cc: "cpp", cpp: "cpp", hpp: "cpp", cs: "csharp",
sh: "bash", bash: "bash", zsh: "bash", fish: "fish", css: "css", scss: "scss", sql: "sql", yaml: "yaml", yml: "yaml", toml: "toml",
xml: "xml", svg: "xml", lua: "lua", zig: "zig", dart: "dart", vue: "vue", svelte: "svelte",
}
export function showKind(path) {
const name = String(path).split("/").pop() || ""
const ext = name.includes(".") ? name.split(".").pop().toLowerCase() : ""
if (ext === "md" || ext === "markdown") return { kind: "markdown", language: "markdown" }
if (ext === "html" || ext === "htm") return { kind: "html", language: "html" }
const language = LANGUAGES[ext] || (name === "Dockerfile" ? "dockerfile" : name === "Makefile" ? "makefile" : "")
return language ? { kind: "code", language } : { kind: "text", language: "" }
}
let current = null // { editor, sealed }
let onChange = () => {}
/** Called when the viewer opens or closes (the layout makes room for it). */
export function onViewer(fn) {
onChange = fn
}
function teardown() {
if (!current) return
current.editor?.destroy()
if (current.sealed) invoke("sealed_close", { url: current.sealed })
current = null
}
export function closeViewer() {
teardown()
$("viewer").hidden = true
clear($("viewer-body"))
onChange(false)
}
/**
* Why a file has no text, in words: this computer's `withheld` is "binary" | "too_big"; the
* server's is { reason: "not_text" | "too_large", message } with the message in the person's
* language already.
*/
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"
? t("This file is too big to show here (%(size)s).", { size: bytes(size) })
: t("This is not a text file; it cannot be shown here.")
}
/**
* Show a file. `file`: { name, path, where (words for its place), text, kind?, language?, line?,
* withheld? (see withheldWords), size?, error? }.
*/
export async function openViewer(file) {
teardown()
const kind = file.kind || showKind(file.path || file.name).kind
const lang = file.language ?? showKind(file.path || file.name).language
$("viewer").hidden = false
onChange(true)
$("viewer-name").textContent = file.name || file.path || ""
$("viewer-name").title = file.path || ""
$("viewer-where").textContent = file.where || ""
const body = clear($("viewer-body"))
const modes = clear($("viewer-modes"))
current = {}
if (file.error) {
body.append(h("p", { class: "viewer__note is-error", text: file.error }))
return
}
if (file.withheld) {
body.append(h("p", { class: "viewer__note", text: withheldWords(file.withheld, file.size) }))
return
}
const text = file.text ?? ""
// Markdown and HTML can be seen drawn or as their source.
const drawn = kind === "markdown" || kind === "html"
const show = async (asSource) => {
teardown()
current = {}
clear(body)
for (const b of modes.querySelectorAll("button")) b.setAttribute("aria-pressed", String(b.dataset.source === String(asSource)))
if (asSource || !drawn) {
const holder = h("div", { class: "viewer__code" })
body.append(holder)
current.editor = await codeView(holder, text, { lang, line: file.line || 0 })
} else if (kind === "markdown") {
body.append(h("div", { class: "md viewer__md" }, markdown(text)))
} else {
const url = await invoke("sealed_open", { html: text })
current.sealed = url
// No allow-same-origin, no allow-top-navigation: an opaque page that can run its own
// scripts and nothing else. The address's own policy says the same again.
body.append(h("iframe", { class: "viewer__frame", src: url, sandbox: "allow-scripts", referrerpolicy: "no-referrer", title: file.name || "" }))
}
}
if (drawn) {
modes.append(
h("button", { type: "button", class: "seg", dataset: { source: "false" }, text: kind === "html" ? t("Page") : t("Preview"), onclick: () => show(false) }),
h("button", { type: "button", class: "seg", dataset: { source: "true" }, text: t("Source"), onclick: () => show(true) }),
)
}
await show(false)
}
export function initViewer() {
$("viewer-close").replaceChildren(icon(ICONS.close))
$("viewer-close").addEventListener("click", closeViewer)
document.addEventListener("keydown", (e) => {
if (e.key === "Escape" && !$("viewer").hidden && !e.defaultPrevented) closeViewer()
})
}