- pendingCommand captured in htmx:beforeRequest (not requestConfig). - ensureLoaded shares a single inflight Promise across concurrent calls. - Document why synthetic null-id entries are safe in the cache. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
179 lines
5.5 KiB
JavaScript
179 lines
5.5 KiB
JavaScript
// 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);
|
|
});
|