// console-history.js // Binds ArrowUp/Down history recall to [data-console-form] elements. // Mirrors the style of sse.js: vanilla JS, dataset attributes, no framework. function bindConsoleForm(form) { if (form.dataset.consoleHistoryBound === "true") { return; } form.dataset.consoleHistoryBound = "true"; const serverId = form.dataset.serverId; const input = form.querySelector("input[name='command']"); if (!input || !serverId) { return; } // --- History cache state --- // Entries are stored newest-first to match the API response shape. let cache = []; let cacheLoaded = false; let loadingPromise = null; let cursor = -1; // -1 = "not in history" (at the live input) let snapshot = ""; // saved input value for restoring via ArrowDown let oldestId = null; // id of the oldest cached entry, used for pagination let exhausted = false; // true when we've fetched all available history let pendingCommand = null; // captured before HTMX submit; used by afterRequest async function loadHistory(params) { const url = new URL(`/servers/${serverId}/console/history`, location.origin); url.searchParams.set("limit", "50"); if (params && params.before != null) { url.searchParams.set("before", params.before); } try { const res = await fetch(url.toString()); if (!res.ok) return []; return await res.json(); } catch { return []; } } async function ensureLoaded() { if (cacheLoaded) return; if (!loadingPromise) { loadingPromise = (async () => { const entries = await loadHistory(); cache = entries; // newest-first from API if (cache.length > 0) { oldestId = cache[cache.length - 1].id; } if (entries.length < 50) { exhausted = true; } cacheLoaded = true; })(); } await loadingPromise; } async function fetchOlderPage() { if (exhausted || oldestId == null) return; const entries = await loadHistory({ before: oldestId }); if (entries.length === 0) { exhausted = true; return; } cache = cache.concat(entries); // append older entries at the end oldestId = cache[cache.length - 1].id; if (entries.length < 50) { exhausted = true; } } input.addEventListener("focus", () => { ensureLoaded(); }, { once: true }); input.addEventListener("keydown", async (event) => { if (event.key !== "ArrowUp" && event.key !== "ArrowDown") { return; } event.preventDefault(); // Ensure we have history before navigating. await ensureLoaded(); if (event.key === "ArrowUp") { if (cursor === -1) { // Entering history from live input — save whatever is typed. snapshot = input.value; } const nextCursor = cursor + 1; // If we've reached the end of cache, try fetching an older page. if (nextCursor >= cache.length) { await fetchOlderPage(); } if (nextCursor < cache.length) { cursor = nextCursor; input.value = cache[cursor].command; } // else: already at oldest end, stay put. } else { // ArrowDown if (cursor <= -1) { // Already at live input, nothing to do. return; } const nextCursor = cursor - 1; if (nextCursor < 0) { // Return to live input, restore snapshot. cursor = -1; input.value = snapshot; } else { cursor = nextCursor; input.value = cache[cursor].command; } } }); // Capture the command value before HTMX submits the form; avoids relying on // event.detail.requestConfig.parameters which changed between HTMX 1.x / 2.x. form.addEventListener("htmx:beforeRequest", () => { const commandInput = form.querySelector("input[name='command']"); pendingCommand = commandInput ? commandInput.value : null; }); // After a successful HTMX POST: prepend the sent command to cache. form.addEventListener("htmx:afterRequest", (event) => { if (!event.detail.successful) return; // Use the value captured before the request; fall back to snapshot. const command = pendingCommand || snapshot || ""; pendingCommand = null; if (!command) return; // Prepend as the newest entry. // id=null is safe here: pagination uses oldestId (the real persisted-row id) // for ?before= queries, so a synthetic null-id entry doesn't break paging. cache.unshift({ id: null, command }); // Reset cursor so ArrowUp immediately recalls this command. cursor = -1; snapshot = ""; }); } function bindAllConsoleForms(root) { if (!root) return; const scope = root.matches && root.matches("[data-console-form]") ? [root] : []; if (root.querySelectorAll) { root.querySelectorAll("[data-console-form]").forEach((el) => scope.push(el)); } scope.forEach(bindConsoleForm); } function scrollConsolesToBottom(root) { if (!root) return; const scope = root.matches && root.matches("[data-autoscroll]") ? [root] : []; if (root.querySelectorAll) { root.querySelectorAll("[data-autoscroll]").forEach((el) => scope.push(el)); } scope.forEach((el) => { el.scrollTop = el.scrollHeight; }); } document.addEventListener("DOMContentLoaded", () => { scrollConsolesToBottom(document); bindAllConsoleForms(document); }); // Support HTMX-injected content (mirrors sse.js pattern). document.addEventListener("htmx:load", (event) => { scrollConsolesToBottom(event.detail.elt); bindAllConsoleForms(event.detail.elt); });