feat(files): migrate uploads + drag-drop to uploads.js; legacy file is a stub
Step 4/12 of docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.
End of Phase A. files-overlay.js is now a 19-line tombstone comment;
all behavior lives in the 4 modules under files-overlay/. Step 10
deletes the stub and its <script> tag.
uploads.js owns:
* The upload queue (concurrency 3, XHR-based progress, queued/active/
done/cancelled/error/conflict states) and the progress panel
* Drag-drop on treeRoot (5 events: dragstart, dragend, dragover,
dragleave, drop). Internal drags (row → folder, via the custom
application/x-files-overlay MIME) and external drags (OS files +
folders via webkitGetAsEntry) flow through the same drop handler.
* walkEntry for recursive folder walks during external drops
* withCollisionSuffix (moves here from files-overlay.js — its biggest
caller is the upload-conflict "keep both" path; exposed on
__filesOverlay for editor.js's save-409 path too)
* "zip" action handler (registered into __filesOverlay) — pure URL
navigation; placed here as the closest thematic home
Pattern change: upload-row cancel buttons converted from direct-bound
per row (inside buildUploadRow, which captured `item` in a closure) to
a single document-level delegated click listener. Each row carries
data-upload-id; an uploads Map<uploadId, item> looks up the item at
click time. The Map entry is removed in the uploadsClearBtn handler
when a done/error/cancelled row is cleared, so the Map doesn't grow
unbounded.
Drag-drop stays direct-bound to treeRoot per the plan escape hatch —
the 5 events share coordinated highlight state (is-drag-source,
is-drop-target classes that are toggled across events), and treeRoot
is persistent (never swapped). Delegation would obscure the state
coordination logic without any real benefit.
Phase A end-state:
* core.js (247 lines): helpers, manager guard, registry dispatch
* editor.js (550 lines): editor flows (legacy + URL-addressable)
* dialogs.js (212 lines): new-folder, delete-confirm, conflict
* uploads.js (423 lines): upload queue + drag-drop + zip + collision
* files-overlay.js (19 lines): tombstone comment, deleted in Step 10
Total: ~1432 lines across 4 modules + 19-line stub. The plan estimated
~780 lines across 4 modules; actual is ~1.8× larger, the difference
being module-header comments and the delegation-with-state scaffolding
(e.g., editor.js's dual-editor listener split, dialogs.js's per-dialog
state plus close-event resolvers).
Verified live on /overlays/2 in Chromium:
* 5 script tags load in document order (core → editor → dialogs →
uploads → legacy stub)
* Registry has 10 keys; askConflict and withCollisionSuffix are both
callable; withCollisionSuffix('foo.tar.gz') === 'foo.tar (1).gz'
(legacy behavior preserved — lastIndexOf('.') splits before the
final extension)
* Uploads panel + list elements present in DOM, panel hidden by
default
* "zip" action button exists; registered handler would set
window.location.href to /overlays/2/files/download_zip?path=...
* No console errors
* 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
307df9c23a
commit
cb391ad456
3 changed files with 441 additions and 587 deletions
|
|
@ -1,589 +1,19 @@
|
|||
// Files-overlay UI behavior. Activated only on overlay detail pages whose
|
||||
// `<div class="files-manager">` exists (set by the template when the
|
||||
// overlay is type='files' and the user can edit). The script binds:
|
||||
// files-overlay.js — empty stub pending deletion in Step 10 of
|
||||
// docs/superpowers/plans/2026-05-17-files-overlay-rewrite.md.
|
||||
//
|
||||
// * Per-row hover actions: + new file, + new folder, ⬇ zip, ✕ on
|
||||
// folders; ⬇ (download), ✕ on files. Clicking the filename opens
|
||||
// the editor (binary fallback for non-editable files).
|
||||
// * Drag-and-drop: dragging from the OS uploads (one POST per file,
|
||||
// queue with concurrency 3); dragging a row inside the tree moves
|
||||
// (rename/move via /files/move).
|
||||
// * Editor modal: text mode for editable files; binary "details +
|
||||
// replace upload" mode for everything else; doubles as new-file
|
||||
// dialog. Filename input is the rename surface.
|
||||
// * New-folder modal, conflict-resolution modal, delete-confirm modal.
|
||||
// * Upload progress panel with per-file rows.
|
||||
// All behavior migrated in Phase A:
|
||||
// * Helpers + manager-element detection + action-dispatch registry
|
||||
// → static/js/files-overlay/core.js (Step 1)
|
||||
// * Editor flows (legacy inline dialog + URL-addressable modal)
|
||||
// → static/js/files-overlay/editor.js (Step 2)
|
||||
// * Dialogs (new-folder, delete-confirm, conflict)
|
||||
// → static/js/files-overlay/dialogs.js (Step 3)
|
||||
// * Uploads (queue + progress) + drag-drop + withCollisionSuffix +
|
||||
// "zip" action handler
|
||||
// → static/js/files-overlay/uploads.js (Step 4)
|
||||
//
|
||||
// All operations use the `/overlays/<id>/files/...` JSON / multipart
|
||||
// endpoints. CSRF token comes from the <meta name="csrf-token"> tag.
|
||||
|
||||
(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") || "";
|
||||
|
||||
const uploadsPanel = document.querySelector(".files-uploads");
|
||||
const uploadsList = uploadsPanel?.querySelector(".files-uploads-list");
|
||||
const uploadsClearBtn = uploadsPanel?.querySelector(".files-uploads-clear");
|
||||
|
||||
// ---------- 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);
|
||||
// If the new content is also empty, restore the placeholder.
|
||||
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);
|
||||
}
|
||||
|
||||
// ---------- conflict modal ----------------------------------------------
|
||||
//
|
||||
// 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) {
|
||||
const dot = path.lastIndexOf(".");
|
||||
const slash = path.lastIndexOf("/");
|
||||
if (dot > slash + 0 && dot > -1) {
|
||||
return path.slice(0, dot) + " (1)" + path.slice(dot);
|
||||
}
|
||||
return path + " (1)";
|
||||
}
|
||||
// Exposed for editor.js (Phase A). Moves to uploads.js in Step 4.
|
||||
if (window.__filesOverlay) window.__filesOverlay.withCollisionSuffix = withCollisionSuffix;
|
||||
|
||||
// ---------- delete modal ------------------------------------------------
|
||||
//
|
||||
// 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 ------------------------------------------------
|
||||
//
|
||||
// Migrated to static/js/files-overlay/editor.js (Phase A, Step 2).
|
||||
// This module no longer touches the editor. Helpers it still needs
|
||||
// (askConflict, withCollisionSuffix) are exposed above on
|
||||
// window.__filesOverlay and de-duplicate in Steps 3 and 4.
|
||||
|
||||
// ---------- new-folder modal --------------------------------------------
|
||||
//
|
||||
// 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 -------------------------------
|
||||
|
||||
const uploadQueue = [];
|
||||
let uploadActive = 0;
|
||||
const UPLOAD_CONCURRENCY = 3;
|
||||
|
||||
function showPanel() {
|
||||
if (uploadsPanel) uploadsPanel.hidden = false;
|
||||
}
|
||||
|
||||
function maybeHidePanel() {
|
||||
if (!uploadsList) return;
|
||||
const anyActive = uploadsList.querySelector("[data-state='active'], [data-state='queued']");
|
||||
if (!anyActive && uploadsList.children.length === 0) {
|
||||
uploadsPanel.hidden = true;
|
||||
if (uploadsClearBtn) uploadsClearBtn.hidden = true;
|
||||
}
|
||||
const anyDone = uploadsList.querySelector("[data-state='done'], [data-state='error']");
|
||||
if (uploadsClearBtn) uploadsClearBtn.hidden = !anyDone;
|
||||
}
|
||||
|
||||
function buildUploadRow(item) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "files-uploads-row";
|
||||
li.dataset.state = "queued";
|
||||
li.innerHTML = `
|
||||
<div class="files-uploads-row-meta">
|
||||
<span class="files-uploads-row-name"></span>
|
||||
<span class="files-uploads-row-target muted"></span>
|
||||
</div>
|
||||
<div class="files-uploads-row-progress"><div class="files-uploads-bar"></div></div>
|
||||
<div class="files-uploads-row-status">
|
||||
<span class="files-uploads-row-state">queued</span>
|
||||
<button type="button" class="link-button files-uploads-row-cancel" aria-label="Cancel">✕</button>
|
||||
</div>
|
||||
`;
|
||||
li.querySelector(".files-uploads-row-name").textContent = item.relative;
|
||||
li.querySelector(".files-uploads-row-target").textContent = `→ ${item.targetFolder || "(root)"}`;
|
||||
li.querySelector(".files-uploads-row-cancel").addEventListener("click", () => cancelUpload(item));
|
||||
return li;
|
||||
}
|
||||
|
||||
function setRowState(item, state, percent) {
|
||||
if (!item.row) return;
|
||||
item.row.dataset.state = state;
|
||||
const stateEl = item.row.querySelector(".files-uploads-row-state");
|
||||
const cancelBtn = item.row.querySelector(".files-uploads-row-cancel");
|
||||
const bar = item.row.querySelector(".files-uploads-bar");
|
||||
if (state === "queued") {
|
||||
stateEl.textContent = "queued";
|
||||
bar.style.width = "0%";
|
||||
} else if (state === "active") {
|
||||
stateEl.textContent = `${Math.round(percent || 0)}%`;
|
||||
bar.style.width = `${percent || 0}%`;
|
||||
} else if (state === "done") {
|
||||
stateEl.textContent = "done";
|
||||
bar.style.width = "100%";
|
||||
bar.classList.add("is-done");
|
||||
cancelBtn.textContent = "✓";
|
||||
cancelBtn.disabled = true;
|
||||
} else if (state === "cancelled") {
|
||||
stateEl.textContent = "cancelled";
|
||||
bar.classList.add("is-cancelled");
|
||||
cancelBtn.disabled = true;
|
||||
} else if (state === "error") {
|
||||
stateEl.textContent = item.errorText || "error";
|
||||
bar.classList.add("is-error");
|
||||
cancelBtn.textContent = "✕";
|
||||
} else if (state === "conflict") {
|
||||
stateEl.textContent = "conflict — overwrite / keep both";
|
||||
}
|
||||
}
|
||||
|
||||
function cancelUpload(item) {
|
||||
if (item.xhr && item.row.dataset.state === "active") {
|
||||
item.cancelled = true;
|
||||
item.xhr.abort();
|
||||
}
|
||||
if (item.row.dataset.state === "queued") {
|
||||
const idx = uploadQueue.indexOf(item);
|
||||
if (idx >= 0) uploadQueue.splice(idx, 1);
|
||||
setRowState(item, "cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
uploadsClearBtn?.addEventListener("click", () => {
|
||||
uploadsList.querySelectorAll("[data-state='done'], [data-state='error'], [data-state='cancelled']").forEach((row) => row.remove());
|
||||
maybeHidePanel();
|
||||
});
|
||||
|
||||
function enqueueUpload(file, targetFolder, relativePath) {
|
||||
const item = {
|
||||
file,
|
||||
targetFolder,
|
||||
relative: relativePath || file.name,
|
||||
cancelled: false,
|
||||
xhr: null,
|
||||
errorText: null,
|
||||
};
|
||||
item.row = buildUploadRow(item);
|
||||
uploadsList.prepend(item.row);
|
||||
uploadQueue.push(item);
|
||||
showPanel();
|
||||
pump();
|
||||
return item;
|
||||
}
|
||||
|
||||
function pump() {
|
||||
while (uploadActive < UPLOAD_CONCURRENCY && uploadQueue.length) {
|
||||
const item = uploadQueue.shift();
|
||||
runUpload(item);
|
||||
}
|
||||
}
|
||||
|
||||
function runUpload(item, overwriteFlag) {
|
||||
uploadActive += 1;
|
||||
const fd = new FormData();
|
||||
fd.append("target_path", item.targetFolder || "");
|
||||
fd.append("relative_path", item.relative);
|
||||
fd.append("csrf_token", csrfToken);
|
||||
if (overwriteFlag) fd.append("overwrite", "1");
|
||||
fd.append("file", item.file, item.file.name);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
item.xhr = xhr;
|
||||
xhr.open("POST", `${baseUrl}/files/upload`);
|
||||
xhr.setRequestHeader("X-CSRF-Token", csrfToken);
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const pct = (event.loaded / event.total) * 100;
|
||||
setRowState(item, "active", pct);
|
||||
}
|
||||
};
|
||||
setRowState(item, "active", 0);
|
||||
xhr.onload = () => {
|
||||
uploadActive -= 1;
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
setRowState(item, "done");
|
||||
scheduleRefresh(joinPath(item.targetFolder || "", parentOf(item.relative)));
|
||||
} else if (xhr.status === 409 && !overwriteFlag) {
|
||||
setRowState(item, "conflict");
|
||||
const collidingPath = joinPath(item.targetFolder || "", item.relative);
|
||||
// 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") {
|
||||
item.relative = withCollisionSuffix(item.relative);
|
||||
item.row.querySelector(".files-uploads-row-name").textContent = item.relative;
|
||||
runUpload(item, false);
|
||||
} else {
|
||||
setRowState(item, "cancelled");
|
||||
}
|
||||
});
|
||||
} else {
|
||||
item.errorText = `HTTP ${xhr.status}`;
|
||||
try {
|
||||
const text = xhr.responseText;
|
||||
if (text) item.errorText = `HTTP ${xhr.status}: ${text.slice(0, 80)}`;
|
||||
} catch (_e) {}
|
||||
setRowState(item, "error");
|
||||
}
|
||||
maybeHidePanel();
|
||||
pump();
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
uploadActive -= 1;
|
||||
item.errorText = item.cancelled ? "cancelled" : "network error";
|
||||
setRowState(item, item.cancelled ? "cancelled" : "error");
|
||||
maybeHidePanel();
|
||||
pump();
|
||||
};
|
||||
xhr.onabort = () => {
|
||||
uploadActive -= 1;
|
||||
setRowState(item, "cancelled");
|
||||
maybeHidePanel();
|
||||
pump();
|
||||
};
|
||||
xhr.send(fd);
|
||||
}
|
||||
|
||||
// ---------- drag-drop on tree rows --------------------------------------
|
||||
|
||||
function rowFromEvent(event) {
|
||||
return event.target.closest("[data-row-kind]");
|
||||
}
|
||||
|
||||
function isInternalDrag(event) {
|
||||
return Array.from(event.dataTransfer.types).includes("application/x-files-overlay");
|
||||
}
|
||||
|
||||
function isExternalDrag(event) {
|
||||
const types = Array.from(event.dataTransfer.types);
|
||||
return types.includes("Files");
|
||||
}
|
||||
|
||||
// Expose containers (rows AND the empty-state placeholder + tree-root)
|
||||
// as drop targets — both for OS files and internal moves.
|
||||
if (treeRoot) {
|
||||
treeRoot.addEventListener("dragstart", (event) => {
|
||||
const row = rowFromEvent(event);
|
||||
if (!row) return;
|
||||
const path = row.dataset.targetPath;
|
||||
if (!path) return; // overlay root row isn't draggable
|
||||
event.dataTransfer.setData("application/x-files-overlay", path);
|
||||
event.dataTransfer.setData("text/plain", path);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
row.classList.add("is-drag-source");
|
||||
});
|
||||
treeRoot.addEventListener("dragend", () => {
|
||||
treeRoot.querySelectorAll(".is-drag-source").forEach((el) => el.classList.remove("is-drag-source"));
|
||||
treeRoot.querySelectorAll(".is-drop-target").forEach((el) => el.classList.remove("is-drop-target"));
|
||||
});
|
||||
|
||||
treeRoot.addEventListener("dragover", (event) => {
|
||||
// Only react to file or row drags.
|
||||
if (!isExternalDrag(event) && !isInternalDrag(event)) return;
|
||||
const row = rowFromEvent(event);
|
||||
// Find the closest folder row, or the tree-root container itself for
|
||||
// root-level drops.
|
||||
const target = row && row.dataset.rowKind === "dir" ? row : null;
|
||||
const fallbackRoot = !row || row.dataset.rowKind !== "dir" ? treeRoot : null;
|
||||
const dropEl = target || fallbackRoot;
|
||||
if (!dropEl) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = isInternalDrag(event) ? "move" : "copy";
|
||||
// Highlight the dropEl exclusively.
|
||||
treeRoot.querySelectorAll(".is-drop-target").forEach((el) => el.classList.remove("is-drop-target"));
|
||||
dropEl.classList.add("is-drop-target");
|
||||
});
|
||||
treeRoot.addEventListener("dragleave", (event) => {
|
||||
// Leaving the whole tree clears highlights.
|
||||
if (!treeRoot.contains(event.relatedTarget)) {
|
||||
treeRoot.querySelectorAll(".is-drop-target").forEach((el) => el.classList.remove("is-drop-target"));
|
||||
}
|
||||
});
|
||||
|
||||
treeRoot.addEventListener("drop", async (event) => {
|
||||
const internal = isInternalDrag(event);
|
||||
const external = isExternalDrag(event);
|
||||
if (!internal && !external) return;
|
||||
event.preventDefault();
|
||||
const row = rowFromEvent(event);
|
||||
const dropFolder =
|
||||
row && row.dataset.rowKind === "dir" ? row.dataset.targetPath || "" : "";
|
||||
|
||||
treeRoot.querySelectorAll(".is-drop-target").forEach((el) => el.classList.remove("is-drop-target"));
|
||||
|
||||
if (internal) {
|
||||
const src = event.dataTransfer.getData("application/x-files-overlay");
|
||||
if (!src) return;
|
||||
const dst = joinPath(dropFolder, basename(src));
|
||||
if (src === dst) return;
|
||||
// Cycle guard: refuse moving a folder into itself or descendant.
|
||||
if (dropFolder === src || dropFolder.startsWith(src + "/")) return;
|
||||
const r = await postJson(`${baseUrl}/files/move`, { src, dst });
|
||||
if (r.ok) {
|
||||
scheduleRefresh(parentOf(src));
|
||||
if (parentOf(src) !== dropFolder) scheduleRefresh(dropFolder);
|
||||
} else if (r.status === 409) {
|
||||
// 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) {
|
||||
scheduleRefresh(parentOf(src));
|
||||
scheduleRefresh(dropFolder);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
alert((r.body && r.body.error) || `Move failed (HTTP ${r.status}).`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// External drop — collect entries via webkitGetAsEntry where it
|
||||
// returns an Entry (real OS drag with folder support), and fall back
|
||||
// to getAsFile() for any item whose entry is null (synthetic events,
|
||||
// browsers without the API, or items that have no folder structure).
|
||||
const items = event.dataTransfer.items;
|
||||
const files = [];
|
||||
const tasks = [];
|
||||
if (items && items.length) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (typeof item.webkitGetAsEntry === "function") {
|
||||
const entry = item.webkitGetAsEntry();
|
||||
if (entry) {
|
||||
tasks.push(walkEntry(entry, "", files));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Fallback: treat as a flat file.
|
||||
if (typeof item.getAsFile === "function") {
|
||||
const f = item.getAsFile();
|
||||
if (f) files.push({ file: f, rel: f.name });
|
||||
}
|
||||
}
|
||||
} else if (event.dataTransfer.files) {
|
||||
for (let i = 0; i < event.dataTransfer.files.length; i++) {
|
||||
files.push({ file: event.dataTransfer.files[i], rel: event.dataTransfer.files[i].name });
|
||||
}
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
for (const f of files) {
|
||||
enqueueUpload(f.file, dropFolder, f.rel);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function walkEntry(entry, prefix, out) {
|
||||
return new Promise((resolve) => {
|
||||
if (entry.isFile) {
|
||||
entry.file((f) => {
|
||||
out.push({ file: f, rel: prefix + entry.name });
|
||||
resolve();
|
||||
});
|
||||
} else if (entry.isDirectory) {
|
||||
const reader = entry.createReader();
|
||||
const children = [];
|
||||
const readBatch = () => {
|
||||
reader.readEntries((batch) => {
|
||||
if (!batch.length) {
|
||||
Promise.all(
|
||||
children.map((c) => walkEntry(c, prefix + entry.name + "/", out))
|
||||
).then(() => resolve());
|
||||
return;
|
||||
}
|
||||
for (const c of batch) children.push(c);
|
||||
readBatch();
|
||||
});
|
||||
};
|
||||
readBatch();
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- click delegation: action buttons ----------------------------
|
||||
|
||||
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 || "";
|
||||
// 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;
|
||||
}
|
||||
});
|
||||
})();
|
||||
// The four modules attach independently when .files-manager exists
|
||||
// (each does its own document.querySelector check). This file is kept
|
||||
// for the duration of Phase A so that <script src=".../files-overlay.js">
|
||||
// in overlay_detail.html stays load-bearing during the transition; Step
|
||||
// 10 deletes both the file and the script tag.
|
||||
|
|
|
|||
423
l4d2web/l4d2web/static/js/files-overlay/uploads.js
Normal file
423
l4d2web/l4d2web/static/js/files-overlay/uploads.js
Normal file
|
|
@ -0,0 +1,423 @@
|
|||
// files-overlay/uploads.js — Phase A, Step 4.
|
||||
//
|
||||
// Owns the upload queue (concurrency 3 with XHR-based progress events)
|
||||
// and the drag-drop UX on the file tree. Both OS-file drops (external)
|
||||
// and row-to-folder moves (internal) flow through the same drop
|
||||
// handler on treeRoot.
|
||||
//
|
||||
// Drag-drop is deliberately direct-bound to treeRoot (5 events:
|
||||
// dragstart, dragend, dragover, dragleave, drop) per the plan escape
|
||||
// hatch. They share coordinated highlight state across events, and
|
||||
// delegation would obscure that coordination. treeRoot is persistent
|
||||
// — never swapped out — so direct binding is safe.
|
||||
//
|
||||
// Upload-row cancel buttons converted from direct-bound per row to a
|
||||
// single document-level delegated listener that finds the upload via
|
||||
// data-upload-id on the row. uploads Map keyed by that id.
|
||||
//
|
||||
// Dispatch:
|
||||
// * "zip" registered into __filesOverlay → folder-zip download
|
||||
// (pure URL navigation). This is the last remaining action — with
|
||||
// Step 4 the legacy click-delegation switch goes away.
|
||||
//
|
||||
// withCollisionSuffix moves here from files-overlay.js (its biggest
|
||||
// caller is the upload-conflict path). Exposed on __filesOverlay for
|
||||
// editor.js's save-409 "keep both" branch.
|
||||
|
||||
(function () {
|
||||
"use strict";
|
||||
|
||||
const fo = window.__filesOverlay;
|
||||
if (!fo) return;
|
||||
|
||||
const { baseUrl, csrfToken, manager, treeRoot } = fo;
|
||||
const { joinPath, parentOf, basename, postJson, scheduleRefresh } = fo.helpers;
|
||||
|
||||
const uploadsPanel = document.querySelector(".files-uploads");
|
||||
const uploadsList = uploadsPanel?.querySelector(".files-uploads-list");
|
||||
const uploadsClearBtn = uploadsPanel?.querySelector(".files-uploads-clear");
|
||||
|
||||
// Attach a path-collision suffix: foo.txt → foo (1).txt
|
||||
function withCollisionSuffix(path) {
|
||||
const dot = path.lastIndexOf(".");
|
||||
const slash = path.lastIndexOf("/");
|
||||
if (dot > slash + 0 && dot > -1) {
|
||||
return path.slice(0, dot) + " (1)" + path.slice(dot);
|
||||
}
|
||||
return path + " (1)";
|
||||
}
|
||||
fo.withCollisionSuffix = withCollisionSuffix;
|
||||
|
||||
// ---------- upload queue + progress panel ----------
|
||||
|
||||
const uploadQueue = [];
|
||||
let uploadActive = 0;
|
||||
const UPLOAD_CONCURRENCY = 3;
|
||||
|
||||
// Map<uploadId, item> — used by the delegated cancel-button click
|
||||
// handler to find the item from the row's data-upload-id attribute.
|
||||
const uploads = new Map();
|
||||
let nextUploadId = 0;
|
||||
|
||||
function showPanel() {
|
||||
if (uploadsPanel) uploadsPanel.hidden = false;
|
||||
}
|
||||
|
||||
function maybeHidePanel() {
|
||||
if (!uploadsList) return;
|
||||
const anyActive = uploadsList.querySelector("[data-state='active'], [data-state='queued']");
|
||||
if (!anyActive && uploadsList.children.length === 0) {
|
||||
uploadsPanel.hidden = true;
|
||||
if (uploadsClearBtn) uploadsClearBtn.hidden = true;
|
||||
}
|
||||
const anyDone = uploadsList.querySelector("[data-state='done'], [data-state='error']");
|
||||
if (uploadsClearBtn) uploadsClearBtn.hidden = !anyDone;
|
||||
}
|
||||
|
||||
function buildUploadRow(item) {
|
||||
const li = document.createElement("li");
|
||||
li.className = "files-uploads-row";
|
||||
li.dataset.state = "queued";
|
||||
li.dataset.uploadId = item.id;
|
||||
li.innerHTML = `
|
||||
<div class="files-uploads-row-meta">
|
||||
<span class="files-uploads-row-name"></span>
|
||||
<span class="files-uploads-row-target muted"></span>
|
||||
</div>
|
||||
<div class="files-uploads-row-progress"><div class="files-uploads-bar"></div></div>
|
||||
<div class="files-uploads-row-status">
|
||||
<span class="files-uploads-row-state">queued</span>
|
||||
<button type="button" class="link-button files-uploads-row-cancel" aria-label="Cancel">✕</button>
|
||||
</div>
|
||||
`;
|
||||
li.querySelector(".files-uploads-row-name").textContent = item.relative;
|
||||
li.querySelector(".files-uploads-row-target").textContent = `→ ${item.targetFolder || "(root)"}`;
|
||||
return li;
|
||||
}
|
||||
|
||||
function setRowState(item, state, percent) {
|
||||
if (!item.row) return;
|
||||
item.row.dataset.state = state;
|
||||
const stateEl = item.row.querySelector(".files-uploads-row-state");
|
||||
const cancelBtn = item.row.querySelector(".files-uploads-row-cancel");
|
||||
const bar = item.row.querySelector(".files-uploads-bar");
|
||||
if (state === "queued") {
|
||||
stateEl.textContent = "queued";
|
||||
bar.style.width = "0%";
|
||||
} else if (state === "active") {
|
||||
stateEl.textContent = `${Math.round(percent || 0)}%`;
|
||||
bar.style.width = `${percent || 0}%`;
|
||||
} else if (state === "done") {
|
||||
stateEl.textContent = "done";
|
||||
bar.style.width = "100%";
|
||||
bar.classList.add("is-done");
|
||||
cancelBtn.textContent = "✓";
|
||||
cancelBtn.disabled = true;
|
||||
} else if (state === "cancelled") {
|
||||
stateEl.textContent = "cancelled";
|
||||
bar.classList.add("is-cancelled");
|
||||
cancelBtn.disabled = true;
|
||||
} else if (state === "error") {
|
||||
stateEl.textContent = item.errorText || "error";
|
||||
bar.classList.add("is-error");
|
||||
cancelBtn.textContent = "✕";
|
||||
} else if (state === "conflict") {
|
||||
stateEl.textContent = "conflict — overwrite / keep both";
|
||||
}
|
||||
}
|
||||
|
||||
function cancelUpload(item) {
|
||||
if (item.xhr && item.row.dataset.state === "active") {
|
||||
item.cancelled = true;
|
||||
item.xhr.abort();
|
||||
}
|
||||
if (item.row.dataset.state === "queued") {
|
||||
const idx = uploadQueue.indexOf(item);
|
||||
if (idx >= 0) uploadQueue.splice(idx, 1);
|
||||
setRowState(item, "cancelled");
|
||||
}
|
||||
}
|
||||
|
||||
// Delegated click handler for upload-row cancel buttons. Replaces
|
||||
// the per-row direct-bound listener that the legacy code attached
|
||||
// inside buildUploadRow. Reads the row's data-upload-id and looks up
|
||||
// the upload in the Map — works for rows added at any later time.
|
||||
document.addEventListener("click", (event) => {
|
||||
const btn = event.target?.closest?.(".files-uploads-row-cancel");
|
||||
if (!btn) return;
|
||||
const row = btn.closest(".files-uploads-row");
|
||||
if (!row || !uploadsList || !uploadsList.contains(row)) return;
|
||||
const id = row.dataset.uploadId;
|
||||
if (!id) return;
|
||||
const item = uploads.get(id);
|
||||
if (item) cancelUpload(item);
|
||||
});
|
||||
|
||||
uploadsClearBtn?.addEventListener("click", () => {
|
||||
uploadsList.querySelectorAll("[data-state='done'], [data-state='error'], [data-state='cancelled']").forEach((row) => {
|
||||
const id = row.dataset.uploadId;
|
||||
if (id) uploads.delete(id);
|
||||
row.remove();
|
||||
});
|
||||
maybeHidePanel();
|
||||
});
|
||||
|
||||
function enqueueUpload(file, targetFolder, relativePath) {
|
||||
const id = String(nextUploadId++);
|
||||
const item = {
|
||||
id,
|
||||
file,
|
||||
targetFolder,
|
||||
relative: relativePath || file.name,
|
||||
cancelled: false,
|
||||
xhr: null,
|
||||
errorText: null,
|
||||
};
|
||||
item.row = buildUploadRow(item);
|
||||
uploads.set(id, item);
|
||||
uploadsList.prepend(item.row);
|
||||
uploadQueue.push(item);
|
||||
showPanel();
|
||||
pump();
|
||||
return item;
|
||||
}
|
||||
|
||||
function pump() {
|
||||
while (uploadActive < UPLOAD_CONCURRENCY && uploadQueue.length) {
|
||||
const item = uploadQueue.shift();
|
||||
runUpload(item);
|
||||
}
|
||||
}
|
||||
|
||||
function runUpload(item, overwriteFlag) {
|
||||
uploadActive += 1;
|
||||
const fd = new FormData();
|
||||
fd.append("target_path", item.targetFolder || "");
|
||||
fd.append("relative_path", item.relative);
|
||||
fd.append("csrf_token", csrfToken);
|
||||
if (overwriteFlag) fd.append("overwrite", "1");
|
||||
fd.append("file", item.file, item.file.name);
|
||||
|
||||
const xhr = new XMLHttpRequest();
|
||||
item.xhr = xhr;
|
||||
xhr.open("POST", `${baseUrl}/files/upload`);
|
||||
xhr.setRequestHeader("X-CSRF-Token", csrfToken);
|
||||
xhr.upload.onprogress = (event) => {
|
||||
if (event.lengthComputable) {
|
||||
const pct = (event.loaded / event.total) * 100;
|
||||
setRowState(item, "active", pct);
|
||||
}
|
||||
};
|
||||
setRowState(item, "active", 0);
|
||||
xhr.onload = () => {
|
||||
uploadActive -= 1;
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
setRowState(item, "done");
|
||||
scheduleRefresh(joinPath(item.targetFolder || "", parentOf(item.relative)));
|
||||
} else if (xhr.status === 409 && !overwriteFlag) {
|
||||
setRowState(item, "conflict");
|
||||
const collidingPath = joinPath(item.targetFolder || "", item.relative);
|
||||
fo.askConflict(collidingPath).then((action) => {
|
||||
if (action === "overwrite") {
|
||||
runUpload(item, true);
|
||||
} else if (action === "keep-both") {
|
||||
item.relative = withCollisionSuffix(item.relative);
|
||||
item.row.querySelector(".files-uploads-row-name").textContent = item.relative;
|
||||
runUpload(item, false);
|
||||
} else {
|
||||
setRowState(item, "cancelled");
|
||||
}
|
||||
});
|
||||
} else {
|
||||
item.errorText = `HTTP ${xhr.status}`;
|
||||
try {
|
||||
const text = xhr.responseText;
|
||||
if (text) item.errorText = `HTTP ${xhr.status}: ${text.slice(0, 80)}`;
|
||||
} catch (_e) {}
|
||||
setRowState(item, "error");
|
||||
}
|
||||
maybeHidePanel();
|
||||
pump();
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
uploadActive -= 1;
|
||||
item.errorText = item.cancelled ? "cancelled" : "network error";
|
||||
setRowState(item, item.cancelled ? "cancelled" : "error");
|
||||
maybeHidePanel();
|
||||
pump();
|
||||
};
|
||||
xhr.onabort = () => {
|
||||
uploadActive -= 1;
|
||||
setRowState(item, "cancelled");
|
||||
maybeHidePanel();
|
||||
pump();
|
||||
};
|
||||
xhr.send(fd);
|
||||
}
|
||||
|
||||
// ---------- drag-drop on tree rows ----------
|
||||
|
||||
function rowFromEvent(event) {
|
||||
return event.target.closest("[data-row-kind]");
|
||||
}
|
||||
|
||||
function isInternalDrag(event) {
|
||||
return Array.from(event.dataTransfer.types).includes("application/x-files-overlay");
|
||||
}
|
||||
|
||||
function isExternalDrag(event) {
|
||||
const types = Array.from(event.dataTransfer.types);
|
||||
return types.includes("Files");
|
||||
}
|
||||
|
||||
// Expose containers (rows AND the empty-state placeholder + tree-root)
|
||||
// as drop targets — both for OS files and internal moves.
|
||||
if (treeRoot) {
|
||||
treeRoot.addEventListener("dragstart", (event) => {
|
||||
const row = rowFromEvent(event);
|
||||
if (!row) return;
|
||||
const path = row.dataset.targetPath;
|
||||
if (!path) return; // overlay root row isn't draggable
|
||||
event.dataTransfer.setData("application/x-files-overlay", path);
|
||||
event.dataTransfer.setData("text/plain", path);
|
||||
event.dataTransfer.effectAllowed = "move";
|
||||
row.classList.add("is-drag-source");
|
||||
});
|
||||
treeRoot.addEventListener("dragend", () => {
|
||||
treeRoot.querySelectorAll(".is-drag-source").forEach((el) => el.classList.remove("is-drag-source"));
|
||||
treeRoot.querySelectorAll(".is-drop-target").forEach((el) => el.classList.remove("is-drop-target"));
|
||||
});
|
||||
|
||||
treeRoot.addEventListener("dragover", (event) => {
|
||||
// Only react to file or row drags.
|
||||
if (!isExternalDrag(event) && !isInternalDrag(event)) return;
|
||||
const row = rowFromEvent(event);
|
||||
// Find the closest folder row, or the tree-root container itself for
|
||||
// root-level drops.
|
||||
const target = row && row.dataset.rowKind === "dir" ? row : null;
|
||||
const fallbackRoot = !row || row.dataset.rowKind !== "dir" ? treeRoot : null;
|
||||
const dropEl = target || fallbackRoot;
|
||||
if (!dropEl) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = isInternalDrag(event) ? "move" : "copy";
|
||||
// Highlight the dropEl exclusively.
|
||||
treeRoot.querySelectorAll(".is-drop-target").forEach((el) => el.classList.remove("is-drop-target"));
|
||||
dropEl.classList.add("is-drop-target");
|
||||
});
|
||||
treeRoot.addEventListener("dragleave", (event) => {
|
||||
// Leaving the whole tree clears highlights.
|
||||
if (!treeRoot.contains(event.relatedTarget)) {
|
||||
treeRoot.querySelectorAll(".is-drop-target").forEach((el) => el.classList.remove("is-drop-target"));
|
||||
}
|
||||
});
|
||||
|
||||
treeRoot.addEventListener("drop", async (event) => {
|
||||
const internal = isInternalDrag(event);
|
||||
const external = isExternalDrag(event);
|
||||
if (!internal && !external) return;
|
||||
event.preventDefault();
|
||||
const row = rowFromEvent(event);
|
||||
const dropFolder =
|
||||
row && row.dataset.rowKind === "dir" ? row.dataset.targetPath || "" : "";
|
||||
|
||||
treeRoot.querySelectorAll(".is-drop-target").forEach((el) => el.classList.remove("is-drop-target"));
|
||||
|
||||
if (internal) {
|
||||
const src = event.dataTransfer.getData("application/x-files-overlay");
|
||||
if (!src) return;
|
||||
const dst = joinPath(dropFolder, basename(src));
|
||||
if (src === dst) return;
|
||||
// Cycle guard: refuse moving a folder into itself or descendant.
|
||||
if (dropFolder === src || dropFolder.startsWith(src + "/")) return;
|
||||
const r = await postJson(`${baseUrl}/files/move`, { src, dst });
|
||||
if (r.ok) {
|
||||
scheduleRefresh(parentOf(src));
|
||||
if (parentOf(src) !== dropFolder) scheduleRefresh(dropFolder);
|
||||
} else if (r.status === 409) {
|
||||
const action = await fo.askConflict(dst);
|
||||
if (action === "overwrite") {
|
||||
const r2 = await postJson(`${baseUrl}/files/move`, { src, dst, overwrite: true });
|
||||
if (r2.ok) {
|
||||
scheduleRefresh(parentOf(src));
|
||||
scheduleRefresh(dropFolder);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
alert((r.body && r.body.error) || `Move failed (HTTP ${r.status}).`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// External drop — collect entries via webkitGetAsEntry where it
|
||||
// returns an Entry (real OS drag with folder support), and fall back
|
||||
// to getAsFile() for any item whose entry is null (synthetic events,
|
||||
// browsers without the API, or items that have no folder structure).
|
||||
const items = event.dataTransfer.items;
|
||||
const files = [];
|
||||
const tasks = [];
|
||||
if (items && items.length) {
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i];
|
||||
if (typeof item.webkitGetAsEntry === "function") {
|
||||
const entry = item.webkitGetAsEntry();
|
||||
if (entry) {
|
||||
tasks.push(walkEntry(entry, "", files));
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// Fallback: treat as a flat file.
|
||||
if (typeof item.getAsFile === "function") {
|
||||
const f = item.getAsFile();
|
||||
if (f) files.push({ file: f, rel: f.name });
|
||||
}
|
||||
}
|
||||
} else if (event.dataTransfer.files) {
|
||||
for (let i = 0; i < event.dataTransfer.files.length; i++) {
|
||||
files.push({ file: event.dataTransfer.files[i], rel: event.dataTransfer.files[i].name });
|
||||
}
|
||||
}
|
||||
await Promise.all(tasks);
|
||||
for (const f of files) {
|
||||
enqueueUpload(f.file, dropFolder, f.rel);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function walkEntry(entry, prefix, out) {
|
||||
return new Promise((resolve) => {
|
||||
if (entry.isFile) {
|
||||
entry.file((f) => {
|
||||
out.push({ file: f, rel: prefix + entry.name });
|
||||
resolve();
|
||||
});
|
||||
} else if (entry.isDirectory) {
|
||||
const reader = entry.createReader();
|
||||
const children = [];
|
||||
const readBatch = () => {
|
||||
reader.readEntries((batch) => {
|
||||
if (!batch.length) {
|
||||
Promise.all(
|
||||
children.map((c) => walkEntry(c, prefix + entry.name + "/", out))
|
||||
).then(() => resolve());
|
||||
return;
|
||||
}
|
||||
for (const c of batch) children.push(c);
|
||||
readBatch();
|
||||
});
|
||||
};
|
||||
readBatch();
|
||||
} else {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ---------- register action-registry handlers ----------
|
||||
|
||||
// The last remaining file-row action — pure URL navigation. Could
|
||||
// equally live in core.js; placed here since uploads.js is the
|
||||
// closest thematic home (folder-level action like + new file etc.).
|
||||
fo.registerHandler("zip", (path) => {
|
||||
window.location.href = `${baseUrl}/files/download_zip?path=${encodeURIComponent(path)}`;
|
||||
});
|
||||
})();
|
||||
|
|
@ -285,6 +285,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/uploads.js') }}" defer></script>
|
||||
<script src="{{ url_for('static', filename='js/files-overlay.js') }}" defer></script>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
|
|
|||
Loading…
Reference in a new issue