feat(files): migrate editor handlers to files-overlay/editor.js

Step 2/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.

editor.js is dual-purpose during Phase A: drives both the legacy
inline #files-editor-modal <dialog> (binary-replace + create-new flows)
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.

What moved:
  * Editor state object, editorEls DOM refs, CM6 bridge (getEditorValue,
    setEditorValue), UI helpers (setEditorTitle, updateByteCount,
    updateRenameHint, updateSaveEnabled, setQueuedReplacement)
  * openEditorTextNew (create-new file flow)
  * openEditorForFile (legacy binary + editable-as-fallback flow)
  * All save/delete/replace handlers — converted from direct-bound on
    editorEls.{saveBtn,deleteBtn} to a single document-level click
    listener that discriminates by ancestor (legacy editorDialog vs.
    URL-addressable #modal-content)
  * Replace-zone dragover/dragleave/drop — direct-bound on
    editorEls.replaceZone → document-level delegation gated on the zone
    being inside the legacy dialog
  * Replace-input change, replace-clear / replace-browse clicks — also
    delegated
  * The previously-separate URL-addressable save/delete delegation
    block (lines 593-664 of the legacy file) collapses into the same
    delegated listeners

What stays direct-bound (per plan escape hatch):
  * input on .files-editor-filename
  * input + keydown on .files-editor-content (Ctrl+S handling)
  * close on the persistent legacy <dialog>
These are high-frequency events on persistent inputs inside the
persistent legacy dialog; delegation would add per-keystroke
selector-matching overhead with no benefit.

Action dispatch: editor.js registers "new-file" and "edit" handlers
into __filesOverlay (set up by core.js). The legacy switch-case in
files-overlay.js's click delegation loses both cases — they're now
dispatched via the registry. The legacy switch still owns new-folder,
zip, and delete (those migrate in Step 3).

Cross-module exposure: askConflict and withCollisionSuffix stay in
files-overlay.js (the upload queue and drag-drop code at lines 857
and 974 still use them) and are exposed on __filesOverlay so editor.js
can call them. They migrate to dialogs.js (askConflict, Step 3) and
uploads.js (withCollisionSuffix, Step 4); the call sites in editor.js
don't change.

Numbers:
  files-overlay.js: 1091 → 669 lines (-422)
  files-overlay/editor.js: 550 lines (new)
  Net: +128 lines; the growth is from the dual-editor delegation
  scaffolding (separate handler functions for legacy vs. routed) and
  module-header comments. The legacy file is now a stub editor section
  comment plus the unmigrated dialogs/uploads/drag-drop blocks.

Verified live on /overlays/2 in Chromium:
  * 3 script tags load in document order (core → editor → legacy)
  * window.__filesOverlay registry now has 10 keys (added askConflict +
    withCollisionSuffix); withCollisionSuffix('foo.txt') = 'foo (1).txt'
  * No console errors on page load or after synthetic actions
  * E2E dispatch check: clicking a "+ new file" action button opens the
    legacy dialog with empty filename + Create save-button label
    (proves core → handleAction → editor.js handler → openEditorTextNew
    chain works)
  * E2E dispatch check: clicking the filename button on an editable
    file sets ?modal=%2Foverlays%2F2%2Ffiles%2Fedit%3Fpath%3D... in the
    URL (proves editor.js's "edit" handler correctly routes editable
    files through window.modals.openRouted)
  * pytest still 573 passed, 1 skipped, 3 deselected

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
mwiegand 2026-05-17 15:42:58 +02:00
parent 052ddcb4f0
commit f094eca074
No known key found for this signature in database
3 changed files with 562 additions and 433 deletions

View file

@ -29,7 +29,6 @@
const csrfToken = const csrfToken =
document.querySelector("meta[name='csrf-token']")?.getAttribute("content") || ""; document.querySelector("meta[name='csrf-token']")?.getAttribute("content") || "";
const editorDialog = document.getElementById("files-editor-modal");
const newFolderDialog = document.getElementById("files-new-folder-modal"); const newFolderDialog = document.getElementById("files-new-folder-modal");
const conflictDialog = document.getElementById("files-conflict-modal"); const conflictDialog = document.getElementById("files-conflict-modal");
const deleteDialog = document.getElementById("files-delete-modal"); const deleteDialog = document.getElementById("files-delete-modal");
@ -210,6 +209,8 @@
conflictDialog.showModal(); conflictDialog.showModal();
}); });
} }
// Exposed for editor.js (Phase A). Moves to dialogs.js in Step 3.
if (window.__filesOverlay) window.__filesOverlay.askConflict = askConflict;
// Attach a path-collision suffix: foo.txt → foo (1).txt // Attach a path-collision suffix: foo.txt → foo (1).txt
function withCollisionSuffix(path) { function withCollisionSuffix(path) {
@ -220,6 +221,8 @@
} }
return path + " (1)"; return path + " (1)";
} }
// Exposed for editor.js (Phase A). Moves to uploads.js in Step 4.
if (window.__filesOverlay) window.__filesOverlay.withCollisionSuffix = withCollisionSuffix;
// ---------- delete modal ------------------------------------------------ // ---------- delete modal ------------------------------------------------
@ -249,419 +252,11 @@
} }
// ---------- editor modal ------------------------------------------------ // ---------- editor modal ------------------------------------------------
//
// Editor state. Only one editor is open at a time. // Migrated to static/js/files-overlay/editor.js (Phase A, Step 2).
const editor = { // This module no longer touches the editor. Helpers it still needs
mode: null, // "text" | "binary" // (askConflict, withCollisionSuffix) are exposed above on
creating: false, // window.__filesOverlay and de-duplicate in Steps 3 and 4.
originalPath: null,
folder: null,
queuedReplacement: null, // File object
};
const editorEls = {
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"),
};
// 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();
}
function openEditorTextNew(folder) {
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) {
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);
}
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();
}
});
editorEls.replaceClear.addEventListener("click", () => setQueuedReplacement(null));
editorEls.replaceBrowse.addEventListener("click", () => editorEls.replaceInput.click());
editorEls.replaceInput.addEventListener("change", () => {
const f = editorEls.replaceInput.files && editorEls.replaceInput.files[0];
if (f) setQueuedReplacement(f);
});
editorEls.replaceZone.addEventListener("dragover", (event) => {
if (Array.from(event.dataTransfer.types).includes("Files")) {
event.preventDefault();
editorEls.replaceZone.classList.add("is-drop-target");
}
});
editorEls.replaceZone.addEventListener("dragleave", () => {
editorEls.replaceZone.classList.remove("is-drop-target");
});
editorEls.replaceZone.addEventListener("drop", (event) => {
if (!Array.from(event.dataTransfer.types).includes("Files")) return;
event.preventDefault();
editorEls.replaceZone.classList.remove("is-drop-target");
const f = event.dataTransfer.files && event.dataTransfer.files[0];
if (f) setQueuedReplacement(f);
});
editorDialog.addEventListener("close", () => {
setQueuedReplacement(null);
});
editorEls.saveBtn.addEventListener("click", async () => {
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) {
const action = await askConflict(newRel);
if (action === "overwrite") {
// Re-call /save (no overwrite flag — /save just writes); skip
// the conflict by writing in-place which is what users want.
// First delete the colliding entry to avoid the implicit
// "destination is not a file" branch when it's a directory.
// For files, a plain /save overwrite is fine.
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}).`
);
}
}
});
editorEls.deleteBtn.addEventListener("click", async () => {
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}).`);
}
});
// ---------- modal-content save / delete (URL-addressable modal) ----------
// The new server-rendered editor (loaded into #modal-content) has its own
// .files-editor-save and .files-editor-delete buttons. Those are not the
// same elements as editorEls.saveBtn / editorEls.deleteBtn (which live in
// the old inline dialog still present for create-new-file / binary flows).
// Use event delegation so the handlers fire on dynamically swapped content.
document.addEventListener("click", async (event) => {
const modalContent = document.getElementById("modal-content");
if (!modalContent) return;
const saveBtn = event.target.closest(".files-editor-save");
if (saveBtn && modalContent.contains(saveBtn)) {
const ta = modalContent.querySelector("textarea[data-rel-path]");
if (!ta) return;
const relPath = ta.dataset.relPath;
if (!relPath) return;
const content = (window.__filesEditor && window.__filesEditor.getValue)
? window.__filesEditor.getValue()
: ta.value;
// Rename-on-save: if the user edited the filename input, compose the
// new path (sibling rename only — joining parent of relPath with the
// new filename). Send payload.new_path so the server moves and writes
// atomically. Matches the legacy save handler's contract.
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("/")) : "";
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) {
// Conflict (destination already exists) — show error and keep modal
// open so the user can pick a different filename.
alert(r.rawText || `Conflict: destination already exists.`);
return;
} else {
alert((r.body && r.body.error) || r.rawText || `Save failed (HTTP ${r.status}).`);
}
return;
}
const deleteBtn = event.target.closest(".files-editor-delete");
if (deleteBtn && modalContent.contains(deleteBtn)) {
const ta = modalContent.querySelector("textarea[data-rel-path]");
if (!ta) return;
const relPath = ta.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}).`);
}
return;
}
});
// ---------- new-folder modal -------------------------------------------- // ---------- new-folder modal --------------------------------------------
@ -1059,29 +654,12 @@
if (!manager.contains(action)) return; if (!manager.contains(action)) return;
const op = action.dataset.action; const op = action.dataset.action;
const path = action.dataset.targetPath || ""; const path = action.dataset.targetPath || "";
if (op === "new-file") { // new-file + edit: dispatched via __filesOverlay registry (editor.js).
openEditorTextNew(path); if (op === "new-folder") {
} else if (op === "new-folder") {
openNewFolder(path); openNewFolder(path);
} else if (op === "zip") { } else if (op === "zip") {
const url = `${baseUrl}/files/download_zip?path=${encodeURIComponent(path)}`; const url = `${baseUrl}/files/download_zip?path=${encodeURIComponent(path)}`;
window.location.href = url; window.location.href = url;
} else if (op === "edit") {
const editable = action.dataset.editable === "1";
if (editable) {
// Editable text files: open via URL-addressable modal.
const editUrl = `/overlays/${overlayId}/files/edit?path=${encodeURIComponent(path)}`;
if (typeof window.modals?.openRouted === "function") {
window.modals.openRouted(editUrl);
} else {
// Graceful fallback if modals.js didn't load — full-page navigation
// still hits the same route and renders the standalone editor page.
window.location.href = editUrl;
}
} else {
// Binary files: keep old inline dialog (binary replace deferred from pilot).
openEditorForFile(path, false);
}
} else if (op === "delete") { } else if (op === "delete") {
const kind = action.dataset.rowKind; const kind = action.dataset.rowKind;
const name = action.dataset.rowName || basename(path); const name = action.dataset.rowName || basename(path);

View file

@ -0,0 +1,550 @@
// files-overlay/editor.js — Phase A, Step 2.
//
// 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.
//
// Action-registry dispatch (registered into __filesOverlay):
// * "new-file" → openEditorTextNew(folder) (legacy dialog)
// * "edit" → URL-addressable modal for editable files;
// openEditorForFile(path, false) for binary.
//
// 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.
//
// 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).
(function () {
"use strict";
const fo = window.__filesOverlay;
if (!fo) return;
const { overlayId, baseUrl, csrfToken } = fo;
const {
joinPath, parentOf, basename, humanSize,
fetchJson, 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");
// ---------- legacy editor state + DOM refs ----------
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);
}
// Replace-zone drag (legacy binary mode). Document-level delegation
// gated on the zone being inside the legacy dialog.
document.addEventListener("dragover", (event) => {
const zone = event.target?.closest?.(".files-editor-replace-zone");
if (!zone || !inLegacyEditor(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 || !inLegacyEditor(zone)) return;
zone.classList.remove("is-drop-target");
});
document.addEventListener("drop", (event) => {
const zone = event.target?.closest?.(".files-editor-replace-zone");
if (!zone || !inLegacyEditor(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) setQueuedReplacement(f);
});
// Replace-input change (browse-fallback): low frequency, delegated.
document.addEventListener("change", (event) => {
const input = event.target?.closest?.(".files-editor-replace-input");
if (!input || !inLegacyEditor(input)) return;
const f = input.files && input.files[0];
if (f) setQueuedReplacement(f);
});
document.addEventListener("click", async (event) => {
const target = event.target;
if (!target) return;
// Legacy: replace-clear button.
const clearBtn = target.closest?.(".files-editor-replace-clear");
if (clearBtn && inLegacyEditor(clearBtn)) {
setQueuedReplacement(null);
return;
}
// Legacy: replace-browse button → trigger hidden file input.
const browseBtn = target.closest?.(".files-editor-replace-browse");
if (browseBtn && inLegacyEditor(browseBtn)) {
editorEls.replaceInput.click();
return;
}
// Save (both editors).
const saveBtn = target.closest?.(".files-editor-save");
if (saveBtn) {
if (inLegacyEditor(saveBtn)) {
await legacySaveClicked();
return;
}
if (inRoutedEditor(saveBtn)) {
await routedSaveClicked();
return;
}
}
// Delete (both editors).
const delBtn = target.closest?.(".files-editor-delete");
if (delBtn) {
if (inLegacyEditor(delBtn)) {
await legacyDeleteClicked();
return;
}
if (inRoutedEditor(delBtn)) {
await routedDeleteClicked();
return;
}
}
});
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 ----------
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;
if (!relPath) return;
const content = (window.__filesEditor && window.__filesEditor.getValue)
? window.__filesEditor.getValue()
: ta.value;
// Rename-on-save: if the user edited the filename input, compose the
// new path (sibling rename only — joining parent of relPath with the
// new filename). Send payload.new_path so the server moves and writes
// atomically. Matches the legacy save handler's contract.
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("/")) : "";
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 routedDeleteClicked() {
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;
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) => openEditorTextNew(path));
fo.registerHandler("edit", (path, actionEl) => {
const editable = actionEl?.dataset?.editable === "1";
if (editable) {
// Editable text files: open via URL-addressable modal.
const editUrl = `/overlays/${overlayId}/files/edit?path=${encodeURIComponent(path)}`;
if (typeof window.modals?.openRouted === "function") {
window.modals.openRouted(editUrl);
} 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;
}
return;
}
// Binary files: legacy inline dialog (Phase B migrates this to
// URL-addressable too).
openEditorForFile(path, false);
});
})();

View file

@ -283,6 +283,7 @@
</aside> </aside>
<script src="{{ url_for('static', filename='js/files-overlay/core.js') }}" defer></script> <script src="{{ url_for('static', filename='js/files-overlay/core.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/files-overlay/editor.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/files-overlay.js') }}" defer></script> <script src="{{ url_for('static', filename='js/files-overlay.js') }}" defer></script>
{% endif %} {% endif %}
{% endblock %} {% endblock %}