From e75280f78009e2537923dcbeed32f13a96779646 Mon Sep 17 00:00:00 2001 From: mwiegand Date: Sun, 17 May 2026 16:15:57 +0200 Subject: [PATCH] feat(files): migrate binary-replace JS flow to URL-addressable modal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 8/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md. The "edit" action handler in editor.js now ALWAYS opens the URL- addressable modal — both editable text files and binary files. The server's /files/edit route picks the template branch (text editor vs. binary-replace panel) based on is_editable(target); data-editable on the action element becomes informational only. Binary-replace handlers extended in editor.js to cover the routed modal (the panel that .files-editor-binary now lives inside): * routedReplacement (module-scope nullable) — the queued File for routed binary mode. Cleared on modal-container close so reopening starts fresh. * setRoutedReplacement(mc, file) — updates idle/queued markup, populates name/size labels, calls updateRoutedBinarySaveEnabled. * isRoutedBinaryMode(mc) — true when #modal-content holds a binary panel; used by the click delegation to route Save → replace. * updateRoutedBinarySaveEnabled(mc) — enables Save when a file is queued OR the filename input has been edited. Mirrors the legacy binary-mode updateSaveEnabled logic. The existing dragover / dragleave / drop / change / click delegated listeners gained routed-mode branches alongside the legacy branches, gated by inLegacyEditor / inRoutedEditor (mutually exclusive — the selectors only match inside one editor at a time). routedReplaceClicked added, mirroring legacy binary save: * Queued file present → POST /files/replace (multipart, with optional new_path for rename-on-replace) * Queued file absent but filename edited → POST /files/move (rename only) * Neither → no-op (Save was disabled, shouldn't be reachable) Filename-input delegated listener re-evaluates Save enablement when the user types in routed binary mode (so rename-only is reachable without queueing a file). The legacy openEditorForFile function in editor.js is now unreachable from a user action (the "edit" handler no longer calls it). It stays in this file until Step 9 deletes the legacy dialog block wholesale. Verified live on /overlays/2 in Chromium: * Click test.png (binary, data-editable="0") → URL becomes ?modal=%2Foverlays%2F2%2Ffiles%2Fedit%3Fpath%3Dtest.png * Routed modal opens with binary panel; Save labeled "Replace" and disabled * Synthetic drop event with a File → Save enables, idle label hides, queued label shows "new.png · 18 B" * Clear button → idle restored, Save disables * Type new filename without queueing → Save enables (rename-only) * Revert filename → Save disables * No console errors * pytest still 579 passed, 1 skipped, 3 deselected (no Python changes in Step 8) Co-Authored-By: Claude Opus 4.7 (1M context) --- .../l4d2web/static/js/files-overlay/editor.js | 220 +++++++++++++++--- 1 file changed, 185 insertions(+), 35 deletions(-) diff --git a/l4d2web/l4d2web/static/js/files-overlay/editor.js b/l4d2web/l4d2web/static/js/files-overlay/editor.js index 29840cd..c965183 100644 --- a/l4d2web/l4d2web/static/js/files-overlay/editor.js +++ b/l4d2web/l4d2web/static/js/files-overlay/editor.js @@ -259,11 +259,58 @@ return !!mc && mc.contains(el); } - // Replace-zone drag (legacy binary mode). Document-level delegation - // gated on the zone being inside the legacy dialog. + // ---------- routed binary-replace state ---------- + + // The queued File for the URL-addressable binary editor. Cleared on + // modal-container close so reopening starts fresh. + let routedReplacement = null; + + function isRoutedBinaryMode(modalContent) { + return !!modalContent?.querySelector(".files-editor-binary"); + } + + function setRoutedReplacement(modalContent, file) { + routedReplacement = file; + const idle = modalContent.querySelector(".files-editor-replace-idle"); + const queued = modalContent.querySelector(".files-editor-replace-queued"); + if (file) { + if (idle) idle.hidden = true; + if (queued) queued.hidden = false; + const name = modalContent.querySelector(".files-editor-replace-name"); + const size = modalContent.querySelector(".files-editor-replace-size"); + if (name) name.textContent = file.name; + if (size) size.textContent = humanSize(file.size); + } else { + if (idle) idle.hidden = false; + if (queued) queued.hidden = true; + } + updateRoutedBinarySaveEnabled(modalContent); + } + + // Enables the Save (Replace) button in routed binary mode when either + // a replacement file is queued OR the filename input has been edited. + // Mirrors legacy updateSaveEnabled's binary-mode branch. + function updateRoutedBinarySaveEnabled(modalContent) { + const panel = modalContent.querySelector(".files-editor-binary[data-rel-path]"); + const saveBtn = modalContent.querySelector(".files-editor-save"); + if (!panel || !saveBtn) return; + const relPath = panel.dataset.relPath || ""; + const originalLeaf = relPath.split("/").pop() || relPath; + const filenameInput = modalContent.querySelector("[data-editor-filename]"); + const filenameChanged = + filenameInput && filenameInput.value.trim() && + filenameInput.value.trim() !== originalLeaf; + saveBtn.disabled = !routedReplacement && !filenameChanged; + } + + // Replace-zone drag — delegated for both legacy and routed binary + // modes. The zone lives inside #files-editor-modal (legacy) or + // #modal-content (routed); the in-{legacy,routed}Editor checks + // dispatch to the right reaction. document.addEventListener("dragover", (event) => { const zone = event.target?.closest?.(".files-editor-replace-zone"); - if (!zone || !inLegacyEditor(zone)) return; + if (!zone) return; + if (!inLegacyEditor(zone) && !inRoutedEditor(zone)) return; if (Array.from(event.dataTransfer.types).includes("Files")) { event.preventDefault(); zone.classList.add("is-drop-target"); @@ -271,45 +318,88 @@ }); document.addEventListener("dragleave", (event) => { const zone = event.target?.closest?.(".files-editor-replace-zone"); - if (!zone || !inLegacyEditor(zone)) return; + if (!zone) return; + if (!inLegacyEditor(zone) && !inRoutedEditor(zone)) return; zone.classList.remove("is-drop-target"); }); document.addEventListener("drop", (event) => { const zone = event.target?.closest?.(".files-editor-replace-zone"); - if (!zone || !inLegacyEditor(zone)) return; + if (!zone) return; + const isLegacy = inLegacyEditor(zone); + const isRouted = !isLegacy && inRoutedEditor(zone); + if (!isLegacy && !isRouted) return; if (!Array.from(event.dataTransfer.types).includes("Files")) return; event.preventDefault(); zone.classList.remove("is-drop-target"); const f = event.dataTransfer.files && event.dataTransfer.files[0]; - if (f) setQueuedReplacement(f); + if (!f) return; + if (isLegacy) { + setQueuedReplacement(f); + } else { + setRoutedReplacement(document.getElementById("modal-content"), f); + } }); - // Replace-input change (browse-fallback): low frequency, delegated. + // Replace-input change (browse-fallback): low frequency, delegated + // for both editors. document.addEventListener("change", (event) => { const input = event.target?.closest?.(".files-editor-replace-input"); - if (!input || !inLegacyEditor(input)) return; + if (!input) return; + const isLegacy = inLegacyEditor(input); + const isRouted = !isLegacy && inRoutedEditor(input); + if (!isLegacy && !isRouted) return; const f = input.files && input.files[0]; - if (f) setQueuedReplacement(f); + if (!f) return; + if (isLegacy) { + setQueuedReplacement(f); + } else { + setRoutedReplacement(document.getElementById("modal-content"), f); + } + }); + + // Filename input in routed binary mode: re-evaluate Save enablement + // when the user types a new name (rename-only is a valid Replace). + document.addEventListener("input", (event) => { + const input = event.target?.closest?.("[data-editor-filename]"); + if (!input || !inRoutedEditor(input)) return; + const mc = document.getElementById("modal-content"); + if (!mc || !isRoutedBinaryMode(mc)) return; + updateRoutedBinarySaveEnabled(mc); }); document.addEventListener("click", async (event) => { const target = event.target; if (!target) return; - // Legacy: replace-clear button. + // Replace-clear (both editors). const clearBtn = target.closest?.(".files-editor-replace-clear"); - if (clearBtn && inLegacyEditor(clearBtn)) { - setQueuedReplacement(null); - return; + if (clearBtn) { + if (inLegacyEditor(clearBtn)) { + setQueuedReplacement(null); + return; + } + if (inRoutedEditor(clearBtn)) { + setRoutedReplacement(document.getElementById("modal-content"), null); + return; + } } - // Legacy: replace-browse button → trigger hidden file input. + // Replace-browse (both editors) → trigger hidden file input. const browseBtn = target.closest?.(".files-editor-replace-browse"); - if (browseBtn && inLegacyEditor(browseBtn)) { - editorEls.replaceInput.click(); - return; + if (browseBtn) { + if (inLegacyEditor(browseBtn)) { + editorEls.replaceInput.click(); + return; + } + if (inRoutedEditor(browseBtn)) { + const mc = document.getElementById("modal-content"); + const fileInput = mc?.querySelector(".files-editor-replace-input"); + fileInput?.click(); + return; + } } - // Save (both editors). + // Save (both editors). Routed mode further branches on text vs. + // binary panel. const saveBtn = target.closest?.(".files-editor-save"); if (saveBtn) { if (inLegacyEditor(saveBtn)) { @@ -317,7 +407,12 @@ return; } if (inRoutedEditor(saveBtn)) { - await routedSaveClicked(); + const mc = document.getElementById("modal-content"); + if (isRoutedBinaryMode(mc)) { + await routedReplaceClicked(); + } else { + await routedSaveClicked(); + } return; } } @@ -336,6 +431,12 @@ } }); + // Clear routed binary state when the modal closes. Otherwise the next + // open would still see a "queued" replacement from the prior session. + document.getElementById("modal-container")?.addEventListener("close", () => { + routedReplacement = null; + }); + async function legacySaveClicked() { const filename = editorEls.filename.value.trim(); if (!filename) return; @@ -525,6 +626,57 @@ } } + async function routedReplaceClicked() { + const modalContent = document.getElementById("modal-content"); + if (!modalContent) return; + const panel = modalContent.querySelector(".files-editor-binary[data-rel-path]"); + if (!panel) return; + const relPath = panel.dataset.relPath; + if (!relPath) return; + + // Rename-on-replace: same shape as routedSaveClicked, sibling-only. + const filenameInput = modalContent.querySelector("[data-editor-filename]"); + const editedFilename = filenameInput ? filenameInput.value.trim() : ""; + const originalLeaf = relPath.split("/").pop() || relPath; + const parent = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/")) : ""; + const renaming = !!editedFilename && editedFilename !== originalLeaf; + const newPath = renaming + ? (parent ? `${parent}/${editedFilename}` : editedFilename) + : null; + + // Two flows: a queued replacement → /files/replace (multipart); + // rename-only (no queued file) → /files/move. Mirrors the legacy + // binary save handler. + if (routedReplacement) { + const fd = new FormData(); + fd.append("path", relPath); + fd.append("csrf_token", csrfToken); + if (newPath) fd.append("new_path", newPath); + fd.append("file", routedReplacement); + const r = await postForm(`${baseUrl}/files/replace`, fd); + if (r.ok) { + routedReplacement = null; + if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted(); + scheduleRefresh(parentOf(newPath || relPath)); + } else { + alert((r.body && r.body.error) || `Replace failed (HTTP ${r.status}).`); + } + return; + } + if (renaming) { + const r = await postJson(`${baseUrl}/files/move`, { + src: relPath, + dst: newPath, + }); + if (r.ok) { + if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted(); + scheduleRefresh(parentOf(newPath)); + } else { + alert((r.body && r.body.error) || `Rename failed (HTTP ${r.status}).`); + } + } + } + async function routedDeleteClicked() { const modalContent = document.getElementById("modal-content"); if (!modalContent) return; @@ -561,22 +713,20 @@ } }); - fo.registerHandler("edit", (path, actionEl) => { - const editable = actionEl?.dataset?.editable === "1"; - if (editable) { - // Editable text files: open via URL-addressable modal. - const editUrl = `/overlays/${overlayId}/files/edit?path=${encodeURIComponent(path)}`; - if (typeof window.modals?.openRouted === "function") { - window.modals.openRouted(editUrl); - } else { - // Graceful fallback if modals.js didn't load — full-page nav - // hits the same route and renders the standalone editor page. - window.location.href = editUrl; - } - return; + fo.registerHandler("edit", (path, _actionEl) => { + // Phase B Step 8: both editable text and binary files route to the + // URL-addressable modal. The server's /files/edit route picks the + // template branch (text editor vs. binary-replace panel) based on + // is_editable(target). The data-editable attribute on the action + // element is now informational only — the server is the + // source of truth. + const editUrl = `/overlays/${overlayId}/files/edit?path=${encodeURIComponent(path)}`; + if (typeof window.modals?.openRouted === "function") { + window.modals.openRouted(editUrl); + } else { + // Graceful fallback if modals.js didn't load — full-page nav + // hits the same route and renders the standalone editor page. + window.location.href = editUrl; } - // Binary files: legacy inline dialog (Phase B migrates this to - // URL-addressable too). - openEditorForFile(path, false); }); })();