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>
336 lines
14 KiB
JavaScript
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;
|
|
}
|
|
});
|
|
})();
|