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:
parent
294b5b8489
commit
e75280f780
1 changed files with 185 additions and 35 deletions
|
|
@ -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);
|
||||
});
|
||||
})();
|
||||
|
|
|
|||
Loading…
Reference in a new issue