left4me/l4d2web/l4d2web/static/js/files-overlay/editor.js
mwiegand 10f93b863b
feat(files): delete legacy editor dialog + gut editor.js legacy paths
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>
2026-05-17 16:20:27 +02:00

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