/** * Notebook chrome: traceback highlight, export split-button, live figures, * inline CSV tables, binary elision. Window contract names assigned in install.ts. */ import { fetchArtifactText } from "../artifacts/api"; import { filesT } from "../artifacts/copy"; import { paintIcon } from "../icons/paths"; import { isReady } from "../../compat/stub"; import { _artBust, _tbl, artifacts } from "../../stores/artifacts"; import { t } from "../../i18n/runtime"; import { delimiterFor, parseDelimited } from "../csv/csv"; import { esc } from "../md/esc"; import { mdHighlight } from "../md/highlight"; import { API } from "../ws/connect"; import type { WsMessage } from "../ws/types"; import { bindNotebookArtifact, resetCellOutputs } from "./cells"; import type { NotebookCell, NotebookOutputArtifact } from "./types"; export function el(tag: string, cls?: string | null, text?: string | null): HTMLElement { const node = document.createElement(tag); if (cls) node.className = cls; if (text != null) node.textContent = text; return node; } export function iconEl(name: string, size: number): HTMLElement { const fn = (globalThis as unknown as { iconEl?: unknown }).iconEl; if (isReady(fn)) return (fn as (n: string, s: number) => HTMLElement)(name, size); // Nothing installs a global iconEl, so this is the path every notebook and // provenance icon takes; paintIcons() has long run by then. const span = document.createElement("span"); paintIcon(span, name, size); return span; } export function bytes(b: number): string { b = b || 0; if (b < 1024) return b + " B"; if (b < 1048576) return (b / 1024).toFixed(1) + " KB"; return (b / 1048576).toFixed(1) + " MB"; } /** app.js:6055-6066 */ export function looksBinary(s: string | null | undefined): boolean { if (!s) return false; const sample = s.slice(0, 4096); let ctrl = 0; for (let i = 0; i < sample.length; i++) { const c = sample.charCodeAt(i); if (c === 9 || c === 10 || c === 13) continue; if (c < 32 || c === 127 || c === 0xfffd) ctrl++; } if (sample.length && ctrl / sample.length > 0.12) return true; return /[A-Za-z0-9+/=]{1200,}/.test(s) || /(?:\\x[0-9a-fA-F]{2}){400,}/.test(s); } export function binElide(len: number): HTMLElement { const d = el("div", "bin-elide"); d.appendChild(iconEl("file", 13)); d.appendChild(el("span", null, t("output.binaryElided", bytes(len || 0)))); return d; } /** app.js:10480-10481 */ export function stripAnsi(s: unknown): string { return String(s == null ? "" : s).replace(/\x1b\[[0-9;?]*[A-Za-z]/g, ""); } /** * Colour File/line locations and the terminal ExceptionType line. * Port of app.js:10513-10521. Uses F-08 `esc` (quote-safe). */ export function highlightTraceback(txt: string): string { const lines = txt.split("\n"); let lastIdx = -1; for (let i = lines.length - 1; i >= 0; i--) { if ((lines[i] || "").trim()) { lastIdx = i; break; } } return lines .map((ln, i) => { const e = esc(ln); if (/^\s*File ".*", line \d+/.test(ln)) return '' + e + ""; if ( i === lastIdx && /^[A-Za-z_][\w.]*(Error|Exception|Warning|Interrupt|Exit|Fault)\b/.test(ln.trim()) ) { return '' + e + ""; } return e; }) .join("\n"); } const highlightMemo = new Map(); /** Highlight only when this cell's source (or lang) actually changed. */ export function highlightCellSource(key: string, source: string, lang: string): string { const prev = highlightMemo.get(key); if (prev && prev.source === source && prev.lang === lang) return prev.html; const html = mdHighlight(source, lang); highlightMemo.set(key, { source, lang, html }); return html; } export function resetHighlightMemo(): void { highlightMemo.clear(); } /** Drop cell-keyed render state only when the workbench moves to another frame. */ export function resetNotebookCellCaches( previousFrameId: string | null | undefined, nextFrameId: string | null | undefined, ): void { if (previousFrameId === nextFrameId) return; resetCellOutputs(); resetHighlightMemo(); } export type NotebookExportOption = { language?: string; key: string; suffix: string; path?: string; }; /** app.js:10119-10132 */ export const NOTEBOOK_EXPORTS: NotebookExportOption[] = [ { language: "bundle", key: "prov.exec.downloadNotebook", suffix: "notebooks.zip" }, { language: "python", key: "prov.exec.downloadPython", suffix: "python.ipynb" }, { language: "r", key: "prov.exec.downloadR", suffix: "r.ipynb" }, { language: "markdown", key: "prov.exec.downloadMarkdown", suffix: "md" }, { path: "/execution-sources/export", key: "prov.exec.downloadSources", suffix: "sources.zip" }, ]; /** app.js:10133-10136 */ export function notebookExportHref(frameId: string, option: NotebookExportOption): string { const base = `${API}/frames/${encodeURIComponent(frameId)}`; return option.path ? `${base}${option.path}` : `${base}/notebook/export?language=${option.language}`; } /** app.js:10231-10264. Contract global. */ export function notebookExportLink(frameId: string): HTMLElement { const wrap = el("div", "prov-dl"); const primary = NOTEBOOK_EXPORTS[0] as NotebookExportOption; const dl = el("a", "prov-dlbtn") as HTMLAnchorElement; dl.appendChild(iconEl("download", 14)); dl.appendChild(el("span", null, t(primary.key))); dl.href = notebookExportHref(frameId, primary); dl.setAttribute("download", `${frameId}.${primary.suffix}`); wrap.appendChild(dl); const toggle = el("button", "prov-dlmore"); toggle.setAttribute("aria-label", t("prov.exec.downloadMore")); toggle.setAttribute("aria-expanded", "false"); toggle.appendChild(iconEl("chevron-down", 13)); const menu = el("div", "prov-dlmenu hidden"); NOTEBOOK_EXPORTS.slice(1).forEach((option) => { const item = el("a", "prov-dlitem") as HTMLAnchorElement; item.appendChild(el("span", null, t(option.key))); item.href = notebookExportHref(frameId, option); item.setAttribute("download", `${frameId}.${option.suffix}`); item.onclick = () => { menu.classList.add("hidden"); toggle.setAttribute("aria-expanded", "false"); }; menu.appendChild(item); }); toggle.onclick = (e) => { e.preventDefault(); e.stopPropagation(); const open = menu.classList.toggle("hidden") === false; toggle.setAttribute("aria-expanded", open ? "true" : "false"); }; wrap.appendChild(toggle); wrap.appendChild(menu); return wrap; } export function artUrl(a: { id?: string }): string { const b = (_artBust.value || {})[String(a.id)]; return `${API}/artifacts/${a.id}` + (b ? `?_=${b}` : ""); } /** app.js:9676-9681 */ export function artUrlByName(fname: string): string { if (!fname) return ""; const base = String(fname).split("/").pop(); const list = Array.isArray(artifacts.value) ? artifacts.value : []; const a = list.find((x) => { const rec = x as { filename?: string }; return (rec.filename || "") === fname || (rec.filename || "").split("/").pop() === base; }) as { id?: string } | undefined; return a ? artUrl(a) : `${API}/artifacts/${encodeURIComponent(fname)}`; } export function notebookArtifactState(cell: NotebookCell, filename: string): { state: "confirmed" | "saving" | "unconfirmed"; artifact?: NotebookOutputArtifact; } { const matches = (cell.output_artifacts || []).filter((row) => row.filename === filename); const unique = new Map(matches.map((row) => [JSON.stringify([row.artifact_id, row.version_id]), row])); if (unique.size === 1) return { state: "confirmed", artifact: [...unique.values()][0] }; return { state: cell.live || cell._artifactBindingsPending ? "saving" : "unconfirmed" }; } /** app.js:9708-9744. `_tbl` lives in the artifacts store (F-06 already busts it). */ export function renderTableInto(holder: HTMLElement, fname: string, url: string): () => void { let disposed = false; const build = (rows: string[][]) => { if (disposed || !rows || !rows.length) return; const view = rows.slice(0, 51); const width = rows.reduce((most, r) => Math.max(most, (r || []).length), 0); const tbl = el("table", "nbc-table"); const thead = el("thead"); const htr = el("tr"); (view[0] || []).slice(0, 24).forEach((h) => htr.appendChild(el("th", null, h))); thead.appendChild(htr); tbl.appendChild(thead); const tb = el("tbody"); view.slice(1).forEach((r) => { const tr = el("tr"); r.slice(0, 24).forEach((cell) => tr.appendChild(el("td", null, cell))); tb.appendChild(tr); }); tbl.appendChild(tb); const scroll = el("div", "nbc-table-scroll"); scroll.appendChild(tbl); holder.appendChild(scroll); const hiddenRows = Math.max(0, rows.length - 51); const hiddenCols = Math.max(0, width - 24); if (hiddenRows && hiddenCols) { holder.appendChild(el("div", "nbc-table-more", t("nb.table.bothHidden", hiddenRows, hiddenCols))); } else if (hiddenRows) { holder.appendChild(el("div", "nbc-table-more", t("nb.table.rowsHidden", hiddenRows))); } else if (hiddenCols) { holder.appendChild(el("div", "nbc-table-more", t("nb.table.colsHidden", hiddenCols))); } }; const cache = _tbl.value || {}; const hit = cache[url]; if (hit) { build(hit as string[][]); return () => { disposed = true; }; } const read = () => fetchArtifactText(url) .then((text) => { if (disposed) return; const firstLine = text.replace(/\r/g, "").split("\n", 1)[0] || ""; const rows = parseDelimited(text, delimiterFor(fname, "", firstLine)); cache[url] = rows; build(rows); }) .catch(() => { if (disposed) return; const error = el("div", "nbc-artifact-error", filesT("nb.artifact.failed")); const retry = el("button", "nbc-action", filesT("nb.artifact.retry")); retry.onclick = () => { error.remove(); void read(); }; error.appendChild(retry); holder.appendChild(error); }); void read(); return () => { disposed = true; }; } /** * Live-render a produced figure onto the current notebook cell. * app.js:5337-5341. `_tbl` bust is already in F-06 upsertArtifactFromEvent. */ export function mountLiveNotebookFigure(m: WsMessage): void { bindNotebookArtifact(m); }