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:
parent
f094eca074
commit
307df9c23a
3 changed files with 235 additions and 102 deletions
|
|
@ -29,9 +29,6 @@
|
|||
const csrfToken =
|
||||
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 uploadsList = uploadsPanel?.querySelector(".files-uploads-list");
|
||||
const uploadsClearBtn = uploadsPanel?.querySelector(".files-uploads-clear");
|
||||
|
|
@ -194,23 +191,11 @@
|
|||
}
|
||||
|
||||
// ---------- conflict modal ----------------------------------------------
|
||||
|
||||
function askConflict(path) {
|
||||
return new Promise((resolve) => {
|
||||
conflictDialog.querySelector(".files-conflict-path").textContent = path;
|
||||
const handler = (event) => {
|
||||
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;
|
||||
//
|
||||
// Migrated to static/js/files-overlay/dialogs.js (Phase A, Step 3).
|
||||
// askConflict is now exposed there on window.__filesOverlay. The
|
||||
// legacy upload + drag-drop code below reads it via __filesOverlay
|
||||
// at call time.
|
||||
|
||||
// Attach a path-collision suffix: foo.txt → foo (1).txt
|
||||
function withCollisionSuffix(path) {
|
||||
|
|
@ -225,31 +210,10 @@
|
|||
if (window.__filesOverlay) window.__filesOverlay.withCollisionSuffix = withCollisionSuffix;
|
||||
|
||||
// ---------- delete modal ------------------------------------------------
|
||||
|
||||
function openDelete(targetPath, kind, name) {
|
||||
deleteDialog.querySelector(".files-delete-name").textContent = name;
|
||||
const errEl = deleteDialog.querySelector(".files-delete-error");
|
||||
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();
|
||||
}
|
||||
//
|
||||
// Migrated to static/js/files-overlay/dialogs.js (Phase A, Step 3).
|
||||
// Now dispatched via the "delete" action-registry handler that
|
||||
// dialogs.js registers.
|
||||
|
||||
// ---------- editor modal ------------------------------------------------
|
||||
//
|
||||
|
|
@ -259,53 +223,10 @@
|
|||
// window.__filesOverlay and de-duplicate in Steps 3 and 4.
|
||||
|
||||
// ---------- new-folder modal --------------------------------------------
|
||||
|
||||
function openNewFolder(targetFolder) {
|
||||
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 = "";
|
||||
|
||||
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();
|
||||
});
|
||||
//
|
||||
// Migrated to static/js/files-overlay/dialogs.js (Phase A, Step 3).
|
||||
// Now dispatched via the "new-folder" action-registry handler that
|
||||
// dialogs.js registers.
|
||||
|
||||
// ---------- upload queue + progress panel -------------------------------
|
||||
|
||||
|
|
@ -449,7 +370,9 @@
|
|||
} else if (xhr.status === 409 && !overwriteFlag) {
|
||||
setRowState(item, "conflict");
|
||||
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") {
|
||||
runUpload(item, true);
|
||||
} else if (action === "keep-both") {
|
||||
|
|
@ -566,7 +489,8 @@
|
|||
scheduleRefresh(parentOf(src));
|
||||
if (parentOf(src) !== dropFolder) scheduleRefresh(dropFolder);
|
||||
} 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") {
|
||||
const r2 = await postJson(`${baseUrl}/files/move`, { src, dst, overwrite: true });
|
||||
if (r2.ok) {
|
||||
|
|
@ -654,16 +578,12 @@
|
|||
if (!manager.contains(action)) return;
|
||||
const op = action.dataset.action;
|
||||
const path = action.dataset.targetPath || "";
|
||||
// new-file + edit: dispatched via __filesOverlay registry (editor.js).
|
||||
if (op === "new-folder") {
|
||||
openNewFolder(path);
|
||||
} else if (op === "zip") {
|
||||
// new-file + edit dispatched via registry (editor.js); new-folder +
|
||||
// delete dispatched via registry (dialogs.js). Only "zip" remains
|
||||
// here — it's pure URL navigation with no module dependency.
|
||||
if (op === "zip") {
|
||||
const url = `${baseUrl}/files/download_zip?path=${encodeURIComponent(path)}`;
|
||||
window.location.href = url;
|
||||
} else if (op === "delete") {
|
||||
const kind = action.dataset.rowKind;
|
||||
const name = action.dataset.rowName || basename(path);
|
||||
openDelete(path, kind, name);
|
||||
}
|
||||
});
|
||||
})();
|
||||
|
|
|
|||
212
l4d2web/l4d2web/static/js/files-overlay/dialogs.js
Normal file
212
l4d2web/l4d2web/static/js/files-overlay/dialogs.js
Normal 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);
|
||||
});
|
||||
})();
|
||||
|
|
@ -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/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>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
Loading…
Reference in a new issue