Skip to content
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());