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:
mwiegand 2026-05-17 15:22:54 +02:00
parent 4fa39642b0
commit 052ddcb4f0
No known key found for this signature in database
2 changed files with 248 additions and 0 deletions

View 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 24 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 24 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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#39;",
})[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 24
// 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,
};
})();

View file

@ -282,6 +282,7 @@
<ul class="files-uploads-list"></ul> <ul class="files-uploads-list"></ul>
</aside> </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> <script src="{{ url_for('static', filename='js/files-overlay.js') }}" defer></script>
{% endif %} {% endif %}
{% endblock %} {% endblock %}