feat(files): migrate dialogs (new-folder, delete, conflict) to dialogs.js

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

dialogs.js owns the three inline <dialog> modals that surround the
file manager:
  * #files-new-folder-modal — "+ folder" / mkdir prompt
  * #files-delete-modal — delete-confirm for files and folders
  * #files-conflict-modal — overwrite / keep-both / cancel choice

Pattern change: the legacy file used clone-and-rebind
(replaceWith(cloneNode(true)) + fresh addEventListener) to drop stale
state-bearing click listeners between dialog opens. dialogs.js replaces
that with a single delegated listener per dialog, reading per-dialog
state from module-scope nullable variables (conflictState,
deleteState, newFolderState). The state is set when the dialog opens
and cleared on the 'close' event so dismissals don't leave stale
references. Listener attaches once per page load.

Dialog opens go through window.modals.openInline()/closeInline()
instead of dialog.showModal()/close() directly, completing the inline-
modal convention from commit c51089d.

askConflict now resolves to "cancel" on any dismissal (Esc, backdrop,
programmatic close) thanks to the 'close' event handler — the legacy
version left the promise pending forever in those paths. Verified
live: closeInline() on an open conflict dialog resolves the pending
askConflict promise to "cancel".

Action-registry dispatch: dialogs.js registers "new-folder" and
"delete" handlers into __filesOverlay. Combined with editor.js's
registration of "new-file" and "edit" (Step 2), only "zip" remains in
the legacy click switch (pure URL navigation, no module dependency).

Cross-module exposure: askConflict moves from files-overlay.js to
dialogs.js; both set __filesOverlay.askConflict, but dialogs.js wins
by document order (it loads before legacy via the <script defer>
ordering in overlay_detail.html). The legacy upload + drag-drop call
sites switch from local askConflict() to window.__filesOverlay
.askConflict() — same shape, different lookup.

The orphaned newFolderDialog / conflictDialog / deleteDialog
declarations at the top of legacy are deleted; legacy no longer holds
references to those elements.

Numbers:
  files-overlay.js: 669 → 589 lines (-80)
  files-overlay/dialogs.js: 212 lines (new)
  Net: +132 lines. Growth is from the delegation/state-management
  scaffolding and module-header comments. The delete went lighter
  than the plan's ~150-line estimate because the new code is more
  carefully structured (less duplication across the 3 dialogs).

Verified live on /overlays/2 in Chromium:
  * 4 script tags load in order (core → editor → dialogs → legacy)
  * Registry has 10 keys; askConflict + withCollisionSuffix still set
  * "+ new folder" on overlay root → new-folder dialog opens with
    empty name input and "/" target label, closes cleanly
  * "✕" on a file row → delete-confirm dialog opens with the file's
    name displayed, closes cleanly
  * askConflict('a/b.txt') → conflict dialog opens with path shown
    - close via window.modals.closeInline() → resolves "cancel"
    - click [data-files-conflict-action="overwrite"] → resolves "overwrite"
    - click [data-files-conflict-action="keep-both"] → resolves "keep-both"
  * No console errors throughout
  * 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:51:47 +02:00
parent f094eca074
commit 307df9c23a
No known key found for this signature in database
3 changed files with 235 additions and 102 deletions

View file

