// 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 = `
${files.length > 12 ? `` : ""}
`; const el = root.querySelector(".fv"), treeEl = el.querySelector(".fv-tree"), body = el.querySelector(".fv-body"); const filterIn = el.querySelector(".fv-filter input"); const pick = el.querySelector(".fv-pick"); // ── the tree ── function rowsOf(node, prefix, depth) { let html = ""; for (const [name, child] of [...node.dirs].sort((a, b) => a[0].localeCompare(b[0]))) { const p = prefix + name, isOpen = open.has(p), st = lazy.get(p); const count = st === "loading" ? `` : st === "unlisted" ? "" : st === "error" ? "!" : fmt.format(child.count); html += `
${icon("chevronRight", 13, "chev")}${icon(isOpen ? "folderOpen" : "folder", 14)}${esc(name)}${count}
`; if (isOpen) html += `
${st === "error" ? `

Couldn't list this folder.

` : ""}${rowsOf(child, p + "/", depth + 1)}
`; } for (const f of [...node.files].sort((a, b) => base(a.path).localeCompare(base(b.path)))) html += fileRow(f, depth, esc(base(f.path))); return html; } const fileRow = (f, depth, label) => `
${icon(fileIcon(f), 14)}${label}${f.withheld ? "hidden" : bytes(f.size)}
`; function renderTree() { if (query) { const q = query.toLowerCase(); const hits = files.filter((f) => f.path.toLowerCase().includes(q)); treeEl.innerHTML = hits.length ? hits.slice(0, 500).map((f) => { const i = f.path.toLowerCase().indexOf(q); const label = `${esc(f.path.slice(0, i))}${esc(f.path.slice(i, i + q.length))}${esc(f.path.slice(i + q.length))}`; return fileRow(f, 0, label); }).join("") : `

No file matches “${esc(query)}”.

