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>
This commit is contained in:
mwiegand 2026-05-17 16:20:27 +02:00
parent e75280f780
commit 10f93b863b
No known key found for this signature in database
3 changed files with 89 additions and 560 deletions

View file

@ -1,28 +1,29 @@
// files-overlay/editor.js — Phase A, Step 2. // files-overlay/editor.js — URL-addressable editor only (post-Step 9).
// //
// Owns the editor flows during Phase A. Dual-purpose: drives both the // After Phase B Step 9 deleted the legacy <dialog id="files-editor-modal">,
// legacy inline #files-editor-modal <dialog> (binary-replace + create- // this module is single-purpose: it drives save / delete / replace /
// new-file) and the URL-addressable modal swapped into #modal-content // new-file flows through the URL-addressable modal swapped into
// (editable text files). Phase B migrates the legacy flows to URL- // #modal-content. The server template overlay_file_editor.html picks
// addressable too and removes the legacy branches here. // 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)
// //
// Action-registry dispatch (registered into __filesOverlay): // Dispatch (registered into __filesOverlay):
// * "new-file" → openEditorTextNew(folder) (legacy dialog) // * "new-file" → opens the new-file route via window.modals.openRouted
// * "edit" → URL-addressable modal for editable files; // * "edit" → opens the edit route via window.modals.openRouted;
// openEditorForFile(path, false) for binary. // the server picks the template branch
// //
// Save / delete: a single document-level click listener handles both // Click delegation on .files-editor-save branches on which panel is in
// the legacy dialog and the URL-addressable modal, discriminated by // #modal-content — text textarea → routedSaveClicked, binary panel →
// which ancestor contains the click target. // routedReplaceClicked. Delete and replace-zone clicks have a single
// path each.
// //
// Direct-bound (per plan, escape hatch): filename `input`, content // All listeners are document-level delegated. #modal-content is
// textarea `input` + `keydown` — high-frequency events on persistent // swapped on every modal open, so direct binding wouldn't survive a
// inputs inside the persistent legacy dialog. // re-open. Module-scope routedReplacement holds the queued File for
// // binary-replace mode; cleared on modal-container close so reopening
// Cross-module deps consumed via window.__filesOverlay (set by // starts fresh.
// core.js): manager, overlayId, baseUrl, csrfToken, helpers, plus
// askConflict (set by the legacy files-overlay.js, used here for save
// 409-conflict handling).
(function () { (function () {
"use strict"; "use strict";
@ -32,239 +33,18 @@
const { overlayId, baseUrl, csrfToken } = fo; const { overlayId, baseUrl, csrfToken } = fo;
const { const {
joinPath, parentOf, basename, humanSize, parentOf, humanSize,
fetchJson, postJson, postForm, scheduleRefresh, postJson, postForm, scheduleRefresh,
} = fo.helpers; } = fo.helpers;
// Legacy inline dialog — present in Phase A. Phase B deletes it; the // ---------- routed binary-replace state ----------
// legacy-branch handlers below short-circuit on its absence.
const editorDialog = document.getElementById("files-editor-modal");
// ---------- legacy editor state + DOM refs ---------- let routedReplacement = null;
const editor = {
mode: null, // "text" | "binary"
creating: false,
originalPath: null,
folder: null,
queuedReplacement: null, // File object
};
const editorEls = editorDialog ? {
title: editorDialog.querySelector(".files-editor-title-text"),
filename: editorDialog.querySelector(".files-editor-filename"),
renameHint: editorDialog.querySelector(".files-editor-rename-hint"),
renameFrom: editorDialog.querySelector(".files-rename-from"),
renameTo: editorDialog.querySelector(".files-rename-to"),
textPanel: editorDialog.querySelector(".files-editor-text"),
contentBox: editorDialog.querySelector(".files-editor-content"),
byteCount: editorDialog.querySelector(".files-editor-byte-count"),
binaryPanel: editorDialog.querySelector(".files-editor-binary"),
binarySize: editorDialog.querySelector(".files-editor-binary-size"),
replaceZone: editorDialog.querySelector(".files-editor-replace-zone"),
replaceIdle: editorDialog.querySelector(".files-editor-replace-idle"),
replaceQueued: editorDialog.querySelector(".files-editor-replace-queued"),
replaceName: editorDialog.querySelector(".files-editor-replace-name"),
replaceSize: editorDialog.querySelector(".files-editor-replace-size"),
replaceClear: editorDialog.querySelector(".files-editor-replace-clear"),
replaceBrowse: editorDialog.querySelector(".files-editor-replace-browse"),
replaceInput: editorDialog.querySelector(".files-editor-replace-input"),
deleteBtn: editorDialog.querySelector(".files-editor-delete"),
downloadBtn: editorDialog.querySelector(".files-editor-download"),
saveBtn: editorDialog.querySelector(".files-editor-save"),
} : null;
// ---------- CM6 bridge + UI updates (legacy dialog) ----------
// Bridge to the CodeMirror 6 controller, set up by static/js/editor.js
// on the .files-editor-content textarea. Falls back to the textarea
// directly if the bundle didn't load (no-JS fallback / file open
// before the controller has been mounted).
function getEditorValue() {
return (window.__filesEditor && window.__filesEditor.getValue)
? window.__filesEditor.getValue()
: editorEls.contentBox.value;
}
function setEditorValue(text) {
if (window.__filesEditor && window.__filesEditor.setContent) {
window.__filesEditor.setContent(text);
} else {
editorEls.contentBox.value = text;
}
}
function setEditorTitle(text) {
editorEls.title.textContent = text;
}
function updateByteCount() {
const bytes = new TextEncoder().encode(getEditorValue()).length;
editorEls.byteCount.textContent = `UTF-8 · ${bytes} bytes`;
}
function updateRenameHint() {
const current = editorEls.filename.value.trim();
const original = basename(editor.originalPath || "");
if (editor.creating || !current || current === original) {
editorEls.renameHint.hidden = true;
return;
}
editorEls.renameFrom.textContent = original;
editorEls.renameTo.textContent = current;
editorEls.renameHint.hidden = false;
}
function updateSaveEnabled() {
if (editor.mode === "binary" && !editor.creating) {
const filenameChanged =
editorEls.filename.value.trim() !== basename(editor.originalPath || "");
const hasReplacement = !!editor.queuedReplacement;
editorEls.saveBtn.disabled = !filenameChanged && !hasReplacement;
editorEls.saveBtn.textContent = "Save";
} else if (editor.creating) {
editorEls.saveBtn.disabled = !editorEls.filename.value.trim();
editorEls.saveBtn.textContent = "Create";
} else {
editorEls.saveBtn.disabled = false;
editorEls.saveBtn.textContent = "Save";
}
}
function setQueuedReplacement(file) {
editor.queuedReplacement = file;
if (file) {
editorEls.replaceIdle.hidden = true;
editorEls.replaceQueued.hidden = false;
editorEls.replaceName.textContent = file.name;
editorEls.replaceSize.textContent = humanSize(file.size);
} else {
editorEls.replaceIdle.hidden = false;
editorEls.replaceQueued.hidden = true;
}
updateSaveEnabled();
}
// ---------- legacy editor openers ----------
function openEditorTextNew(folder) {
if (!editorDialog) return;
editor.mode = "text";
editor.creating = true;
editor.originalPath = null;
editor.folder = folder;
editor.queuedReplacement = null;
setEditorTitle(`${folder ? folder + "/" : ""}…new file`);
editorEls.filename.value = "";
editorEls.filename.disabled = false;
setEditorValue("");
editorEls.contentBox.disabled = false;
editorEls.renameHint.hidden = true;
editorEls.textPanel.hidden = false;
editorEls.binaryPanel.hidden = true;
editorEls.deleteBtn.hidden = true;
editorEls.downloadBtn.hidden = true;
editorEls.saveBtn.textContent = "Create";
updateByteCount();
updateSaveEnabled();
editorDialog.showModal();
setTimeout(() => editorEls.filename.focus(), 0);
}
async function openEditorForFile(path, isEditable) {
if (!editorDialog) return;
editor.creating = false;
editor.originalPath = path;
editor.folder = parentOf(path);
editor.queuedReplacement = null;
setQueuedReplacement(null);
editorEls.filename.value = basename(path);
editorEls.filename.disabled = false;
editorEls.renameHint.hidden = true;
editorEls.deleteBtn.hidden = false;
editorEls.downloadBtn.hidden = false;
editorEls.downloadBtn.href = `${baseUrl}/files/download?path=${encodeURIComponent(path)}`;
setEditorTitle(path);
if (isEditable) {
editor.mode = "text";
editorEls.textPanel.hidden = false;
editorEls.binaryPanel.hidden = true;
setEditorValue("Loading…");
editorEls.contentBox.disabled = true;
const r = await fetchJson(
`${baseUrl}/files/content?path=${encodeURIComponent(path)}`
);
if (r.ok && r.body) {
setEditorValue(r.body.content);
editorEls.contentBox.disabled = false;
updateByteCount();
updateSaveEnabled();
editorDialog.showModal();
setTimeout(() => editorEls.contentBox.focus(), 0);
return;
}
// Fallback: server says not editable. Re-open as binary.
editorEls.contentBox.disabled = false;
editor.mode = "binary";
} else {
editor.mode = "binary";
}
// Binary mode setup.
editorEls.textPanel.hidden = true;
editorEls.binaryPanel.hidden = false;
editorEls.binarySize.textContent = "—"; // server gave us no size; cosmetic
updateSaveEnabled();
editorDialog.showModal();
setTimeout(() => editorEls.filename.focus(), 0);
}
// ---------- direct-bound persistent-input listeners (legacy dialog) ----------
if (editorDialog) {
// Per plan: filename + content textarea input/keydown stay direct-
// bound. They're high-frequency events on persistent inputs inside
// the persistent legacy dialog; delegation would add per-keystroke
// selector-matching overhead.
editorEls.filename.addEventListener("input", () => {
updateRenameHint();
updateSaveEnabled();
});
editorEls.contentBox.addEventListener("input", () => {
updateByteCount();
updateSaveEnabled();
});
editorEls.contentBox.addEventListener("keydown", (event) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") {
event.preventDefault();
editorEls.saveBtn.click();
}
});
editorDialog.addEventListener("close", () => {
setQueuedReplacement(null);
});
}
// ---------- delegated handlers (legacy + URL-addressable) ----------
function inLegacyEditor(el) {
return !!editorDialog && editorDialog.contains(el);
}
function inRoutedEditor(el) { function inRoutedEditor(el) {
const mc = document.getElementById("modal-content"); const mc = document.getElementById("modal-content");
return !!mc && mc.contains(el); return !!mc && mc.contains(el);
} }
// ---------- routed binary-replace state ----------
// The queued File for the URL-addressable binary editor. Cleared on
// modal-container close so reopening starts fresh.
let routedReplacement = null;
function isRoutedBinaryMode(modalContent) { function isRoutedBinaryMode(modalContent) {
return !!modalContent?.querySelector(".files-editor-binary"); return !!modalContent?.querySelector(".files-editor-binary");
} }
@ -287,9 +67,9 @@
updateRoutedBinarySaveEnabled(modalContent); updateRoutedBinarySaveEnabled(modalContent);
} }
// Enables the Save (Replace) button in routed binary mode when either // Enables Save (labeled "Replace" in binary mode) when either a file
// a replacement file is queued OR the filename input has been edited. // is queued OR the filename input has been edited. Rename-only is a
// Mirrors legacy updateSaveEnabled's binary-mode branch. // valid Replace and routes to /files/move below.
function updateRoutedBinarySaveEnabled(modalContent) { function updateRoutedBinarySaveEnabled(modalContent) {
const panel = modalContent.querySelector(".files-editor-binary[data-rel-path]"); const panel = modalContent.querySelector(".files-editor-binary[data-rel-path]");
const saveBtn = modalContent.querySelector(".files-editor-save"); const saveBtn = modalContent.querySelector(".files-editor-save");
@ -303,14 +83,11 @@
saveBtn.disabled = !routedReplacement && !filenameChanged; saveBtn.disabled = !routedReplacement && !filenameChanged;
} }
// Replace-zone drag — delegated for both legacy and routed binary // ---------- delegated handlers (#modal-content only) ----------
// 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) return; if (!zone || !inRoutedEditor(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");
@ -318,95 +95,54 @@
}); });
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) return; if (!zone || !inRoutedEditor(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) return; if (!zone || !inRoutedEditor(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) return; if (f) setRoutedReplacement(document.getElementById("modal-content"), f);
if (isLegacy) {
setQueuedReplacement(f);
} else {
setRoutedReplacement(document.getElementById("modal-content"), f);
}
}); });
// 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) return; if (!input || !inRoutedEditor(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) return; if (f) setRoutedReplacement(document.getElementById("modal-content"), f);
if (isLegacy) {
setQueuedReplacement(f);
} else {
setRoutedReplacement(document.getElementById("modal-content"), f);
}
}); });
// Filename input in routed binary mode: re-evaluate Save enablement // Filename input: re-evaluate Save enablement in routed binary mode
// when the user types a new name (rename-only is a valid Replace). // so rename-only Replace becomes reachable without queueing a file.
document.addEventListener("input", (event) => { document.addEventListener("input", (event) => {
const input = event.target?.closest?.("[data-editor-filename]"); const input = event.target?.closest?.("[data-editor-filename]");
if (!input || !inRoutedEditor(input)) return; if (!input || !inRoutedEditor(input)) return;
const mc = document.getElementById("modal-content"); const mc = document.getElementById("modal-content");
if (!mc || !isRoutedBinaryMode(mc)) return; if (mc && isRoutedBinaryMode(mc)) updateRoutedBinarySaveEnabled(mc);
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;
// Replace-clear (both editors).
const clearBtn = target.closest?.(".files-editor-replace-clear"); const clearBtn = target.closest?.(".files-editor-replace-clear");
if (clearBtn) { if (clearBtn && inRoutedEditor(clearBtn)) {
if (inLegacyEditor(clearBtn)) {
setQueuedReplacement(null);
return;
}
if (inRoutedEditor(clearBtn)) {
setRoutedReplacement(document.getElementById("modal-content"), null); setRoutedReplacement(document.getElementById("modal-content"), null);
return; 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) { if (browseBtn && inRoutedEditor(browseBtn)) {
if (inLegacyEditor(browseBtn)) {
editorEls.replaceInput.click();
return;
}
if (inRoutedEditor(browseBtn)) {
const mc = document.getElementById("modal-content"); const mc = document.getElementById("modal-content");
const fileInput = mc?.querySelector(".files-editor-replace-input"); const fileInput = mc?.querySelector(".files-editor-replace-input");
fileInput?.click(); fileInput?.click();
return; return;
} }
}
// 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 && inRoutedEditor(saveBtn)) {
if (inLegacyEditor(saveBtn)) {
await legacySaveClicked();
return;
}
if (inRoutedEditor(saveBtn)) {
const mc = document.getElementById("modal-content"); const mc = document.getElementById("modal-content");
if (isRoutedBinaryMode(mc)) { if (isRoutedBinaryMode(mc)) {
await routedReplaceClicked(); await routedReplaceClicked();
@ -415,158 +151,22 @@
} }
return; return;
} }
}
// Delete (both editors).
const delBtn = target.closest?.(".files-editor-delete"); const delBtn = target.closest?.(".files-editor-delete");
if (delBtn) { if (delBtn && inRoutedEditor(delBtn)) {
if (inLegacyEditor(delBtn)) {
await legacyDeleteClicked();
return;
}
if (inRoutedEditor(delBtn)) {
await routedDeleteClicked(); await routedDeleteClicked();
return; return;
} }
}
}); });
// Clear routed binary state when the modal closes. Otherwise the next // Clear queued replacement when the modal closes (Esc, backdrop,
// open would still see a "queued" replacement from the prior session. // dismiss button, programmatic closeRouted). Otherwise reopening
// would inherit a stale queued File from the prior session.
document.getElementById("modal-container")?.addEventListener("close", () => { document.getElementById("modal-container")?.addEventListener("close", () => {
routedReplacement = null; routedReplacement = null;
}); });
async function legacySaveClicked() { // ---------- save / replace / delete ----------
const filename = editorEls.filename.value.trim();
if (!filename) return;
if (filename.includes("/")) {
alert("Filename can't contain '/'. Use drag-to-move to relocate.");
return;
}
const folder = editor.folder || "";
const newRel = joinPath(folder, filename);
if (editor.creating) {
// Text-flavor create → /save with no new_path.
const r = await postJson(`${baseUrl}/files/save`, {
path: newRel,
content: getEditorValue(),
});
if (r.ok) {
editorDialog.close();
scheduleRefresh(folder);
} else if (r.status === 409) {
// askConflict lives in the legacy files-overlay.js for Phase A;
// exposed there on __filesOverlay. Phase A Step 3 moves it to
// dialogs.js (still on __filesOverlay), so this call site
// doesn't change in Step 3.
const askConflict = fo.askConflict;
const withCollisionSuffix = fo.withCollisionSuffix;
if (typeof askConflict !== "function" || typeof withCollisionSuffix !== "function") {
alert(`Save failed (HTTP ${r.status}).`);
return;
}
const action = await askConflict(newRel);
if (action === "overwrite") {
const r2 = await postJson(`${baseUrl}/files/save`, {
path: newRel,
content: getEditorValue(),
});
if (r2.ok) {
editorDialog.close();
scheduleRefresh(folder);
} else {
alert(
(r2.body && r2.body.error) ||
`Save failed (HTTP ${r2.status}).`
);
}
} else if (action === "keep-both") {
const altered = withCollisionSuffix(newRel);
const r2 = await postJson(`${baseUrl}/files/save`, {
path: altered,
content: getEditorValue(),
});
if (r2.ok) {
editorDialog.close();
scheduleRefresh(folder);
}
}
} else {
alert((r.body && r.body.error) || `Save failed (HTTP ${r.status}).`);
}
return;
}
const renaming = newRel !== editor.originalPath;
if (editor.mode === "text") {
const payload = {
path: editor.originalPath,
content: getEditorValue(),
};
if (renaming) payload.new_path = newRel;
const r = await postJson(`${baseUrl}/files/save`, payload);
if (r.ok) {
editorDialog.close();
scheduleRefresh(folder);
} else {
alert(
(r.body && r.body.error) || `Save failed (HTTP ${r.status}).`
);
}
return;
}
// Binary mode.
const fd = new FormData();
fd.append("path", editor.originalPath);
fd.append("csrf_token", csrfToken);
if (renaming) fd.append("new_path", newRel);
if (editor.queuedReplacement) {
fd.append("file", editor.queuedReplacement);
const r = await postForm(`${baseUrl}/files/replace`, fd);
if (r.ok) {
editorDialog.close();
scheduleRefresh(folder);
} else {
alert(
(r.body && r.body.error) || `Replace failed (HTTP ${r.status}).`
);
}
} else if (renaming) {
// Rename only via /move (no content change).
const r = await postJson(`${baseUrl}/files/move`, {
src: editor.originalPath,
dst: newRel,
});
if (r.ok) {
editorDialog.close();
scheduleRefresh(folder);
} else {
alert(
(r.body && r.body.error) || `Rename failed (HTTP ${r.status}).`
);
}
}
}
async function legacyDeleteClicked() {
if (!editor.originalPath) return;
if (!confirm(`Delete ${editor.originalPath}?`)) return;
const fd = new FormData();
fd.append("path", editor.originalPath);
fd.append("csrf_token", csrfToken);
const r = await postForm(`${baseUrl}/files/delete`, fd);
if (r.ok) {
editorDialog.close();
scheduleRefresh(parentOf(editor.originalPath));
} else {
alert((r.body && r.body.error) || `Delete failed (HTTP ${r.status}).`);
}
}
// ---------- URL-addressable modal (#modal-content) save / delete ----------
async function routedSaveClicked() { async function routedSaveClicked() {
const modalContent = document.getElementById("modal-content"); const modalContent = document.getElementById("modal-content");
@ -580,12 +180,10 @@
? window.__filesEditor.getValue() ? window.__filesEditor.getValue()
: ta.value; : ta.value;
// is_new mode: relPath is empty; the new file's path comes from // is_new mode: relPath is empty; compose path from data-at-folder
// data-at-folder + filename input. The server route at // + filename. The /save endpoint creates the file when missing.
// /overlays/<id>/files/new sets data-at-folder; the /save endpoint
// creates the file when the path doesn't already exist.
if (!relPath) { if (!relPath) {
if (!editedFilename) return; // empty filename — wait for input if (!editedFilename) return;
const atFolder = ta.dataset.atFolder || ""; const atFolder = ta.dataset.atFolder || "";
const fullPath = atFolder const fullPath = atFolder
? `${atFolder.replace(/\/+$/, "")}/${editedFilename}` ? `${atFolder.replace(/\/+$/, "")}/${editedFilename}`
@ -602,19 +200,15 @@
return; return;
} }
// Edit mode: rename-on-save if the filename input changed. Compose // Edit mode: rename-on-save (sibling rename only — parent stays).
// a sibling rename (parent of relPath + new filename), send
// payload.new_path so the server moves and writes atomically.
const originalLeaf = relPath.split("/").pop() || relPath; const originalLeaf = relPath.split("/").pop() || relPath;
const parent = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/")) : ""; const parent = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/")) : "";
let newPath = null; let newPath = null;
if (editedFilename && editedFilename !== originalLeaf) { if (editedFilename && editedFilename !== originalLeaf) {
newPath = parent ? `${parent}/${editedFilename}` : editedFilename; newPath = parent ? `${parent}/${editedFilename}` : editedFilename;
} }
const payload = { path: relPath, content }; const payload = { path: relPath, content };
if (newPath) payload.new_path = newPath; if (newPath) payload.new_path = newPath;
const r = await postJson(`${baseUrl}/files/save`, payload); const r = await postJson(`${baseUrl}/files/save`, payload);
if (r.ok) { if (r.ok) {
if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted(); if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted();
@ -634,7 +228,6 @@
const relPath = panel.dataset.relPath; const relPath = panel.dataset.relPath;
if (!relPath) return; if (!relPath) return;
// Rename-on-replace: same shape as routedSaveClicked, sibling-only.
const filenameInput = modalContent.querySelector("[data-editor-filename]"); const filenameInput = modalContent.querySelector("[data-editor-filename]");
const editedFilename = filenameInput ? filenameInput.value.trim() : ""; const editedFilename = filenameInput ? filenameInput.value.trim() : "";
const originalLeaf = relPath.split("/").pop() || relPath; const originalLeaf = relPath.split("/").pop() || relPath;
@ -644,9 +237,6 @@
? (parent ? `${parent}/${editedFilename}` : editedFilename) ? (parent ? `${parent}/${editedFilename}` : editedFilename)
: null; : null;
// Two flows: a queued replacement → /files/replace (multipart);
// rename-only (no queued file) → /files/move. Mirrors the legacy
// binary save handler.
if (routedReplacement) { if (routedReplacement) {
const fd = new FormData(); const fd = new FormData();
fd.append("path", relPath); fd.append("path", relPath);
@ -664,10 +254,7 @@
return; return;
} }
if (renaming) { if (renaming) {
const r = await postJson(`${baseUrl}/files/move`, { const r = await postJson(`${baseUrl}/files/move`, { src: relPath, dst: newPath });
src: relPath,
dst: newPath,
});
if (r.ok) { if (r.ok) {
if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted(); if (typeof window.modals?.closeRouted === "function") window.modals.closeRouted();
scheduleRefresh(parentOf(newPath)); scheduleRefresh(parentOf(newPath));
@ -680,9 +267,10 @@
async function routedDeleteClicked() { async function routedDeleteClicked() {
const modalContent = document.getElementById("modal-content"); const modalContent = document.getElementById("modal-content");
if (!modalContent) return; 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 ta = modalContent.querySelector("textarea[data-rel-path]");
if (!ta) return; const panel = modalContent.querySelector(".files-editor-binary[data-rel-path]");
const relPath = ta.dataset.relPath; const relPath = ta?.dataset?.relPath || panel?.dataset?.relPath;
if (!relPath) return; if (!relPath) return;
if (!confirm(`Delete ${relPath}?`)) return; if (!confirm(`Delete ${relPath}?`)) return;
const fd = new FormData(); const fd = new FormData();
@ -700,11 +288,6 @@
// ---------- register action-registry handlers ---------- // ---------- register action-registry handlers ----------
fo.registerHandler("new-file", (path) => { fo.registerHandler("new-file", (path) => {
// Phase B Step 6: create-new-file uses the URL-addressable modal
// (via the new GET /overlays/<id>/files/new?at=<folder> route).
// The legacy openEditorTextNew remains in this file until Step 9
// deletes the legacy dialog block wholesale; it's no longer
// reachable from a user action.
const url = `/overlays/${overlayId}/files/new?at=${encodeURIComponent(path)}`; const url = `/overlays/${overlayId}/files/new?at=${encodeURIComponent(path)}`;
if (typeof window.modals?.openRouted === "function") { if (typeof window.modals?.openRouted === "function") {
window.modals.openRouted(url); window.modals.openRouted(url);
@ -714,19 +297,13 @@
}); });
fo.registerHandler("edit", (path, _actionEl) => { fo.registerHandler("edit", (path, _actionEl) => {
// Phase B Step 8: both editable text and binary files route to the // Both text and binary files use the same edit route — the server
// URL-addressable modal. The server's /files/edit route picks the // picks the template branch based on is_editable(target).
// template branch (text editor vs. binary-replace panel) based on const url = `/overlays/${overlayId}/files/edit?path=${encodeURIComponent(path)}`;
// 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)}`;
if (typeof window.modals?.openRouted === "function") { if (typeof window.modals?.openRouted === "function") {
window.modals.openRouted(editUrl); window.modals.openRouted(url);
} else { } else {
// Graceful fallback if modals.js didn't load — full-page nav window.location.href = url;
// hits the same route and renders the standalone editor page.
window.location.href = editUrl;
} }
}); });
})(); })();

View file

@ -162,68 +162,6 @@
{% endif %} {% endif %}
{% if files_can_edit %} {% if files_can_edit %}
<dialog id="files-editor-modal" class="modal modal-wide" aria-labelledby="files-editor-title">
<div class="modal-header">
<h2 id="files-editor-title" class="files-editor-path"><span class="files-editor-title-text"></span></h2>
<button type="button" class="modal-close" data-inline-modal-close aria-label="Close">&times;</button>
</div>
<div class="modal-body">
<label class="files-editor-field">
<span class="files-field-label">Filename</span>
<input type="text" class="files-editor-filename" data-editor-filename autocomplete="off" spellcheck="false">
</label>
<p class="files-editor-rename-hint" hidden>↻ Save will rename <code class="files-rename-from"></code><code class="files-rename-to"></code>.</p>
<div class="files-editor-text">
<label class="files-editor-field files-editor-language-field">
<span class="files-field-label">Language</span>
<select data-editor-language-select aria-label="Editor language">
<option value="auto">auto (from filename)</option>
<option value="srccfg">srccfg (.cfg)</option>
<option value="bash">bash (.sh)</option>
<option value="plain">plain</option>
</select>
</label>
<label class="files-editor-field">
<span class="files-field-label">Content</span>
<div class="editor-mount" style="--editor-rows: 14"><textarea class="files-editor-content" rows="14" spellcheck="false" data-editor-language="auto"></textarea></div>
</label>
<div class="files-editor-meta muted">
<span class="files-editor-byte-count">UTF-8 · 0 bytes</span>
<span>Ctrl+S to save</span>
</div>
</div>
<div class="files-editor-binary" hidden>
<div class="files-editor-binary-note">
<strong>⛌ Inline editing not available</strong>
· <span class="files-editor-binary-size"></span> · binary content
</div>
<label class="files-field-label files-editor-binary-replace-label">Replace file</label>
<div class="files-editor-replace-zone">
<p class="files-editor-replace-idle">↑ Drop a file here to replace ·
<button type="button" class="link-button files-editor-replace-browse">browse</button> ·
single file only · keeps the filename
</p>
<p class="files-editor-replace-queued" hidden>
<strong class="files-editor-replace-name"></strong> ·
<span class="files-editor-replace-size"></span> ·
<span class="muted">queued</span>
<button type="button" class="link-button files-editor-replace-clear" aria-label="Clear queued replacement"></button>
</p>
<input type="file" class="files-editor-replace-input" hidden>
</div>
</div>
</div>
<div class="modal-footer files-editor-footer">
<button type="button" class="danger-outline files-editor-delete">Delete</button>
<span class="files-editor-footer-spacer"></span>
<a class="button-secondary files-editor-download" href="#" hidden>⬇ Download</a>
<button type="button" class="button-secondary" data-inline-modal-close>Cancel</button>
<button type="button" class="files-editor-save">Save</button>
</div>
</dialog>
<dialog id="files-new-folder-modal" class="modal" aria-labelledby="files-new-folder-title"> <dialog id="files-new-folder-modal" class="modal" aria-labelledby="files-new-folder-title">
<div class="modal-header"> <div class="modal-header">
<h2 id="files-new-folder-title">New folder in <code class="files-new-folder-target"></code></h2> <h2 id="files-new-folder-title">New folder in <code class="files-new-folder-target"></code></h2>

View file

@ -175,6 +175,20 @@ def test_edit_route_404s_for_non_files_overlay(tmp_path, monkeypatch):
# ---------------------------------------------------------------- /files/new # ---------------------------------------------------------------- /files/new
def test_overlay_detail_no_longer_renders_legacy_editor_dialog(tmp_path, monkeypatch):
"""Phase B Step 9: the inline <dialog id="files-editor-modal"> is gone.
All editor flows route through the URL-addressable modal instead."""
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "no-legacy-dialog.db")
response = client.get(f"/overlays/{overlay_id}")
text = response.get_data(as_text=True)
assert response.status_code == 200
assert 'id="files-editor-modal"' not in text
# The other inline dialogs (new-folder, conflict, delete-confirm) are
# still inline — only the editor dialog moved out.
assert 'id="files-new-folder-modal"' in text
def test_new_route_renders_with_empty_content(tmp_path, monkeypatch): def test_new_route_renders_with_empty_content(tmp_path, monkeypatch):
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "new-empty.db") client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "new-empty.db")
response = client.get(f"/overlays/{overlay_id}/files/new") response = client.get(f"/overlays/{overlay_id}/files/new")