@ -29,9 +29,6 @@
const csrfToken = const csrfToken =
document.querySelector("meta[name='csrf-token']")?.getAttribute("content") || ""; document.querySelector("meta[name='csrf-token']")?.getAttribute("content") || "";
const newFolderDialog = document.getElementById("files-new-folder-modal");
const conflictDialog = document.getElementById("files-conflict-modal");
const deleteDialog = document.getElementById("files-delete-modal");
const uploadsPanel = document.querySelector(".files-uploads"); const uploadsPanel = document.querySelector(".files-uploads");
const uploadsList = uploadsPanel?.querySelector(".files-uploads-list"); const uploadsList = uploadsPanel?.querySelector(".files-uploads-list");
const uploadsClearBtn = uploadsPanel?.querySelector(".files-uploads-clear"); const uploadsClearBtn = uploadsPanel?.querySelector(".files-uploads-clear");
@ -194,23 +191,11 @@
} }
// ---------- conflict modal ---------------------------------------------- // ---------- conflict modal ----------------------------------------------
//
function askConflict(path) { // Migrated to static/js/files-overlay/dialogs.js (Phase A, Step 3).
return new Promise((resolve) => { // askConflict is now exposed there on window.__filesOverlay. The
conflictDialog.querySelector(".files-conflict-path").textContent = path; // legacy upload + drag-drop code below reads it via __filesOverlay
const handler = (event) => { // at call time.
const action = event.target?.dataset?.filesConflictAction;
if (!action) return;
conflictDialog.removeEventListener("click", handler);
conflictDialog.close();
resolve(action);
};
conflictDialog.addEventListener("click", handler);
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) {
@ -225,31 +210,10 @@
if (window.__filesOverlay) window.__filesOverlay.withCollisionSuffix = withCollisionSuffix; if (window.__filesOverlay) window.__filesOverlay.withCollisionSuffix = withCollisionSuffix;
// ---------- delete modal ------------------------------------------------ // ---------- delete modal ------------------------------------------------
//
function openDelete(targetPath, kind, name) { // Migrated to static/js/files-overlay/dialogs.js (Phase A, Step 3).
deleteDialog.querySelector(".files-delete-name").textContent = name; // Now dispatched via the "delete" action-registry handler that
const errEl = deleteDialog.querySelector(".files-delete-error"); // dialogs.js registers.
errEl.hidden = true;
errEl.textContent = "";
const confirmBtn = deleteDialog.querySelector(".files-delete-confirm");
const onConfirm = async () => {
const fd = new FormData();
fd.append("path", targetPath);
fd.append("csrf_token", csrfToken);
const r = await postForm(`${baseUrl}/files/delete`, fd);
if (r.ok) {
deleteDialog.close();
scheduleRefresh(parentOf(targetPath));
} else {
errEl.hidden = false;
errEl.textContent = (r.body && r.body.error) || `Delete failed (HTTP ${r.status}).`;
}
};
confirmBtn.replaceWith(confirmBtn.cloneNode(true));
deleteDialog.querySelector(".files-delete-confirm").addEventListener("click", onConfirm);
deleteDialog.showModal();
}
// ---------- editor modal ------------------------------------------------ // ---------- editor modal ------------------------------------------------
// //
@ -259,53 +223,10 @@
// window.__filesOverlay and de-duplicate in Steps 3 and 4. // window.__filesOverlay and de-duplicate in Steps 3 and 4.
// ---------- new-folder modal -------------------------------------------- // ---------- new-folder modal --------------------------------------------
//
function openNewFolder(targetFolder) { // Migrated to static/js/files-overlay/dialogs.js (Phase A, Step 3).
const folder = targetFolder || ""; // Now dispatched via the "new-folder" action-registry handler that
newFolderDialog.querySelector(".files-new-folder-target").textContent = // dialogs.js registers.
folder ? folder + "/" : "/";
const input = newFolderDialog.querySelector(".files-new-folder-name");
const errEl = newFolderDialog.querySelector(".files-new-folder-error");
input.value = "";
errEl.hidden = true;
errEl.textContent = "";
const createBtn = newFolderDialog.querySelector(".files-new-folder-create");
const fresh = createBtn.cloneNode(true);
createBtn.replaceWith(fresh);
fresh.addEventListener("click", async () => {
const name = input.value.trim().replace(/^\/+|\/+$/g, "");
if (!name) return;
const fullPath = joinPath(folder, name);
const r = await postJson(`${baseUrl}/files/mkdir`, { path: fullPath });
if (r.ok) {
newFolderDialog.close();
scheduleRefresh(folder);
} else {
errEl.hidden = false;
errEl.textContent =
(r.body && r.body.error) ||
(r.status === 409
? "A file or folder with that name already exists."
: `Failed (HTTP ${r.status}).`);
}
});
newFolderDialog.showModal();
setTimeout(() => input.focus(), 0);
}
// Enter on the new-folder input submits — bound once at module init so
// it survives multiple openings of the dialog. (A previous version used
// `{once: true}` inside openNewFolder, which was consumed by the first
// letter the user typed and never saw Enter.)
newFolderDialog
.querySelector(".files-new-folder-name")
.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
event.preventDefault();
newFolderDialog.querySelector(".files-new-folder-create")?.click();
});
// ---------- upload queue + progress panel ------------------------------- // ---------- upload queue + progress panel -------------------------------
@ -449,7 +370,9 @@
} else if (xhr.status === 409 && !overwriteFlag) { } else if (xhr.status === 409 && !overwriteFlag) {
setRowState(item, "conflict"); setRowState(item, "conflict");
const collidingPath = joinPath(item.targetFolder || "", item.relative); const collidingPath = joinPath(item.targetFolder || "", item.relative);
askConflict(collidingPath).then((action) => { // askConflict moved to dialogs.js in Step 3 — reach it via the
// shared registry rather than a closure-scope reference.
window.__filesOverlay.askConflict(collidingPath).then((action) => {
if (action === "overwrite") { if (action === "overwrite") {
runUpload(item, true); runUpload(item, true);
} else if (action === "keep-both") { } else if (action === "keep-both") {
@ -566,7 +489,8 @@
scheduleRefresh(parentOf(src)); scheduleRefresh(parentOf(src));
if (parentOf(src) !== dropFolder) scheduleRefresh(dropFolder); if (parentOf(src) !== dropFolder) scheduleRefresh(dropFolder);
} else if (r.status === 409) { } else if (r.status === 409) {
const action = await askConflict(dst); // askConflict moved to dialogs.js in Step 3 — see Edit above.
const action = await window.__filesOverlay.askConflict(dst);
if (action === "overwrite") { if (action === "overwrite") {
const r2 = await postJson(`${baseUrl}/files/move`, { src, dst, overwrite: true }); const r2 = await postJson(`${baseUrl}/files/move`, { src, dst, overwrite: true });
if (r2.ok) { if (r2.ok) {
@ -654,16 +578,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 || "";
// new-file + edit: dispatched via __filesOverlay registry (editor.js). // new-file + edit dispatched via registry (editor.js); new-folder +
if (op === "new-folder") { // delete dispatched via registry (dialogs.js). Only "zip" remains
openNewFolder(path); // here — it's pure URL navigation with no module dependency.
} else if (op === "zip") { 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 === "delete") {
const kind = action.dataset.rowKind;
const name = action.dataset.rowName || basename(path);
openDelete(path, kind, name);
} }
}); });
})(); })();

View file

@ -0,0 +1,212 @@
// files-overlay/dialogs.js — Phase A, Step 3.
//
// Owns the three inline dialogs that surround the file manager:
// * #files-new-folder-modal — "+ folder" / mkdir prompt
// * #files-delete-modal — delete-confirm for files and folders
// * #files-conflict-modal — overwrite / keep-both / cancel choice
// shown when save / upload / move hits 409
//
// Dispatch:
// * "new-folder" registered into __filesOverlay → openNewFolder(path)
// * "delete" registered into __filesOverlay → openDelete(path,
// kind, name) — kind + name read from actionEl
// dataset.
//
// askConflict(path) → Promise<"overwrite" | "keep-both" | "cancel">
// is exposed on __filesOverlay so legacy upload / drag-drop callers in
// files-overlay.js (lines runUpload at 452 and the drop handler at
// 569) can still reach it after the function moved out of their file.
// editor.js's save 409-conflict path also goes through this same
// exposure.
//
// All three dialogs use a single delegated click listener per dialog,
// reading per-dialog state from module-scope. The clone-and-rebind
// pattern from the legacy file (replaceWith(cloneNode(true)) to drop
// stale listeners) is gone — the listener is attached once and reads
// freshly-set state each time the dialog opens.
//
// Dialogs open through window.modals.openInline / closeInline (the
// consolidated modal API at static/js/modals.js) rather than calling
// dialog.showModal() / close() directly, completing the inline-modal
// convention from commit c51089d.
(function () {
"use strict";
const fo = window.__filesOverlay;
if (!fo) return;
const { baseUrl, csrfToken } = fo;
const { joinPath, parentOf, basename, postJson, postForm, scheduleRefresh } = fo.helpers;
const newFolderDialog = document.getElementById("files-new-folder-modal");
const deleteDialog = document.getElementById("files-delete-modal");
const conflictDialog = document.getElementById("files-conflict-modal");
function openModal(dialog) {
if (window.modals && typeof window.modals.openInline === "function") {
window.modals.openInline(dialog);
} else {
dialog.showModal();
}
}
function closeModal(dialog) {
if (window.modals && typeof window.modals.closeInline === "function") {
window.modals.closeInline(dialog);
} else {
dialog.close();
}
}
// ---------- conflict modal ----------------------------------------------
// While a conflict dialog is open, conflictState.resolve is the
// pending Promise resolver. The delegated click handler calls it and
// nulls it so subsequent clicks don't double-resolve.
let conflictState = null;
function askConflict(path) {
return new Promise((resolve) => {
if (!conflictDialog) {
resolve("cancel");
return;
}
conflictDialog.querySelector(".files-conflict-path").textContent = path;
conflictState = { resolve };
openModal(conflictDialog);
});
}
fo.askConflict = askConflict;
if (conflictDialog) {
conflictDialog.addEventListener("click", (event) => {
const action = event.target?.dataset?.filesConflictAction;
if (!action) return;
const state = conflictState;
conflictState = null;
closeModal(conflictDialog);
if (state && typeof state.resolve === "function") {
state.resolve(action);
}
});
// If the user dismisses the dialog any other way (Esc, backdrop,
// programmatic close), resolve as "cancel" so callers don't hang.
conflictDialog.addEventListener("close", () => {
const state = conflictState;
conflictState = null;
if (state && typeof state.resolve === "function") {
state.resolve("cancel");
}
});
}
// ---------- delete-confirm modal ----------------------------------------
let deleteState = null;
function openDelete(targetPath, kind, name) {
if (!deleteDialog) return;
deleteDialog.querySelector(".files-delete-name").textContent = name;
const errEl = deleteDialog.querySelector(".files-delete-error");
errEl.hidden = true;
errEl.textContent = "";
deleteState = { path: targetPath, kind, errEl };
openModal(deleteDialog);
}
if (deleteDialog) {
deleteDialog.addEventListener("click", async (event) => {
const btn = event.target?.closest?.(".files-delete-confirm");
if (!btn) return;
if (!deleteState) return;
const { path, errEl } = deleteState;
const fd = new FormData();
fd.append("path", path);
fd.append("csrf_token", csrfToken);
const r = await postForm(`${baseUrl}/files/delete`, fd);
if (r.ok) {
deleteState = null;
closeModal(deleteDialog);
scheduleRefresh(parentOf(path));
} else {
errEl.hidden = false;
errEl.textContent =
(r.body && r.body.error) || `Delete failed (HTTP ${r.status}).`;
}
});
deleteDialog.addEventListener("close", () => {
deleteState = null;
});
}
// ---------- new-folder modal --------------------------------------------
let newFolderState = null;
function openNewFolder(targetFolder) {
if (!newFolderDialog) return;
const folder = targetFolder || "";
newFolderDialog.querySelector(".files-new-folder-target").textContent =
folder ? folder + "/" : "/";
const input = newFolderDialog.querySelector(".files-new-folder-name");
const errEl = newFolderDialog.querySelector(".files-new-folder-error");
input.value = "";
errEl.hidden = true;
errEl.textContent = "";
newFolderState = { folder, input, errEl };
openModal(newFolderDialog);
setTimeout(() => input.focus(), 0);
}
async function newFolderSubmit() {
if (!newFolderState) return;
const { folder, input, errEl } = newFolderState;
const name = input.value.trim().replace(/^\/+|\/+$/g, "");
if (!name) return;
const fullPath = joinPath(folder, name);
const r = await postJson(`${baseUrl}/files/mkdir`, { path: fullPath });
if (r.ok) {
newFolderState = null;
closeModal(newFolderDialog);
scheduleRefresh(folder);
} else {
errEl.hidden = false;
errEl.textContent =
(r.body && r.body.error) ||
(r.status === 409
? "A file or folder with that name already exists."
: `Failed (HTTP ${r.status}).`);
}
}
if (newFolderDialog) {
newFolderDialog.addEventListener("click", (event) => {
if (event.target?.closest?.(".files-new-folder-create")) {
newFolderSubmit();
}
});
// Enter inside the name input submits. Direct-bound to the
// persistent input — high-frequency keydown, no benefit from
// delegation.
newFolderDialog
.querySelector(".files-new-folder-name")
.addEventListener("keydown", (event) => {
if (event.key !== "Enter") return;
event.preventDefault();
newFolderSubmit();
});
newFolderDialog.addEventListener("close", () => {
newFolderState = null;
});
}
// ---------- register action-registry handlers ----------
fo.registerHandler("new-folder", (path) => openNewFolder(path));
fo.registerHandler("delete", (path, actionEl) => {
const kind = actionEl?.dataset?.rowKind;
const name = actionEl?.dataset?.rowName || basename(path);
openDelete(path, kind, name);
});
})();

View file

@ -284,6 +284,7 @@
<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/editor.js') }}" defer></script>
<script src="{{ url_for('static', filename='js/files-overlay/dialogs.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 %}