Compare commits

...

13 commits

Author SHA1 Message Date
mwiegand
2fcf9c3778
docs(console): note single-form assumption near activeBinding
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 19:10:01 +02:00
mwiegand
97a4e51f8a
refactor(console): module-scope listeners + form-level event delegation
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 19:05:27 +02:00
mwiegand
25016b0ff6
refactor(css): consolidate monospace stack into --font-mono token
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 19:00:11 +02:00
mwiegand
81ba4ac83a
test(files): cover filename-rename on save
Opens server.cfg, changes the filename input to server-renamed.cfg,
clicks Save, and asserts: the routed modal closes, the old name is
gone from disk, the new name carries the original content, and the
tree row swaps over. Pins the rename-on-save branch in
routedSaveClicked (the non-is_new path that diffs originalLeaf vs
editedFilename and emits `new_path`).

Deliberately omits __filesEditor.setContent — rename should preserve
the textarea-seeded content. A regression that wrote an empty body
fails on the content-equality assertion.

Tier 1 complete: 7 tests + fixture extension. Full suite runs in
<10s on warm Chromium.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:46:40 +02:00
mwiegand
3ea57b2bdb
test(files): cover new-folder + delete cycle
Creates a folder via the inline new-folder dialog's Enter-keydown
submit path, then deletes it via the inline delete-confirm dialog.
Each step asserts disk + tree state. The Enter path is the
direct-bound listener (not delegated), so it's the most likely to
break under future refactors — pinning it here surfaces such
regressions immediately.

Row-action buttons (`✕`) live inside `<li draggable="true">` — the
draggable ancestor confuses Playwright's hit-test even though real
browsers click through fine. The click uses force=True to skip the
hit-test (documented inline).

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:44:58 +02:00
mwiegand
6b0fbb75bf
test(files): cover binary replace via browse
Opens icon.png, attaches new bytes via Playwright's file chooser
(intercepting the click → hidden-input.click() → OS picker chain),
clicks Replace, and asserts the new bytes land on disk under the
unchanged filename. Covers the multipart /files/replace endpoint and
the click → setRoutedReplacement → save-enabled UI wiring.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:42:13 +02:00
mwiegand
c1ea5eb11e
test(files): cover binary editor UI
Opens icon.png and asserts the routed editor renders the binary
branch of overlay_file_editor.html: replace-zone present, save
button labelled "Replace" and disabled on open, download link
pointing back at /files/download.

The same /files/edit route serves both text and binary modes — the
server picks the template branch from is_editable() + a magic-byte
check. Without this test, a regression that flipped a binary file
into the text branch would mean rendering raw PNG bytes inside an
editable textarea (and a misleadingly working save button).

Also asserts no textarea[data-rel-path] is in the DOM, so a future
regression that left both branches enabled fails loudly.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:41:37 +02:00
mwiegand
aad8356613
test(files): cover 409 askConflict keep-both path
Tries to create `cfg`, which collides with the seeded directory of the
same name. /files/save returns 409 "destination is not a file";
routedSaveClicked routes that through fo.askConflict, which opens the
inline #files-conflict-modal on top of the still-open routed editor.
Clicking keep-both triggers a second POST with the suffixed path
(`cfg (1)`), the routed modal closes, and the new row materialises in
the tree.

