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:
parent
e75280f780
commit
10f93b863b
3 changed files with 89 additions and 560 deletions
|
|
@ -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
|
||||
// legacy inline #files-editor-modal <dialog> (binary-replace + create-
|
||||
// new-file) and the URL-addressable modal swapped into #modal-content
|
||||
// (editable text files). Phase B migrates the legacy flows to URL-
|
||||
// addressable too and removes the legacy branches here.
|
||||
// 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)
|
||||
//
|
||||
// Action-registry dispatch (registered into __filesOverlay):
|
||||
// * "new-file" → openEditorTextNew(folder) (legacy dialog)
|
||||
// * "edit" → URL-addressable modal for editable files;
|
||||
// openEditorForFile(path, false) for binary.
|
||||
// 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
|
||||
//
|
||||
// Save / delete: a single document-level click listener handles both
|
||||
// the legacy dialog and the URL-addressable modal, discriminated by
|
||||
// which ancestor contains the click target.
|
||||
// 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.
|
||||
//
|
||||
// Direct-bound (per plan, escape hatch): filename `input`, content
|
||||
// textarea `input` + `keydown` — high-frequency events on persistent
|
||||
// inputs inside the persistent legacy dialog.
|
||||
//
|
||||
// Cross-module deps consumed via window.__filesOverlay (set by
|
||||
// core.js): manager, overlayId, baseUrl, csrfToken, helpers, plus
|
||||
// askConflict (set by the legacy files-overlay.js, used here for save
|
||||
// 409-conflict handling).
|
||||
// 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";
|
||||
|
|
@ -32,239 +33,18 @@
|
|||
|
||||
const { overlayId, baseUrl, csrfToken } = fo;
|
||||
const {
|
||||
joinPath, parentOf, basename, humanSize,
|
||||
fetchJson, postJson, postForm, scheduleRefresh,
|
||||
parentOf, humanSize,
|
||||
postJson, postForm, scheduleRefresh,
|
||||
} = fo.helpers;
|
||||
|
||||
// Legacy inline dialog — present in Phase A. Phase B deletes it; the
|
||||
// legacy-branch handlers below short-circuit on its absence.
|
||||
const editorDialog = document.getElementById("files-editor-modal");
|
||||
// ---------- routed binary-replace state ----------
|
||||
|
||||
// ---------- 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) {
|
||||
const mc = document.getElementById("modal-content");
|
||||
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) {
|
||||
return !!modalContent?.querySelector(".files-editor-binary");
|
||||
}
|
||||
|
|
@ -287,9 +67,9 @@
|
|||
updateRoutedBinarySaveEnabled(modalContent);
|
||||
}
|
||||
|
||||
// Enables the Save (Replace) button in routed binary mode when either
|
||||
// a replacement file is queued OR the filename input has been edited.
|
||||
// Mirrors legacy updateSaveEnabled's binary-mode branch.
|
||||
// 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");
|
||||
|
|
@ -303,14 +83,11 @@
|
|||
saveBtn.disabled = !routedReplacement && !filenameChanged;
|
||||
}
|
||||
|
||||
// Replace-zone drag — delegated for both legacy and routed binary
|
||||
// modes. The zone lives inside #files-editor-modal (legacy) or
|
||||
// #modal-content (routed); the in-{legacy,routed}Editor checks
|
||||
// dispatch to the right reaction.
|
||||
// ---------- delegated handlers (#modal-content only) ----------
|
||||
|
||||
document.addEventListener("dragover", (event) => {
|
||||
const zone = event.target?.closest?.(".files-editor-replace-zone");
|
||||
if (!zone) return;
|
||||
if (!inLegacyEditor(zone) && !inRoutedEditor(zone)) return;
|
||||
if (!zone || !inRoutedEditor(zone)) return;
|
||||
if (Array.from(event.dataTransfer.types).includes("Files")) {
|
||||
event.preventDefault();
|
||||
zone.classList.add("is-drop-target");
|
||||
|
|
@ -318,95 +95,54 @@
|
|||
});
|
||||
document.addEventListener("dragleave", (event) => {
|
||||
const zone = event.target?.closest?.(".files-editor-replace-zone");
|
||||
if (!zone) return;
|
||||
if (!inLegacyEditor(zone) && !inRoutedEditor(zone)) return;
|
||||
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) return;
|
||||
const isLegacy = inLegacyEditor(zone);
|
||||
const isRouted = !isLegacy && inRoutedEditor(zone);
|
||||
if (!isLegacy && !isRouted) return;
|
||||
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) return;
|
||||
if (isLegacy) {
|
||||
setQueuedReplacement(f);
|
||||
} else {
|
||||
setRoutedReplacement(document.getElementById("modal-content"), f);
|
||||
}
|
||||
if (f) setRoutedReplacement(document.getElementById("modal-content"), f);
|
||||
});
|
||||
|
||||
// Replace-input change (browse-fallback): low frequency, delegated
|
||||
// for both editors.
|
||||
document.addEventListener("change", (event) => {
|
||||
const input = event.target?.closest?.(".files-editor-replace-input");
|
||||
if (!input) return;
|
||||
const isLegacy = inLegacyEditor(input);
|
||||
const isRouted = !isLegacy && inRoutedEditor(input);
|
||||
if (!isLegacy && !isRouted) return;
|
||||
if (!input || !inRoutedEditor(input)) return;
|
||||
const f = input.files && input.files[0];
|
||||
if (!f) return;
|
||||
if (isLegacy) {
|
||||
setQueuedReplacement(f);
|
||||
} else {
|
||||
setRoutedReplacement(document.getElementById("modal-content"), f);
|
||||
}
|
||||
if (f) setRoutedReplacement(document.getElementById("modal-content"), f);
|
||||
});
|
||||
|
||||
// Filename input in routed binary mode: re-evaluate Save enablement
|
||||
// when the user types a new name (rename-only is a valid Replace).
|
||||
// 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)) return;
|
||||
updateRoutedBinarySaveEnabled(mc);
|
||||
if (mc && isRoutedBinaryMode(mc)) updateRoutedBinarySaveEnabled(mc);
|
||||
});
|
||||
|
||||
document.addEventListener("click", async (event) => {
|
||||
const target = event.target;
|
||||
if (!target) return;
|
||||
|
||||
// Replace-clear (both editors).
|
||||
const clearBtn = target.closest?.(".files-editor-replace-clear");
|
||||
if (clearBtn) {
|
||||
if (inLegacyEditor(clearBtn)) {
|
||||
setQueuedReplacement(null);
|
||||
return;
|
||||
}
|
||||
if (inRoutedEditor(clearBtn)) {
|
||||
if (clearBtn && inRoutedEditor(clearBtn)) {
|
||||
setRoutedReplacement(document.getElementById("modal-content"), null);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Replace-browse (both editors) → trigger hidden file input.
|
||||
const browseBtn = target.closest?.(".files-editor-replace-browse");
|
||||
if (browseBtn) {
|
||||
if (inLegacyEditor(browseBtn)) {
|
||||
editorEls.replaceInput.click();
|
||||
return;
|
||||
}
|
||||
if (inRoutedEditor(browseBtn)) {
|
||||
if (browseBtn && inRoutedEditor(browseBtn)) {
|
||||
const mc = document.getElementById("modal-content");
|
||||
const fileInput = mc?.querySelector(".files-editor-replace-input");
|
||||
fileInput?.click();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Save (both editors). Routed mode further branches on text vs.
|
||||
// binary panel.
|
||||
const saveBtn = target.closest?.(".files-editor-save");
|
||||
if (saveBtn) {
|
||||
if (inLegacyEditor(saveBtn)) {
|
||||
await legacySaveClicked();
|
||||
return;
|
||||
}
|
||||
if (inRoutedEditor(saveBtn)) {
|
||||
if (saveBtn && inRoutedEditor(saveBtn)) {
|
||||
const mc = document.getElementById("modal-content");
|
||||
if (isRoutedBinaryMode(mc)) {
|
||||
await routedReplaceClicked();
|
||||
|
|
@ -415,158 +151,22 @@
|
|||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Delete (both editors).
|
||||
const delBtn = target.closest?.(".files-editor-delete");
|
||||
if (delBtn) {
|
||||
if (inLegacyEditor(delBtn)) {
|
||||
await legacyDeleteClicked();
|
||||
return;
|
||||
}
|
||||
if (inRoutedEditor(delBtn)) {
|
||||
if (delBtn && inRoutedEditor(delBtn)) {
|
||||
await routedDeleteClicked();
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Clear routed binary state when the modal closes. Otherwise the next
|
||||
// open would still see a "queued" replacement from the prior session.
|
||||
// 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;
|
||||
});
|
||||
|
||||
async function legacySaveClicked() {
|
||||
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 ----------
|
||||
// ---------- save / replace / delete ----------
|
||||
|
||||
async function routedSaveClicked() {
|
||||
const modalContent = document.getElementById("modal-content");
|
||||
|
|
@ -580,12 +180,10 @@
|
|||
? window.__filesEditor.getValue()
|
||||
: ta.value;
|
||||
|
||||
// is_new mode: relPath is empty; the new file's path comes from
|
||||
// data-at-folder + filename input. The server route at
|
||||
// /overlays/<id>/files/new sets data-at-folder; the /save endpoint
|
||||
// creates the file when the path doesn't already exist.
|
||||
// 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; // empty filename — wait for input
|
||||
if (!editedFilename) return;
|
||||
const atFolder = ta.dataset.atFolder || "";
|
||||
const fullPath = atFolder
|
||||
? `${atFolder.replace(/\/+$/, "")}/${editedFilename}`
|
||||
|
|
@ -602,19 +200,15 @@
|
|||
return;
|
||||
}
|
||||
|
||||
// Edit mode: rename-on-save if the filename input changed. Compose
|
||||
// a sibling rename (parent of relPath + new filename), send
|
||||
// payload.new_path so the server moves and writes atomically.
|
||||
// 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();
|
||||
|
|
@ -634,7 +228,6 @@
|
|||
const relPath = panel.dataset.relPath;
|
||||
if (!relPath) return;
|
||||
|
||||
// Rename-on-replace: same shape as routedSaveClicked, sibling-only.
|
||||
const filenameInput = modalContent.querySelector("[data-editor-filename]");
|
||||
const editedFilename = filenameInput ? filenameInput.value.trim() : "";
|
||||
const originalLeaf = relPath.split("/").pop() || relPath;
|
||||
|
|
@ -644,9 +237,6 @@
|
|||
? (parent ? `${parent}/${editedFilename}` : editedFilename)
|
||||
: null;
|
||||
|
||||
// Two flows: a queued replacement → /files/replace (multipart);
|
||||
// rename-only (no queued file) → /files/move. Mirrors the legacy
|
||||
// binary save handler.
|
||||
if (routedReplacement) {
|
||||
const fd = new FormData();
|
||||
fd.append("path", relPath);
|
||||
|
|
@ -664,10 +254,7 @@
|
|||
return;
|
||||
}
|
||||
if (renaming) {
|
||||
const r = await postJson(`${baseUrl}/files/move`, {
|
||||
src: relPath,
|
||||
dst: newPath,
|
||||
});
|
||||
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));
|
||||
|
|
@ -680,9 +267,10 @@
|
|||
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]");
|
||||
if (!ta) return;
|
||||
const relPath = ta.dataset.relPath;
|
||||
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();
|
||||
|
|
@ -700,11 +288,6 @@
|
|||
// ---------- register action-registry handlers ----------
|
||||
|
||||
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)}`;
|
||||
if (typeof window.modals?.openRouted === "function") {
|
||||
window.modals.openRouted(url);
|
||||
|
|
@ -714,19 +297,13 @@
|
|||
});
|
||||
|
||||
fo.registerHandler("edit", (path, _actionEl) => {
|
||||
// Phase B Step 8: both editable text and binary files route to the
|
||||
// URL-addressable modal. The server's /files/edit route picks the
|
||||
// template branch (text editor vs. binary-replace panel) based on
|
||||
// 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)}`;
|
||||
// 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(editUrl);
|
||||
window.modals.openRouted(url);
|
||||
} else {
|
||||
// Graceful fallback if modals.js didn't load — full-page nav
|
||||
// hits the same route and renders the standalone editor page.
|
||||
window.location.href = editUrl;
|
||||
window.location.href = url;
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
|
|
|||
|
|
@ -162,68 +162,6 @@
|
|||
{% endif %}
|
||||
|
||||
{% 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">×</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">
|
||||
<div class="modal-header">
|
||||
<h2 id="files-new-folder-title">New folder in <code class="files-new-folder-target">…</code></h2>
|
||||
|
|
|
|||
|
|
@ -175,6 +175,20 @@ def test_edit_route_404s_for_non_files_overlay(tmp_path, monkeypatch):
|
|||
# ---------------------------------------------------------------- /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):
|
||||
client, overlay_id = _auth_client_with_files_overlay(tmp_path, monkeypatch, "new-empty.db")
|
||||
response = client.get(f"/overlays/{overlay_id}/files/new")
|
||||
|
|
|
|||
Loading…
Reference in a new issue