feat(files): migrate binary-replace JS flow to URL-addressable modal

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) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-17 16:15:57 +02:00
parent 294b5b8489
commit e75280f780
No known key found for this signature in database

View file

@ -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);
});
})();