This is the F4 path from 8dc14f0 ("wire askConflict into the routed
new-file 409 path"). Before that commit, the routed code branch fell
through to a generic alert(). With this test in place, a missing
call site fails loudly instead of silently.

Pins three invariants:
  * The conflict dialog is INLINE, not routed — it appears without a
    URL change (the decision tree in AGENTS.md "Modals: inline vs
    routed" hinges on this).
  * .files-conflict-path echoes the original colliding path, not the
    computed suffix — the suffix is internal, the user sees the
    collision.
  * withCollisionSuffix("cfg") → "cfg (1)" (no dot after the last
    slash → trailing-suffix branch in uploads.js).

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:40:56 +02:00
mwiegand
d92f71f691
test(files): cover routed new-file flow
Clicks `+ new file` at the overlay root, fills the routed editor's
filename + CM6 content, and clicks Create. Asserts the modal closes,
the file lands on disk, AND the new row appears in the live tree
after the debounced HTMX refresh — the last assertion catches the
class of bug where /files/save persists but
scheduleRefresh(parentOf(fullPath)) never lands a fresh listing.

The new-file modal reuses overlay_file_editor.html with is_new=True;
this test exercises the branch in routedSaveClicked that composes
fullPath from filename + data-at-folder, distinct from the
rename-on-save path the edit-mode test covers.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:39:51 +02:00
mwiegand
3cafdba2cc
test(files): cover text-file edit round trip
Opens server.cfg through the file-row name button, drives the CM6
controller via window.__filesEditor.setContent, clicks save, and
asserts both that the routed modal closes and that the new bytes
landed on disk under overlay_root. Guards against four classes of
regression at once: the /files/edit fragment delivering the wrong
data-rel-path, editor.js's htmx:afterSwap re-init failing to wire
__filesEditor, routedSaveClicked stopping short of closeRouted(),
and the /files/save endpoint failing to persist.

Adds a `_wait_for_routed_editor` helper centered on `.cm-content`
inside `#files-editor-fragment` — the textarea itself is
display:none after CM6 mounts, so to_be_visible on the textarea
would always fail; the cm-content surface is the real "editor is
ready" signal.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:39:03 +02:00
mwiegand
19357124f4
docs(editor): document vocab argument shape on rankVocab
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 18:38:01 +02:00
mwiegand
2060af44f2
fix(console): guard against missing window.__rankVocab
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-17 18:37:42 +02:00
mwiegand
911bbf9103
test(files): add files-overlay e2e fixture
Extracts the shared boot/serve plumbing out of live_server into
_boot_app + _serve helpers, then adds files_overlay_server: a
function-scoped fixture that monkey-patches LEFT4ME_ROOT to tmp_path
BEFORE create_app(), seeds a files-type Overlay owned by alice, and
populates the overlay root with one editable text file, one binary
file, and one nested folder. Sets up the surface area the Tier-1
files-overlay e2e tests need without duplicating the live_server
boilerplate.

Also exposes a top-level `login(page, base_url, ...)` helper so
future test modules can share it instead of re-pasting the form-POST
flow.

Per docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:35:43 +02:00
8 changed files with 611 additions and 77 deletions

View file

@ -61,7 +61,7 @@ textarea {
} }
textarea { textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: var(--font-mono);
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.5; line-height: 1.5;
resize: vertical; resize: vertical;
@ -214,7 +214,7 @@ dialog.modal::backdrop {
margin: 0; margin: 0;
display: grid; display: grid;
gap: var(--space-xs); gap: var(--space-xs);
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-family: var(--font-mono);
font-size: 0.875rem; font-size: 0.875rem;
} }
@ -348,7 +348,7 @@ dialog.modal::backdrop {
.overlay-picker-handle { .overlay-picker-handle {
color: var(--color-muted); color: var(--color-muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: var(--font-mono);
letter-spacing: -0.1em; letter-spacing: -0.1em;
} }
@ -516,7 +516,7 @@ button.danger-outline:hover {
border: var(--line); border: var(--line);
border-bottom: none; border-bottom: none;
border-radius: var(--radius-s) var(--radius-s) 0 0; border-radius: var(--radius-s) var(--radius-s) 0 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: var(--font-mono);
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.5; line-height: 1.5;
white-space: pre; white-space: pre;
@ -579,7 +579,7 @@ button.danger-outline:hover {
.files-row-root > .files-row-root-label { .files-row-root > .files-row-root-label {
color: var(--color-muted); color: var(--color-muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: var(--font-mono);
} }
.files-root-children { .files-root-children {
@ -672,7 +672,7 @@ div.modal.modal-wide {
.files-editor-field input, .files-editor-field input,
.files-editor-field textarea { .files-editor-field textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: var(--font-mono);
font-size: 0.875rem; font-size: 0.875rem;
} }
@ -684,7 +684,7 @@ div.modal.modal-wide {
} }
.files-editor-path { .files-editor-path {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: var(--font-mono);
font-size: 0.95rem; font-size: 0.95rem;
margin: 0; margin: 0;
} }
@ -702,7 +702,7 @@ div.modal.modal-wide {
} }
.files-editor-rename-hint code { .files-editor-rename-hint code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: var(--font-mono);
} }
.files-editor-binary { .files-editor-binary {
@ -779,7 +779,7 @@ div.modal.modal-wide {
} }
.files-conflict-path { .files-conflict-path {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: var(--font-mono);
} }
/* Floating uploads panel — bottom-right of the page. */ /* Floating uploads panel — bottom-right of the page. */
@ -829,7 +829,7 @@ div.modal.modal-wide {
} }
.files-uploads-row-name { .files-uploads-row-name {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-family: var(--font-mono);
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
@ -942,7 +942,7 @@ div.modal.modal-wide {
color: var(--color-log-text); color: var(--color-log-text);
border-radius: var(--radius-s); border-radius: var(--radius-s);
padding: var(--space-m); padding: var(--space-m);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-family: var(--font-mono);
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.4; line-height: 1.4;
margin-bottom: var(--space-m); margin-bottom: var(--space-m);
@ -988,7 +988,7 @@ div.modal.modal-wide {
} }
.console-prompt-glyph { .console-prompt-glyph {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-family: var(--font-mono);
font-weight: 600; font-weight: 600;
color: var(--color-primary); color: var(--color-primary);
flex-shrink: 0; flex-shrink: 0;
@ -1002,7 +1002,7 @@ div.modal.modal-wide {
.console-spinner { .console-spinner {
display: none; display: none;
color: var(--color-muted); color: var(--color-muted);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-family: var(--font-mono);
flex-shrink: 0; flex-shrink: 0;
} }

View file

@ -12,7 +12,7 @@
color: var(--cm-fg, #e0e0e0); color: var(--cm-fg, #e0e0e0);
border: 1px solid var(--color-border, #444); border: 1px solid var(--color-border, #444);
border-radius: 4px; border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-family: var(--font-mono);
font-size: 13px; font-size: 13px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
} }

View file

@ -6,7 +6,7 @@
color: var(--color-log-text); color: var(--color-log-text);
border-radius: var(--radius-s); border-radius: var(--radius-s);
padding: var(--space-m); padding: var(--space-m);
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; font-family: var(--font-mono);
font-size: 0.875rem; font-size: 0.875rem;
line-height: 1.4; line-height: 1.4;
white-space: pre-wrap; white-space: pre-wrap;

View file

@ -26,6 +26,8 @@
--radius-s: var(--radius-base); --radius-s: var(--radius-base);
--radius-m: calc(var(--radius-base) * 2); --radius-m: calc(var(--radius-base) * 2);
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
--line: 1px solid var(--color-border); --line: 1px solid var(--color-border);
--line-soft: 1px solid color-mix(in srgb, var(--color-border) 50%, transparent); --line-soft: 1px solid color-mix(in srgb, var(--color-border) 50%, transparent);

View file

@ -6,15 +6,28 @@
// //
// First-token only: the dropdown is hidden as soon as the cursor // First-token only: the dropdown is hidden as soon as the cursor
// is past the first space in the input. // is past the first space in the input.
//
// Listeners are delegated to the form (not the input) so the input
// element can be swapped via HTMX without breaking autocomplete.
// Document- and window-level handlers are module-scoped and operate
// on the currently-open dropdown via the activeBinding pointer.
const VOCAB_URL = "/static/data/srccfg-vocab.json"; const VOCAB_URL = "/static/data/srccfg-vocab.json";
const MAX_RENDERED = 8; const MAX_RENDERED = 8;
// Module-scoped state.
let vocabPromise = null; let vocabPromise = null;
let vocab = null;
// activeBinding assumes a single dropdown open at a time. Today the
// project has one console form per page; multi-form support would
// need per-form open-state tracking (e.g. a Set) here.
let activeBinding = null;
function loadVocab() { function loadVocab() {
if (vocabPromise) return vocabPromise; if (vocabPromise) return vocabPromise;
vocabPromise = fetch(VOCAB_URL, { credentials: "same-origin" }) vocabPromise = fetch(VOCAB_URL, { credentials: "same-origin" })
.then(r => r.ok ? r.json() : Promise.reject(new Error("vocab fetch failed: " + r.status))) .then(r => r.ok ? r.json() : Promise.reject(new Error("vocab fetch failed: " + r.status)))
.then(v => { vocab = v; return v; })
.catch(err => { console.warn("[console-autocomplete] vocab load failed", err); return null; }); .catch(err => { console.warn("[console-autocomplete] vocab load failed", err); return null; });
return vocabPromise; return vocabPromise;
} }
@ -30,18 +43,45 @@ function firstTokenSlice(value, caret) {
return { token: value.slice(0, spaceIdx), from: 0, to: spaceIdx }; return { token: value.slice(0, spaceIdx), from: 0, to: spaceIdx };
} }
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
// Module-scoped document/window listeners installed once.
// Operate on whichever binding has its dropdown currently open.
document.addEventListener("mousedown", (event) => {
if (!activeBinding) return;
const dropdown = activeBinding.getDropdown();
if (!dropdown || dropdown.style.display === "none") return;
const row = event.target.closest(".console-autocomplete-row");
if (!row || !dropdown.contains(row)) return;
event.preventDefault();
activeBinding.acceptRow(row);
});
window.addEventListener("resize", () => {
if (activeBinding) activeBinding.position();
});
window.addEventListener("scroll", () => {
if (activeBinding) activeBinding.position();
}, true);
function bindConsoleAutocomplete(form) { function bindConsoleAutocomplete(form) {
if (form.dataset.consoleAutocompleteBound === "true") return; if (form.dataset.consoleAutocompleteBound === "true") return;
form.dataset.consoleAutocompleteBound = "true"; form.dataset.consoleAutocompleteBound = "true";
const input = form.querySelector("input[name='command']"); // Per-binding state.
if (!input) return;
// --- Dropdown DOM (created lazily on first show) ---
let dropdown = null; let dropdown = null;
let items = []; // current ranked items let items = [];
let highlightIdx = 0; // index of currently-highlighted row let highlightIdx = 0;
let vocab = null; let focusHandledOnce = false;
function getInput() {
return form.querySelector("input[name='command']");
}
function ensureDropdown() { function ensureDropdown() {
if (dropdown) return dropdown; if (dropdown) return dropdown;
@ -54,7 +94,8 @@ function bindConsoleAutocomplete(form) {
} }
function position() { function position() {
if (!dropdown) return; const input = getInput();
if (!input || !dropdown) return;
const rect = input.getBoundingClientRect(); const rect = input.getBoundingClientRect();
dropdown.style.left = `${rect.left + window.scrollX}px`; dropdown.style.left = `${rect.left + window.scrollX}px`;
dropdown.style.top = `${rect.bottom + window.scrollY}px`; dropdown.style.top = `${rect.bottom + window.scrollY}px`;
@ -62,10 +103,14 @@ function bindConsoleAutocomplete(form) {
} }
function close() { function close() {
if (!dropdown) return; if (!dropdown) {
if (activeBinding === binding) activeBinding = null;
return;
}
dropdown.style.display = "none"; dropdown.style.display = "none";
items = []; items = [];
highlightIdx = 0; highlightIdx = 0;
if (activeBinding === binding) activeBinding = null;
} }
function render() { function render() {
@ -79,34 +124,41 @@ function bindConsoleAutocomplete(form) {
}).join(""); }).join("");
dropdown.innerHTML = rows; dropdown.innerHTML = rows;
dropdown.style.display = "block"; dropdown.style.display = "block";
activeBinding = binding;
position(); position();
} }
function escapeHtml(s) {
return String(s).replace(/[&<>"']/g, c => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;",
}[c]));
}
function acceptHighlighted() { function acceptHighlighted() {
if (items.length === 0) return; if (items.length === 0) return;
const input = getInput();
if (!input) return;
const chosen = items[highlightIdx]; const chosen = items[highlightIdx];
const slice = firstTokenSlice(input.value, input.selectionStart || 0); const slice = firstTokenSlice(input.value, input.selectionStart || 0);
if (!slice) return; 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; } if (slice.token === chosen.name) { close(); return; }
const before = input.value.slice(0, slice.from); const before = input.value.slice(0, slice.from);
const after = input.value.slice(slice.to); const after = input.value.slice(slice.to);
input.value = before + chosen.name + after; input.value = before + chosen.name + after;
// Place caret at end of inserted name
const caret = before.length + chosen.name.length; const caret = before.length + chosen.name.length;
input.setSelectionRange(caret, caret); input.setSelectionRange(caret, caret);
recompute(); recompute();
} }
function acceptRow(rowEl) {
highlightIdx = parseInt(rowEl.dataset.idx, 10) || 0;
acceptHighlighted();
const input = getInput();
if (input) input.focus();
}
function recompute() { function recompute() {
if (!vocab) return; if (!vocab) return;
if (typeof window.__rankVocab !== "function") {
console.warn("[console-autocomplete] window.__rankVocab unavailable — vocab-rank.bundle.js failed to load?");
return;
}
const input = getInput();
if (!input) return;
const slice = firstTokenSlice(input.value, input.selectionStart || 0); const slice = firstTokenSlice(input.value, input.selectionStart || 0);
if (!slice || !slice.token) { close(); return; } if (!slice || !slice.token) { close(); return; }
items = window.__rankVocab(slice.token, vocab); items = window.__rankVocab(slice.token, vocab);
@ -115,22 +167,41 @@ function bindConsoleAutocomplete(form) {
render(); render();
} }
// --- Lazy vocab fetch on first focus --- // Binding object exposed to module-scope listeners.
input.addEventListener("focus", async () => { // getDropdown() returns the current dropdown reference, which is
if (!vocab) { // assigned lazily by ensureDropdown(); the getter is needed because
vocab = await loadVocab(); // the dropdown variable's binding changes after construction.
// If the user already typed during the fetch, rank now so the const binding = {
// dropdown doesn't appear to lag a keystroke behind on cold load. getDropdown: () => dropdown,
if (vocab && document.activeElement === input) recompute(); position,
} close,
}, { once: true }); acceptRow,
};
input.addEventListener("input", () => { // Form-level event delegation. event.target.matches gates each
if (!vocab) return; // fetch may not have resolved yet; next input will recompute // handler to the command input, so the input element can be
// swapped via HTMX without rebinding.
form.addEventListener("focusin", async (event) => {
if (!event.target.matches('input[name="command"]')) return;
if (focusHandledOnce) return;
focusHandledOnce = true;
if (!vocab) {
await loadVocab();
// If the user typed during the fetch, rank now so the dropdown
// doesn't appear to lag a keystroke behind on cold load.
if (vocab && document.activeElement === event.target) recompute();
}
});
form.addEventListener("input", (event) => {
if (!event.target.matches('input[name="command"]')) return;
if (!vocab) return; // fetch may not have resolved yet
recompute(); recompute();
}); });
input.addEventListener("keydown", (event) => { form.addEventListener("keydown", (event) => {
if (!event.target.matches('input[name="command"]')) return;
if (event.key === "Tab" && !event.shiftKey) { if (event.key === "Tab" && !event.shiftKey) {
if (items.length > 0) { if (items.length > 0) {
event.preventDefault(); event.preventDefault();
@ -152,28 +223,14 @@ function bindConsoleAutocomplete(form) {
// ArrowUp/ArrowDown/Enter intentionally NOT handled here. // ArrowUp/ArrowDown/Enter intentionally NOT handled here.
}); });
input.addEventListener("blur", () => { form.addEventListener("focusout", (event) => {
if (!event.target.matches('input[name="command"]')) return;
// Delay close so a click on a dropdown row can fire first. // Delay close so a click on a dropdown row can fire first.
setTimeout(close, 100); 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. // HTMX form submission clears the input; close on submit.
form.addEventListener("htmx:beforeRequest", close); 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) { function bindAll(root) {

View file

@ -16,6 +16,7 @@ function score(query, label) {
return -1; return -1;
} }
// vocab shape: { cvars: [{name, desc?}, …], commands: [{name, desc?}, …] }
export function rankVocab(query, vocab, { limit = 50 } = {}) { export function rankVocab(query, vocab, { limit = 50 } = {}) {
if (!query) return []; if (!query) return [];
const q = query.toLowerCase(); const q = query.toLowerCase();

View file

@ -14,7 +14,7 @@ from werkzeug.serving import make_server
from l4d2web.app import create_app from l4d2web.app import create_app
from l4d2web.auth import hash_password from l4d2web.auth import hash_password
from l4d2web.db import init_db, session_scope from l4d2web.db import init_db, session_scope
from l4d2web.models import Blueprint, User from l4d2web.models import Blueprint, Overlay, User
def _free_port() -> int: def _free_port() -> int:
@ -25,17 +25,19 @@ def _free_port() -> int:
return port return port
@pytest.fixture(scope="function") def _boot_app(tmp_path, monkeypatch):
def live_server(tmp_path, monkeypatch): """Set the env vars + create_app + init_db combo shared by every e2e
fixture. Returns the Flask app.
Sets DATABASE_URL temp sqlite; SESSION_COOKIE_SECURE=0 so the
browser keeps the session cookie over http://127.0.0.1 (app.py:57
would otherwise mark it Secure). Caller-specific env vars (e.g.
LEFT4ME_ROOT for files-overlay tests) must be set BEFORE calling
this, since create_app reads them during app construction.
"""
db_path = tmp_path / "e2e.db" db_path = tmp_path / "e2e.db"
db_url = f"sqlite:///{db_path}" db_url = f"sqlite:///{db_path}"
monkeypatch.setenv("DATABASE_URL", db_url) monkeypatch.setenv("DATABASE_URL", db_url)
# app.py:57 sets SESSION_COOKIE_SECURE = not TESTING, which would
# mark the session cookie Secure. The browser then drops it over
# http://127.0.0.1 in e2e tests and the login flow silently fails
# with a redirect back to /login. Force it off explicitly via the
# env-var override (app.py:53-55) rather than flipping TESTING,
# which would skip the SECRET_KEY guard and other production paths.
monkeypatch.setenv("SESSION_COOKIE_SECURE", "0") monkeypatch.setenv("SESSION_COOKIE_SECURE", "0")
app = create_app({"TESTING": False, "DATABASE_URL": db_url, "SECRET_KEY": "e2e"}) app = create_app({"TESTING": False, "DATABASE_URL": db_url, "SECRET_KEY": "e2e"})
# create_app() already calls init_db() inside an app context, which # create_app() already calls init_db() inside an app context, which
@ -45,6 +47,37 @@ def live_server(tmp_path, monkeypatch):
# call creates the tables on that env-derived engine so the seed # call creates the tables on that env-derived engine so the seed
# inserts have somewhere to land. # inserts have somewhere to land.
init_db() init_db()
return app
def _serve(app):
"""Start the app on a background thread, return (base_url, shutdown).
Caller is responsible for calling shutdown() in a finally block.
"""
port = _free_port()
server = make_server("127.0.0.1", port, app)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
def shutdown():
server.shutdown()
thread.join(timeout=2)
return f"http://127.0.0.1:{port}", shutdown
def login(page, base_url: str, username: str = "alice", password: str = "secret") -> None:
"""Form-POST login helper. Reused by every e2e test."""
page.goto(f"{base_url}/login")
page.fill('input[name="username"]', username)
page.fill('input[name="password"]', password)
page.click('button[type="submit"]')
@pytest.fixture(scope="function")
def live_server(tmp_path, monkeypatch):
app = _boot_app(tmp_path, monkeypatch)
with session_scope() as session: with session_scope() as session:
user = User( user = User(
@ -62,16 +95,72 @@ def live_server(tmp_path, monkeypatch):
blueprint_id = bp.id blueprint_id = bp.id
user_id = user.id user_id = user.id
port = _free_port() base_url, shutdown = _serve(app)
server = make_server("127.0.0.1", port, app)
thread = threading.Thread(target=server.serve_forever, daemon=True)
thread.start()
try: try:
yield { yield {
"base_url": f"http://127.0.0.1:{port}", "base_url": base_url,
"user_id": user_id, "user_id": user_id,
"blueprint_id": blueprint_id, "blueprint_id": blueprint_id,
} }
finally: finally:
server.shutdown() shutdown()
thread.join(timeout=2)
@pytest.fixture(scope="function")
def files_overlay_server(tmp_path, monkeypatch):
"""live_server + a files-type Overlay owned by alice, seeded with:
<overlay_root>/server.cfg (text, editable)
<overlay_root>/icon.png (binary, replaceable)
<overlay_root>/cfg/admins.txt (nested-folder fixture)
LEFT4ME_ROOT is monkey-patched to tmp_path BEFORE create_app() so
overlay path resolution (l4d2host.paths.overlay_path) lands under
the temp directory instead of /var/lib/left4me without this every
files-overlay route 404s on macOS (see AGENTS.md "symptom-to-cause").
"""
monkeypatch.setenv("LEFT4ME_ROOT", str(tmp_path))
app = _boot_app(tmp_path, monkeypatch)
with session_scope() as session:
user = User(
username="alice",
password_digest=hash_password("secret"),
admin=False,
)
session.add(user)
session.flush()
# Same insert-then-update-path pattern dev-server.py uses: we
# need overlay.id to write into overlay.path, but the column is
# NOT NULL so it gets a placeholder until we know the id.
overlay = Overlay(
name="cfgs",
path="_pending",
type="files",
user_id=user.id,
)
session.add(overlay)
session.flush()
overlay.path = str(overlay.id)
user_id = user.id
overlay_id = overlay.id
overlay_root = tmp_path / "overlays" / str(overlay_id)
overlay_root.mkdir(parents=True)
(overlay_root / "server.cfg").write_text('hostname "left4me"\n')
# 8-byte PNG signature + 60 null bytes — large enough for the
# editor's binary-mode detection (which checks the magic header).
(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")
base_url, shutdown = _serve(app)
try:
yield {
"base_url": base_url,
"user_id": user_id,
"overlay_id": overlay_id,
"overlay_root": overlay_root,
}
finally:
shutdown()

View file

@ -0,0 +1,385 @@
"""End-to-end Playwright tests for the files-overlay file manager.
The files_overlay_server fixture (conftest.py) seeds user "alice"/"secret",
a files-type Overlay owned by alice, and an overlay root pre-populated
with one editable text file, one binary file, and one nested folder.
Each test logs in, opens /overlays/<id>, drives the UI through real
clicks + HTMX swaps, and asserts on DOM + filesystem state.
Running locally: `uv run pytest -m e2e l4d2web/tests/e2e/test_files_overlay.py`.
Requires `uv run playwright install chromium` once.
Pattern model: test_editor.py (CM6 controller flow) + the handoff doc at
docs/superpowers/plans/2026-05-17-files-overlay-e2e-handoff.md.
"""
from __future__ import annotations
import pytest
from playwright.sync_api import Page, expect
from .conftest import login
pytestmark = pytest.mark.e2e
def _open_overlay(page: Page, base_url: str, overlay_id: int) -> None:
login(page, base_url)
page.goto(f"{base_url}/overlays/{overlay_id}")
def _wait_for_routed_editor(page: Page) -> None:
"""Wait until the routed modal's CM6 surface is mounted.
The textarea itself is `display:none` (editor.css pre-hides + CM6's
mount sets `style.display = "none"`), so we cannot assert visibility
on it directly. The `.cm-content` element appearing under
`#files-editor-fragment` is the canonical "editor is ready, the
`window.__filesEditor` controller is wired" signal.
"""
expect(page.locator("#files-editor-fragment")).to_be_visible(timeout=5000)
expect(page.locator("#files-editor-fragment .cm-content")).to_be_visible(timeout=5000)
def test_edit_text_file_save_round_trip(page: Page, files_overlay_server) -> None:
"""Open server.cfg via the file-row name button, drive the CM6
controller to a new doc, save, and assert the disk reflects it.
Exercises the full edit path: row click openRouted htmx swap
CM6 mount save POST modal close. Failure modes guarded against:
* /overlays/<id>/files/edit returns the wrong fragment (textarea
gets the wrong data-rel-path, or none).
* editor.js's htmx:afterSwap re-init doesn't fire / fails then
window.__filesEditor stays unset and the save reads the stale
textarea.value instead of the user's edits.
* routedSaveClicked stops short of closeRouted() modal stays
open and the test's hidden-assertion catches it.
* The POST /files/save endpoint doesn't actually write — covered
by reading the file back from disk.
"""
base = files_overlay_server["base_url"]
overlay_id = files_overlay_server["overlay_id"]
overlay_root = files_overlay_server["overlay_root"]
_open_overlay(page, base, overlay_id)
page.click('button.file-tree-name-button[data-target-path="server.cfg"]')
_wait_for_routed_editor(page)
# Sanity: the modal opened for the file we asked for, not some other.
assert page.locator('textarea[data-rel-path="server.cfg"]').count() == 1
new_content = 'hostname "edited by e2e"\nmp_gamemode coop\n'
page.evaluate("(text) => window.__filesEditor.setContent(text)", new_content)
page.click(".files-editor-save")
# Modal close is async (await postJson then closeRouted()).
expect(page.locator("#modal-container")).to_be_hidden(timeout=5000)
assert (overlay_root / "server.cfg").read_text() == new_content
def test_create_new_file_routed(page: Page, files_overlay_server) -> None:
"""Click `+ new file` at the overlay root, fill the routed
new-file editor, click Create, and assert the file lands on disk
AND the row appears in the tree.
The routed new-file modal is the SAME template as the edit modal
(overlay_file_editor.html with is_new=True). The differences this
test exercises:
* textarea has data-rel-path="" routedSaveClicked branches on
the empty value into the "compose fullPath from filename"
path, not the rename-on-save path.
* Save button reads "Create", not "Save".
* scheduleRefresh(parentOf(fullPath)) fires after POST for a
root-level file that's refreshFolder("") which re-fetches the
whole root listing; the new row should appear without a page
reload.
"""
base = files_overlay_server["base_url"]
overlay_id = files_overlay_server["overlay_id"]
overlay_root = files_overlay_server["overlay_root"]
_open_overlay(page, base, overlay_id)
page.click('button[data-action="new-file"][data-target-path=""]')
_wait_for_routed_editor(page)
# Confirm the fragment opened in is_new mode (empty rel-path).
assert page.locator('textarea[data-rel-path=""]').count() == 1
new_filename = "new-file.cfg"
new_content = 'sv_cheats 1\nmp_gamemode versus\n'
page.fill('input[data-editor-filename]', new_filename)
page.evaluate("(text) => window.__filesEditor.setContent(text)", new_content)
page.click(".files-editor-save")
expect(page.locator("#modal-container")).to_be_hidden(timeout=5000)
assert (overlay_root / new_filename).read_text() == new_content
# Tree refresh is debounced 50ms then HTMX-fetched — wait for the
# new row to appear rather than asserting synchronously.
expect(
page.locator(f'.file-tree-row-file[data-target-path="{new_filename}"]')
).to_be_visible(timeout=5000)
def test_create_new_file_409_askConflict_keep_both(page: Page, files_overlay_server) -> None:
"""Try to create a file named `cfg`, which collides with the
seeded directory of the same name. /files/save returns 409 because
the target exists and is not a file; routedSaveClicked routes that
409 through fo.askConflict, which opens the INLINE
#files-conflict-modal on top of the still-open routed editor.
Clicking "keep-both" causes a second POST with the suffixed path
(cfg "cfg (1)"), the routed modal closes, and the new row
appears in the tree.
This is the F4 path from commit 8dc14f0 ("wire askConflict into
the routed new-file 409 path"). Without coverage it can regress
silently the legacy create-new flow went through a different code
path before the rewrite, and a missing call site would only
manifest as a confusing alert() instead of the conflict dialog.
Key invariants this asserts:
* The conflict dialog is inline, NOT routed it appears WITHOUT
a URL change (we don't navigate, we just wait for the dialog).
* .files-conflict-path shows the original colliding path, not
the suffixed one.
* withCollisionSuffix("cfg") returns "cfg (1)" (single-extension
path with no dot falls into the trailing-suffix branch).
"""
base = files_overlay_server["base_url"]
overlay_id = files_overlay_server["overlay_id"]
overlay_root = files_overlay_server["overlay_root"]
_open_overlay(page, base, overlay_id)
page.click('button[data-action="new-file"][data-target-path=""]')
_wait_for_routed_editor(page)
page.fill('input[data-editor-filename]', "cfg")
new_content = "STEAM_1:0:5\n"
page.evaluate("(text) => window.__filesEditor.setContent(text)", new_content)
page.click(".files-editor-save")
# The conflict dialog should pop up because `cfg` is a directory.
conflict = page.locator("#files-conflict-modal")
expect(conflict).to_be_visible(timeout=5000)
# The dialog must echo the ORIGINAL colliding path, not the suffix.
expect(conflict.locator(".files-conflict-path")).to_have_text("cfg")
page.click('[data-files-conflict-action="keep-both"]')
# Both modals should close (conflict dialog first, routed modal
# after the second /files/save resolves).
expect(page.locator("#modal-container")).to_be_hidden(timeout=5000)
expect(conflict).to_be_hidden(timeout=5000)
# The seeded `cfg/` directory must still be intact + the new
# suffixed file landed alongside it.
assert (overlay_root / "cfg").is_dir()
assert (overlay_root / "cfg (1)").read_text() == new_content
expect(
page.locator('.file-tree-row-file[data-target-path="cfg (1)"]')
).to_be_visible(timeout=5000)
def test_open_binary_file_renders_replace_ui(page: Page, files_overlay_server) -> None:
"""Open icon.png and assert the routed editor renders the binary
branch of overlay_file_editor.html replace-zone present, save
button labelled "Replace" and disabled until a file is queued or
the filename is edited.
The same `/files/edit?path=...` route serves both text and binary
modes; the server picks the template branch based on is_editable +
a magic-byte check. This test pins the binary contract: the user
cannot save by accident on a fresh open, and the queue UI is wired
correctly.
Pins:
* .files-editor-binary[data-rel-path="icon.png"] exists (the
data-rel-path stable hook for save logic).
* Save button has visible text "Replace" + the `disabled`
attribute is set on open.
* .files-editor-replace-zone + .files-editor-replace-browse +
the hidden file <input> exist.
* Download link points back at /files/download?path=icon.png.
"""
base = files_overlay_server["base_url"]
overlay_id = files_overlay_server["overlay_id"]
_open_overlay(page, base, overlay_id)
page.click('button.file-tree-name-button[data-target-path="icon.png"]')
fragment = page.locator("#files-editor-fragment")
expect(fragment).to_be_visible(timeout=5000)
binary_panel = page.locator('.files-editor-binary[data-rel-path="icon.png"]')
expect(binary_panel).to_be_visible(timeout=5000)
save_btn = page.locator(".files-editor-save")
expect(save_btn).to_have_text("Replace")
expect(save_btn).to_be_disabled()
expect(page.locator(".files-editor-replace-zone")).to_be_visible()
expect(page.locator(".files-editor-replace-browse")).to_be_visible()
# The file input is type=file hidden — Playwright's "visible"
# assertions treat hidden inputs as not visible, so check
# attached + the type instead.
expect(page.locator(".files-editor-replace-input")).to_have_attribute(
"type", "file"
)
# Download link points back at the overlay's /files/download.
download = page.locator("a.files-editor-download")
expect(download).to_have_attribute(
"href", f"/overlays/{overlay_id}/files/download?path=icon.png"
)
# In binary mode there's no <textarea data-rel-path> — only the
# binary panel carries the rel-path. Catches a regression where
# the server accidentally rendered the text branch for a binary
# file (which would mean an editable textarea full of PNG bytes).
assert page.locator("textarea[data-rel-path]").count() == 0
def test_binary_replace_via_browse_writes_new_bytes(page: Page, files_overlay_server) -> None:
"""Open icon.png, click "browse" to attach a new file buffer via
Playwright's file chooser, click Replace, and assert disk reflects
the new bytes. Exercises:
* .files-editor-replace-browse triggers the hidden file input's
click() (line ~140 in files-overlay/editor.js).
* `change` event on the input flows into setRoutedReplacement,
which enables the save button + paints the queued-state UI.
* routedReplaceClicked posts FormData (multipart) to
/files/replace with the original rel-path NOT a rename
and the server streams the upload into place.
"""
base = files_overlay_server["base_url"]
overlay_id = files_overlay_server["overlay_id"]
overlay_root = files_overlay_server["overlay_root"]
_open_overlay(page, base, overlay_id)
page.click('button.file-tree-name-button[data-target-path="icon.png"]')
expect(page.locator('.files-editor-binary[data-rel-path="icon.png"]')).to_be_visible(timeout=5000)
save_btn = page.locator(".files-editor-save")
expect(save_btn).to_be_disabled()
# The browse button calls fileInput.click() in the JS handler —
# Playwright's expect_file_chooser intercepts the resulting OS
# picker so we can hand back a buffer instead of an OS path.
new_bytes = b"\x89PNG\r\n\x1a\nE2E_REPLACED" + b"\x00" * 20
with page.expect_file_chooser() as fc_info:
page.click(".files-editor-replace-browse")
fc_info.value.set_files({
"name": "new-icon.png",
"mimeType": "image/png",
"buffer": new_bytes,
})
# Queueing a file flips the save button enabled.
expect(save_btn).to_be_enabled(timeout=2000)
save_btn.click()
expect(page.locator("#modal-container")).to_be_hidden(timeout=5000)
# The filename on disk stays the same (no rename) — only the bytes
# changed.
assert (overlay_root / "icon.png").read_bytes() == new_bytes
def test_new_folder_then_delete(page: Page, files_overlay_server) -> None:
"""Create a folder via the inline new-folder dialog (Enter-to-submit
keydown path), then delete it via the inline delete-confirm dialog.
Each step asserts disk + tree state, so a regression in either the
mkdir or delete route or in the modal close + refresh wiring
fails loudly.
The new-folder dialog has TWO submit paths (click on
.files-new-folder-create + Enter keydown on the name input); this
test pins the keydown path, which is direct-bound rather than
delegated, because it's the one most likely to break under future
refactors.
"""
base = files_overlay_server["base_url"]
overlay_id = files_overlay_server["overlay_id"]
overlay_root = files_overlay_server["overlay_root"]
_open_overlay(page, base, overlay_id)
# --- Create folder via Enter-to-submit -----------------------------
page.click('button[data-action="new-folder"][data-target-path=""]')
new_folder_modal = page.locator("#files-new-folder-modal")
expect(new_folder_modal).to_be_visible(timeout=5000)
folder_name = "sourcemod"
page.fill(".files-new-folder-name", folder_name)
page.locator(".files-new-folder-name").press("Enter")
expect(new_folder_modal).to_be_hidden(timeout=5000)
assert (overlay_root / folder_name).is_dir()
folder_row = page.locator(
f'.file-tree-row-dir[data-target-path="{folder_name}"]'
)
expect(folder_row).to_be_visible(timeout=5000)
# --- Delete folder via inline confirm ------------------------------
# `force=True`: the row-action buttons are positioned inside a
# `<li draggable="true">`, and Playwright's hit-test treats the
# draggable ancestor as intercepting pointer events even though
# real browsers dispatch the click correctly. Forcing skips the
# hit-test and dispatches on the target — matches user behavior.
page.locator(
f'button[data-action="delete"]'
f'[data-target-path="{folder_name}"]'
f'[data-row-kind="dir"]'
).click(force=True)
delete_modal = page.locator("#files-delete-modal")
expect(delete_modal).to_be_visible(timeout=5000)
# The dialog should name the row being deleted — the user must see
# what they're confirming.
expect(delete_modal.locator(".files-delete-name")).to_have_text(folder_name)
page.click(".files-delete-confirm")
expect(delete_modal).to_be_hidden(timeout=5000)
assert not (overlay_root / folder_name).exists()
# The tree refresh removes the row.
expect(folder_row).to_have_count(0, timeout=5000)
def test_filename_rename_on_save(page: Page, files_overlay_server) -> None:
"""Open server.cfg, change the filename input to server-renamed.cfg,
and click Save. The single POST /files/save call performs an atomic
rename + write; the test asserts the disk reflects both the new
name and the original content, AND that the tree row swaps over.
This pins the rename-on-save branch in routedSaveClicked (the
non-is_new code path that diffs originalLeaf vs editedFilename
and emits `new_path`). A regression that dropped the rename
payload would silently leave the original file in place the
"old name gone" + "new name present" assertion pair catches that.
We do NOT call __filesEditor.setContent here: the rename should
preserve the original textarea-seeded content. If the save handler
accidentally writes an empty body, the content-equality assertion
fails.
"""
base = files_overlay_server["base_url"]
overlay_id = files_overlay_server["overlay_id"]
overlay_root = files_overlay_server["overlay_root"]
original_content = (overlay_root / "server.cfg").read_text()
_open_overlay(page, base, overlay_id)
page.click('button.file-tree-name-button[data-target-path="server.cfg"]')
_wait_for_routed_editor(page)
new_name = "server-renamed.cfg"
page.fill("input[data-editor-filename]", new_name)
page.click(".files-editor-save")
expect(page.locator("#modal-container")).to_be_hidden(timeout=5000)
# Disk: old name gone, new name present with the original content.
assert not (overlay_root / "server.cfg").exists()
assert (overlay_root / new_name).read_text() == original_content
# Tree refresh runs at parentOf(new_path) = "" (root) — both rows
# come from that same listing, so we wait for the new one and
# assert the old one is gone in the same refresh.
expect(
page.locator(f'.file-tree-row-file[data-target-path="{new_name}"]')
).to_be_visible(timeout=5000)
expect(
page.locator('.file-tree-row-file[data-target-path="server.cfg"]')
).to_have_count(0)