// The file viewer: a folder tree on the left, the open file on the right, like an editor. Used on task pages (Harbor
// and custom environments alike). Contents are fetched on click (and on hover, a moment early), never shipped up front:
// a task can hold thousands of files and a person reads three.
//
// fileViewer(root, { files, load, initial, line, hubUrl, rawUrl, onChange })
// files [{path, size, withheld}]; withheld files are listed with a lock and never opened
// load async path -> {text, binary, withheld, truncated, size, error}
// initial the path to open first; line: "12" or "12-20" to highlight and scroll to
// hubUrl path -> link to the file on the Hub (or null); rawUrl: path -> image URL for previews (or null)
// onChange (path, line) after the open file or the highlighted lines change, for the page's URL
// listDir async folder -> [{path, size, withheld, dir, lazy}]: lists a folder the index left unlisted (entries
// with dir+lazy, path ending in "/"), when it's opened
import { esc, fmt, bytes, md, toast, storage } from "./util.js";
import { icon } from "./icons.js";
import { highlight, langOf, LANG_NAMES } from "./code.js";
const IMAGE = /\.(png|jpe?g|gif|webp|svg|bmp|ico|avif)$/i;
const HIGHLIGHT_MAX = 400_000; // characters; past this a file is shown plain, so a huge log can't stall the page
const ICON_BY_LANG = { sh: "terminal", docker: "box", md: "doc", json: "list", toml: "list", yaml: "list", ini: "list", diff: "diff" };
const fileIcon = (f) => (f.withheld ? "lock" : IMAGE.test(f.path) ? "image" : /\.(csv|tsv|parquet|xlsx?)$/i.test(f.path) ? "sheet"
: ICON_BY_LANG[langOf(f.path)] || (langOf(f.path) ? "code" : "file"));
const base = (p) => p.split("/").pop();
const lineCount = (t) => (t === "" ? 0 : t.split("\n").length - (t.endsWith("\n") ? 1 : 0));
// path list -> nested folders (folders first, then files, each by name, like an editor); `folders` adds known
// folders whose contents aren't listed yet
function buildTree(files, folders = []) {
const root = { dirs: new Map(), files: [], count: 0 };
for (const d of folders) {
let node = root;
for (const part of d.split("/")) {
if (!node.dirs.has(part)) node.dirs.set(part, { dirs: new Map(), files: [], count: 0 });
node = node.dirs.get(part);
}
}
for (const f of files) {
const parts = f.path.split("/");
let node = root;
node.count++;
for (const d of parts.slice(0, -1)) {
if (!node.dirs.has(d)) node.dirs.set(d, { dirs: new Map(), files: [], count: 0 });
node = node.dirs.get(d);
node.count++;
}
node.files.push(f);
}
return root;
}
// a text range "12" / "12-20" -> [from, to]
const parseLines = (s) => {
const m = String(s || "").match(/^(\d+)(?:-(\d+))?$/);
if (!m) return null;
const a = +m[1], b = +(m[2] || m[1]);
return [Math.min(a, b), Math.max(a, b)];
};
export function fileViewer(root, { files: given, load, initial, line, hubUrl = () => null, rawUrl = () => null, onChange = () => {}, listDir = null }) {
const cache = new Map(); // path -> Promise of the loaded file
const get = (p) => { if (!cache.has(p)) cache.set(p, load(p).catch((e) => ({ error: e.message || String(e) }))); return cache.get(p); };
let files = given.filter((f) => !f.dir);
const lazy = new Map(given.filter((f) => f.dir && f.lazy).map((f) => [f.path.replace(/\/$/, ""), "unlisted"])); // folder -> unlisted | loading | listed | error
let byPath = new Map(files.map((f) => [f.path, f]));
let tree = buildTree(files, [...lazy.keys()]);
const many = files.length > 40;
// folders start open when there are few files; otherwise only the ones on the way to the open file
const open = new Set();
const walk = (node, prefix) => node.dirs.forEach((child, name) => { const p = prefix + name; if (!many && !lazy.has(p)) open.add(p); walk(child, p + "/"); });
walk(tree, "");
let current = null, lines = null, wrap = storage.get("fv-wrap") === "1", mdMode = storage.get("fv-md") || "preview", query = "";
let full = false, hoverT = null;
root.classList.add("fv-host"); // the viewer fits the column it's in (container queries in fv.css)
root.innerHTML = `
`;
} else treeEl.innerHTML = rowsOf(tree, "", 0);
const sel = treeEl.querySelector('[aria-selected="true"]') || treeEl.querySelector(".fv-row");
if (sel) sel.tabIndex = 0;
}
const visibleRows = () => [...treeEl.querySelectorAll(".fv-row")];
function focusRow(r) {
if (!r) return;
treeEl.querySelectorAll('.fv-row[tabindex="0"]').forEach((x) => (x.tabIndex = -1));
r.tabIndex = 0;
r.focus({ preventScroll: true });
r.scrollIntoView({ block: "nearest" });
}
function toggleDir(p, force) {
const on = force ?? !open.has(p);
on ? open.add(p) : open.delete(p);
renderTree();
focusRow(treeEl.querySelector(`[data-dir="${CSS.escape(p)}"]`));
if (on && lazy.get(p) === "unlisted") listFolder(p);
}
const withheldDir = (p) => /(^|\/)(solutions?|gold|answers?|oracle)(\/|$)/i.test(p);
async function listFolder(p) {
if (!listDir) return;
lazy.set(p, "loading");
renderTree();
try {
const entries = await listDir(p);
for (const e of entries) {
if (e.dir) { const d = e.path.replace(/\/$/, ""); if (!lazy.has(d)) lazy.set(d, "unlisted"); }
else if (!byPath.has(e.path)) files.push({ path: e.path, size: e.size, withheld: e.withheld });
}
lazy.set(p, "listed");
} catch { lazy.set(p, "error"); }
byPath = new Map(files.map((f) => [f.path, f]));
tree = buildTree(files, [...lazy.keys()]);
const had = document.activeElement?.dataset?.dir === p;
renderTree();
if (had) focusRow(treeEl.querySelector(`[data-dir="${CSS.escape(p)}"]`));
}
function reveal(p) { // open the folders on the way to a file
const parts = p.split("/");
for (let i = 1; i < parts.length; i++) open.add(parts.slice(0, i).join("/"));
}
// ── the open file ──
function setBar(f, d) {
const parts = f.path.split("/");
el.querySelector(".fv-path").innerHTML = `${parts.slice(0, -1).map((x) => `${esc(x)}/`).join("")}${esc(parts.at(-1))}`;
el.querySelector(".fv-path").title = f.path;
const lang = d?.text != null ? langOf(f.path, d.text.slice(0, 120)) : null;
const meta = [d?.text != null ? `${fmt.format(lineCount(d.text))} line${lineCount(d.text) === 1 ? "" : "s"}` : null, bytes(d?.size ?? f.size), LANG_NAMES[lang]].filter(Boolean);
el.querySelector(".fv-meta").textContent = f.withheld || d?.withheld ? "not shown" : meta.join(" · ");
const hub = el.querySelector('[data-act="hub"]'), url = f.withheld ? null : hubUrl(f.path);
hub.hidden = !url;
if (url) hub.href = url;
const isMd = /\.(md|markdown)$/i.test(f.path) && d?.text != null;
el.querySelector(".fv-mdseg").hidden = !isMd;
el.querySelectorAll("[data-md]").forEach((b) => b.setAttribute("aria-pressed", String(b.dataset.md === mdMode)));
const textual = d?.text != null && !(isMd && mdMode === "preview");
el.querySelector('[data-act="wrap"]').disabled = !textual;
el.querySelector('[data-act="copy"]').disabled = d?.text == null;
pick.querySelector(".fv-pick-p").textContent = f.path;
}
function placeholder(ic, title, text = "", action = "") {
return `
${icon(ic, 22)}${title}${text ? `
${text}
` : ""}${action}
`;
}
function renderFile(f, d) {
setBar(f, d);
const hub = f.withheld ? null : hubUrl(f.path);
const onHub = hub ? `${icon("external", 13)}Open on the Hub` : "";
if (f.withheld || d.withheld) { body.innerHTML = placeholder("lock", "Not shown", "Part of the reference solution or the expected answer. A browsing tool is not the place to hand those out."); return; }
if (d.error) { body.innerHTML = placeholder("alert", "Couldn't open this file", esc(d.error), onHub); return; }
if (d.binary) {
const img = IMAGE.test(f.path) && rawUrl(f.path);
body.innerHTML = img ? `