Compare commits
17 commits
5f82950d7c
...
2d5a72b317
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d5a72b317 | ||
|
|
2173685de6 | ||
|
|
7aa9b0b49c | ||
|
|
5a85153c4f | ||
|
|
cdb6a87960 | ||
|
|
40961eacdd | ||
|
|
d8dd2d23d2 | ||
|
|
ca6a7aa74c | ||
|
|
2875993339 | ||
|
|
9ff93164d7 | ||
|
|
02d96b593e | ||
|
|
86ac10a1d9 | ||
|
|
e1723f751c | ||
|
|
8dc14f0cca | ||
|
|
8ccb2339ca | ||
|
|
1de61e8e4d | ||
|
|
b6db596d0f |
24 changed files with 1594 additions and 340 deletions
47
AGENTS.md
47
AGENTS.md
|
|
@ -46,11 +46,56 @@ 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 `files-overlay.js` lines ~599-664 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 `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).
|
||||||
- **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.
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,725 @@
|
||||||
|
# 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 => ({
|
||||||
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||||
|
}[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).
|
||||||
243
docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md
Normal file
243
docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
# 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.
|
||||||
|
|
@ -1,271 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -0,0 +1,122 @@
|
||||||
|
# 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.
|
||||||
|
|
@ -129,7 +129,6 @@ 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"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -195,7 +194,6 @@ 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,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
55
l4d2web/l4d2web/static/css/console-autocomplete.css
Normal file
55
l4d2web/l4d2web/static/css/console-autocomplete.css
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
189
l4d2web/l4d2web/static/js/console-autocomplete.js
Normal file
189
l4d2web/l4d2web/static/js/console-autocomplete.js
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
// 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 => ({
|
||||||
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||||
|
}[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));
|
||||||
|
|
@ -181,7 +181,10 @@
|
||||||
: 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 || "";
|
||||||
|
|
@ -192,11 +195,35 @@
|
||||||
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));
|
||||||
} else if (r.status === 409) {
|
return;
|
||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -37,11 +37,29 @@
|
||||||
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 dot = path.lastIndexOf(".");
|
|
||||||
const slash = path.lastIndexOf("/");
|
const slash = path.lastIndexOf("/");
|
||||||
if (dot > slash + 0 && dot > -1) {
|
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(".");
|
||||||
|
if (dot > slash && 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)";
|
||||||
|
|
|
||||||
20
l4d2web/l4d2web/static/vendor/README.md
vendored
20
l4d2web/l4d2web/static/vendor/README.md
vendored
|
|
@ -1,7 +1,11 @@
|
||||||
# Editor bundle vendor README
|
# Editor bundle vendor README
|
||||||
|
|
||||||
`editor.bundle.js` is a pre-built IIFE produced by esbuild from
|
This directory contains pre-built JavaScript bundles produced by esbuild from
|
||||||
`l4d2web/scripts/editor-src/`. It exposes `window.__editor.mount(textarea, opts)`.
|
`l4d2web/scripts/editor-src/`:
|
||||||
|
|
||||||
|
- `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
|
||||||
|
|
||||||
|
|
@ -11,9 +15,10 @@ From repo root:
|
||||||
./l4d2web/scripts/build-editor.sh
|
./l4d2web/scripts/build-editor.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This runs `npm install` inside `editor-src/` then `npx esbuild`. The
|
This runs `npm install` inside `editor-src/` then `npm run build`, which
|
||||||
output overwrites `editor.bundle.js` and `editor.bundle.css` in this
|
rebuilds **all bundles** (`editor.bundle.js` and `vocab-rank.bundle.js`) in one
|
||||||
directory and refreshes `editor.bundle.sha256`.
|
pass. The output overwrites the bundles in this directory and refreshes
|
||||||
|
`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
|
||||||
|
|
@ -27,6 +32,7 @@ See `l4d2web/scripts/editor-src/package.json` for semver ranges and
|
||||||
|
|
||||||
## Integrity
|
## Integrity
|
||||||
|
|
||||||
`editor.bundle.sha256` contains the hashes of the committed bundle.
|
`editor.bundle.sha256` contains the hashes of the committed bundles
|
||||||
If the bundle drifts from this hash in CI / review, the artifact was
|
(`editor.bundle.js`, `editor.bundle.css`, `vocab-rank.bundle.js`).
|
||||||
|
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.
|
||||||
|
|
|
||||||
18
l4d2web/l4d2web/static/vendor/editor.bundle.js
vendored
18
l4d2web/l4d2web/static/vendor/editor.bundle.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -1,2 +1,3 @@
|
||||||
910031cfc346106af240df71b9ef8069f1b38f1a4c63128392c2aa074e7e57b2 editor.bundle.js
|
939e3d9ba5ae65a23b17f57050144e8444e0a6ce1b85b705055bf3dc1d9a36d4 editor.bundle.js
|
||||||
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 editor.bundle.css
|
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 editor.bundle.css
|
||||||
|
7fefca5b1197490283c86f6d46036aaf719cc032a3bde96483aa10f6b0ba35b1 vocab-rank.bundle.js
|
||||||
|
|
|
||||||
1
l4d2web/l4d2web/static/vendor/vocab-rank.bundle.js
vendored
Normal file
1
l4d2web/l4d2web/static/vendor/vocab-rank.bundle.js
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
(()=>{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;})();
|
||||||
|
|
@ -18,8 +18,7 @@
|
||||||
<div class="file-tree-children" hidden></div>
|
<div class="file-tree-children" hidden></div>
|
||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
{% set show_download = download_supported and not entry.broken %}
|
{% set has_actions = 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 %}
|
||||||
|
|
@ -36,9 +35,7 @@
|
||||||
{% 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 %}
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@
|
||||||
<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>
|
||||||
|
|
@ -47,5 +48,7 @@
|
||||||
<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>
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,6 @@
|
||||||
{% 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 %}
|
||||||
|
|
@ -106,7 +105,6 @@
|
||||||
{% 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 %}
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,6 @@
|
||||||
{% 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>
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,10 @@ 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"
|
||||||
|
|
||||||
npx esbuild editor-entry.js \
|
# Build all bundles via npm run build (editor.bundle.js + vocab-rank.bundle.js).
|
||||||
--bundle --minify \
|
# Do not call npx esbuild directly — package.json is the single source of truth
|
||||||
--format=iife \
|
# for build targets so new bundles are never silently omitted.
|
||||||
--global-name=__editor_pkg \
|
npm run build
|
||||||
--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
|
||||||
|
|
@ -28,6 +25,7 @@ npx esbuild editor-entry.js \
|
||||||
# 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 > editor.bundle.sha256)
|
(cd "$OUT" && shasum -a 256 editor.bundle.js editor.bundle.css vocab-rank.bundle.js > 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)"
|
||||||
|
|
|
||||||
|
|
@ -1,38 +1,16 @@
|
||||||
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 scored = [];
|
const ranked = rankVocab(word.text, vocab);
|
||||||
for (const e of entries) {
|
const options = ranked.map(e => ({
|
||||||
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",
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,9 @@
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "esbuild editor-entry.js --bundle --minify --format=iife --global-name=__editor_pkg --outfile=../../l4d2web/static/vendor/editor.bundle.js --metafile=meta.json"
|
"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"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"esbuild": "^0.24.0"
|
"esbuild": "^0.24.0"
|
||||||
|
|
|
||||||
5
l4d2web/scripts/editor-src/vocab-rank-entry.js
Normal file
5
l4d2web/scripts/editor-src/vocab-rank-entry.js
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
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;
|
||||||
37
l4d2web/scripts/editor-src/vocab-rank.js
Normal file
37
l4d2web/scripts/editor-src/vocab-rank.js
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
78
l4d2web/scripts/editor-src/vocab-rank.test.js
Normal file
78
l4d2web/scripts/editor-src/vocab-rank.test.js
Normal file
|
|
@ -0,0 +1,78 @@
|
||||||
|
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");
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue