left4me/l4d2web/l4d2web/static/js/files-overlay/editor.js
mwiegand 8dc14f0cca
feat(files): wire askConflict into the routed new-file 409 path
The is_new branch of routedSaveClicked in editor.js used to alert on
409 and force the user to manually pick a different filename. Restore
the overwrite / keep-both / cancel prompt the legacy openEditorTextNew
flow had (via askConflict, lost when Step 9 deleted legacySaveClicked).

Flow on 409:
  * "overwrite" → re-POST /save with the same path. /save overwrites
    in place when the destination is a regular file.
  * "keep-both" → compose a suffixed path via fo.withCollisionSuffix
    (now multi-extension-aware after F3) and POST that.
  * "cancel" → leave the routed modal open with the user's typed
    content intact so they can edit the filename and retry.

Defensively gates the askConflict + withCollisionSuffix calls on
typeof === "function" so older bookmarks (or a dev environment with
one of the modules missing) fall back to an alert rather than a
TypeError. The 409 alert branch is preserved for that path.

Note on when /save actually 409s: regular-file collisions overwrite
silently (200). 409 fires only when the new path collides with a
directory (or a symlink, or a non-file fs entry) — same contract as
the legacy flow had.

Verified live on /overlays/2 in Chromium with a real round-trip:
  1. Click "+ new folder" → create tmp_409_probe
  2. Click "+ new file" → type "tmp_409_probe" → click Create
  3. /save returns 409 (destination is not a file) → askConflict
     opens with the colliding path displayed
  4. Routed modal stays open behind the conflict dialog (typed
     content preserved)
  5. Cancel on conflict → conflict closes, routed modal still open
  6. Cleanup: delete the tmp_409_probe folder via the action API
  * No console errors throughout
  * Demo overlay state unchanged after cleanup

pytest stays at 577 passed, 1 skipped, 3 deselected (no Python
changes in F4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 17:12:14 +02:00

336 lines
14 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,
// 409s when the destination already exists. On 409 we offer the
// same overwrite / keep-both / cancel prompt that the legacy
// create-new flow used (via askConflict in dialogs.js).
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));
return;
}
if (r.status === 409 && typeof fo.askConflict === "function") {
const action = await fo.askConflict(fullPath);
if (action === "overwrite") {
// /save overwrites in place when the destination is a file —
// a plain re-POST does the right thing.
const r2 = await postJson(`${baseUrl}/files/save`, { path: fullPath, content });
if (r2.ok) {
if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted();
scheduleRefresh(parentOf(fullPath));
} else {
alert((r2.body && r2.body.error) || `Save failed (HTTP ${r2.status}).`);
}
} else if (action === "keep-both" && typeof fo.withCollisionSuffix === "function") {
const altered = fo.withCollisionSuffix(fullPath);
const r2 = await postJson(`${baseUrl}/files/save`, { path: altered, content });
if (r2.ok) {
if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted();
scheduleRefresh(parentOf(altered));
} else {
alert((r2.body && r2.body.error) || `Save failed (HTTP ${r2.status}).`);
}
}
// "cancel" → leave the modal open so the user can edit the
// filename and try again without losing typed content.
return;
}
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;
}
});
})();