left4me/l4d2web/l4d2web/static/js/console-history.js
mwiegand 49992b3a26
refactor(repo): uv workspace + hatchling + layout restructure
Migrate from pip-install-e + setuptools to a uv workspace with a
committed uv.lock for deterministic deps. Switch both members to
hatchling, and move package sources into nested standard layout
(l4d2host/l4d2host/, l4d2web/l4d2web/) so builds work from a
read-only source tree — setuptools wrote egg-info to source under
the old layout, which broke uv sync on the root-owned /opt/left4me/src.

Local dev install: `pip install -e ./l4d2host -e ./l4d2web` -> `uv sync`.
.envrc switches from `layout python python3.13` to `use uv`. Python
pinned to 3.13 via .python-version.

l4d2web now declares its cross-dep on l4d2host explicitly via
[tool.uv.sources] (workspace = true). l4d2web/alembic.ini and
l4d2web/alembic/ stay at the project root (standard alembic layout).

Test fixes:
- tests/__init__.py added to both test dirs so pytest doesn't shadow
  l4d2host as a namespace package via outer-dir walk.
- 3 CWD-relative paths in tests (l4d2web/static/css/{tokens,layout}.css
  and js/sse.js) anchored to Path(__file__) so they survive layout
  changes.
- Two test_install.py tests now monkeypatch HOME to tmp_path so they
  stop silently mutating ~/.steam/sdk32 on every run.

628 tests pass under sandboxed `uv run pytest`.

Per docs/superpowers/plans/2026-05-15-uv-workspace-execution.md;
prereq for the ckn-bw bundle's uv-sync action (queued).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 22:04:29 +02:00

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);
});