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);
|
return !!mc && mc.contains(el);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Replace-zone drag (legacy binary mode). Document-level delegation
|
// ---------- routed binary-replace state ----------
|
||||||
// gated on the zone being inside the legacy dialog.
|
|
||||||
|
// 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) => {
|
document.addEventListener("dragover", (event) => {
|
||||||
const zone = event.target?.closest?.(".files-editor-replace-zone");
|
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")) {
|
if (Array.from(event.dataTransfer.types).includes("Files")) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
zone.classList.add("is-drop-target");
|
zone.classList.add("is-drop-target");
|
||||||
|
|
@ -271,45 +318,88 @@
|
||||||
});
|
});
|
||||||
document.addEventListener("dragleave", (event) => {
|
document.addEventListener("dragleave", (event) => {
|
||||||
const zone = event.target?.closest?.(".files-editor-replace-zone");
|
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");
|
zone.classList.remove("is-drop-target");
|
||||||
});
|
});
|
||||||
document.addEventListener("drop", (event) => {
|
document.addEventListener("drop", (event) => {
|
||||||
const zone = event.target?.closest?.(".files-editor-replace-zone");
|
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;
|
if (!Array.from(event.dataTransfer.types).includes("Files")) return;
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
zone.classList.remove("is-drop-target");
|
zone.classList.remove("is-drop-target");
|
||||||
const f = event.dataTransfer.files && event.dataTransfer.files[0];
|
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) => {
|
document.addEventListener("change", (event) => {
|
||||||
const input = event.target?.closest?.(".files-editor-replace-input");
|
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];
|
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) => {
|
document.addEventListener("click", async (event) => {
|
||||||
const target = event.target;
|
const target = event.target;
|
||||||
if (!target) return;
|
if (!target) return;
|
||||||
|
|
||||||
// Legacy: replace-clear button.
|
// Replace-clear (both editors).
|
||||||
const clearBtn = target.closest?.(".files-editor-replace-clear");
|
const clearBtn = target.closest?.(".files-editor-replace-clear");
|
||||||
if (clearBtn && inLegacyEditor(clearBtn)) {
|
if (clearBtn) {
|
||||||
|
if (inLegacyEditor(clearBtn)) {
|
||||||
setQueuedReplacement(null);
|
setQueuedReplacement(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Legacy: replace-browse button → trigger hidden file input.
|
if (inRoutedEditor(clearBtn)) {
|
||||||
|
setRoutedReplacement(document.getElementById("modal-content"), null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Replace-browse (both editors) → trigger hidden file input.
|
||||||
const browseBtn = target.closest?.(".files-editor-replace-browse");
|
const browseBtn = target.closest?.(".files-editor-replace-browse");
|
||||||
if (browseBtn && inLegacyEditor(browseBtn)) {
|
if (browseBtn) {
|
||||||
|
if (inLegacyEditor(browseBtn)) {
|
||||||
editorEls.replaceInput.click();
|
editorEls.replaceInput.click();
|
||||||
return;
|
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");
|
const saveBtn = target.closest?.(".files-editor-save");
|
||||||
if (saveBtn) {
|
if (saveBtn) {
|
||||||
if (inLegacyEditor(saveBtn)) {
|
if (inLegacyEditor(saveBtn)) {
|
||||||
|
|
@ -317,7 +407,12 @@
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (inRoutedEditor(saveBtn)) {
|
if (inRoutedEditor(saveBtn)) {
|
||||||
|
const mc = document.getElementById("modal-content");
|
||||||
|
if (isRoutedBinaryMode(mc)) {
|
||||||
|
await routedReplaceClicked();
|
||||||
|
} else {
|
||||||
await routedSaveClicked();
|
await routedSaveClicked();
|
||||||
|
}
|
||||||
return;
|
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() {
|
async function legacySaveClicked() {
|
||||||
const filename = editorEls.filename.value.trim();
|
const filename = editorEls.filename.value.trim();
|
||||||
if (!filename) return;
|
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() {
|
async function routedDeleteClicked() {
|
||||||
const modalContent = document.getElementById("modal-content");
|
const modalContent = document.getElementById("modal-content");
|
||||||
if (!modalContent) return;
|
if (!modalContent) return;
|
||||||
|
|
@ -561,10 +713,13 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
fo.registerHandler("edit", (path, actionEl) => {
|
fo.registerHandler("edit", (path, _actionEl) => {
|
||||||
const editable = actionEl?.dataset?.editable === "1";
|
// Phase B Step 8: both editable text and binary files route to the
|
||||||
if (editable) {
|
// URL-addressable modal. The server's /files/edit route picks the
|
||||||
// Editable text files: open via URL-addressable modal.
|
// 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)}`;
|
const editUrl = `/overlays/${overlayId}/files/edit?path=${encodeURIComponent(path)}`;
|
||||||
if (typeof window.modals?.openRouted === "function") {
|
if (typeof window.modals?.openRouted === "function") {
|
||||||
window.modals.openRouted(editUrl);
|
window.modals.openRouted(editUrl);
|
||||||
|
|
@ -573,10 +728,5 @@
|
||||||
// hits the same route and renders the standalone editor page.
|
// hits the same route and renders the standalone editor page.
|
||||||
window.location.href = editUrl;
|
window.location.href = editUrl;
|
||||||
}
|
}
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Binary files: legacy inline dialog (Phase B migrates this to
|
|
||||||
// URL-addressable too).
|
|
||||||
openEditorForFile(path, false);
|
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue