Step 9/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.
The inline <dialog id="files-editor-modal"> block in overlay_detail.html
is gone. All editor flows (text edit, binary replace, create new) now
exclusively use the URL-addressable modal swapped into #modal-content.
editor.js is now single-purpose (URL-addressable only). Removed:
* editorDialog reference and the editorEls DOM-ref struct
* The legacy editor state object
* CM6 bridge wrappers + UI helpers (getEditorValue/setEditorValue/
setEditorTitle/updateByteCount/updateRenameHint/updateSaveEnabled/
setQueuedReplacement) — they only ever drove the legacy dialog
* withCollisionSuffix (uploads.js still has its copy for the
upload-conflict path; editor.js no longer needs it since the
URL-addressable conflict path is "alert + keep modal open" rather
than overwrite/keep-both)
* openEditorTextNew and openEditorForFile — both functions were
already unreachable from a user action after Steps 6/8
* inLegacyEditor predicate
* Direct-bound listeners on editorEls.filename / contentBox /
editorDialog (input, keydown for Ctrl+S, close)
* Legacy branches in every delegated handler (dragover, dragleave,
drop, change, click)
* legacySaveClicked and legacyDeleteClicked
What stays:
* Routed state (routedReplacement, isRoutedBinaryMode,
setRoutedReplacement, updateRoutedBinarySaveEnabled)
* Delegated dragover/dragleave/drop/change/click handlers — now
single-path each, no legacy/routed branching
* Filename input delegated listener for routed binary mode (so
rename-only Replace stays reachable)
* modal-container close listener that clears routedReplacement
* routedSaveClicked (text edit + is_new), routedReplaceClicked
(binary, with rename-or-replace fork), routedDeleteClicked
* "new-file" and "edit" registered handlers (the "edit" handler is
no longer split editable/binary — the server picks the template
branch)
routedDeleteClicked gained one capability that was missing in Step 8:
it now reads rel-path from either the textarea (text mode) OR the
.files-editor-binary panel, so deletion works for binary files in the
URL-addressable modal too (previously routed-mode binary delete fell
through to legacy, which is now gone).
Test added: test_overlay_detail_no_longer_renders_legacy_editor_dialog
asserts the legacy dialog markup is absent from /overlays/<id> while
the other inline dialogs are still present (Step 3 didn't move them).
Numbers:
editor.js: 660 → 309 lines (-351). Plan estimated ~200; actual is
~50% larger due to module-header comments + the rename-or-replace
fork in routedReplaceClicked. Pure-routing-only and single-mode
per click, which was the structural goal.
Total across files-overlay/: 1432 → 1191 lines.
pytest: 579 → 580 passed, 1 skipped, 3 deselected.
Verified live on /overlays/2 in Chromium:
* id="files-editor-modal" not in DOM; new-folder/delete/conflict
dialogs still present
* "+ new file" → routed modal, Create button
* Click other.cfg (editable) → routed modal, Save button, content
pre-filled
* Click test.png (binary) → routed modal, Replace button, initially
disabled
* No console errors
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
309 lines
12 KiB
JavaScript
309 lines
12 KiB
JavaScript
// files-overlay/editor.js — URL-addressable editor only (post-Step 9).
|
|
//
|
|
// After Phase B Step 9 deleted the legacy <dialog id="files-editor-modal">,
|
|
// this module is single-purpose: it drives save / delete / replace /
|
|
// new-file flows through the URL-addressable modal swapped into
|
|
// #modal-content. The server template overlay_file_editor.html picks
|
|
// one of three modes based on the route + the target file:
|
|
// * is_new (GET /overlays/<id>/files/new?at=<folder>)
|
|
// * text edit (GET /overlays/<id>/files/edit?path=<rel>, editable)
|
|
// * binary-replace (GET /overlays/<id>/files/edit?path=<rel>, !editable)
|
|
//
|
|
// Dispatch (registered into __filesOverlay):
|
|
// * "new-file" → opens the new-file route via window.modals.openRouted
|
|
// * "edit" → opens the edit route via window.modals.openRouted;
|
|
// the server picks the template branch
|
|
//
|
|
// Click delegation on .files-editor-save branches on which panel is in
|
|
// #modal-content — text textarea → routedSaveClicked, binary panel →
|
|
// routedReplaceClicked. Delete and replace-zone clicks have a single
|
|
// path each.
|
|
//
|
|
// All listeners are document-level delegated. #modal-content is
|
|
// swapped on every modal open, so direct binding wouldn't survive a
|
|
// re-open. Module-scope routedReplacement holds the queued File for
|
|
// binary-replace mode; cleared on modal-container close so reopening
|
|
// starts fresh.
|
|
|
|
(function () {
|
|
"use strict";
|
|
|
|
const fo = window.__filesOverlay;
|
|
if (!fo) return;
|
|
|
|
const { overlayId, baseUrl, csrfToken } = fo;
|
|
const {
|
|
parentOf, humanSize,
|
|
postJson, postForm, scheduleRefresh,
|
|
} = fo.helpers;
|
|
|
|
// ---------- routed binary-replace state ----------
|
|
|
|
let routedReplacement = null;
|
|
|
|
function inRoutedEditor(el) {
|
|
const mc = document.getElementById("modal-content");
|
|
return !!mc && mc.contains(el);
|
|
}
|
|
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 Save (labeled "Replace" in binary mode) when either a file
|
|
// is queued OR the filename input has been edited. Rename-only is a
|
|
// valid Replace and routes to /files/move below.
|
|
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;
|
|
}
|
|
|
|
// ---------- delegated handlers (#modal-content only) ----------
|
|
|
|
document.addEventListener("dragover", (event) => {
|
|
const zone = event.target?.closest?.(".files-editor-replace-zone");
|
|
if (!zone || !inRoutedEditor(zone)) return;
|
|
if (Array.from(event.dataTransfer.types).includes("Files")) {
|
|
event.preventDefault();
|
|
zone.classList.add("is-drop-target");
|
|
}
|
|
});
|
|
document.addEventListener("dragleave", (event) => {
|
|
const zone = event.target?.closest?.(".files-editor-replace-zone");
|
|
if (!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 || !inRoutedEditor(zone)) 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) setRoutedReplacement(document.getElementById("modal-content"), f);
|
|
});
|
|
|
|
document.addEventListener("change", (event) => {
|
|
const input = event.target?.closest?.(".files-editor-replace-input");
|
|
if (!input || !inRoutedEditor(input)) return;
|
|
const f = input.files && input.files[0];
|
|
if (f) setRoutedReplacement(document.getElementById("modal-content"), f);
|
|
});
|
|
|
|
// Filename input: re-evaluate Save enablement in routed binary mode
|
|
// so rename-only Replace becomes reachable without queueing a file.
|
|
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)) updateRoutedBinarySaveEnabled(mc);
|
|
});
|
|
|
|
document.addEventListener("click", async (event) => {
|
|
const target = event.target;
|
|
if (!target) return;
|
|
|
|
const clearBtn = target.closest?.(".files-editor-replace-clear");
|
|
if (clearBtn && inRoutedEditor(clearBtn)) {
|
|
setRoutedReplacement(document.getElementById("modal-content"), null);
|
|
return;
|
|
}
|
|
const browseBtn = target.closest?.(".files-editor-replace-browse");
|
|
if (browseBtn && inRoutedEditor(browseBtn)) {
|
|
const mc = document.getElementById("modal-content");
|
|
const fileInput = mc?.querySelector(".files-editor-replace-input");
|
|
fileInput?.click();
|
|
return;
|
|
}
|
|
|
|
const saveBtn = target.closest?.(".files-editor-save");
|
|
if (saveBtn && inRoutedEditor(saveBtn)) {
|
|
const mc = document.getElementById("modal-content");
|
|
if (isRoutedBinaryMode(mc)) {
|
|
await routedReplaceClicked();
|
|
} else {
|
|
await routedSaveClicked();
|
|
}
|
|
return;
|
|
}
|
|
|
|
const delBtn = target.closest?.(".files-editor-delete");
|
|
if (delBtn && inRoutedEditor(delBtn)) {
|
|
await routedDeleteClicked();
|
|
return;
|
|
}
|
|
});
|
|
|
|
// Clear queued replacement when the modal closes (Esc, backdrop,
|
|
// dismiss button, programmatic closeRouted). Otherwise reopening
|
|
// would inherit a stale queued File from the prior session.
|
|
document.getElementById("modal-container")?.addEventListener("close", () => {
|
|
routedReplacement = null;
|
|
});
|
|
|
|
// ---------- save / replace / delete ----------
|
|
|
|
async function routedSaveClicked() {
|
|
const modalContent = document.getElementById("modal-content");
|
|
if (!modalContent) return;
|
|
const ta = modalContent.querySelector("textarea[data-rel-path]");
|
|
if (!ta) return;
|
|
const relPath = ta.dataset.relPath;
|
|
const filenameInput = modalContent.querySelector("[data-editor-filename]");
|
|
const editedFilename = filenameInput ? filenameInput.value.trim() : "";
|
|
const content = (window.__filesEditor && window.__filesEditor.getValue)
|
|
? window.__filesEditor.getValue()
|
|
: ta.value;
|
|
|
|
// is_new mode: relPath is empty; compose path from data-at-folder
|
|
// + filename. The /save endpoint creates the file when missing.
|
|
if (!relPath) {
|
|
if (!editedFilename) return;
|
|
const atFolder = ta.dataset.atFolder || "";
|
|
const fullPath = atFolder
|
|
? `${atFolder.replace(/\/+$/, "")}/${editedFilename}`
|
|
: editedFilename;
|
|
const r = await postJson(`${baseUrl}/files/save`, { path: fullPath, content });
|
|
if (r.ok) {
|
|
if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted();
|
|
scheduleRefresh(parentOf(fullPath));
|
|
} else if (r.status === 409) {
|
|
alert(r.rawText || `A file at ${fullPath} already exists. Pick a different name.`);
|
|
} else {
|
|
alert((r.body && r.body.error) || r.rawText || `Create failed (HTTP ${r.status}).`);
|
|
}
|
|
return;
|
|
}
|
|
|
|
// Edit mode: rename-on-save (sibling rename only — parent stays).
|
|
const originalLeaf = relPath.split("/").pop() || relPath;
|
|
const parent = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/")) : "";
|
|
let newPath = null;
|
|
if (editedFilename && editedFilename !== originalLeaf) {
|
|
newPath = parent ? `${parent}/${editedFilename}` : editedFilename;
|
|
}
|
|
const payload = { path: relPath, content };
|
|
if (newPath) payload.new_path = newPath;
|
|
const r = await postJson(`${baseUrl}/files/save`, payload);
|
|
if (r.ok) {
|
|
if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted();
|
|
scheduleRefresh(parentOf(newPath || relPath));
|
|
} else if (r.status === 409) {
|
|
alert(r.rawText || `Conflict: destination already exists.`);
|
|
} else {
|
|
alert((r.body && r.body.error) || r.rawText || `Save failed (HTTP ${r.status}).`);
|
|
}
|
|
}
|
|
|
|
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;
|
|
|
|
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;
|
|
|
|
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;
|
|
// rel-path lives on the text-mode textarea OR on the binary panel.
|
|
const ta = modalContent.querySelector("textarea[data-rel-path]");
|
|
const panel = modalContent.querySelector(".files-editor-binary[data-rel-path]");
|
|
const relPath = ta?.dataset?.relPath || panel?.dataset?.relPath;
|
|
if (!relPath) return;
|
|
if (!confirm(`Delete ${relPath}?`)) return;
|
|
const fd = new FormData();
|
|
fd.append("path", relPath);
|
|
fd.append("csrf_token", csrfToken);
|
|
const r = await postForm(`${baseUrl}/files/delete`, fd);
|
|
if (r.ok) {
|
|
if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted();
|
|
scheduleRefresh(parentOf(relPath));
|
|
} else {
|
|
alert((r.body && r.body.error) || `Delete failed (HTTP ${r.status}).`);
|
|
}
|
|
}
|
|
|
|
// ---------- register action-registry handlers ----------
|
|
|
|
fo.registerHandler("new-file", (path) => {
|
|
const url = `/overlays/${overlayId}/files/new?at=${encodeURIComponent(path)}`;
|
|
if (typeof window.modals?.openRouted === "function") {
|
|
window.modals.openRouted(url);
|
|
} else {
|
|
window.location.href = url;
|
|
}
|
|
});
|
|
|
|
fo.registerHandler("edit", (path, _actionEl) => {
|
|
// Both text and binary files use the same edit route — the server
|
|
// picks the template branch based on is_editable(target).
|
|
const url = `/overlays/${overlayId}/files/edit?path=${encodeURIComponent(path)}`;
|
|
if (typeof window.modals?.openRouted === "function") {
|
|
window.modals.openRouted(url);
|
|
} else {
|
|
window.location.href = url;
|
|
}
|
|
});
|
|
})();
|