`; } 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 ? `
${esc(base(f.path))}
` : placeholder("archive", "Binary file", `${bytes(d.size ?? f.size)}, not shown here.`, onHub); return; } const text = d.text ?? ""; if (!text.length) { body.innerHTML = placeholder("file", "This file is empty"); return; } const isMd = /\.(md|markdown)$/i.test(f.path); const tail = d.truncated ? `
${icon("info", 13)}Showing the first ${bytes(text.length)} of ${bytes(d.size)}.${hub ? ` The whole file is on the Hub.` : ""}
` : ""; if (isMd && mdMode === "preview") { body.innerHTML = `
${md(text)}
${tail}`; body.scrollTop = 0; return; } const lang = text.length <= HIGHLIGHT_MAX ? langOf(f.path, text.slice(0, 120)) : null; const rows = highlight(text.endsWith("\n") ? text.slice(0, -1) : text, lang); body.innerHTML = `
${rows.map((h, i) => `
${h || " "}
`).join("")}
${tail}`; paintLines(true); } function paintLines(scroll) { body.querySelectorAll(".ln.hl").forEach((x) => x.classList.remove("hl")); const r = parseLines(lines); if (!r) { if (scroll) body.scrollTop = 0; return; } const all = body.querySelectorAll(".ln"); for (let i = r[0]; i <= Math.min(r[1], all.length); i++) all[i - 1].classList.add("hl"); if (scroll && all[r[0] - 1]) body.scrollTop = Math.max(0, all[r[0] - 1].offsetTop - 48); } async function openFile(p, ln = null, { focus = false, silent = false } = {}) { const f = byPath.get(p); if (!f) return; current = p; lines = ln; reveal(p); if (!query) renderTree(); else treeEl.querySelectorAll(".fv-row.fv-file").forEach((r) => r.setAttribute("aria-selected", String(r.dataset.path === p))); const row = treeEl.querySelector(`.fv-row.fv-file[data-path="${CSS.escape(p)}"]`); if (row) { treeEl.querySelectorAll('.fv-row[tabindex="0"]').forEach((x) => (x.tabIndex = -1)); row.tabIndex = 0; row.scrollIntoView({ block: "nearest" }); if (focus) row.focus({ preventScroll: true }); } el.classList.remove("picking"); pick.setAttribute("aria-expanded", "false"); if (!silent) onChange(current, lines); // the file opened by default isn't put in the page's URL if (f.withheld) { renderFile(f, { withheld: true }); return; } setBar(f, null); const t = setTimeout(() => { if (current === p) body.innerHTML = `
${Array.from({ length: 9 }, (_, i) => ``).join("")}
`; }, 120); const d = await get(p); clearTimeout(t); if (current !== p) return; // another file was picked meanwhile renderFile(f, d); } function setFull(on) { full = on; el.classList.toggle("full", on); document.body.classList.toggle("fv-locked", on); const b = el.querySelector('[data-act="full"]'); b.innerHTML = icon(on ? "minimize" : "maximize", 15); b.title = on ? "Close full view (Esc)" : "Full view (Esc to close)"; if (on) { el.insertAdjacentHTML("beforebegin", '
'); } else root.querySelector(".fv-scrim")?.remove(); } // ── events ── el.addEventListener("click", async (e) => { const t = e.target; const row = t.closest(".fv-row"); if (row) { if (row.dataset.dir) toggleDir(row.dataset.dir); else openFile(row.dataset.path); return; } if (t.closest(".fv-pick")) { const on = !el.classList.contains("picking"); el.classList.toggle("picking", on); pick.setAttribute("aria-expanded", String(on)); return; } const n = t.closest("[data-line]"); if (n) { e.preventDefault(); const k = +n.dataset.line, r = parseLines(lines); lines = e.shiftKey && r ? `${Math.min(r[0], k)}-${Math.max(r[1], k)}` : (r && r[0] === k && r[1] === k ? null : String(k)); paintLines(false); onChange(current, lines); return; } const m = t.closest("[data-md]"); if (m) { mdMode = m.dataset.md; storage.set("fv-md", mdMode); renderFile(byPath.get(current), await get(current)); return; } const act = t.closest("[data-act]")?.dataset.act; if (act === "wrap") { wrap = !wrap; storage.set("fv-wrap", wrap ? "1" : "0"); t.closest("[data-act]").setAttribute("aria-pressed", String(wrap)); body.querySelector(".fv-code")?.classList.toggle("fv-wrap", wrap); } else if (act === "copy") { const d = await get(current); if (d?.text == null) return; navigator.clipboard?.writeText(d.text).then(() => toast(d.truncated ? "Copied the part shown" : "File copied"), () => toast("Couldn't copy")); } else if (act === "link") { onChange(current, lines); navigator.clipboard?.writeText(location.href).then(() => toast("Link copied"), () => toast("Couldn't copy")); } else if (act === "full") setFull(!full); }); root.addEventListener("click", (e) => { if (e.target.classList.contains("fv-scrim")) setFull(false); }); el.addEventListener("pointerover", (e) => { const row = e.target.closest(".fv-row.fv-file:not(.withheld)"); clearTimeout(hoverT); if (row) hoverT = setTimeout(() => get(row.dataset.path), 140); }); treeEl.addEventListener("keydown", (e) => { const rows = visibleRows(), i = rows.indexOf(document.activeElement), r = rows[i]; if (i < 0) return; const k = e.key; if (k === "ArrowDown") focusRow(rows[i + 1]); else if (k === "ArrowUp") focusRow(rows[i - 1]); else if (k === "Home") focusRow(rows[0]); else if (k === "End") focusRow(rows.at(-1)); else if (k === "ArrowRight" && r.dataset.dir) { if (!open.has(r.dataset.dir)) toggleDir(r.dataset.dir, true); else focusRow(rows[i + 1]); } else if (k === "ArrowLeft") { if (r.dataset.dir && open.has(r.dataset.dir)) toggleDir(r.dataset.dir, false); else { const parent = (r.dataset.dir || r.dataset.path).split("/").slice(0, -1).join("/"); if (parent) focusRow(treeEl.querySelector(`[data-dir="${CSS.escape(parent)}"]`)); } } else if (k === "Enter" || k === " ") { if (r.dataset.dir) toggleDir(r.dataset.dir); else openFile(r.dataset.path, null, { focus: true }); } else return; e.preventDefault(); }); filterIn?.addEventListener("input", () => { query = filterIn.value.trim(); renderTree(); }); filterIn?.addEventListener("keydown", (e) => { if (e.key === "ArrowDown") { e.preventDefault(); focusRow(treeEl.querySelector(".fv-row")); } if (e.key === "Enter") { const first = treeEl.querySelector(".fv-row.fv-file"); if (first) openFile(first.dataset.path); } if (e.key === "Escape" && filterIn.value) { e.stopPropagation(); filterIn.value = ""; query = ""; renderTree(); } }); const onKey = (e) => { if (e.key === "Escape" && full) { setFull(false); return; } if (e.key === "/" && full && document.activeElement !== filterIn && filterIn) { e.preventDefault(); filterIn.focus(); } }; document.addEventListener("keydown", onKey); const first = (initial && byPath.has(initial) && initial) || ["instruction.md", "README.md", "task.toml"].find((p) => byPath.has(p) && !byPath.get(p).withheld) || files.find((f) => !f.withheld)?.path || files[0]?.path; renderTree(); if (first) openFile(first, initial === first ? line : null, { silent: initial !== first }); else body.innerHTML = placeholder("folder", "No files"); return { open: (p, ln) => openFile(p, ln), destroy: () => { document.removeEventListener("keydown", onKey); document.body.classList.remove("fv-locked"); }, }; } // A read-only highlighted block with line numbers, for code shown inline on a page (test.sh, a Dockerfile). export function codeBlock(text, path, { tall = false } = {}) { const t = String(text ?? "").replace(/\n$/, ""); const rows = highlight(t, t.length <= HIGHLIGHT_MAX ? langOf(path, t.slice(0, 120)) : null); return `
${rows.map((h, i) => `
${h || " "}
`).join("")}
`; }