Compare commits
No commits in common. "2fcf9c37783bbd9c5c1ba8df08735ec7ba8f3bfd" and "2d5a72b3173cce76a736e271fd881e545d2bd934" have entirely different histories.
2fcf9c3778
...
2d5a72b317
8 changed files with 75 additions and 609 deletions
|
|
@ -61,7 +61,7 @@ textarea {
|
||||||
}
|
}
|
||||||
|
|
||||||
textarea {
|
textarea {
|
||||||
font-family: var(--font-mono);
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
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: var(--font-mono);
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
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: var(--font-mono);
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
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: var(--font-mono);
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
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: var(--font-mono);
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.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: var(--font-mono);
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -684,7 +684,7 @@ div.modal.modal-wide {
|
||||||
}
|
}
|
||||||
|
|
||||||
.files-editor-path {
|
.files-editor-path {
|
||||||
font-family: var(--font-mono);
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
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: var(--font-mono);
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.files-editor-binary {
|
.files-editor-binary {
|
||||||
|
|
@ -779,7 +779,7 @@ div.modal.modal-wide {
|
||||||
}
|
}
|
||||||
|
|
||||||
.files-conflict-path {
|
.files-conflict-path {
|
||||||
font-family: var(--font-mono);
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 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: var(--font-mono);
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
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: var(--font-mono);
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
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: var(--font-mono);
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
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: var(--font-mono);
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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: var(--font-mono);
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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: var(--font-mono);
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,6 @@
|
||||||
--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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,28 +6,15 @@
|
||||||
//
|
//
|
||||||
// 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;
|
||||||
}
|
}
|
||||||
|
|
@ -43,45 +30,18 @@ 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 => ({
|
|
||||||
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
|
||||||
}[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";
|
||||||
|
|
||||||
// Per-binding state.
|
const input = form.querySelector("input[name='command']");
|
||||||
let dropdown = null;
|
if (!input) return;
|
||||||
let items = [];
|
|
||||||
let highlightIdx = 0;
|
|
||||||
let focusHandledOnce = false;
|
|
||||||
|
|
||||||
function getInput() {
|
// --- Dropdown DOM (created lazily on first show) ---
|
||||||
return form.querySelector("input[name='command']");
|
let dropdown = null;
|
||||||
}
|
let items = []; // current ranked items
|
||||||
|
let highlightIdx = 0; // index of currently-highlighted row
|
||||||
|
let vocab = null;
|
||||||
|
|
||||||
function ensureDropdown() {
|
function ensureDropdown() {
|
||||||
if (dropdown) return dropdown;
|
if (dropdown) return dropdown;
|
||||||
|
|
@ -94,8 +54,7 @@ function bindConsoleAutocomplete(form) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function position() {
|
function position() {
|
||||||
const input = getInput();
|
if (!dropdown) return;
|
||||||
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`;
|
||||||
|
|
@ -103,14 +62,10 @@ function bindConsoleAutocomplete(form) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function close() {
|
function close() {
|
||||||
if (!dropdown) {
|
if (!dropdown) return;
|
||||||
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() {
|
||||||
|
|
@ -124,41 +79,34 @@ 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 => ({
|
||||||
|
"&": "&", "<": "<", ">": ">", '"': """, "'": "'",
|
||||||
|
}[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);
|
||||||
|
|
@ -167,41 +115,22 @@ function bindConsoleAutocomplete(form) {
|
||||||
render();
|
render();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Binding object exposed to module-scope listeners.
|
// --- Lazy vocab fetch on first focus ---
|
||||||
// getDropdown() returns the current dropdown reference, which is
|
input.addEventListener("focus", async () => {
|
||||||
// assigned lazily by ensureDropdown(); the getter is needed because
|
|
||||||
// the dropdown variable's binding changes after construction.
|
|
||||||
const binding = {
|
|
||||||
getDropdown: () => dropdown,
|
|
||||||
position,
|
|
||||||
close,
|
|
||||||
acceptRow,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Form-level event delegation. event.target.matches gates each
|
|
||||||
// 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) {
|
if (!vocab) {
|
||||||
await loadVocab();
|
vocab = await loadVocab();
|
||||||
// If the user typed during the fetch, rank now so the dropdown
|
// If the user already typed during the fetch, rank now so the
|
||||||
// doesn't appear to lag a keystroke behind on cold load.
|
// dropdown doesn't appear to lag a keystroke behind on cold load.
|
||||||
if (vocab && document.activeElement === event.target) recompute();
|
if (vocab && document.activeElement === input) recompute();
|
||||||
}
|
}
|
||||||
});
|
}, { once: true });
|
||||||
|
|
||||||
form.addEventListener("input", (event) => {
|
input.addEventListener("input", () => {
|
||||||
if (!event.target.matches('input[name="command"]')) return;
|
if (!vocab) return; // fetch may not have resolved yet; next input will recompute
|
||||||
if (!vocab) return; // fetch may not have resolved yet
|
|
||||||
recompute();
|
recompute();
|
||||||
});
|
});
|
||||||
|
|
||||||
form.addEventListener("keydown", (event) => {
|
input.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();
|
||||||
|
|
@ -223,14 +152,28 @@ function bindConsoleAutocomplete(form) {
|
||||||
// ArrowUp/ArrowDown/Enter intentionally NOT handled here.
|
// ArrowUp/ArrowDown/Enter intentionally NOT handled here.
|
||||||
});
|
});
|
||||||
|
|
||||||
form.addEventListener("focusout", (event) => {
|
input.addEventListener("blur", () => {
|
||||||
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) {
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,6 @@ 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();
|
||||||
|
|
|
||||||
|
|
@ -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, Overlay, User
|
from l4d2web.models import Blueprint, User
|
||||||
|
|
||||||
|
|
||||||
def _free_port() -> int:
|
def _free_port() -> int:
|
||||||
|
|
@ -25,19 +25,17 @@ def _free_port() -> int:
|
||||||
return port
|
return port
|
||||||
|
|
||||||
|
|
||||||
def _boot_app(tmp_path, monkeypatch):
|
@pytest.fixture(scope="function")
|
||||||
"""Set the env vars + create_app + init_db combo shared by every e2e
|
def live_server(tmp_path, monkeypatch):
|
||||||
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
|
||||||
|
|
@ -47,37 +45,6 @@ def _boot_app(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(
|
||||||
|
|
@ -95,72 +62,16 @@ def live_server(tmp_path, monkeypatch):
|
||||||
blueprint_id = bp.id
|
blueprint_id = bp.id
|
||||||
user_id = user.id
|
user_id = user.id
|
||||||
|
|
||||||
base_url, shutdown = _serve(app)
|
port = _free_port()
|
||||||
|
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": base_url,
|
"base_url": f"http://127.0.0.1:{port}",
|
||||||
"user_id": user_id,
|
"user_id": user_id,
|
||||||
"blueprint_id": blueprint_id,
|
"blueprint_id": blueprint_id,
|
||||||
}
|
}
|
||||||
finally:
|
finally:
|
||||||
shutdown()
|
server.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()
|
|
||||||
|
|
|
||||||
|
|
@ -1,385 +0,0 @@
|
||||||
"""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)
|
|
||||||
Loading…
Reference in a new issue