const base64 = "{{}}";
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
let wasm;
// --- Wire protocol (matches src/wasm/wire.rs) -------------------------------
// Structured data crosses the FFI as UTF-8 using two ASCII control characters
// as separators: Unit Separator between fields, Record Separator between rows.
const US = "\u001f";
const RS = "\u001e";
// How many rows to sample and how many distinct values to keep per column when
// inferring a column's type/shape.
const SAMPLE_ROWS = 50;
const SAMPLE_POOL = 20;
// --- AG Grid discovery ------------------------------------------------------
// Each entry: { wrapper, id, api, rowModel, index }. Rebuilt on every scan.
let agGrids = [];
// Text of the most recently loaded config file, pulled by `config_text`.
let pendingConfig = "";
// Whether the flyout panel is expanded. Held in JS because every render
// replaces the panel's innerHTML, so the open state must be re-applied after
// each mount rather than living in the (regenerated) markup.
let panelOpen = false;
// Which grid's detail view is open, or null for the searchable list view. Like
// `panelOpen`, this is JS state re-applied after every render (the markup
// always carries both views; JS decides which is visible).
let selectedIndex = null;
// Current text in the list search box, preserved across re-renders.
let searchQuery = "";
// Reflect `panelOpen` onto every flyout by toggling the `hecatomb-open` class
// the CSS keys off. Called after each mount so re-renders keep the panel state.
function applyPanelState() {
document.querySelectorAll(".hecatomb-flyout").forEach((el) => {
el.classList.toggle("hecatomb-open", panelOpen);
});
}
// Snapshot the row-count inputs (keyed by grid index) before a re-render. Like
// `panelOpen`, the typed value lives only in the DOM the next render replaces,
// so it must be carried across by hand. Without this, generating into a
// server-side grid resets the field: the grid is mid-reload when we re-render,
// so it reports a single loading row and the input rebuilds with value 1.
function captureCounts() {
const counts = {};
document.querySelectorAll(".heca-count").forEach((el) => {
const key = el.getAttribute("data-index");
if (key !== null) counts[key] = el.value;
});
return counts;
}
// Re-apply values captured by `captureCounts` onto the freshly rendered inputs.
function restoreCounts(counts) {
document.querySelectorAll(".heca-count").forEach((el) => {
const key = el.getAttribute("data-index");
if (key !== null && key in counts) el.value = counts[key];
});
}
// Show either the searchable list or the selected grid's detail view, matching
// `selectedIndex`. Re-applied after every mount since each render rebuilds the
// markup with both views present (the detail views start hidden).
function applyViewState() {
// Forget a selection whose grid no longer exists (e.g. after a rescan).
if (
selectedIndex !== null &&
!document.querySelector('.heca-detail[data-index="' + selectedIndex + '"]')
) {
selectedIndex = null;
}
const showList = selectedIndex === null;
document.querySelectorAll(".heca-list").forEach((el) => {
el.style.display = showList ? "" : "none";
});
document.querySelectorAll(".heca-detail").forEach((el) => {
const match = el.getAttribute("data-index") === String(selectedIndex);
el.style.display = !showList && match ? "" : "none";
});
}
// Reflect `searchQuery` into the search box and filter the grid list by it.
// Filtering lives in JS so it stays responsive per keystroke without an FFI
// round-trip (which would also drop input focus).
function applySearch() {
document.querySelectorAll(".heca-search").forEach((el) => {
if (el.value !== searchQuery) el.value = searchQuery;
});
const q = searchQuery.trim().toLowerCase();
document.querySelectorAll(".hecatomb-flyout").forEach((flyout) => {
let shown = 0;
flyout.querySelectorAll(".heca-grid-row").forEach((row) => {
const match = !q || (row.textContent || "").toLowerCase().indexOf(q) !== -1;
// Hide the <li> wrapper so the gap between rows collapses too.
const item = row.closest("li") || row;
item.style.display = match ? "" : "none";
if (match) shown++;
});
const empty = flyout.querySelector(".heca-empty");
if (empty) empty.style.display = q && shown === 0 ? "" : "none";
});
}
// --- wasm memory helpers ----------------------------------------------------
// Memory can grow (detaching old buffers), so always take a fresh view.
function mem() {
return new Uint8Array(wasm.instance.exports.memory.buffer);
}
function decodeMem(ptr, len) {
return new TextDecoder().decode(mem().subarray(ptr, ptr + len));
}
// Write `str` into wasm memory at `outPtr`, capped at `outCap` bytes. Returns
// the *full* byte length so Rust can grow its buffer and ask again if needed.
function writeMem(str, outPtr, outCap) {
const out = new TextEncoder().encode(str);
mem().set(out.subarray(0, Math.min(out.length, outCap)), outPtr);
return out.length;
}
function sanitize(s) {
// Keep field/record separators out of sampled values.
return String(s).replace(/[\u001e\u001f]/g, " ");
}
function safeJson(v) {
try {
return JSON.stringify(v);
} catch (e) {
return String(v);
}
}
// --- Grid API access --------------------------------------------------------
// Retrieve the GridApi for a `.ag-root-wrapper`. The wrapper is the immediate
// child of the element passed to `createGrid`, which is what `getGridApi`
// accepts. Requires the UMD global (present on the test fixture); degrades
// gracefully to DOM-only info when unavailable.
function getApi(wrapper) {
const g = typeof window !== "undefined" ? window.agGrid : null;
if (!g || typeof g.getGridApi !== "function") return null;
// Primary: the wrapper is the immediate child of the createGrid element.
try {
const api = g.getGridApi(wrapper);
if (api) return api;
} catch (e) {}
// Fallback: reference the grid div itself by id selector.
try {
const parent = wrapper.parentElement;
if (parent && parent.id) {
const sel = "#" + (window.CSS && CSS.escape ? CSS.escape(parent.id) : parent.id);
const api = g.getGridApi(sel);
if (api) return api;
}
} catch (e) {}
return null;
}
function rowModelOf(api) {
try {
let t = api.getGridOption && api.getGridOption("rowModelType");
if (!t && api.getModel && api.getModel()) t = api.getModel().getType();
return t || "clientSide";
} catch (e) {
return "clientSide";
}
}
// Columns as { field, header } in display order.
function columnsOf(api) {
let cols = [];
try {
const agCols = api.getColumns ? api.getColumns() : null;
if (agCols && agCols.length) {
cols = agCols.map((c) => {
const def = (c.getColDef && c.getColDef()) || {};
const field = def.field || (c.getColId && c.getColId()) || "";
return { field, header: def.headerName || field };
});
} else if (api.getColumnDefs) {
const defs = api.getColumnDefs() || [];
cols = defs
.filter((d) => d && (d.field || d.colId))
.map((d) => ({ field: d.field || d.colId, header: d.headerName || d.field || d.colId }));
}
} catch (e) {}
return cols.filter((c) => c.field);
}
// Record one sampled value against a column meta accumulator.
function recordSample(m, v) {
if (v === null || v === undefined) return;
let hint, s;
if (v instanceof Date) {
hint = "date";
s = String(v.getTime());
} else {
hint = typeof v;
if (hint === "number") s = String(v);
else if (hint === "boolean") s = v ? "true" : "false";
else if (hint === "object") {
hint = "string";
s = sanitize(safeJson(v));
} else s = sanitize(String(v));
}
if (m.hint === null) m.hint = hint;
else if (m.hint !== hint && !(m.hint === "number" && hint === "number")) m.hint = "string";
if (m.samples.length < SAMPLE_POOL) m.samples.push(s);
}
// Walk up to SAMPLE_ROWS loaded rows. Works for both row models: only loaded
// nodes have `.data`, so server-side stubs are skipped.
function sampleColumns(api, cols) {
const meta = cols.map((c) => ({ field: c.field, header: c.header, hint: null, samples: [] }));
let total = 0;
try {
total = api.getDisplayedRowCount ? api.getDisplayedRowCount() : 0;
} catch (e) {}
let seen = 0;
for (let i = 0; i < total && seen < SAMPLE_ROWS; i++) {
let node = null;
try {
node = api.getDisplayedRowAtIndex ? api.getDisplayedRowAtIndex(i) : null;
} catch (e) {}
const data = node && node.data;
if (!data) continue;
seen++;
for (const m of meta) recordSample(m, data[m.field]);
}
return { meta, total };
}
// (Re)scan the page for AG Grid instances and capture their API + row model.
function scanGrids() {
const wrappers = document.querySelectorAll(".ag-root-wrapper");
agGrids = Array.from(wrappers).map((wrapper, index) => {
// Prefer the grid div's own id (the createGrid element); fall back to the
// nearest id-bearing ancestor for naming.
const host = wrapper.closest("[id]");
const parent = wrapper.parentElement;
const id = (parent && parent.id) || (host && host.id) || "";
const api = getApi(wrapper);
const rowModel = api ? rowModelOf(api) : "clientSide";
return { wrapper, id, api, rowModel, index };
});
return agGrids;
}
const ROW_MODEL_BADGES = {
clientSide: "client-side",
serverSide: "server-side",
infinite: "infinite",
viewport: "viewport",
};
// Build the `label\tbadge\tdefaultCount` triple consumed by grid::split_label.
function describe(g) {
const api = g.api;
let cols = 0;
let rows = 0;
if (api) {
try {
const c = api.getColumns ? api.getColumns() : null;
cols = c ? c.length : api.getColumnDefs ? (api.getColumnDefs() || []).length : 0;
} catch (e) {}
try {
rows = api.getDisplayedRowCount ? api.getDisplayedRowCount() : 0;
} catch (e) {}
}
if (!cols || !rows) {
const root = g.wrapper.querySelector('[role="grid"]') || g.wrapper;
if (!cols) {
const colAttr = parseInt(root.getAttribute("aria-colcount"), 10);
cols =
Number.isFinite(colAttr) && colAttr > 0
? colAttr
: g.wrapper.querySelectorAll(".ag-header-cell").length;
}
if (!rows) {
const headerRows = g.wrapper.querySelectorAll(".ag-header-row").length || 1;
const rowAttr = parseInt(root.getAttribute("aria-rowcount"), 10);
rows =
Number.isFinite(rowAttr) && rowAttr >= 0
? Math.max(0, rowAttr - headerRows)
: g.wrapper.querySelectorAll(".ag-row").length;
}
}
const name = g.id ? "#" + g.id : "Grid " + (g.index + 1);
const badge = ROW_MODEL_BADGES[g.rowModel] || g.rowModel;
const label = name + " — " + cols + " columns × " + rows + " rows";
const count = rows > 0 ? rows : 100;
return [label, badge, String(count)].join("\t");
}
// Sample grid `index` and encode its schema for the wasm side.
// Record 0: rowModel US totalRows US gridId. Then one record per column:
// field US header US typeofHint US sample US sample ...
function buildSchemaPayload(index) {
const g = agGrids[index];
if (!g) return "";
const cols = g.api ? columnsOf(g.api) : [];
let total = 0;
let meta = cols.map((c) => ({ field: c.field, header: c.header, hint: "string", samples: [] }));
if (g.api) {
const sampled = sampleColumns(g.api, cols);
meta = sampled.meta;
total = sampled.total;
}
const metaRec = [g.rowModel || "clientSide", String(total), g.id || ""].join(US);
const colRecs = meta.map((m) =>
[m.field, m.header, m.hint || "string"].concat(m.samples).join(US),
);
return [metaRec].concat(colRecs).join(RS);
}
// --- Injection (data overwrite) --------------------------------------------
function coerceVal(kind, s) {
if (s === undefined) return null;
switch (kind) {
case "bool":
return s === "true";
case "number":
return Number(s);
case "date":
return new Date(Number(s));
default:
return s;
}
}
// Push synthetic rows into the grid according to its row model.
function applyRows(g, rows) {
const api = g.api;
try {
if (g.rowModel === "serverSide") {
api.setGridOption("serverSideDatasource", {
getRows(params) {
const start = params.request.startRow || 0;
const end = params.request.endRow != null ? params.request.endRow : rows.length;
params.success({ rowData: rows.slice(start, end), rowCount: rows.length });
},
});
} else if (g.rowModel === "infinite") {
api.setGridOption("datasource", {
getRows(params) {
params.successCallback(rows.slice(params.startRow, params.endRow), rows.length);
},
});
} else {
api.setGridOption("rowData", rows);
}
} catch (e) {
console.warn("hecatomb: injection failed", e);
}
}
// Decode the generated-row payload and inject it. Self-describing header of
// field US coerce pairs means this works for both live and config-driven data.
function applyInjection(index, text) {
const g = agGrids[index];
if (!g || !g.api) return 0;
const records = text.split(RS);
if (!records.length) return 0;
const head = records[0].split(US);
const fields = [];
const coerce = [];
for (let i = 0; i + 1 < head.length; i += 2) {
fields.push(head[i]);
coerce.push(head[i + 1]);
}
const rows = [];
for (let r = 1; r < records.length; r++) {
if (records[r] === "") continue;
const cells = records[r].split(US);
const obj = {};
for (let c = 0; c < fields.length; c++) obj[fields[c]] = coerceVal(coerce[c], cells[c]);
rows.push(obj);
}
applyRows(g, rows);
return 1;
}
function doDownload(name, data) {
try {
const blob = new Blob([data], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = name || "config.heca.txt";
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (e) {
console.warn("hecatomb: download failed", e);
}
}
const importObject = {
env: {
console_log: (ptr, len) => {
console.log(decodeMem(ptr, len));
},
mount_view: (ptr, len) => {
const html = decodeMem(ptr, len);
// Preserve any user-typed row counts: replacing innerHTML below would
// otherwise reset them to the freshly observed default.
const counts = captureCounts();
document.querySelectorAll(".hecatomb").forEach((el) => {
el.innerHTML = html;
});
restoreCounts(counts);
// The markup is regenerated on every render, so re-assert the JS-held
// state: panel open/closed, list-vs-detail view, and the search filter.
applyPanelState();
applyViewState();
applySearch();
},
// Scan the DOM for AG Grid instances; returns how many were found.
ag_grid_count: () => scanGrids().length,
// Write `label\tbadge\tdefaultCount` for grid `index` into wasm memory.
ag_grid_label: (index, outPtr, outCap) => {
const g = agGrids[index];
return writeMem(g ? describe(g) : "", outPtr, outCap);
},
// Write the sampled schema for grid `index` into wasm memory.
ag_grid_schema: (index, outPtr, outCap) => writeMem(buildSchemaPayload(index), outPtr, outCap),
// Inject a generated-row payload into grid `index`. Returns 1 on success.
ag_grid_inject: (index, ptr, len) => {
try {
return applyInjection(index, decodeMem(ptr, len));
} catch (e) {
console.warn("hecatomb: inject error", e);
return 0;
}
},
// Trigger a browser download of a config file.
download_text: (namePtr, nameLen, dataPtr, dataLen) => {
doDownload(decodeMem(namePtr, nameLen), decodeMem(dataPtr, dataLen));
},
// Hand the most recently loaded config file's text to the wasm side.
config_text: (outPtr, outCap) => writeMem(pendingConfig, outPtr, outCap),
},
};
wasm = await WebAssembly.instantiate(bytes, importObject);
const exports = wasm.instance.exports;
export default exports;
// --- Config file loading ----------------------------------------------------
// A single reusable hidden file input; remembers which grid requested the load.
let loadTargetIndex = -1;
const fileInput = document.createElement("input");
fileInput.type = "file";
fileInput.accept = ".txt,.heca,text/plain";
fileInput.style.display = "none";
document.body.appendChild(fileInput);
fileInput.addEventListener("change", () => {
const file = fileInput.files && fileInput.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
pendingConfig = String(reader.result || "");
fileInput.value = "";
if (loadTargetIndex >= 0) exports.apply_config(loadTargetIndex);
};
reader.readAsText(file);
});
function indexOf(el) {
return parseInt(el.getAttribute("data-index"), 10) || 0;
}
// Read the row-count input for a grid; 0 tells wasm to use the observed count.
function countFor(index) {
const el = document.querySelector('.heca-count[data-index="' + index + '"]');
const n = el ? parseInt(el.value, 10) : NaN;
if (!Number.isFinite(n) || n < 1) return 0;
return Math.min(n, 1000000);
}
// Delegated on document so it survives every re-render of the panel.
document.addEventListener("click", (event) => {
const toggle = event.target.closest(".hecatomb-toggle");
if (toggle) {
event.preventDefault();
panelOpen = !panelOpen;
applyPanelState();
return;
}
const rescan = event.target.closest(".hecatomb-rescan");
if (rescan) {
event.preventDefault();
exports.rescan();
return;
}
// Open a grid's detail view from the list.
const gridRow = event.target.closest(".heca-grid-row");
if (gridRow) {
event.preventDefault();
selectedIndex = indexOf(gridRow);
applyViewState();
return;
}
// Return from a detail view to the searchable list.
const back = event.target.closest(".heca-back");
if (back) {
event.preventDefault();
selectedIndex = null;
applyViewState();
return;
}
const generate = event.target.closest(".heca-generate");
if (generate) {
event.preventDefault();
const i = indexOf(generate);
exports.generate(i, countFor(i));
return;
}
const save = event.target.closest(".heca-save");
if (save) {
event.preventDefault();
const i = indexOf(save);
exports.save_config(i, countFor(i));
return;
}
const load = event.target.closest(".heca-load");
if (load) {
event.preventDefault();
loadTargetIndex = indexOf(load);
fileInput.click();
}
});
// Filter the grid list as the user types. Delegated so it survives re-renders;
// `searchQuery` is re-applied by `applySearch` after each mount.
document.addEventListener("input", (event) => {
const search = event.target.closest(".heca-search");
if (!search) return;
searchQuery = search.value;
applySearch();
});
// Defer one frame so any AG Grid instances created by the host page have
// finished their initial render before we scan for them.
requestAnimationFrame(() => exports.run());