feat(files): scaffold files-overlay/core.js with helpers + action registry
Step 1/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.
Pure scaffolding — no behavior change. core.js is loaded with defer in
overlay_detail.html before the existing files-overlay.js script tag
(both with defer, so execution order follows document order). Both
files run the same .files-manager guard, both attach document-level
click listeners on the action selector. The legacy file's switch-case
still owns dispatch; core.js's listener dispatches into an empty
registry until Steps 2–4 populate handlers.
window.__filesOverlay exposes:
* manager / overlayId / baseUrl / treeRoot / csrfToken (manager-
element-derived state, computed once)
* helpers.{joinPath, parentOf, basename, escapeHtml, humanSize,
fetchJson, postJson, postForm, refreshFolder, findRowByPath,
cssEscape, scheduleRefresh} (duplicated from legacy file for the
duration of Phase A; de-duplicates as feature modules migrate out)
* registerHandler(op, fn) / handleAction(op, path, actionEl) — the
action-dispatch registry that Steps 2–4 populate
Per the canonical plan's errata commit (d76ee05), the script tag goes
in overlay_detail.html (not base.html as the original plan said) and
uses defer to match the existing pattern.
Verified live on /overlays/2 in Chromium: both <script> tags present
in DOM order; window.__filesOverlay shape matches expectation (8
top-level keys, 12 helpers); overlayId="2", baseUrl="/overlays/2",
treeRoot resolved; joinPath('foo/','/bar') === 'foo/bar' smoke test
passes; no console errors; existing 3-file/1-folder tree still
renders. 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
4fa39642b0
commit
052ddcb4f0
2 changed files with 248 additions and 0 deletions
247
l4d2web/l4d2web/static/js/files-overlay/core.js
Normal file
247
l4d2web/l4d2web/static/js/files-overlay/core.js
Normal file
|
|
@ -0,0 +1,247 @@
|
|||
// files-overlay/core.js — Phase A scaffold (step 1/12).
|
||||
//
|
||||
// First module of the multi-file rewrite of files-overlay.js. Provides:
|
||||
// * Manager-element detection (.files-manager guard, same as legacy).
|
||||
// * Helpers (joinPath, parentOf, basename, escapeHtml, humanSize,
|
||||
// fetchJson, postJson, postForm, refreshFolder, findRowByPath,
|
||||
// cssEscape, scheduleRefresh) — duplicated from the legacy file
|
||||
// for the duration of Phase A. They de-duplicate in steps 2–4 as
|
||||
// features migrate out and the legacy file shrinks.
|
||||
// * window.__filesOverlay registry: feature modules call
|
||||
// registerHandler(op, fn); a document-level click listener here
|
||||
// dispatches matching actions via handleAction.
|
||||
//
|
||||
// Until Steps 2–4 populate the registry, the click listener here is a
|
||||
// no-op — the legacy files-overlay.js still owns dispatch via its own
|
||||
// switch-case at the same selector. Both listeners fire on every click
|
||||
// during the transition; the new one matches and dispatches into an
|
||||
// empty registry, the old one runs the actual handler. Per-op
|
||||
// migration removes each case from the legacy switch as the
|
||||
// corresponding feature module registers its handler.
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const manager = document.querySelector(".files-manager");
|
||||
if (!manager) return;
|
||||
|
||||
const overlayId = manager.dataset.overlayId;
|
||||
const baseUrl = manager.dataset.baseUrl; // /overlays/<id>
|
||||
const treeRoot = manager.querySelector(".files-tree-root");
|
||||
const csrfToken =
|
||||
document.querySelector("meta[name='csrf-token']")?.getAttribute("content") || "";
|
||||
|
||||
// ---------- helpers ----------
|
||||
|
||||
function joinPath(folder, leaf) {
|
||||
folder = (folder || "").replace(/^\/+|\/+$/g, "");
|
||||
leaf = (leaf || "").replace(/^\/+|\/+$/g, "");
|
||||
if (folder && leaf) return folder + "/" + leaf;
|
||||
return folder || leaf;
|
||||
}
|
||||
|
||||
function parentOf(rel) {
|
||||
const i = (rel || "").lastIndexOf("/");
|
||||
return i < 0 ? "" : rel.slice(0, i);
|
||||
}
|
||||
|
||||
function basename(rel) {
|
||||
const i = (rel || "").lastIndexOf("/");
|
||||
return i < 0 ? rel : rel.slice(i + 1);
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s).replace(/[&<>"']/g, (c) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
})[c]);
|
||||
}
|
||||
|
||||
function humanSize(bytes) {
|
||||
if (bytes === undefined || bytes === null) return "";
|
||||
if (bytes < 1024) return bytes + " B";
|
||||
const units = ["KB", "MB", "GB", "TB"];
|
||||
let v = bytes / 1024;
|
||||
let u = "KB";
|
||||
for (const next of units) {
|
||||
u = next;
|
||||
if (v < 1024) break;
|
||||
v /= 1024;
|
||||
}
|
||||
return v.toFixed(1) + " " + u;
|
||||
}
|
||||
|
||||
async function fetchJson(url, options) {
|
||||
options = options || {};
|
||||
options.headers = Object.assign({ Accept: "application/json" }, options.headers || {});
|
||||
if (options.method && options.method !== "GET") {
|
||||
options.headers["X-CSRF-Token"] = csrfToken;
|
||||
}
|
||||
options.credentials = "same-origin";
|
||||
const response = await fetch(url, options);
|
||||
let body = null;
|
||||
let rawText = "";
|
||||
try {
|
||||
rawText = await response.text();
|
||||
body = rawText ? JSON.parse(rawText) : null;
|
||||
} catch (_e) {
|
||||
body = null;
|
||||
}
|
||||
return { ok: response.ok, status: response.status, body, rawText };
|
||||
}
|
||||
|
||||
async function postJson(url, payload) {
|
||||
return fetchJson(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
async function postForm(url, formData) {
|
||||
return fetchJson(url, { method: "POST", body: formData });
|
||||
}
|
||||
|
||||
// ---------- tree refresh ----------
|
||||
|
||||
// Re-fetch a folder's listing partial and swap it into the tree.
|
||||
// `path === ""` refreshes the overlay root container.
|
||||
async function refreshFolder(path) {
|
||||
if (!treeRoot) return;
|
||||
const url = `${baseUrl}/files?path=${encodeURIComponent(path || "")}`;
|
||||
let html;
|
||||
try {
|
||||
const r = await fetch(url, {
|
||||
headers: { Accept: "text/html" },
|
||||
credentials: "same-origin",
|
||||
});
|
||||
if (!r.ok) {
|
||||
if (r.status === 404) {
|
||||
// Folder no longer exists — refresh its parent instead.
|
||||
if (path) await refreshFolder(parentOf(path));
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
html = await r.text();
|
||||
} catch (_e) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!path) {
|
||||
// Overlay root — swap into the synthetic root row's children div.
|
||||
const target = manager.querySelector(".files-root-children");
|
||||
if (!target) return;
|
||||
const empty = target.querySelector(".files-empty");
|
||||
if (empty) empty.remove();
|
||||
const existingUl = target.querySelector(":scope > ul.file-tree");
|
||||
if (existingUl) existingUl.remove();
|
||||
target.insertAdjacentHTML("beforeend", html);
|
||||
const newUl = target.querySelector(":scope > ul.file-tree");
|
||||
if (newUl && newUl.children.length === 0) {
|
||||
newUl.remove();
|
||||
const p = document.createElement("p");
|
||||
p.className = "muted files-empty";
|
||||
p.textContent = 'Empty — drop files here, or click "+ new file" on this row.';
|
||||
target.appendChild(p);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Sub-folder: find the matching folder row and swap its children div.
|
||||
const row = findRowByPath(path, "dir");
|
||||
if (!row) return;
|
||||
const childrenDiv = row.querySelector(":scope > .file-tree-children");
|
||||
const toggleBtn = row.querySelector(":scope > .file-tree-toggle");
|
||||
if (!childrenDiv || !toggleBtn) return;
|
||||
childrenDiv.innerHTML = html;
|
||||
childrenDiv.hidden = false;
|
||||
toggleBtn.setAttribute("aria-expanded", "true");
|
||||
toggleBtn.dataset.loaded = "1";
|
||||
}
|
||||
|
||||
function findRowByPath(path, kind) {
|
||||
const sel = kind
|
||||
? `[data-target-path="${cssEscape(path)}"][data-row-kind="${kind}"]`
|
||||
: `[data-target-path="${cssEscape(path)}"]`;
|
||||
return treeRoot ? treeRoot.querySelector(sel) : null;
|
||||
}
|
||||
|
||||
function cssEscape(s) {
|
||||
if (window.CSS && window.CSS.escape) return window.CSS.escape(s);
|
||||
return String(s).replace(/["\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
// Debounce per-folder refreshes so a flurry of finishes coalesces.
|
||||
const pendingRefresh = new Map();
|
||||
function scheduleRefresh(path) {
|
||||
const key = path || "";
|
||||
if (pendingRefresh.has(key)) return;
|
||||
const timer = setTimeout(() => {
|
||||
pendingRefresh.delete(key);
|
||||
refreshFolder(key);
|
||||
}, 50);
|
||||
pendingRefresh.set(key, timer);
|
||||
}
|
||||
|
||||
// ---------- action-dispatch registry ----------
|
||||
|
||||
const handlers = new Map();
|
||||
|
||||
function registerHandler(op, fn) {
|
||||
handlers.set(op, fn);
|
||||
}
|
||||
|
||||
function handleAction(op, path, actionEl) {
|
||||
const fn = handlers.get(op);
|
||||
if (typeof fn === "function") fn(path, actionEl);
|
||||
}
|
||||
|
||||
// ---------- action click delegation ----------
|
||||
//
|
||||
// Document-level delegation mirroring the legacy switch-case in
|
||||
// files-overlay.js. While the registry is empty (Step 1 state), this
|
||||
// is a no-op — the legacy file still owns dispatch. Steps 2–4
|
||||
// migrate one op at a time: feature module registers its handler
|
||||
// here, then the matching case is deleted from the legacy file.
|
||||
|
||||
document.addEventListener("click", (event) => {
|
||||
const action = event.target?.closest?.(
|
||||
".files-row-action[data-action], .file-tree-name-button[data-action]"
|
||||
);
|
||||
if (!action) return;
|
||||
if (!manager.contains(action)) return;
|
||||
const op = action.dataset.action;
|
||||
const path = action.dataset.targetPath || "";
|
||||
handleAction(op, path, action);
|
||||
});
|
||||
|
||||
// ---------- public ----------
|
||||
|
||||
window.__filesOverlay = {
|
||||
manager,
|
||||
overlayId,
|
||||
baseUrl,
|
||||
treeRoot,
|
||||
csrfToken,
|
||||
helpers: {
|
||||
joinPath,
|
||||
parentOf,
|
||||
basename,
|
||||
escapeHtml,
|
||||
humanSize,
|
||||
fetchJson,
|
||||
postJson,
|
||||
postForm,
|
||||
refreshFolder,
|
||||
findRowByPath,
|
||||
cssEscape,
|
||||
scheduleRefresh,
|
||||
},
|
||||
registerHandler,
|
||||
handleAction,
|
||||
};
|
||||
})();
|
||||
|
|
@ -282,6 +282,7 @@
|
|||
<ul class="files-uploads-list"></ul>
|
||||
</aside>
|
||||
|
||||
<script src="{{ url_for('static', filename='js/files-overlay/core.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='js/files-overlay.js') }}" defer></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
Loading…
Reference in a new issue