Compare commits

..

No commits in common. "2d5a72b3173cce76a736e271fd881e545d2bd934" and "5f82950d7c2b709a6cee9cef697b8f98aa9ab6a4" have entirely different histories.

24 changed files with 340 additions and 1594 deletions

View file

@ -46,56 +46,11 @@ Hooks: `<a data-routed-modal href="<path>">` opens (full-page nav fallback if JS
- **The outermost element of `{% block content %}` is a `<div>`, NOT a `<dialog>`.** The persistent slot in `base.html` already provides top-layer + backdrop + focus-trap + Esc-to-close semantics. Nested `<dialog>` collapses to 2 px in every browser. - **The outermost element of `{% block content %}` is a `<div>`, NOT a `<dialog>`.** The persistent slot in `base.html` already provides top-layer + backdrop + focus-trap + Esc-to-close semantics. Nested `<dialog>` collapses to 2 px in every browser.
- **Close buttons use `data-routed-modal-dismiss`** (NOT the inline-modal attribute). `modals.js` delegates at document level. - **Close buttons use `data-routed-modal-dismiss`** (NOT the inline-modal attribute). `modals.js` delegates at document level.
- **Form-bearing content needs document-level event delegation** for submit/save/delete, gated on `event.target.closest("#modal-content")`. Direct binding to elements in the swapped-in fragment only works in standalone mode — HTMX-swapped content arrives as fresh DOM nodes with no listeners attached. See `static/js/files-overlay/editor.js`'s document-level click listener + the `routedSaveClicked` / `routedReplaceClicked` / `routedDeleteClicked` functions for the canonical pattern (read `data-*` attributes from the swapped DOM, NOT from JS state set during open). - **Form-bearing content needs document-level event delegation** for submit/save/delete, gated on `event.target.closest("#modal-content")`. Direct binding to elements in the swapped-in fragment only works in standalone mode — HTMX-swapped content arrives as fresh DOM nodes with no listeners attached. See `files-overlay.js` lines ~599-664 for the canonical pattern (read `data-*` attributes from the swapped DOM, NOT from JS state set during open).
- **CSS classes targeting modal chrome are scoped to the outer slot**`dialog.modal, div.modal` in `components.css`. The inner content div should NOT carry `class="modal modal-wide"` (the outer dialog owns chrome; otherwise both paint card-in-a-card). - **CSS classes targeting modal chrome are scoped to the outer slot**`dialog.modal, div.modal` in `components.css`. The inner content div should NOT carry `class="modal modal-wide"` (the outer dialog owns chrome; otherwise both paint card-in-a-card).
**Reference:** `docs/superpowers/specs/2026-05-17-url-addressable-modals-design.md` (design + verification matrix) and the plan errata at the top of `docs/superpowers/plans/2026-05-17-url-addressable-modals.md`. **Reference:** `docs/superpowers/specs/2026-05-17-url-addressable-modals-design.md` (design + verification matrix) and the plan errata at the top of `docs/superpowers/plans/2026-05-17-url-addressable-modals.md`.
### Files overlay: module layout
The file-manager JS for files-type overlays is split across four
modules under `l4d2web/l4d2web/static/js/files-overlay/`, all loaded
with `defer` from `templates/overlay_detail.html`. They cooperate via
the `window.__filesOverlay` action registry that `core.js` sets up:
- **`core.js`** — manager-element detection (`.files-manager` guard),
derived state (`overlayId`, `baseUrl`, `treeRoot`, `csrfToken`),
shared helpers (`joinPath`, `parentOf`, `basename`, `humanSize`,
`fetchJson`, `postJson`, `postForm`, `refreshFolder`,
`findRowByPath`, `cssEscape`, `scheduleRefresh`), and the
document-level click listener that dispatches `[data-action]`
clicks through `__filesOverlay.handleAction(op, path, actionEl)`
into per-feature handlers.
- **`editor.js`** — URL-addressable editor only. Handles the new-file
route (`/files/new?at=...`), edit route for text + binary
(`/files/edit?path=...`), and the save / replace / delete delegated
click handlers scoped to `#modal-content`. Registers `"new-file"`
and `"edit"` into the registry.
- **`dialogs.js`** — the three inline `<dialog>` modals (new-folder,
delete-confirm, conflict). Module-scope state per dialog (one
delegated listener each, no clone-and-rebind). Exposes
`askConflict(path) → Promise<"overwrite"|"keep-both"|"cancel">`
on `__filesOverlay` for use by editor.js + uploads.js. Registers
`"new-folder"` and `"delete"` into the registry.
- **`uploads.js`** — upload queue (concurrency 3, XHR-based progress,
`data-upload-id` delegated cancel), drag-drop on `treeRoot`
(direct-bound — 5 coordinated events share highlight state), and
the `"zip"` registry handler. Exposes
`withCollisionSuffix(path) → suffixedPath` for the upload + save
conflict paths. Drag-drop on `treeRoot` is the **only** direct-bound
listener block in the four modules; everything else is document-level
delegation (see escape-hatch comments in-source).
When adding a new file-row action, the contract is:
1. Render the `<button data-action="my-op" data-target-path="...">` in
`templates/_overlay_file_node.html` (gated on the right capability
flag).
2. Pick the module that owns the action and register a handler:
`fo.registerHandler("my-op", (path, actionEl) => { ... })`.
3. The dispatch wiring in `core.js` takes care of catching the click
and calling the handler. No new listeners needed.
### Dev server and filesystem paths ### Dev server and filesystem paths
- **Production paths (`/var/lib/left4me`, `/usr/local/lib/systemd/system`, `/usr/local/libexec/left4me`, `/etc/left4me`) exist only on Linux deploy hosts.** Never create or write to these on a developer machine. They are referenced in `l4d2host/l4d2host/paths.py` and the spec only as the production layout. - **Production paths (`/var/lib/left4me`, `/usr/local/lib/systemd/system`, `/usr/local/libexec/left4me`, `/etc/left4me`) exist only on Linux deploy hosts.** Never create or write to these on a developer machine. They are referenced in `l4d2host/l4d2host/paths.py` and the spec only as the production layout.

View file

@ -1,725 +0,0 @@
# Console Command Autocomplete Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Add command/cvar autocomplete to the runtime console input on `server_detail.html`, sharing the editor's ranking algorithm via a pure-JS module compiled to a tiny additional bundle, with a vanilla dropdown that does not collide with the existing ArrowUp/Down history recall.
**Architecture:** Extract the editor's inlined ranking logic into a pure ES module `editor-src/vocab-rank.js`. The editor imports it directly; for the console, a second esbuild entry point bundles it into a small `static/vendor/vocab-rank.bundle.js` that exposes `window.__rankVocab`. A new `static/js/console-autocomplete.js` builds a vanilla dropdown (positioned absolutely under the console input), lazy-fetches `srccfg-vocab.json` on first focus, hides the dropdown once the user types past the first token, and binds Tab/Shift+Tab/Esc only — leaving ArrowUp/Down/Enter untouched for `console-history.js`.
**Tech Stack:** Vanilla JS (no framework), esbuild (IIFE bundles), CodeMirror 6 (editor-side only — console is plain `<input>`), HTMX (existing — for form submission and dynamic page-fragment swap), CSS variables defined in `tokens.css`/`editor.css`. Tests use Node's built-in `node:test` runner (no extra deps).
**Reference Spec:** `docs/superpowers/specs/2026-05-17-console-command-autocomplete-design.md`
---
## File Structure
**New files:**
- `l4d2web/scripts/editor-src/vocab-rank.js` — pure ranking module (ES, exports `rankVocab`)
- `l4d2web/scripts/editor-src/vocab-rank-entry.js` — IIFE entry that assigns `rankVocab` to `window.__rankVocab`
- `l4d2web/scripts/editor-src/vocab-rank.test.js` — Node `node:test` unit tests for the ranker
- `l4d2web/l4d2web/static/js/console-autocomplete.js` — vanilla dropdown, lazy fetch, key handling
- `l4d2web/l4d2web/static/css/console-autocomplete.css` — dropdown styling using existing CSS tokens
**Modified files:**
- `l4d2web/scripts/editor-src/autocomplete.js` — replace inlined `rank()` + scoring with `import { rankVocab } from "./vocab-rank.js"`
- `l4d2web/scripts/editor-src/package.json` — add `build:vocab-rank` script; chain into `build`
- `l4d2web/l4d2web/templates/base.html` — add `<script defer>` for `vocab-rank.bundle.js` and `console-autocomplete.js`; add `<link>` for `console-autocomplete.css`
**Build artifacts (regenerated, do not hand-edit):**
- `l4d2web/l4d2web/static/vendor/editor.bundle.js` — rebuilt because `autocomplete.js` changed
- `l4d2web/l4d2web/static/vendor/vocab-rank.bundle.js` — new tiny bundle
---
## Task 1: Extract `rankVocab` into a pure module (TDD)
**Goal:** Move the editor's inlined ranking logic into a standalone, testable, dependency-free function.
**Files:**
- Create: `l4d2web/scripts/editor-src/vocab-rank.js`
- Create: `l4d2web/scripts/editor-src/vocab-rank.test.js`
- [ ] **Step 1: Write the failing test file**
Create `l4d2web/scripts/editor-src/vocab-rank.test.js`:
```javascript
import { test } from "node:test";
import assert from "node:assert/strict";
import { rankVocab } from "./vocab-rank.js";
const vocab = {
cvars: [
{ name: "sv_cheats", desc: "Allow cheats" },
{ name: "sv_gravity" },
{ name: "mp_friendlyfire", desc: "Toggle FF" },
],
commands: [
{ name: "kick", desc: "Kick a player" },
{ name: "kickall", desc: "Kick everyone" },
{ name: "changelevel", desc: "Change map" },
],
};
test("exact match comes first", () => {
const out = rankVocab("kick", vocab);
assert.equal(out[0].name, "kick");
assert.equal(out[1].name, "kickall");
});
test("prefix matches beat substring matches", () => {
const out = rankVocab("sv_", vocab);
assert.equal(out[0].name, "sv_cheats");
assert.equal(out[1].name, "sv_gravity");
// mp_friendlyfire contains no "sv_" → should not appear
assert.ok(!out.some(e => e.name === "mp_friendlyfire"));
});
test("substring matches included after prefix matches", () => {
// "iendly" is a substring of mp_friendlyfire but a prefix of nothing
const out = rankVocab("iendly", vocab);
assert.equal(out.length, 1);
assert.equal(out[0].name, "mp_friendlyfire");
});
test("kind is preserved on each result", () => {
const out = rankVocab("kick", vocab);
assert.equal(out[0].kind, "command");
const sv = rankVocab("sv_cheats", vocab);
assert.equal(sv[0].kind, "cvar");
});
test("desc is preserved when present", () => {
const out = rankVocab("kick", vocab);
assert.equal(out[0].desc, "Kick a player");
});
test("desc is undefined when source had no desc", () => {
const out = rankVocab("sv_gravity", vocab);
assert.equal(out[0].desc, undefined);
});
test("results are capped at the configured limit", () => {
const big = { cvars: [], commands: [] };
for (let i = 0; i < 200; i++) big.commands.push({ name: `cmd${i}` });
const out = rankVocab("cmd", big, { limit: 50 });
assert.equal(out.length, 50);
});
test("default limit is 50", () => {
const big = { cvars: [], commands: [] };
for (let i = 0; i < 200; i++) big.commands.push({ name: `cmd${i}` });
const out = rankVocab("cmd", big);
assert.equal(out.length, 50);
});
test("empty query returns no results", () => {
const out = rankVocab("", vocab);
assert.equal(out.length, 0);
});
test("case-insensitive match", () => {
const out = rankVocab("KICK", vocab);
assert.equal(out[0].name, "kick");
});
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
cd l4d2web/scripts/editor-src && node --test vocab-rank.test.js
```
Expected: FAIL with `Cannot find module './vocab-rank.js'` or `ERR_MODULE_NOT_FOUND`.
- [ ] **Step 3: Create the ranker module**
Create `l4d2web/scripts/editor-src/vocab-rank.js`:
```javascript
// Pure, dependency-free ranking of a vocabulary against a query string.
// Used by both the CodeMirror editor (via autocomplete.js) and the
// runtime console (via the vocab-rank bundle exposed on window).
//
// Score (lower = better):
// exact match → 0
// prefix match → 1 + label.length (shorter prefix matches win)
// substring match → 10000 + indexOf (earlier substring beats later)
// no match → -1 (excluded)
function score(query, label) {
if (label === query) return 0;
if (label.startsWith(query)) return 1 + label.length;
const i = label.indexOf(query);
if (i !== -1) return 10000 + i;
return -1;
}
export function rankVocab(query, vocab, { limit = 50 } = {}) {
if (!query) return [];
const q = query.toLowerCase();
const entries = [
...vocab.cvars.map(e => ({ ...e, kind: "cvar" })),
...vocab.commands.map(e => ({ ...e, kind: "command" })),
];
const scored = [];
for (const e of entries) {
const s = score(q, e.name.toLowerCase());
if (s === -1) continue;
scored.push([s, e]);
if (scored.length > limit * 4) break;
}
scored.sort((a, b) => a[0] - b[0]);
return scored.slice(0, limit).map(([, e]) => e);
}
```
- [ ] **Step 4: Run tests to verify they pass**
```bash
cd l4d2web/scripts/editor-src && node --test vocab-rank.test.js
```
Expected: PASS — 10 tests passing.
- [ ] **Step 5: Commit**
```bash
git add l4d2web/scripts/editor-src/vocab-rank.js \
l4d2web/scripts/editor-src/vocab-rank.test.js
git commit -m "feat(editor): extract pure rankVocab module + tests"
```
---
## Task 2: Refactor `autocomplete.js` to use the shared ranker
**Goal:** Replace the inlined `rank()` and scoring loop in `autocomplete.js` with a call to `rankVocab`, with no behavior change.
**Files:**
- Modify: `l4d2web/scripts/editor-src/autocomplete.js`
- [ ] **Step 1: Rewrite `autocomplete.js`**
Replace the entire file contents with:
```javascript
import { autocompletion } from "@codemirror/autocomplete";
import { rankVocab } from "./vocab-rank.js";
const WORD_RE = /[A-Za-z0-9_]{2,}/;
export function vocabCompletions(vocab) {
// vocab: { cvars: [{name, desc?}, …], commands: [{name, desc?}, …] }
return (context) => {
const word = context.matchBefore(WORD_RE);
if (!word || (word.from === word.to && !context.explicit)) return null;
const ranked = rankVocab(word.text, vocab);
const options = ranked.map(e => ({
label: e.name,
info: e.desc || e.kind,
type: e.kind === "command" ? "function" : "variable",
}));
return { from: word.from, options, validFor: WORD_RE };
};
}
export function autocompleteExtension(vocab) {
return autocompletion({
override: [vocabCompletions(vocab)],
activateOnTyping: true,
maxRenderedOptions: 8,
});
}
```
- [ ] **Step 2: Rebuild the editor bundle**
```bash
cd l4d2web/scripts/editor-src && npm run build
```
Expected: `editor.bundle.js` regenerated in `l4d2web/l4d2web/static/vendor/`. No esbuild warnings or errors.
- [ ] **Step 3: Manually verify editor autocomplete still works (regression check)**
```bash
cd l4d2web && python ../scripts/dev-server.py
```
(Note: per memory, the dev server is `scripts/dev-server.py` at repo root, not `flask run`.) Then in a browser:
1. Open a server-detail page with a config file editor visible, or navigate to any `.cfg` file edit view.
2. In the editor, type `sv_` — autocomplete dropdown appears with cvars (e.g. `sv_cheats`, `sv_gravity`).
3. Type `sv_cheats` exactly — `sv_cheats` is first in the list.
4. Press Tab — completion is accepted.
Stop the dev server (Ctrl+C).
- [ ] **Step 4: Commit**
```bash
git add l4d2web/scripts/editor-src/autocomplete.js \
l4d2web/l4d2web/static/vendor/editor.bundle.js
git commit -m "refactor(editor): use shared rankVocab in autocomplete"
```
---
## Task 3: Build a standalone ranker bundle for the console
**Goal:** Produce `vocab-rank.bundle.js` — a tiny IIFE that exposes `window.__rankVocab` — so the non-bundled console-autocomplete.js can call the same ranker.
**Files:**
- Create: `l4d2web/scripts/editor-src/vocab-rank-entry.js`
- Modify: `l4d2web/scripts/editor-src/package.json`
- [ ] **Step 1: Create the IIFE entry point**
Create `l4d2web/scripts/editor-src/vocab-rank-entry.js`:
```javascript
import { rankVocab } from "./vocab-rank.js";
// Expose as a global function so plain (non-module) scripts on
// server_detail.html can call window.__rankVocab(query, vocab).
window.__rankVocab = rankVocab;
```
- [ ] **Step 2: Add a build script for it in `package.json`**
Open `l4d2web/scripts/editor-src/package.json` and replace the `"scripts"` block with:
```json
"scripts": {
"build:editor": "esbuild editor-entry.js --bundle --minify --format=iife --global-name=__editor_pkg --outfile=../../l4d2web/static/vendor/editor.bundle.js --metafile=meta.json",
"build:vocab-rank": "esbuild vocab-rank-entry.js --bundle --minify --format=iife --outfile=../../l4d2web/static/vendor/vocab-rank.bundle.js",
"build": "npm run build:editor && npm run build:vocab-rank"
},
```
- [ ] **Step 3: Run the build**
```bash
cd l4d2web/scripts/editor-src && npm run build
```
Expected: two output files updated/created. Verify with:
```bash
ls -la l4d2web/l4d2web/static/vendor/editor.bundle.js l4d2web/l4d2web/static/vendor/vocab-rank.bundle.js
```
Expected: `vocab-rank.bundle.js` exists (should be ~1-3 KB).
- [ ] **Step 4: Smoke-test the bundle from Node**
Quick check the bundle is well-formed (no syntax errors):
```bash
node -e 'const fs = require("fs"); const code = fs.readFileSync("l4d2web/l4d2web/static/vendor/vocab-rank.bundle.js", "utf8"); new Function("window", code)({}); console.log("ok");'
```
Expected: prints `ok` (means the IIFE parsed and ran).
- [ ] **Step 5: Commit**
```bash
git add l4d2web/scripts/editor-src/vocab-rank-entry.js \
l4d2web/scripts/editor-src/package.json \
l4d2web/l4d2web/static/vendor/vocab-rank.bundle.js
git commit -m "feat(editor): build standalone vocab-rank bundle for console"
```
---
## Task 4: Build the console-autocomplete module
**Goal:** Create the vanilla-JS module that renders the dropdown, handles keyboard interaction, and binds to console forms (including HTMX-injected ones).
**Files:**
- Create: `l4d2web/l4d2web/static/js/console-autocomplete.js`
- [ ] **Step 1: Write the module**
Create `l4d2web/l4d2web/static/js/console-autocomplete.js`:
```javascript
// console-autocomplete.js
// Vanilla dropdown autocomplete for [data-console-form] inputs.
// Reads ranked completions from window.__rankVocab (loaded via
// vocab-rank.bundle.js). Owns: Tab, Shift+Tab, Esc, mouse events.
// Leaves: ArrowUp, ArrowDown, Enter (console-history.js owns those).
//
// First-token only: the dropdown is hidden as soon as the cursor
// is past the first space in the input.
const VOCAB_URL = "/static/data/srccfg-vocab.json";
const MAX_RENDERED = 8;
let vocabPromise = null;
function loadVocab() {
if (vocabPromise) return vocabPromise;
vocabPromise = fetch(VOCAB_URL, { credentials: "same-origin" })
.then(r => r.ok ? r.json() : Promise.reject(new Error("vocab fetch failed: " + r.status)))
.catch(err => { console.warn("[console-autocomplete] vocab load failed", err); return null; });
return vocabPromise;
}
function firstTokenSlice(value, caret) {
// Returns the substring [0, end-of-first-token) if the caret is
// within the first token; otherwise null.
const spaceIdx = value.indexOf(" ");
if (spaceIdx === -1) {
return { token: value, from: 0, to: value.length };
}
if (caret > spaceIdx) return null;
return { token: value.slice(0, spaceIdx), from: 0, to: spaceIdx };
}
function bindConsoleAutocomplete(form) {
if (form.dataset.consoleAutocompleteBound === "true") return;
form.dataset.consoleAutocompleteBound = "true";
const input = form.querySelector("input[name='command']");
if (!input) return;
// --- Dropdown DOM (created lazily on first show) ---
let dropdown = null;
let items = []; // current ranked items
let highlightIdx = 0; // index of currently-highlighted row
let vocab = null;
function ensureDropdown() {
if (dropdown) return dropdown;
dropdown = document.createElement("div");
dropdown.className = "console-autocomplete-dropdown";
dropdown.setAttribute("role", "listbox");
dropdown.style.display = "none";
document.body.appendChild(dropdown);
return dropdown;
}
function position() {
if (!dropdown) return;
const rect = input.getBoundingClientRect();
dropdown.style.left = `${rect.left + window.scrollX}px`;
dropdown.style.top = `${rect.bottom + window.scrollY}px`;
dropdown.style.minWidth = `${rect.width}px`;
}
function close() {
if (!dropdown) return;
dropdown.style.display = "none";
items = [];
highlightIdx = 0;
}
function render() {
ensureDropdown();
if (items.length === 0) { close(); return; }
const rows = items.slice(0, MAX_RENDERED).map((e, i) => {
const selected = i === highlightIdx ? " aria-selected='true'" : "";
const kindClass = e.kind === "command" ? "kind-command" : "kind-cvar";
const desc = e.desc ? `<span class="console-autocomplete-desc">${escapeHtml(e.desc)}</span>` : "";
return `<div class="console-autocomplete-row ${kindClass}"${selected} role="option" data-idx="${i}"><span class="console-autocomplete-name">${escapeHtml(e.name)}</span>${desc}</div>`;
}).join("");
dropdown.innerHTML = rows;
dropdown.style.display = "block";
position();
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
function acceptHighlighted() {
if (items.length === 0) return;
const chosen = items[highlightIdx];
const slice = firstTokenSlice(input.value, input.selectionStart || 0);
if (!slice) return;
const before = input.value.slice(0, slice.from);
const after = input.value.slice(slice.to);
input.value = before + chosen.name + after;
// Place caret at end of inserted name
const caret = before.length + chosen.name.length;
input.setSelectionRange(caret, caret);
recompute();
}
function recompute() {
if (!vocab) return;
const slice = firstTokenSlice(input.value, input.selectionStart || 0);
if (!slice || !slice.token) { close(); return; }
items = window.__rankVocab(slice.token, vocab);
if (items.length === 0) { close(); return; }
highlightIdx = 0;
render();
}
// --- Lazy vocab fetch on first focus ---
input.addEventListener("focus", async () => {
if (!vocab) {
vocab = await loadVocab();
}
}, { once: true });
input.addEventListener("input", () => {
if (!vocab) return; // fetch may not have resolved yet; next input will recompute
recompute();
});
input.addEventListener("keydown", (event) => {
if (event.key === "Tab" && !event.shiftKey) {
if (items.length > 0) {
event.preventDefault();
acceptHighlighted();
}
} else if (event.key === "Tab" && event.shiftKey) {
if (items.length > 0) {
event.preventDefault();
highlightIdx = (highlightIdx - 1 + Math.min(items.length, MAX_RENDERED))
% Math.min(items.length, MAX_RENDERED);
render();
}
} else if (event.key === "Escape") {
if (dropdown && dropdown.style.display !== "none") {
event.preventDefault();
close();
}
}
// ArrowUp/ArrowDown/Enter intentionally NOT handled here.
});
input.addEventListener("blur", () => {
// Delay close so a click on a dropdown row can fire first.
setTimeout(close, 100);
});
// Mouse click on a row → accept that row.
document.addEventListener("mousedown", (event) => {
if (!dropdown || dropdown.style.display === "none") return;
const row = event.target.closest(".console-autocomplete-row");
if (!row || !dropdown.contains(row)) return;
event.preventDefault();
highlightIdx = parseInt(row.dataset.idx, 10) || 0;
acceptHighlighted();
input.focus();
});
// HTMX form submission clears the input; close on submit.
form.addEventListener("htmx:beforeRequest", close);
// Reposition on resize/scroll while dropdown is open.
window.addEventListener("resize", () => { if (dropdown && dropdown.style.display !== "none") position(); });
window.addEventListener("scroll", () => { if (dropdown && dropdown.style.display !== "none") position(); }, true);
}
function bindAll(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(bindConsoleAutocomplete);
}
document.addEventListener("DOMContentLoaded", () => bindAll(document));
document.addEventListener("htmx:load", (event) => bindAll(event.detail.elt));
```
- [ ] **Step 2: Commit (no template/CSS wire-up yet — module is not yet loaded)**
```bash
git add l4d2web/l4d2web/static/js/console-autocomplete.js
git commit -m "feat(console): add vanilla autocomplete dropdown module"
```
---
## Task 5: Add dropdown stylesheet
**Goal:** Provide minimal CSS so the dropdown is positioned, themed via existing CSS tokens, and visually consistent with the editor's autocomplete popup.
**Files:**
- Create: `l4d2web/l4d2web/static/css/console-autocomplete.css`
- [ ] **Step 1: Write the stylesheet**
Create `l4d2web/l4d2web/static/css/console-autocomplete.css`:
```css
/* Console autocomplete dropdown.
Positioned absolutely under the console input by JS; visuals match
the editor's tooltip styling (var(--cm-*) tokens defined in
tokens.css and editor.css). */
.console-autocomplete-dropdown {
position: absolute;
z-index: 1000;
max-height: calc(8 * 2.4rem);
overflow-y: auto;
background-color: var(--cm-bg, #1e1e1e);
color: var(--cm-fg, #e0e0e0);
border: 1px solid var(--border-strong, #444);
border-radius: 4px;
font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
font-size: 13px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.console-autocomplete-row {
display: flex;
align-items: baseline;
gap: 0.75em;
padding: 0.3em 0.6em;
cursor: pointer;
white-space: nowrap;
}
.console-autocomplete-row[aria-selected="true"] {
background-color: var(--cm-selection, #264f78);
}
.console-autocomplete-row:hover {
background-color: var(--cm-selection, #264f78);
}
.console-autocomplete-name {
font-weight: 600;
}
.console-autocomplete-row.kind-cvar .console-autocomplete-name {
color: var(--cm-keyword, #569cd6);
}
.console-autocomplete-row.kind-command .console-autocomplete-name {
color: var(--cm-string, #ce9178);
}
.console-autocomplete-desc {
color: var(--fg-muted, #888);
font-size: 0.9em;
overflow: hidden;
text-overflow: ellipsis;
max-width: 40em;
}
```
- [ ] **Step 2: Commit**
```bash
git add l4d2web/l4d2web/static/css/console-autocomplete.css
git commit -m "feat(console): add autocomplete dropdown stylesheet"
```
---
## Task 6: Wire up in `base.html`
**Goal:** Load the ranker bundle, the console-autocomplete script, and the stylesheet — placed alongside the existing `console-history.js` tag so loading order matches.
**Files:**
- Modify: `l4d2web/l4d2web/templates/base.html`
- [ ] **Step 1: Read the current head/body script section**
Open `l4d2web/l4d2web/templates/base.html` and find the line that currently loads `console-history.js`:
```html
<script defer src="{{ url_for('static', filename='js/console-history.js') }}"></script>
```
- [ ] **Step 2: Add the new tags directly after it**
Add immediately after the `console-history.js` script tag:
```html
<script defer src="{{ url_for('static', filename='vendor/vocab-rank.bundle.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/console-autocomplete.js') }}"></script>
```
And add to the `<head>` section (alongside other `<link rel="stylesheet">` tags — search for existing ones in `base.html`):
```html
<link rel="stylesheet" href="{{ url_for('static', filename='css/console-autocomplete.css') }}">
```
- [ ] **Step 3: Sanity-check the template renders without syntax errors**
```bash
cd l4d2web && python -c "from l4d2web.app import create_app; create_app(); print('ok')"
```
Expected: prints `ok` (Flask app boots; templates are valid Jinja).
- [ ] **Step 4: Commit**
```bash
git add l4d2web/l4d2web/templates/base.html
git commit -m "feat(console): wire up autocomplete bundle + stylesheet in base.html"
```
---
## Task 7: End-to-end smoke test
**Goal:** Verify the full feature works in the browser against the dev server.
- [ ] **Step 1: Start the dev server**
```bash
cd l4d2web && python ../scripts/dev-server.py
```
Expected: server starts on `http://localhost:5000` (or whatever the script reports). `LEFT4ME_ROOT` is auto-set to `.tmp/dev-server` and seeded with demo content per memory.
- [ ] **Step 2: Run through the smoke-test checklist in a browser**
Open a server-detail page (one of the demo servers seeded by the dev server). Then verify each:
1. **Vocab fetch is lazy.** Open DevTools → Network → filter `srccfg-vocab`. Reload page. **Expected:** no request yet.
2. **Click into the console input.** **Expected:** one `srccfg-vocab.json` request fires.
3. **Type `sv_`.** **Expected:** dropdown appears showing cvars starting with `sv_`. Top row highlighted.
4. **Press Tab.** **Expected:** first token replaced with the highlighted suggestion (e.g. `sv_cheats`). Dropdown updates with matches for the new query.
5. **Press Shift+Tab.** **Expected:** highlight moves up; or wraps to bottom if at top.
6. **Press Esc.** **Expected:** dropdown closes. Input value unchanged.
7. **Type a space then `god`.** **Expected:** dropdown stays hidden (we're past the first token).
8. **Press ArrowUp.** **Expected:** history recall works — input is replaced with a previously submitted command. No interference from autocomplete.
9. **Clear the input. Type `sv_che`.** Verify `sv_cheats` is highlighted in the dropdown. **Press Enter.** **Expected:** the server console receives `sv_che` (the typed text), not `sv_cheats`. Confirm in the console transcript.
10. **Refocus the input.** **Expected:** no second `srccfg-vocab.json` request (cached in module-scope promise).
11. **Click on a dropdown row with the mouse.** **Expected:** that row's command is inserted into the input.
12. **Editor regression check.** Navigate to a `.cfg` file in the editor (files view). Type `sv_`. **Expected:** editor's autocomplete still works exactly as before.
If all 12 pass, the feature is complete.
- [ ] **Step 3: Stop dev server (Ctrl+C) and confirm final commit state**
```bash
git log --oneline -10
git status
```
Expected: 6 new commits ahead of the pre-feature state; working tree clean.
---
## Verification Summary
- **Unit tests:** `cd l4d2web/scripts/editor-src && node --test vocab-rank.test.js` — 10 passing tests for the ranker.
- **Manual editor regression:** Editor autocomplete still works on `.cfg` files.
- **Manual console smoke test:** 12-point checklist in Task 7 Step 2.
- **No new runtime JS dependencies** added (vocab-rank.test.js uses only `node:test` + `node:assert/strict`, which are built into Node ≥ 18).
## What's Explicitly Out of Scope
- Argument value completion (player names, map names) — would require runtime data, not in `srccfg-vocab.json`.
- Fuzzy / typo-tolerant matching.
- Replacing CodeMirror's editor dropdown with a custom widget.
- Cross-browser e2e automation (no Playwright/Cypress in the codebase; not adding one as part of this work).

View file

@ -1,243 +0,0 @@
# Files-overlay E2E test handoff
## Context
The files-overlay rewrite (commits `4fa3964..8dc14f0`, May 2026)
moved all editor flows behind URL-addressable modals and split the
1091-line `files-overlay.js` monolith into four focused modules under
`l4d2web/l4d2web/static/js/files-overlay/`. Behavior was verified
step-by-step in Chromium during the rewrite, but there is no automated
browser regression coverage for the editor / dialog / upload flows.
The existing Playwright suite (`l4d2web/tests/e2e/test_editor.py`)
covers only the CodeMirror 6 controller — autocomplete, form-bridge,
copy/paste — invoked through a blueprint detail page. Nothing
exercises the file manager UI.
This handoff specifies what to add: fixture extensions, the test
cases worth writing, and the patterns / pitfalls a future implementer
should know before starting. Estimated effort: a focused half-day for
the seven critical cases, a full day for the full matrix.
## Goal
Lock down the user-visible behavior of the four files-overlay modules
against future regressions. The rewrite proved each module works in
isolation; e2e proves they cooperate over real DOM, real HTTP, real
HTMX, and real CodeMirror.
## Out of scope
- Re-testing pure CodeMirror behavior (the existing `test_editor.py`
covers this on a non-files page; the controller is the same one).
- Replacing the existing pytest route tests (`tests/test_overlay_files_routes.py`,
`tests/test_url_addressable_modals.py`). E2E adds *integration*
coverage on top of those, not in place.
- Performance / load testing of the upload queue (concurrency 3 is
the current behavior; testing it would need 4+ simultaneous uploads
and is high-flake low-value).
- The drag-drop-from-OS path. Playwright can't synthesize a real OS
drag (`webkitGetAsEntry` returns `null` for synthetic drops, so the
fallback `getAsFile` branch always runs). The internal-drag path
(row → folder) is testable; the external drag fallback is covered
enough by the route tests.
## Fixture work
`l4d2web/tests/e2e/conftest.py` currently seeds only a `User` and a
`Blueprint`. The files-overlay tests need a files-type overlay with a
working filesystem root. Add a new fixture (or extend `live_server`):
```python
# tests/e2e/conftest.py
@pytest.fixture(scope="function")
def files_overlay_server(tmp_path, monkeypatch):
"""live_server + a files-type Overlay seeded with a small fixture
set: one editable text file, one binary file, one nested folder
with one file inside.
Returns {base_url, user_id, overlay_id, overlay_root: Path}.
"""
# Same boot as live_server (extract a helper to avoid duplication).
# Set LEFT4ME_ROOT to tmp_path before create_app() so the files
# overlay's path resolution lands under tmp_path.
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
...
with session_scope() as session:
user = User(username="alice", password_digest=hash_password("secret"), admin=False)
session.add(user); session.flush()
overlay = Overlay(name="cfgs", path="", type="files", user_id=user.id)
session.add(overlay); session.flush()
overlay.path = str(overlay.id)
overlay_root = tmp_path / "overlays" / str(overlay.id)
overlay_root.mkdir(parents=True)
(overlay_root / "server.cfg").write_text("hostname \"left4me\"\n")
(overlay_root / "icon.png").write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 60)
(overlay_root / "cfg").mkdir()
(overlay_root / "cfg" / "admins.txt").write_text("STEAM_1:0:1\n")
user_id, overlay_id = user.id, overlay.id
...
yield {
"base_url": ...,
"user_id": user_id,
"overlay_id": overlay_id,
"overlay_root": overlay_root,
}
```
The `LEFT4ME_ROOT` env-var monkey-patch is critical — without it,
`overlay_files.resolve_overlay_root` falls back to the production
`/var/lib/left4me` path (per the `AGENTS.md` "symptom-to-cause"
note) and every route returns 404. Set it BEFORE `create_app()`.
## Test cases to add
Suggested file: `l4d2web/tests/e2e/test_files_overlay.py`. Pattern
each test like the existing `test_editor.py`: log in via the form,
navigate to `/overlays/<id>`, drive the UI through Playwright `page`
locators, assert on DOM state + filesystem state under
`overlay_root`.
### Tier 1 — critical paths (write these first)
1. **`test_edit_text_file_save_round_trip`**
- Click `server.cfg` filename. Wait for `#modal-content
textarea[data-rel-path="server.cfg"]`. URL should contain
`?modal=%2Foverlays%2F<id>%2Ffiles%2Fedit%3Fpath%3Dserver.cfg`.
- Modify content via Playwright `page.fill` on the textarea (or
via the `__filesEditor.setContent` controller for the CM6 case
— the existing `test_editor.py` shows both approaches).
- Click `.files-editor-save`. Modal closes (modal-container
`aria-modal` gone / `open` false).
- Assert `overlay_root / "server.cfg"` on disk has the new content.
2. **`test_create_new_file_routed`**
- Click `+ new file` on the overlay-root row. Wait for
`#modal-content textarea[data-rel-path=""]` and save button
labeled `Create`.
- Type a filename and content. Click Create.
- Assert file appears on disk + the file tree refreshes to show
the new row.
3. **`test_create_new_file_409_askConflict_keep_both`**
- Click `+ new file`. Type `cfg` as the filename (collides with
the seeded directory). Click Create.
- Wait for `#files-conflict-modal[open]`. Its
`.files-conflict-path` should read `cfg`.
- Click `[data-files-conflict-action="keep-both"]`.
- Assert the file `cfg (1)` appears on disk and the routed modal
closes.
- This is the path F4 (`8dc14f0`) added; without coverage it can
regress silently.
4. **`test_open_binary_file_renders_replace_ui`**
- Click `icon.png`. Modal opens.
- Assert `#modal-content .files-editor-binary[data-rel-path="icon.png"]`
exists, save button reads `Replace` and is disabled,
`.files-editor-replace-zone` and the download anchor are present.
5. **`test_binary_replace_via_browse_writes_new_bytes`**
- Open `icon.png` editor (as above).
- Click `.files-editor-replace-browse`. Use Playwright's
`page.expect_file_chooser()` to attach a small File buffer.
- Save button enables. Click it. Modal closes.
- Assert the file's bytes on disk are the new content.
6. **`test_new_folder_then_delete`**
- Click `+ new folder` on the overlay root. Inline dialog opens.
- Type a name, press Enter (keydown path). Dialog closes.
- Assert folder exists on disk + appears in tree.
- Click the folder's `✕`. Delete-confirm dialog opens with the
folder name. Click `.files-delete-confirm`.
- Assert folder gone from disk + from tree.
7. **`test_filename_rename_on_save`**
- Open `server.cfg`. Change the filename input to
`server-renamed.cfg`. Click Save.
- Assert disk has the new name + old name gone + tree row updated.
### Tier 2 — round out the matrix
8. **`test_drag_row_to_folder_moves_file`** — internal drag.
Playwright's `locator.drag_to()` can move a row onto a folder.
Assert the move via `/files/move` succeeded and disk reflects it.
9. **`test_upload_queue_progress`** — drop a single file onto the
tree root. The progress panel becomes visible; the row enters
`data-state="active"`, then `data-state="done"`. Assert the
uploaded file is on disk. (Skip the 409 / conflict / cancel
permutations — they're covered by the route tests.)
10. **`test_modal_close_on_escape_preserves_no_state`** — open the
routed editor, type some content but don't save, press Escape.
Modal closes. Reopen — content is fresh (no stale buffer),
`routedReplacement` cleared.
11. **`test_share_url_deep_link_reopens_editor`** — navigate
directly to `/overlays/<id>?modal=%2Foverlays%2F<id>%2Ffiles%2Fedit%3Fpath%3Dserver.cfg`.
Modal should auto-open on DOMContentLoaded (the bootstrap path
from `modals.js`). This is the URL-addressable spec's central
promise; without coverage it regresses easily.
### Tier 3 — nice to have
12. Server detail page hover-download button (the F0 prefactor):
seed a server, navigate to `/servers/<id>`, hover a file row,
click the `⬇` button, assert a file download initiates.
## Patterns to follow / pitfalls
- **The existing `test_editor.py` is the closest pattern.** Read it
end-to-end before starting. The login helper, the `live_server`
fixture shape, the `expect`-based assertions, and the way
Playwright interacts with the CM6 controller (`page.evaluate(...)`
on `window.__filesEditor`) all transfer.
- **Run with `uv run pytest -m e2e tests/e2e/test_files_overlay.py`.**
Anything else crashes Chromium under macOS sandbox.
`uv run playwright install chromium` once per fresh checkout.
- **Routed modals load via `htmx.ajax` — they're async.** Don't assert
immediately after the click. Use `expect(page.locator(...)).to_be_visible()`
with a timeout (Playwright's default 5s is fine).
- **Reading the file tree after a refresh is also async.** The JS
`scheduleRefresh` debounces by 50ms then fetches the directory
partial via HTMX. Use `expect(page.locator(".file-tree-row-file[data-target-path='...']")).to_be_visible()`
rather than polling DOM directly.
- **`data-rel-path` lives on the textarea in text mode and on the
binary panel in binary mode.** Tests asserting "the editor opened
for X" should query whichever matches — or use the fragment
wrapper `#files-editor-fragment` as a stable container.
- **The conflict dialog is inline, not routed.** Don't expect URL
changes when it opens. The decision tree:
- "Did the URL change?" → routed modal (editor) vs. inline modal
(new-folder, conflict, delete-confirm).
- **`SESSION_COOKIE_SECURE=0` is non-optional.** The fixture must set
it; otherwise the browser drops the session cookie over http and
every test redirects back to `/login`. The existing `conftest.py`
has the right pattern at line 39.
## Verification
Per AGENTS.md: `uv run pytest -m e2e tests/e2e/test_files_overlay.py -v`.
The tier-1 seven cases should pass green in <60s on a warm Chromium.
The full matrix (12 cases) target <2 minutes.
When wiring CI / pre-push hooks: the e2e marker is excluded from the
default fast suite, so the existing 580-passing `uv run pytest tests/`
run remains the quick check. The e2e suite runs explicitly when
`-m e2e` is set.
## References
- `l4d2web/tests/e2e/test_editor.py` — pattern model
- `l4d2web/tests/e2e/conftest.py:39``SESSION_COOKIE_SECURE` note
- `l4d2web/tests/test_url_addressable_modals.py` — non-browser route
tests that already cover the server-side contract (200/404/415/400
on edit, new, save). E2E shouldn't duplicate these.
- `l4d2web/l4d2web/static/js/files-overlay/{core,editor,dialogs,uploads}.js`
— read each file's module header comment for the listener layout
before writing assertions.
- `AGENTS.md` "Files overlay: module layout" — high-level orientation.
- `AGENTS.md` "Modals: inline vs routed" — decision tree the test
matrix follows.

View file

@ -0,0 +1,271 @@
# files-overlay.js Rewrite + Editor Migration + API Cleanup (handoff-ready)
## Context
`l4d2web/l4d2web/static/js/files-overlay.js` has grown to ~35 KB / 1092 lines and hosts 9+ feature areas in one IIFE: helpers, the legacy inline editor dialog (text + binary + create-new modes), new-folder modal, conflict modal, delete-confirm modal, file-row action dispatch, drag-drop on the file tree, upload queue + progress panel, and document-level delegation for the URL-addressable editor's save/delete. Event-binding patterns are inconsistent — direct-binds, clone-and-rebind anti-patterns, bind-and-remove per-call, and document-level delegation all coexist for similar tasks.
Two flows still use the legacy inline `<dialog id="files-editor-modal">`: **create-new-file** (no URL to deep-link a file that doesn't exist yet) and **binary-replace** (the new URL-addressable template intentionally omitted binary-replace UI per Task 2's pilot scope cut). Migrating both flows to URL-addressable lets us delete the legacy dialog and simplifies the JS editor module dramatically.
Meanwhile `routes/files_routes.py` (640+ lines) duplicates path-resolution and editability checks between `overlay_file_content` (JSON) and `overlay_file_edit_page` (HTML), and the JSON `/files/content` endpoint becomes dead code once create-new + binary-replace migrate away from JS-populated inline dialogs.
**Goal:** rewrite the JS into focused modules with consistent delegation; migrate create-new + binary-replace flows to URL-addressable modals; delete the legacy dialog; clean up `routes/files_routes.py` to share helpers and drop dead endpoints. Behavior visible to the user is unchanged — same features, same UX. Each migration commit leaves the working tree in a known-good Chromium-verified state, so this plan can be paused, resumed, and handed off across sessions.
## Approach: three phases, twelve commits
Three natural pause points for cross-session handoff. Each phase is independently completable and verifiable.
| Phase | Steps | Scope | Where the working tree lands |
|-------|-------|-------|------------------------------|
| **A** | 1-4 | JS rewrite into 4 modules (`core.js`, `editor.js`, `dialogs.js`, `uploads.js`). `editor.js` is dual-purpose at this point (legacy inline dialog + URL-addressable modal). | Old `files-overlay.js` is empty/stub; behavior unchanged; legacy dialog still exists and is still used by create-new + binary-replace. |
| **B** | 5-10 | URL-addressable migration of create-new + binary-replace. Adds `GET /overlays/<id>/files/new?at=<folder>`; binary-file detection in the edit route; binary-replace UI in `overlay_file_editor.html`; JS code-paths for both flows move to URL-addressable. Legacy dialog deleted from `overlay_detail.html`. `editor.js` becomes single-purpose. | Legacy dialog gone; `editor.js` ~200 lines, URL-addressable-only; create-new + binary-replace are URL-addressable. |
| **C** | 11-12 | API cleanup. Extract shared path-resolution + editability helper used by edit/save/content endpoints. Delete `GET /overlays/<id>/files/content` JSON endpoint if confirmed unused. Consolidate save/replace duplication where reasonable. | `routes/files_routes.py` ~450 lines; no dead routes; pytest still green. |
Total estimate: ~12 commits, each independently revertable.
## Decomposition (new JS modules)
| Module | Phase A end | Phase B end | Responsibility |
|--------|-------------|-------------|----------------|
| `static/js/files-overlay/core.js` | ~120 lines | ~120 lines | Helpers (`postJson`, `postForm`, `scheduleRefresh`, `parentOf`, `joinPath`). Manager-element detection, CSRF read. File-row click delegation dispatch. |
| `static/js/files-overlay/editor.js` | ~350 lines (dual-purpose) | ~200 lines (URL-addressable only) | Save / delete / rename-on-save / 409 conflict handling. Filename rename hint. Ctrl+S. After Phase B, only handles URL-addressable modal content via document-level delegation gated on `#modal-content`. |
| `static/js/files-overlay/dialogs.js` | ~180 lines | ~180 lines | New-folder modal, delete-confirm modal, conflict modal (`askConflict` returning a promise). All document-level delegated, no clone-and-rebind. |
| `static/js/files-overlay/uploads.js` | ~280 lines | ~280 lines | Upload queue (concurrency 3), progress panel, per-row cancel via `data-upload-id`. Drag-drop on `treeRoot` kept direct-bound (5 coordinated events, persistent target). |
Total after rewrite: ~780 lines across 4 files (vs 1092 in one).
Direct-bind escape hatches (places we keep direct binding deliberately):
- `editor.js`: `input` / `keydown` on `.files-editor-filename` and `.files-editor-content` (high-frequency, persistent input elements in the swapped-in modal content — re-bound on each `htmx:afterSwap`).
- `uploads.js`: `dragstart` / `dragend` / `dragover` / `dragleave` / `drop` on `treeRoot` (5 coordinated events sharing per-target highlight state; document delegation would obscure the coordination logic).
## Migration sequence
### Phase A — JS rewrite (steps 1-4)
**Step 1: scaffold `files-overlay/core.js`**
Create the new directory. Move helpers (lines ~40-170 of the current file: `postJson`, `postForm`, `scheduleRefresh`, `parentOf`, `joinPath`, byte-count utility, manager-element detection, CSRF read). Add the file-row click delegation that currently lives at line 1054 — same selector match, but instead of switch-casing into local handlers, dispatch through a registry `window.__filesOverlay.handleAction(action, path, actionEl)` that feature modules register handlers into. Old `files-overlay.js` still has its own handlers for now — `core.js` dispatch is unused until subsequent steps wire features into it.
`overlay_detail.html` (NOT base.html): add `<script src="files-overlay/core.js" defer>` BEFORE the existing `files-overlay.js` script tag (currently at `overlay_detail.html:285`, also `defer`). The script tag lives in `overlay_detail.html` because `files-overlay.js` activates only when `.files-manager` exists (line 23-24 of the current file) — loading from `base.html` would pull the JS onto every page unnecessarily. **All new module script tags use `defer`** to match the existing pattern (the modules query the DOM at load time and need the body parsed first).
Verification: existing functionality still works (click any file row → opens editor, +new-file → opens editor, +new-folder → opens new-folder modal, zip download works).
**Step 2: migrate editor handlers to `files-overlay/editor.js`**
Move the editor section (lines 262-591: `editorEls` object, `openEditorTextNew`, `openEditorForFile`, save/delete handlers + sub-element handlers). At the start of `editor.js`'s IIFE, query the legacy dialog once: `const editorDialog = document.getElementById("files-editor-modal")`. If present, register handlers; if absent (i.e., later when the legacy dialog is deleted in Phase B), skip the legacy branch.
For save/delete: convert from direct-bound `editorEls.saveBtn.addEventListener` to document-level delegation scoped via `event.target.closest("#files-editor-modal")`. The URL-addressable modal save/delete delegation already at lines 600-664 moves over too.
For replace-zone drag (lines 449-458): convert to delegation gated on `event.target.closest(".files-editor-replace-zone")` inside `#files-editor-modal`.
Keep direct-bound: `input` on filename, `input` and `keydown` on content textarea (these target persistent inputs inside the persistent legacy dialog).
Register the file-row action handlers (`new-file`, `edit`) into the `core.js` dispatch registry from step 1.
Delete the migrated handlers from the old `files-overlay.js`. The old file shrinks by ~330 lines.
Verification: open editor on text file → edit + save works; rename + save works; rename + 409 conflict alerts and modal stays open; delete works; binary file → opens in binary mode with replace UI; URL-addressable editor flow (file-row click on editable file) still works including rename and 409.
**Step 3: migrate dialogs to `files-overlay/dialogs.js`**
Move new-folder modal (lines 666-722), delete-confirm modal (lines 222-258), conflict modal (`askConflict`, lines 174-211).
Eliminate the clone-and-rebind pattern: register single document-level delegated handlers, scoped to each dialog by id. Per-dialog state (target folder for new-folder, current path for delete-confirm, resolve-callback for conflict) lives in module-scope variables set when the dialog opens and cleared when it closes.
Open the dialogs via `window.modals.openInline(idOrEl)` instead of `dialog.showModal()` directly, completing the inline-modal convention from commit `c51089d`.
Register the file-row action handlers (`new-folder`, `delete`) into `core.js` dispatch registry.
Delete migrated handlers from old `files-overlay.js`. Shrinks another ~150 lines.
Verification: new-folder open + create works; new-folder Enter-in-input creates; delete-confirm open + confirm deletes; upload-conflict prompt (overwrite + keep-both branches both work).
**Step 4: migrate uploads + drag-drop to `files-overlay/uploads.js`**
Move upload queue (lines ~750-900) and drag-drop on `treeRoot` (lines 913-1020).
Keep drag-drop direct-bound to `treeRoot` (deliberate — coordinated state across 5 events).
Convert upload-row cancel buttons (currently direct-bound at row creation, line 753) to document-level delegation: store `data-upload-id="<id>"` on each row and look up the upload at click time.
Register file-row action handlers (`zip`) into `core.js` dispatch registry. (`zip` is just a navigation — could live in `core.js` directly; pick whichever reads cleaner.)
Delete remaining migrated handlers from old `files-overlay.js`. After this step the old file is empty or near-empty. Add `<script src="files-overlay/uploads.js" defer>` etc. to `overlay_detail.html` alongside the (now-empty) `files-overlay.js` script tag (or delete the old `<script>` tag entirely if the file is empty). Initially leave the old file in place to avoid stretching this step further.
Verification: drop a single file onto tree → uploads + appears; drop a folder onto tree → multiple uploads with progress; cancel in-flight upload → stops, row shows cancelled; click Clear → done rows removed; drag a file row to another folder → moves.
End of Phase A: working tree has 4 focused modules + a (near-)empty old file. All current behavior preserved.
### Phase B — URL-addressable migration of create-new + binary-replace (steps 5-10)
**Step 5: extend the edit route to support new-file mode (server + template + tests)**
Add `GET /overlays/<id>/files/new?at=<folder>` to `routes/files_routes.py`. Returns the editor template (`overlay_file_editor.html`) with:
- `rel_path = ""` (empty filename input — user types name)
- `content = ""` (empty editor)
- `byte_count = 0`
- A new context flag `is_new = True`
- The save button label "Create" instead of "Save"
- The delete button hidden
- The target folder rendered as a `data-at-folder="<folder>"` attribute on the textarea so JS save can compose `path = at_folder + "/" + filename` on submit.
Extend `overlay_file_editor.html` to render conditionally based on `is_new`. The existing template has filename, content, save button — just add a `{% if is_new %}Create{% else %}Save{% endif %}` and `{% if not is_new %}<button class="files-editor-delete">{% endif %}`.
Add pytest tests in `tests/test_url_addressable_modals.py`:
- `test_new_route_renders_with_empty_content`
- `test_new_route_renders_with_target_folder_attribute`
- `test_new_route_renders_create_button_not_save`
- `test_new_route_400s_for_invalid_at_path` (path traversal)
- `test_new_route_404s_for_non_files_overlay`
Verification: pytest green; curl the new route, see the editor markup with empty content + "Create" button.
**Step 6: migrate create-new-file JS flow to URL-addressable**
In `editor.js`, the `openEditorTextNew(folder)` path currently populates the legacy inline dialog with an empty filename + content. Change it to call `window.modals.openRouted("/overlays/<id>/files/new?at=" + encodeURIComponent(folder))`.
In the URL-addressable save delegation, detect `is_new` mode (look for `data-at-folder` on the textarea or `value === ""` on the filename input). When new: compose `path = at_folder + "/" + filename.trim()` from the form, send `{path, content}` to `/files/save` (existing endpoint handles creation when the file doesn't exist).
The `core.js` dispatch for `op === "new-file"` (currently calls into the old/legacy flow) is updated to call the new URL-addressable open.
Verification: click + on a folder → URL gains `?modal=/overlays/<id>/files/new?at=foo`; editor opens with empty content + Create button + target folder shown; type filename + content + Create → file appears in tree at the right folder; rename test (type a path-like value into the filename input — should create nested as expected or 422 if invalid).
**Step 7: add binary-file support to the edit route + template (server + template + tests)**
Change `overlay_file_edit_page` in `routes/files_routes.py`: when `is_editable(target)` is False but the file IS readable (size check, no UnicodeDecodeError), instead of returning 415, return the editor template with:
- `is_binary = True`
- `byte_count = target.stat().st_size`
- No `content` (or `content = ""`)
- A `download_url` and `mime_type` (best-effort guess)
Extend `overlay_file_editor.html` to render the binary-replace UI when `is_binary`:
- Hide the CM6 textarea + language dropdown
- Show: file info (name, size), Download button, Replace zone (drag-drop drop zone with browse fallback)
- The Save button is replaced by "Replace" (only enabled when a file is queued)
- The Delete button stays visible
Add pytest tests:
- `test_edit_route_renders_binary_template_for_non_editable`
- `test_edit_route_still_404s_for_missing_file`
- `test_edit_route_still_400s_for_path_traversal`
- `test_binary_template_has_replace_zone`
Verification: navigate to `/overlays/<id>/files/edit?path=image.png` (a real binary file) → page renders with replace UI; navigate via URL-addressable modal → modal opens with same UI.
**Step 8: migrate binary-replace JS flow to URL-addressable**
In `editor.js`, add document-level delegation for the binary-replace zone inside `#modal-content`:
- `dragover`, `dragleave`, `drop` on `.files-editor-replace-zone` (delegated via `event.target.closest(...)`)
- `click` on `.files-editor-replace-browse` and the file input's `change` event for click-to-browse
- `click` on `.files-editor-replace-clear` to clear the queued file
- `click` on `.files-editor-save` when in binary mode → POST `/files/replace` (multipart) with the queued file
The legacy `openEditorForFile(path, false)` branch in `files-overlay.js` (currently called for binary files at line 1083) is replaced by `window.modals.openRouted("/overlays/<id>/files/edit?path=" + encodeURIComponent(path))` — same as for editable files. The server figures out which template branch to render.
Verification: click on a binary file in the tree → URL-addressable modal opens with replace UI; drag a new binary onto the replace zone → queued; click Replace → POST 200; file size updates; file still binary.
**Step 9: delete the legacy `<dialog id="files-editor-modal">` from `overlay_detail.html`**
By this point the legacy dialog has no callers. Delete the block from `overlay_detail.html` (originally lines 165-228, may have shifted).
In `editor.js`, delete all code paths that handle the legacy dialog: the `editorDialog` ref + the document-delegated handlers scoped to `#files-editor-modal` + the `input`/`keydown` direct-binds that were only needed for the legacy persistent inputs. The `editor.js` module shrinks to ~200 lines, single-purpose (URL-addressable modal only).
Add a pytest assertion (in `test_url_addressable_modals.py`) that `id="files-editor-modal"` does NOT appear in the rendered overlay detail page.
Verification: overlay detail page renders without the legacy dialog (DOM inspector); URL-addressable editor still works for text + binary + create-new flows; all existing pytest tests still pass.
**Step 10: delete `files-overlay.js` stub + update base.html**
If `files-overlay.js` is empty or just an IIFE shell, delete it. Update `overlay_detail.html` (NOT base.html) to load only the 4 new modules (`core.js`, `editor.js`, `dialogs.js`, `uploads.js`), all with `defer`. Order doesn't strictly matter for execution (each is an independent IIFE), but `core.js` first makes the registry-of-handlers pattern explicit.
Verification: full re-run of the URL-addressable-modals spec's verification matrix (10 checks); 4 new modules' features all work (editor text + binary + create-new; new-folder; conflict; delete-confirm; drag-drop; uploads cancel + clear).
End of Phase B: legacy dialog gone, `editor.js` single-purpose, all editor flows URL-addressable.
### Phase C — API cleanup (steps 11-12)
**Step 11: extract shared path-resolution + editability helper**
`routes/files_routes.py` has duplication between `overlay_file_content` (lines 203-234, JSON output) and `overlay_file_edit_page` (lines 237-275, HTML output, added in Task 3). Both:
- Read `request.args.get("path", "")`
- Call `_load_files_overlay(overlay_id, user)`
- Call `safe_resolve_for_listing(overlay.path, sub_path)`
- Check `target.exists() and target.is_file()`
- Call `is_editable(target)`
- Try `target.read_text(encoding="utf-8")` with OSError + UnicodeDecodeError fallback
Extract `_load_file_for_editing(overlay_id, sub_path, user) -> (overlay, target_path, content_or_None, is_binary, byte_count) | Response`:
- Returns a tuple on success, a Response on any failure case (404, 415, 400, 403)
- Both routes call this helper and translate the tuple into their respective output shapes
Same path: `is_editable` checks become part of the helper.
Add pytest tests for the helper directly if reasonable, plus confirm existing route tests still pass.
Verification: existing pytest tests stay green (no behavior change); both routes shorter and obviously parallel.
**Step 12: delete `GET /overlays/<id>/files/content` if unused; consolidate save/replace**
Audit: search the codebase for callers of `/files/content` (JSON endpoint). After Phase B, the legacy `openEditorForFile()` is gone, which was its only caller. If grep confirms no other callers, delete the endpoint + its tests.
`overlay_file_save` and `overlay_file_replace` share the rename branch (lines 276-285 of save, lines 322-335 of replace). Extract `_apply_optional_rename(overlay, path, new_path) -> (write_target, echo_path) | Response`. Both endpoints call it.
Verification: pytest stays green; grep confirms no remaining references to `/files/content`; both save/replace routes shorter.
End of Phase C: `routes/files_routes.py` ~450 lines (vs 640), no dead endpoints, shared helpers.
## Critical files
| Path | Phases | Action |
|------|--------|--------|
| `l4d2web/l4d2web/static/js/files-overlay/core.js` | A | New file |
| `l4d2web/l4d2web/static/js/files-overlay/editor.js` | A → B | New file, then shrinks in Phase B |
| `l4d2web/l4d2web/static/js/files-overlay/dialogs.js` | A | New file |
| `l4d2web/l4d2web/static/js/files-overlay/uploads.js` | A | New file |
| `l4d2web/l4d2web/static/js/files-overlay.js` | A → B | Shrinks each step; deleted in step 10 |
| `l4d2web/l4d2web/templates/overlay_detail.html` | A, B | Script tag updates each phase end (currently loads files-overlay.js at line 285 with `defer`) |
| `l4d2web/l4d2web/templates/overlay_file_editor.html` | B | Add new-file + binary-replace branches |
| `l4d2web/l4d2web/templates/overlay_detail.html` | B | Delete legacy `<dialog id="files-editor-modal">` (step 9) |
| `l4d2web/l4d2web/routes/files_routes.py` | B, C | Add `/files/new` route (step 5); extend `/files/edit` for binary (step 7); extract helpers (step 11); delete `/files/content` (step 12) |
| `l4d2web/tests/test_url_addressable_modals.py` | B | Add tests for new + binary modes (steps 5, 7); add legacy-dialog-gone assertion (step 9) |
## Existing functions and utilities to reuse
- `window.modals.openInline(idOrEl)` / `closeInline()` / `openRouted(path)` / `closeRouted()` — at `static/js/modals.js`. New code uses these instead of `dialog.showModal()` directly.
- `window.__editor.initEditors(root)` — at `static/js/editor.js`. CM6 re-init on swapped-in textareas. The new-file flow's empty textarea also needs CM6 mount; this just works because the `htmx:afterSwap` listener already covers it.
- `window.__filesEditor.getValue()` — set by `editor.js` when CM6 mounts in the modal content. Used by `editor.js`'s save delegation.
- `_load_files_overlay`, `safe_resolve_for_listing`, `is_editable`, `_validate_save_content`, `_stream_upload_into` — all in `routes/files_routes.py`. Reused by new routes and the extracted helper.
- Existing Flask routes `/files/{save,delete,replace,upload,move,mkdir,download,download_zip,edit}` — consumed unchanged by the new JS modules. `/files/content` deleted in Phase C.
## Verification
Each step has its own check (above). Phase-end checks:
**Phase A end** (after step 4): all current features still work. 573 backend tests pass. Chromium pass on: file-row click → editor; +new-file → editor (in legacy dialog); +new-folder → new-folder modal; click binary file → editor in binary mode; drag file row → moves; drop file onto tree → uploads.
**Phase B end** (after step 10): legacy dialog gone; create-new + binary-replace are URL-addressable. Full re-run of the URL-addressable-modals spec verification matrix (`docs/superpowers/specs/2026-05-17-url-addressable-modals-design.md`, ## Verification, 10 checks) passes. New checks added: create-new URL deep-link works, binary-replace URL deep-link works.
**Phase C end** (after step 12): `routes/files_routes.py` shorter; 573 backend tests stay green; `grep -rn "/files/content"` returns nothing (or only confirms the deletion).
## Handoff state
After **each** commit, the working tree is in a known-good Chromium-verified state. A future session resumes by:
1. Reading this plan file at `docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md`.
2. Reading `git log --oneline` to see which steps have shipped.
3. Picking up at the next un-committed step.
4. Per `feedback_textarea_editor_v2_run` memory: direct-to-master on left4me, skip subagent middleman for verbatim-from-plan tasks, document plan deviations in commit messages, Chromium verification works in default sandbox via `./scripts/dev-server.py`.
Natural pause points: end of Phase A, end of Phase B, end of Phase C. Each phase delivers value independently — Phase A alone is a useful cleanup if the user doesn't want to do B and C.
## Errata (pre-execution)
- **Script tag location:** the plan's first draft incorrectly referenced `base.html` for the script tag updates. The actual location is `overlay_detail.html:285` (with `defer`). `files-overlay.js` and its replacement modules are page-scoped to overlay detail — loading from `base.html` would pull the JS onto every page when the `.files-manager` element only exists on overlay detail. All script tag references in this plan now correctly say `overlay_detail.html`. This errata is itself an example of the `feedback_validate_before_implementing` memory: probe the live state before trusting a plan claim.
## Out of scope
- Adding a JS test framework (no JS tests in the project today; verification stays Chromium-driven).
- Migrating the other inline modals (rename, delete-overlay, new-folder-overlay, etc.) — unrelated to this file.
- Restructuring the `/files/upload` chunking or progress-event behavior.
- Refactoring `_overlay_file_node.html`, `_overlay_file_tree.html`, or other template partials that this JS doesn't own.

View file

@ -1,122 +0,0 @@
# Console Command Autocomplete — Design
**Date:** 2026-05-17
**Status:** Approved for implementation planning
## Context
The L4D2 web admin already has a config editor (CodeMirror 6 based) that offers autocomplete for Source-engine console commands and cvars, sourced from a JSON vocab file (`l4d2web/l4d2web/static/data/srccfg-vocab.json`, 671 commands + cvars with descriptions). The same vocabulary is useful at the **runtime console** input on `server_detail.html`, where admins type commands against a live server — but no autocomplete exists there today. Users have to remember exact command names, leading to typos and trial-and-error against a production server.
Goal: add autocomplete to the runtime console input, reusing the editor's ranking logic where reuse is cheap, without modifying the editor's behavior and without colliding with the existing `console-history.js` ArrowUp/Down history recall.
## Scope
- **In scope:** dropdown autocomplete on the server-detail console input, sharing the editor's ranking algorithm and the existing `srccfg-vocab.json` data file.
- **Out of scope:** argument-value completion (player names for `kick`, map names for `changelevel`, etc. — needs runtime data); fuzzy/typo-tolerant matching; custom per-game vocabs (the API accepts a URL so the door is open, but only `srccfg-vocab.json` ships); replacing CodeMirror's built-in dropdown in the editor.
## Key Decisions (from brainstorming)
| # | Decision | Rationale |
|---|---|---|
| 1 | **Level A reuse:** extract ranker into a shared module; build a small vanilla dropdown for the console. Editor keeps CodeMirror's built-in dropdown. | Shares the product judgment (ranking algorithm) without coupling the console to CodeMirror. ~10-line shared brain + ~80-line vanilla dropdown. |
| 2 | **Fetch vocab on console input focus, once per page load.** | Doesn't compete with page load. Browser cache makes subsequent visits free. By the time the user finishes typing the first character, fetch is usually done. |
| 3 | **Tab / Shift+Tab cycle the dropdown; Esc dismisses; Enter submits the input as-typed; ArrowUp/Down untouched (history keeps them).** | ArrowUp/Down already belongs to `console-history.js`. Autocomplete cannot ever *change* the submitted command — safety on a live game server. Mouse + Tab covers selection. |
| 4 | **Dropdown rows show name + description**, mirroring the editor's format. | Same visual affordance as editor → muscle memory transfers. Vocab JSON already carries `desc`. |
| 5 | **First-token only**: dropdown hides as soon as the user types a space. | Vocab only knows command names, not argument grammars. Quiet-when-uncertain beats confident-and-wrong. |
## Architecture
Three moving parts:
1. **Extract the ranker.** Move pure ranking logic out of `l4d2web/scripts/editor-src/autocomplete.js` into new module `l4d2web/scripts/editor-src/vocab-rank.js`. Function signature: `rankVocab(query: string, vocab: {cvars, commands}) → Array<{name, desc, kind}>`. No CodeMirror dependency. `autocomplete.js` re-imports it; the editor build keeps working unchanged.
2. **Console autocomplete module.** New file `l4d2web/l4d2web/static/js/console-autocomplete.js`. Vanilla JS, no framework. Imports the ranker (delivery details — separate static script, or compiled into the editor bundle output — to be resolved in the implementation plan). Exposes one entry point: `attachAutocomplete(inputEl, vocabUrl)`.
3. **Wire-up on server-detail.** A new `<script>` tag in `server_detail.html` (placed alongside the existing `console-history.js` tag) calls `attachAutocomplete(form.querySelector('input[name=command]'), '/static/data/srccfg-vocab.json')` once on page load. `console-history.js` is **not modified** — it keeps owning ArrowUp/Down, Enter, and the input's `data-console-form` attribute. The two modules cooperate by owning disjoint key sets.
## Components & Interfaces
- **`vocab-rank.js`** — `rankVocab(query, vocab) → ranked items`. Pure. Score: exact (3) → prefix (2) → substring (1). Truncates to a configurable limit (default 50). Identical algorithm to the editor's current behavior.
- **`autocomplete.js`** (editor) — unchanged externally; internally now calls `rankVocab` instead of inlining the ranking. Editor build artifacts (`l4d2web/l4d2web/static/js/editor.js`) should be regenerated.
- **`console-autocomplete.js`** — owns:
- module-scoped fetch promise (deduplicates concurrent fetches; `{ once: true }` semantics)
- one dropdown DOM element, lazily created, appended to `document.body`, positioned with `getBoundingClientRect()` of the input
- `focus` listener (one-shot) → triggers fetch
- `input` event listener → recompute matches, show/hide dropdown based on first-token rule
- `keydown` listener for Tab / Shift+Tab / Esc *only*`preventDefault()` only for those keys
- `blur` / `Esc` / form `submit` → close dropdown
- **`console-history.js`** — unchanged. Continues to own ArrowUp/Down, Enter handling, history fetch endpoint.
## Data Flow
```
[user focuses input]
→ console-autocomplete fetches /static/data/srccfg-vocab.json (once)
→ vocab cached in module-scope promise
[user types 'sv_che']
→ 'input' event fires
→ first-token check: cursor in first token? yes
→ rankVocab('sv_che', vocab) → [{name:'sv_cheats',desc:'...'}, ...]
→ render dropdown rows, highlight first row
[user presses Tab]
→ preventDefault, replace first token with highlighted suggestion
→ keep dropdown open with new matches (or close on exact match)
[user types space]
→ first-token check: cursor past first space? yes
→ hide dropdown
[user presses Enter]
→ console-history.js submits input verbatim (as-typed)
→ autocomplete dropdown closes on input blur after submit
```
## Critical Files
- `l4d2web/scripts/editor-src/autocomplete.js` — refactored to import the ranker; behavior unchanged.
- `l4d2web/scripts/editor-src/vocab-rank.js`**new**. Pure ranker.
- `l4d2web/scripts/editor-src/editor-entry.js` — no functional change; verify the autocomplete extension still wires up correctly after the ranker extraction.
- `l4d2web/l4d2web/static/js/console-autocomplete.js`**new**. Vanilla dropdown.
- `l4d2web/l4d2web/static/js/console-history.js`**untouched** (verify by diff after wiring change).
- `l4d2web/l4d2web/templates/server_detail.html` — add one `<script>` tag for `console-autocomplete.js` (or extend an existing module-loader block; check current convention during planning).
- `l4d2web/l4d2web/static/data/srccfg-vocab.json` — unchanged data source.
## Error Handling & Edge Cases
- **Vocab fetch fails:** silent. Dropdown never appears; console keeps working. `console.warn` for devtools visibility.
- **No matches:** dropdown stays hidden (don't render an empty box).
- **Pasted long command:** treated as text input. If pasted text contains a space, dropdown hides immediately (paste lands cursor at end, past first token).
- **Input is empty:** dropdown hidden. ArrowUp recalls history as before.
- **Dropdown scrolling:** max ~8 rows visible (match editor's `maxRenderedOptions: 8`); internal scroll for overflow.
- **Multiple console forms on one page:** call `attachAutocomplete` per form. Each instance owns its own dropdown DOM node.
## Verification
End-to-end check after implementation:
1. **Console smoke test on server-detail page** (run dev server: `python scripts/dev-server.py`, open a server-detail page):
- Focus console input. Network panel shows one fetch for `srccfg-vocab.json`.
- Type `sv_` → dropdown appears with cvars, top row highlighted.
- Press Tab → first token replaced; dropdown stays/closes as expected.
- Press Esc → dropdown closes; input retains current text.
- Press ArrowUp → history recall works (no autocomplete interference).
- Type `sv_cheats 1` → after the space, dropdown is hidden.
- Press Enter on `sv_che` (with `sv_cheats` highlighted in dropdown) → server receives `sv_che` (the *typed* text), not `sv_cheats`.
- Refocus input → no second fetch (cached).
2. **Editor regression check:** open a `.cfg` file in the editor, verify autocomplete still works exactly as before (ranker extraction is transparent).
3. **Unit test for `rankVocab`:** small test file covering exact/prefix/substring ordering and the truncation limit. Pure function, fast.
4. **No automated browser e2e** unless the project already has Playwright/Cypress — check during planning phase. Don't add a test framework as part of this work.
## Non-Goals (Repeated for Clarity)
These are explicitly **not** part of this work and should be deferred to follow-up specs if needed:
- Argument value completions (player/map/etc. names).
- Fuzzy matching.
- Replacing CodeMirror's editor dropdown.
- Multi-vocab/multi-game support beyond the URL parameter being available.

View file

@ -129,6 +129,7 @@ def overlay_files_fragment(overlay_id: int):
truncated=truncated_count > 0, truncated=truncated_count > 0,
truncated_count=truncated_count, truncated_count=truncated_count,
files_base_url=f"/overlays/{overlay_id}", files_base_url=f"/overlays/{overlay_id}",
download_supported=True,
files_overlay=(overlay.type == "files"), files_overlay=(overlay.type == "files"),
) )
@ -194,6 +195,7 @@ def server_files_fragment(server_id: int):
truncated=truncated_count > 0, truncated=truncated_count > 0,
truncated_count=truncated_count, truncated_count=truncated_count,
files_base_url=f"/servers/{server_id}", files_base_url=f"/servers/{server_id}",
download_supported=True,
) )

View file

@ -1,55 +0,0 @@
/* Console autocomplete dropdown.
Positioned absolutely under the console input by JS; visuals match
the editor's tooltip styling (var(--cm-*) tokens defined in
tokens.css and editor.css). */
.console-autocomplete-dropdown {
position: absolute;
z-index: 1000;
max-height: calc(8 * 2.4rem);
overflow-y: auto;
background-color: var(--cm-bg, #1e1e1e);
color: var(--cm-fg, #e0e0e0);
border: 1px solid var(--color-border, #444);
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 13px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
}
.console-autocomplete-row {
display: flex;
align-items: baseline;
gap: 0.75em;
padding: 0.3em 0.6em;
cursor: pointer;
white-space: nowrap;
}
.console-autocomplete-row[aria-selected="true"] {
background-color: var(--cm-selection, #264f78);
}
.console-autocomplete-row:hover {
background-color: var(--cm-selection, #264f78);
}
.console-autocomplete-name {
font-weight: 600;
}
.console-autocomplete-row.kind-cvar .console-autocomplete-name {
color: var(--cm-keyword, #569cd6);
}
.console-autocomplete-row.kind-command .console-autocomplete-name {
color: var(--cm-string, #ce9178);
}
.console-autocomplete-desc {
color: var(--color-muted, #888);
font-size: 0.9em;
overflow: hidden;
text-overflow: ellipsis;
max-width: 40em;
}

View file

@ -1,189 +0,0 @@
// console-autocomplete.js
// Vanilla dropdown autocomplete for [data-console-form] inputs.
// Reads ranked completions from window.__rankVocab (loaded via
// vocab-rank.bundle.js). Owns: Tab, Shift+Tab, Esc, mouse events.
// Leaves: ArrowUp, ArrowDown, Enter (console-history.js owns those).
//
// First-token only: the dropdown is hidden as soon as the cursor
// is past the first space in the input.
const VOCAB_URL = "/static/data/srccfg-vocab.json";
const MAX_RENDERED = 8;
let vocabPromise = null;
function loadVocab() {
if (vocabPromise) return vocabPromise;
vocabPromise = fetch(VOCAB_URL, { credentials: "same-origin" })
.then(r => r.ok ? r.json() : Promise.reject(new Error("vocab fetch failed: " + r.status)))
.catch(err => { console.warn("[console-autocomplete] vocab load failed", err); return null; });
return vocabPromise;
}
function firstTokenSlice(value, caret) {
// Returns the substring [0, end-of-first-token) if the caret is
// within the first token; otherwise null.
const spaceIdx = value.indexOf(" ");
if (spaceIdx === -1) {
return { token: value, from: 0, to: value.length };
}
if (caret > spaceIdx) return null;
return { token: value.slice(0, spaceIdx), from: 0, to: spaceIdx };
}
function bindConsoleAutocomplete(form) {
if (form.dataset.consoleAutocompleteBound === "true") return;
form.dataset.consoleAutocompleteBound = "true";
const input = form.querySelector("input[name='command']");
if (!input) return;
// --- Dropdown DOM (created lazily on first show) ---
let dropdown = null;
let items = []; // current ranked items
let highlightIdx = 0; // index of currently-highlighted row
let vocab = null;
function ensureDropdown() {
if (dropdown) return dropdown;
dropdown = document.createElement("div");
dropdown.className = "console-autocomplete-dropdown";
dropdown.setAttribute("role", "listbox");
dropdown.style.display = "none";
document.body.appendChild(dropdown);
return dropdown;
}
function position() {
if (!dropdown) return;
const rect = input.getBoundingClientRect();
dropdown.style.left = `${rect.left + window.scrollX}px`;
dropdown.style.top = `${rect.bottom + window.scrollY}px`;
dropdown.style.minWidth = `${rect.width}px`;
}
function close() {
if (!dropdown) return;
dropdown.style.display = "none";
items = [];
highlightIdx = 0;
}
function render() {
ensureDropdown();
if (items.length === 0) { close(); return; }
const rows = items.slice(0, MAX_RENDERED).map((e, i) => {
const selected = i === highlightIdx ? " aria-selected='true'" : "";
const kindClass = e.kind === "command" ? "kind-command" : "kind-cvar";
const desc = e.desc ? `<span class="console-autocomplete-desc">${escapeHtml(e.desc)}</span>` : "";
return `<div class="console-autocomplete-row ${kindClass}"${selected} role="option" data-idx="${i}"><span class="console-autocomplete-name">${escapeHtml(e.name)}</span>${desc}</div>`;
}).join("");
dropdown.innerHTML = rows;
dropdown.style.display = "block";
position();
}
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
function acceptHighlighted() {
if (items.length === 0) return;
const chosen = items[highlightIdx];
const slice = firstTokenSlice(input.value, input.selectionStart || 0);
if (!slice) return;
// If the first token is already exactly the chosen name, accepting it
// would be a no-op; close the dropdown so Tab feels responsive.
if (slice.token === chosen.name) { close(); return; }
const before = input.value.slice(0, slice.from);
const after = input.value.slice(slice.to);
input.value = before + chosen.name + after;
// Place caret at end of inserted name
const caret = before.length + chosen.name.length;
input.setSelectionRange(caret, caret);
recompute();
}
function recompute() {
if (!vocab) return;
const slice = firstTokenSlice(input.value, input.selectionStart || 0);
if (!slice || !slice.token) { close(); return; }
items = window.__rankVocab(slice.token, vocab);
if (items.length === 0) { close(); return; }
highlightIdx = 0;
render();
}
// --- Lazy vocab fetch on first focus ---
input.addEventListener("focus", async () => {
if (!vocab) {
vocab = await loadVocab();
// If the user already typed during the fetch, rank now so the
// dropdown doesn't appear to lag a keystroke behind on cold load.
if (vocab && document.activeElement === input) recompute();
}
}, { once: true });
input.addEventListener("input", () => {
if (!vocab) return; // fetch may not have resolved yet; next input will recompute
recompute();
});
input.addEventListener("keydown", (event) => {
if (event.key === "Tab" && !event.shiftKey) {
if (items.length > 0) {
event.preventDefault();
acceptHighlighted();
}
} else if (event.key === "Tab" && event.shiftKey) {
if (items.length > 0) {
event.preventDefault();
highlightIdx = (highlightIdx - 1 + Math.min(items.length, MAX_RENDERED))
% Math.min(items.length, MAX_RENDERED);
render();
}
} else if (event.key === "Escape") {
if (dropdown && dropdown.style.display !== "none") {
event.preventDefault();
close();
}
}
// ArrowUp/ArrowDown/Enter intentionally NOT handled here.
});
input.addEventListener("blur", () => {
// Delay close so a click on a dropdown row can fire first.
setTimeout(close, 100);
});
// Mouse click on a row → accept that row.
document.addEventListener("mousedown", (event) => {
if (!dropdown || dropdown.style.display === "none") return;
const row = event.target.closest(".console-autocomplete-row");
if (!row || !dropdown.contains(row)) return;
event.preventDefault();
highlightIdx = parseInt(row.dataset.idx, 10) || 0;
acceptHighlighted();
input.focus();
});
// HTMX form submission clears the input; close on submit.
form.addEventListener("htmx:beforeRequest", close);
// Reposition on resize/scroll while dropdown is open.
window.addEventListener("resize", () => { if (dropdown && dropdown.style.display !== "none") position(); });
window.addEventListener("scroll", () => { if (dropdown && dropdown.style.display !== "none") position(); }, true);
}
function bindAll(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(bindConsoleAutocomplete);
}
document.addEventListener("DOMContentLoaded", () => bindAll(document));
document.addEventListener("htmx:load", (event) => bindAll(event.detail.elt));

View file

@ -181,10 +181,7 @@
: ta.value; : ta.value;
// is_new mode: relPath is empty; compose path from data-at-folder // is_new mode: relPath is empty; compose path from data-at-folder
// + filename. The /save endpoint creates the file when missing, // + filename. The /save endpoint creates the file when missing.
// 409s when the destination already exists. On 409 we offer the
// same overwrite / keep-both / cancel prompt that the legacy
// create-new flow used (via askConflict in dialogs.js).
if (!relPath) { if (!relPath) {
if (!editedFilename) return; if (!editedFilename) return;
const atFolder = ta.dataset.atFolder || ""; const atFolder = ta.dataset.atFolder || "";
@ -195,35 +192,11 @@
if (r.ok) { if (r.ok) {
if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted(); if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted();
scheduleRefresh(parentOf(fullPath)); scheduleRefresh(parentOf(fullPath));
return; } else if (r.status === 409) {
alert(r.rawText || `A file at ${fullPath} already exists. Pick a different name.`);
} else {
alert((r.body && r.body.error) || r.rawText || `Create failed (HTTP ${r.status}).`);
} }
if (r.status === 409 && typeof fo.askConflict === "function") {
const action = await fo.askConflict(fullPath);
if (action === "overwrite") {
// /save overwrites in place when the destination is a file —
// a plain re-POST does the right thing.
const r2 = await postJson(`${baseUrl}/files/save`, { path: fullPath, content });
if (r2.ok) {
if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted();
scheduleRefresh(parentOf(fullPath));
} else {
alert((r2.body && r2.body.error) || `Save failed (HTTP ${r2.status}).`);
}
} else if (action === "keep-both" && typeof fo.withCollisionSuffix === "function") {
const altered = fo.withCollisionSuffix(fullPath);
const r2 = await postJson(`${baseUrl}/files/save`, { path: altered, content });
if (r2.ok) {
if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted();
scheduleRefresh(parentOf(altered));
} else {
alert((r2.body && r2.body.error) || `Save failed (HTTP ${r2.status}).`);
}
}
// "cancel" → leave the modal open so the user can edit the
// filename and try again without losing typed content.
return;
}
alert((r.body && r.body.error) || r.rawText || `Create failed (HTTP ${r.status}).`);
return; return;
} }

View file

@ -37,29 +37,11 @@
const uploadsList = uploadsPanel?.querySelector(".files-uploads-list"); const uploadsList = uploadsPanel?.querySelector(".files-uploads-list");
const uploadsClearBtn = uploadsPanel?.querySelector(".files-uploads-clear"); const uploadsClearBtn = uploadsPanel?.querySelector(".files-uploads-clear");
// Attach a path-collision suffix: foo.txt → foo (1).txt. // Attach a path-collision suffix: foo.txt → foo (1).txt
// Recognizes common compressed-tar double-extensions so
// foo.tar.gz → foo (1).tar.gz, not foo.tar (1).gz.
const DOUBLE_EXTS = [
".tar.gz", ".tar.bz2", ".tar.xz", ".tar.zst", ".tar.lz", ".tar.lzma",
];
function withCollisionSuffix(path) { function withCollisionSuffix(path) {
const slash = path.lastIndexOf("/");
const stemStart = slash + 1;
const basename = path.slice(stemStart);
const lower = basename.toLowerCase();
for (const ext of DOUBLE_EXTS) {
if (lower.endsWith(ext) && basename.length > ext.length) {
const cut = stemStart + basename.length - ext.length;
return path.slice(0, cut) + " (1)" + path.slice(cut);
}
}
// Single extension (or none). A dot must appear after the last
// slash to count as an extension — preserves the legacy behavior
// where a leading-dot basename like ".hidden" gets the suffix at
// the end rather than at position 0.
const dot = path.lastIndexOf("."); const dot = path.lastIndexOf(".");
if (dot > slash && dot > -1) { const slash = path.lastIndexOf("/");
if (dot > slash + 0 && dot > -1) {
return path.slice(0, dot) + " (1)" + path.slice(dot); return path.slice(0, dot) + " (1)" + path.slice(dot);
} }
return path + " (1)"; return path + " (1)";

View file

@ -1,11 +1,7 @@
# Editor bundle vendor README # Editor bundle vendor README
This directory contains pre-built JavaScript bundles produced by esbuild from `editor.bundle.js` is a pre-built IIFE produced by esbuild from
`l4d2web/scripts/editor-src/`: `l4d2web/scripts/editor-src/`. It exposes `window.__editor.mount(textarea, opts)`.
- `editor.bundle.js` — CodeMirror 6 IIFE, exposes `window.__editor.mount(textarea, opts)`.
- `vocab-rank.bundle.js` — vocabulary ranking helpers used by the console autocomplete.
- `editor.bundle.css` — placeholder CSS file (always empty; cm6 injects styles at runtime via StyleModule).
## Rebuild ## Rebuild
@ -15,10 +11,9 @@ From repo root:
./l4d2web/scripts/build-editor.sh ./l4d2web/scripts/build-editor.sh
``` ```
This runs `npm install` inside `editor-src/` then `npm run build`, which This runs `npm install` inside `editor-src/` then `npx esbuild`. The
rebuilds **all bundles** (`editor.bundle.js` and `vocab-rank.bundle.js`) in one output overwrites `editor.bundle.js` and `editor.bundle.css` in this
pass. The output overwrites the bundles in this directory and refreshes directory and refreshes `editor.bundle.sha256`.
`editor.bundle.sha256`.
The build script uses `$TMPDIR/npm-cache` (override with the The build script uses `$TMPDIR/npm-cache` (override with the
`NPM_CACHE` env var) as the npm cache to avoid permission issues with `NPM_CACHE` env var) as the npm cache to avoid permission issues with
@ -32,7 +27,6 @@ See `l4d2web/scripts/editor-src/package.json` for semver ranges and
## Integrity ## Integrity
`editor.bundle.sha256` contains the hashes of the committed bundles `editor.bundle.sha256` contains the hashes of the committed bundle.
(`editor.bundle.js`, `editor.bundle.css`, `vocab-rank.bundle.js`). If the bundle drifts from this hash in CI / review, the artifact was
If a bundle drifts from its hash in CI / review, the artifact was
rebuilt without committing the updated bundle. rebuilt without committing the updated bundle.

File diff suppressed because one or more lines are too long

View file

@ -1,3 +1,2 @@
939e3d9ba5ae65a23b17f57050144e8444e0a6ce1b85b705055bf3dc1d9a36d4 editor.bundle.js 910031cfc346106af240df71b9ef8069f1b38f1a4c63128392c2aa074e7e57b2 editor.bundle.js
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 editor.bundle.css e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 editor.bundle.css
7fefca5b1197490283c86f6d46036aaf719cc032a3bde96483aa10f6b0ba35b1 vocab-rank.bundle.js

View file

@ -1 +0,0 @@
(()=>{function f(r,o){if(o===r)return 0;if(o.startsWith(r))return 1+o.length;let t=o.indexOf(r);return t!==-1?1e4+t:-1}function e(r,o,{limit:t=50}={}){if(!r)return[];let i=r.toLowerCase(),a=[...o.cvars.map(n=>({...n,kind:"cvar"})),...o.commands.map(n=>({...n,kind:"command"}))],s=[];for(let n of a){let c=f(i,n.name.toLowerCase());if(c!==-1&&(s.push([c,n]),s.length>t*4))break}return s.sort((n,c)=>n[0]-c[0]),s.slice(0,t).map(([,n])=>n)}window.__rankVocab=e;})();

View file

@ -18,7 +18,8 @@
<div class="file-tree-children" hidden></div> <div class="file-tree-children" hidden></div>
</li> </li>
{% else %} {% else %}
{% set has_actions = not entry.broken %} {% set show_download = download_supported and not entry.broken %}
{% set has_actions = (files_overlay or show_download) and not entry.broken %}
<li class="file-tree-row file-tree-row-file{% if has_actions %} files-row{% endif %}" <li class="file-tree-row file-tree-row-file{% if has_actions %} files-row{% endif %}"
{% if files_overlay %}draggable="true" data-target-path="{{ entry.rel }}" data-row-kind="file" data-editable="{{ '1' if entry.editable else '0' }}"{% endif %}> {% if files_overlay %}draggable="true" data-target-path="{{ entry.rel }}" data-row-kind="file" data-editable="{{ '1' if entry.editable else '0' }}"{% endif %}>
{% if entry.broken %} {% if entry.broken %}
@ -35,7 +36,9 @@
{% endif %} {% endif %}
{% if has_actions %} {% if has_actions %}
<span class="files-row-actions" aria-label="File actions"> <span class="files-row-actions" aria-label="File actions">
{% if show_download %}
<a class="files-row-action" href="{{ files_base_url }}/files/download?path={{ entry.rel|urlencode }}" title="Download"></a> <a class="files-row-action" href="{{ files_base_url }}/files/download?path={{ entry.rel|urlencode }}" title="Download"></a>
{% endif %}
{% if files_overlay %} {% if files_overlay %}
<button type="button" class="files-row-action files-row-danger" data-action="delete" data-target-path="{{ entry.rel }}" data-row-kind="file" data-row-name="{{ entry.name }}" title="Delete"></button> <button type="button" class="files-row-action files-row-danger" data-action="delete" data-target-path="{{ entry.rel }}" data-row-kind="file" data-row-name="{{ entry.name }}" title="Delete"></button>
{% endif %} {% endif %}

View file

@ -9,7 +9,6 @@
<link rel="stylesheet" href="{{ url_for('static', filename='css/layout.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/layout.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/components.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/components.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/logs.css') }}"> <link rel="stylesheet" href="{{ url_for('static', filename='css/logs.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='css/console-autocomplete.css') }}">
{% block extra_head %}{% endblock %} {% block extra_head %}{% endblock %}
</head> </head>
<body> <body>
@ -48,7 +47,5 @@
<script src="{{ url_for('static', filename='js/file-tree.js') }}"></script> <script src="{{ url_for('static', filename='js/file-tree.js') }}"></script>
<script src="{{ url_for('static', filename='js/password-reveal.js') }}"></script> <script src="{{ url_for('static', filename='js/password-reveal.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/console-history.js') }}"></script> <script defer src="{{ url_for('static', filename='js/console-history.js') }}"></script>
<script defer src="{{ url_for('static', filename='vendor/vocab-rank.bundle.js') }}"></script>
<script defer src="{{ url_for('static', filename='js/console-autocomplete.js') }}"></script>
</body> </body>
</html> </html>

View file

@ -90,6 +90,7 @@
{% set truncated = file_tree_truncated %} {% set truncated = file_tree_truncated %}
{% set truncated_count = file_tree_truncated_count %} {% set truncated_count = file_tree_truncated_count %}
{% set files_base_url = "/overlays/" ~ overlay.id %} {% set files_base_url = "/overlays/" ~ overlay.id %}
{% set download_supported = True %}
{% set files_overlay = True %} {% set files_overlay = True %}
{% include "_overlay_file_tree.html" %} {% include "_overlay_file_tree.html" %}
{% endif %} {% endif %}
@ -105,6 +106,7 @@
{% set truncated = file_tree_truncated %} {% set truncated = file_tree_truncated %}
{% set truncated_count = file_tree_truncated_count %} {% set truncated_count = file_tree_truncated_count %}
{% set files_base_url = "/overlays/" ~ overlay.id %} {% set files_base_url = "/overlays/" ~ overlay.id %}
{% set download_supported = True %}
{% include "_overlay_file_tree.html" %} {% include "_overlay_file_tree.html" %}
{% endif %} {% endif %}
{% endif %} {% endif %}

View file

@ -71,6 +71,7 @@
{% set truncated = file_tree_truncated %} {% set truncated = file_tree_truncated %}
{% set truncated_count = file_tree_truncated_count %} {% set truncated_count = file_tree_truncated_count %}
{% set files_base_url = "/servers/" ~ server.id %} {% set files_base_url = "/servers/" ~ server.id %}
{% set download_supported = True %}
{% include "_overlay_file_tree.html" %} {% include "_overlay_file_tree.html" %}
{% endif %} {% endif %}
</section> </section>

View file

@ -13,10 +13,13 @@ cd "$SRC"
NPM_CACHE="${NPM_CACHE:-$TMPDIR/npm-cache}" NPM_CACHE="${NPM_CACHE:-$TMPDIR/npm-cache}"
npm install --cache "$NPM_CACHE" npm install --cache "$NPM_CACHE"
# Build all bundles via npm run build (editor.bundle.js + vocab-rank.bundle.js). npx esbuild editor-entry.js \
# Do not call npx esbuild directly — package.json is the single source of truth --bundle --minify \
# for build targets so new bundles are never silently omitted. --format=iife \
npm run build --global-name=__editor_pkg \
--outfile="$OUT/editor.bundle.js" \
--metafile=meta.json \
--loader:.css=text
# cm6 injects its styles at runtime via the StyleModule machinery, so the # cm6 injects its styles at runtime via the StyleModule machinery, so the
# bundle does not produce a separate .css file. We create an empty # bundle does not produce a separate .css file. We create an empty
@ -25,7 +28,6 @@ npm run build
# drop it in without a template change). # drop it in without a template change).
: > "$OUT/editor.bundle.css" : > "$OUT/editor.bundle.css"
(cd "$OUT" && shasum -a 256 editor.bundle.js editor.bundle.css vocab-rank.bundle.js > editor.bundle.sha256) (cd "$OUT" && shasum -a 256 editor.bundle.js editor.bundle.css > editor.bundle.sha256)
echo "Built $OUT/editor.bundle.js ($(wc -c < "$OUT/editor.bundle.js") bytes)" echo "Built $OUT/editor.bundle.js ($(wc -c < "$OUT/editor.bundle.js") bytes)"
echo "Built $OUT/vocab-rank.bundle.js ($(wc -c < "$OUT/vocab-rank.bundle.js") bytes)"

View file

@ -1,16 +1,38 @@
import { autocompletion } from "@codemirror/autocomplete"; import { autocompletion } from "@codemirror/autocomplete";
import { rankVocab } from "./vocab-rank.js";
const WORD_RE = /[A-Za-z0-9_]{2,}/; const WORD_RE = /[A-Za-z0-9_]{2,}/;
function rank(query, label) {
const q = query.toLowerCase();
const l = label.toLowerCase();
if (l === q) return 0;
if (l.startsWith(q)) return 1 + l.length; // shorter prefix matches first
const i = l.indexOf(q);
if (i !== -1) return 10000 + i; // substring matches after all prefix matches
return -1;
}
export function vocabCompletions(vocab) { export function vocabCompletions(vocab) {
// vocab: { cvars: [{name, desc?}, …], commands: [{name, desc?}, …] } // vocab: { cvars: [{name, desc?}, …], commands: [{name, desc?}, …] }
const entries = [
...vocab.cvars.map(e => ({ ...e, kind: "cvar" })),
...vocab.commands.map(e => ({ ...e, kind: "command" })),
];
return (context) => { return (context) => {
const word = context.matchBefore(WORD_RE); const word = context.matchBefore(WORD_RE);
if (!word || (word.from === word.to && !context.explicit)) return null; if (!word || (word.from === word.to && !context.explicit)) return null;
const q = word.text;
const ranked = rankVocab(word.text, vocab); const scored = [];
const options = ranked.map(e => ({ for (const e of entries) {
const r = rank(q, e.name);
if (r === -1) continue;
scored.push([r, e]);
if (scored.length > 200) break; // bound work; we cap to 50 below
}
scored.sort((a, b) => a[0] - b[0]);
const options = scored.slice(0, 50).map(([, e]) => ({
label: e.name, label: e.name,
info: e.desc || e.kind, info: e.desc || e.kind,
type: e.kind === "command" ? "function" : "variable", type: e.kind === "command" ? "function" : "variable",

View file

@ -5,9 +5,7 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"build:editor": "esbuild editor-entry.js --bundle --minify --format=iife --global-name=__editor_pkg --outfile=../../l4d2web/static/vendor/editor.bundle.js --metafile=meta.json", "build": "esbuild editor-entry.js --bundle --minify --format=iife --global-name=__editor_pkg --outfile=../../l4d2web/static/vendor/editor.bundle.js --metafile=meta.json"
"build:vocab-rank": "esbuild vocab-rank-entry.js --bundle --minify --format=iife --outfile=../../l4d2web/static/vendor/vocab-rank.bundle.js",
"build": "npm run build:editor && npm run build:vocab-rank"
}, },
"devDependencies": { "devDependencies": {
"esbuild": "^0.24.0" "esbuild": "^0.24.0"

View file

@ -1,5 +0,0 @@
import { rankVocab } from "./vocab-rank.js";
// Expose as a global function so plain (non-module) scripts on
// server_detail.html can call window.__rankVocab(query, vocab).
window.__rankVocab = rankVocab;

View file

@ -1,37 +0,0 @@
// Pure, dependency-free ranking of a vocabulary against a query string.
// Used by both the CodeMirror editor (via autocomplete.js) and the
// runtime console (via the vocab-rank bundle exposed on window).
//
// Score (lower = better):
// exact match → 0
// prefix match → 1 + label.length (shorter prefix matches win)
// substring match → 10000 + indexOf (earlier substring beats later)
// no match → -1 (excluded)
function score(query, label) {
if (label === query) return 0;
if (label.startsWith(query)) return 1 + label.length;
const i = label.indexOf(query);
if (i !== -1) return 10000 + i;
return -1;
}
export function rankVocab(query, vocab, { limit = 50 } = {}) {
if (!query) return [];
const q = query.toLowerCase();
const entries = [
...vocab.cvars.map(e => ({ ...e, kind: "cvar" })),
...vocab.commands.map(e => ({ ...e, kind: "command" })),
];
const scored = [];
for (const e of entries) {
const s = score(q, e.name.toLowerCase());
if (s === -1) continue;
scored.push([s, e]);
if (scored.length > limit * 4) break;
}
scored.sort((a, b) => a[0] - b[0]);
return scored.slice(0, limit).map(([, e]) => e);
}

View file

@ -1,78 +0,0 @@
import { test } from "node:test";
import assert from "node:assert/strict";
import { rankVocab } from "./vocab-rank.js";
const vocab = {
cvars: [
{ name: "sv_cheats", desc: "Allow cheats" },
{ name: "sv_gravity" },
{ name: "mp_friendlyfire", desc: "Toggle FF" },
],
commands: [
{ name: "kick", desc: "Kick a player" },
{ name: "kickall", desc: "Kick everyone" },
{ name: "changelevel", desc: "Change map" },
],
};
test("exact match comes first", () => {
const out = rankVocab("kick", vocab);
assert.equal(out[0].name, "kick");
assert.equal(out[1].name, "kickall");
});
test("prefix matches beat substring matches", () => {
const out = rankVocab("sv_", vocab);
assert.equal(out[0].name, "sv_cheats");
assert.equal(out[1].name, "sv_gravity");
// mp_friendlyfire contains no "sv_" → should not appear
assert.ok(!out.some(e => e.name === "mp_friendlyfire"));
});
test("substring matches included after prefix matches", () => {
// "iendly" is a substring of mp_friendlyfire but a prefix of nothing
const out = rankVocab("iendly", vocab);
assert.equal(out.length, 1);
assert.equal(out[0].name, "mp_friendlyfire");
});
test("kind is preserved on each result", () => {
const out = rankVocab("kick", vocab);
assert.equal(out[0].kind, "command");
const sv = rankVocab("sv_cheats", vocab);
assert.equal(sv[0].kind, "cvar");
});
test("desc is preserved when present", () => {
const out = rankVocab("kick", vocab);
assert.equal(out[0].desc, "Kick a player");
});
test("desc is undefined when source had no desc", () => {
const out = rankVocab("sv_gravity", vocab);
assert.equal(out[0].desc, undefined);
});
test("results are capped at the configured limit", () => {
const big = { cvars: [], commands: [] };
for (let i = 0; i < 200; i++) big.commands.push({ name: `cmd${i}` });
const out = rankVocab("cmd", big, { limit: 50 });
assert.equal(out.length, 50);
});
test("default limit is 50", () => {
const big = { cvars: [], commands: [] };
for (let i = 0; i < 200; i++) big.commands.push({ name: `cmd${i}` });
const out = rankVocab("cmd", big);
assert.equal(out.length, 50);
});
test("empty query returns no results", () => {
const out = rankVocab("", vocab);
assert.equal(out.length, 0);
});
test("case-insensitive match", () => {
const out = rankVocab("KICK", vocab);
assert.equal(out[0].name, "kick");
});