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>
212 lines
7.3 KiB
JavaScript
212 lines
7.3 KiB
JavaScript
// 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);
|
|
});
|
|
})();
|