// Files-overlay UI behavior. Activated only on overlay detail pages whose // `
` exists (set by the template when the // overlay is type='files' and the user can edit). The script binds: // // * Per-row hover actions: + new file, + new folder, ⬇ zip, ✕ on // folders; edit, ✕ on files (download is a regular ). // * 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 operations use the `/overlays//files/...` JSON / multipart // endpoints. CSRF token comes from the tag. (function () { "use strict"; const manager = document.querySelector(".files-manager"); if (!manager) return; const overlayId = manager.dataset.overlayId; const baseUrl = manager.dataset.baseUrl; // /overlays/ const treeRoot = manager.querySelector(".files-tree-root"); const csrfToken = document.querySelector("meta[name='csrf-token']")?.getAttribute("content") || ""; const editorDialog = document.getElementById("files-editor-modal"); 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"); // ---------- 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; try { const text = await response.text(); body = text ? JSON.parse(text) : null; } catch (_e) { body = null; } return { ok: response.ok, status: response.status, body }; } 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 ---------------------------------------------- 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(); }); } // 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)"; } // ---------- 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(); } // ---------- editor modal ------------------------------------------------ // Editor state. Only one editor is open at a time. const editor = { mode: null, // "text" | "binary" creating: false, originalPath: null, folder: null, queuedReplacement: null, // File object }; const editorEls = { title: editorDialog.querySelector(".files-editor-title-text"), filename: editorDialog.querySelector(".files-editor-filename"), renameHint: editorDialog.querySelector(".files-editor-rename-hint"), renameFrom: editorDialog.querySelector(".files-rename-from"), renameTo: editorDialog.querySelector(".files-rename-to"), textPanel: editorDialog.querySelector(".files-editor-text"), contentBox: editorDialog.querySelector(".files-editor-content"), byteCount: editorDialog.querySelector(".files-editor-byte-count"), binaryPanel: editorDialog.querySelector(".files-editor-binary"), binarySize: editorDialog.querySelector(".files-editor-binary-size"), replaceZone: editorDialog.querySelector(".files-editor-replace-zone"), replaceIdle: editorDialog.querySelector(".files-editor-replace-idle"), replaceQueued: editorDialog.querySelector(".files-editor-replace-queued"), replaceName: editorDialog.querySelector(".files-editor-replace-name"), replaceSize: editorDialog.querySelector(".files-editor-replace-size"), replaceClear: editorDialog.querySelector(".files-editor-replace-clear"), replaceBrowse: editorDialog.querySelector(".files-editor-replace-browse"), replaceInput: editorDialog.querySelector(".files-editor-replace-input"), deleteBtn: editorDialog.querySelector(".files-editor-delete"), downloadBtn: editorDialog.querySelector(".files-editor-download"), saveBtn: editorDialog.querySelector(".files-editor-save"), }; function setEditorTitle(text) { editorEls.title.textContent = text; } function updateByteCount() { const bytes = new TextEncoder().encode(editorEls.contentBox.value).length; editorEls.byteCount.textContent = `UTF-8 · ${bytes} bytes`; } function updateRenameHint() { const current = editorEls.filename.value.trim(); const original = basename(editor.originalPath || ""); if (editor.creating || !current || current === original) { editorEls.renameHint.hidden = true; return; } editorEls.renameFrom.textContent = original; editorEls.renameTo.textContent = current; editorEls.renameHint.hidden = false; } function updateSaveEnabled() { if (editor.mode === "binary" && !editor.creating) { const filenameChanged = editorEls.filename.value.trim() !== basename(editor.originalPath || ""); const hasReplacement = !!editor.queuedReplacement; editorEls.saveBtn.disabled = !filenameChanged && !hasReplacement; editorEls.saveBtn.textContent = "Save"; } else if (editor.creating) { editorEls.saveBtn.disabled = !editorEls.filename.value.trim(); editorEls.saveBtn.textContent = "Create"; } else { editorEls.saveBtn.disabled = false; editorEls.saveBtn.textContent = "Save"; } } function setQueuedReplacement(file) { editor.queuedReplacement = file; if (file) { editorEls.replaceIdle.hidden = true; editorEls.replaceQueued.hidden = false; editorEls.replaceName.textContent = file.name; editorEls.replaceSize.textContent = humanSize(file.size); } else { editorEls.replaceIdle.hidden = false; editorEls.replaceQueued.hidden = true; } updateSaveEnabled(); } function openEditorTextNew(folder) { editor.mode = "text"; editor.creating = true; editor.originalPath = null; editor.folder = folder; editor.queuedReplacement = null; setEditorTitle(`${folder ? folder + "/" : ""}…new file`); editorEls.filename.value = ""; editorEls.filename.disabled = false; editorEls.contentBox.value = ""; editorEls.contentBox.disabled = false; editorEls.renameHint.hidden = true; editorEls.textPanel.hidden = false; editorEls.binaryPanel.hidden = true; editorEls.deleteBtn.hidden = true; editorEls.downloadBtn.hidden = true; editorEls.saveBtn.textContent = "Create"; updateByteCount(); updateSaveEnabled(); editorDialog.showModal(); setTimeout(() => editorEls.filename.focus(), 0); } async function openEditorForFile(path, isEditable) { editor.creating = false; editor.originalPath = path; editor.folder = parentOf(path); editor.queuedReplacement = null; setQueuedReplacement(null); editorEls.filename.value = basename(path); editorEls.filename.disabled = false; editorEls.renameHint.hidden = true; editorEls.deleteBtn.hidden = false; editorEls.downloadBtn.hidden = false; editorEls.downloadBtn.href = `${baseUrl}/files/download?path=${encodeURIComponent(path)}`; setEditorTitle(path); if (isEditable) { editor.mode = "text"; editorEls.textPanel.hidden = false; editorEls.binaryPanel.hidden = true; editorEls.contentBox.value = "Loading…"; editorEls.contentBox.disabled = true; const r = await fetchJson( `${baseUrl}/files/content?path=${encodeURIComponent(path)}` ); if (r.ok && r.body) { editorEls.contentBox.value = r.body.content; editorEls.contentBox.disabled = false; updateByteCount(); updateSaveEnabled(); editorDialog.showModal(); setTimeout(() => editorEls.contentBox.focus(), 0); return; } // Fallback: server says not editable. Re-open as binary. editorEls.contentBox.disabled = false; editor.mode = "binary"; } else { editor.mode = "binary"; } // Binary mode setup. editorEls.textPanel.hidden = true; editorEls.binaryPanel.hidden = false; editorEls.binarySize.textContent = "—"; // server gave us no size; cosmetic updateSaveEnabled(); editorDialog.showModal(); setTimeout(() => editorEls.filename.focus(), 0); } editorEls.filename.addEventListener("input", () => { updateRenameHint(); updateSaveEnabled(); }); editorEls.contentBox.addEventListener("input", () => { updateByteCount(); updateSaveEnabled(); }); editorEls.contentBox.addEventListener("keydown", (event) => { if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "s") { event.preventDefault(); editorEls.saveBtn.click(); } }); editorEls.replaceClear.addEventListener("click", () => setQueuedReplacement(null)); editorEls.replaceBrowse.addEventListener("click", () => editorEls.replaceInput.click()); editorEls.replaceInput.addEventListener("change", () => { const f = editorEls.replaceInput.files && editorEls.replaceInput.files[0]; if (f) setQueuedReplacement(f); }); editorEls.replaceZone.addEventListener("dragover", (event) => { if (Array.from(event.dataTransfer.types).includes("Files")) { event.preventDefault(); editorEls.replaceZone.classList.add("is-drop-target"); } }); editorEls.replaceZone.addEventListener("dragleave", () => { editorEls.replaceZone.classList.remove("is-drop-target"); }); editorEls.replaceZone.addEventListener("drop", (event) => { if (!Array.from(event.dataTransfer.types).includes("Files")) return; event.preventDefault(); editorEls.replaceZone.classList.remove("is-drop-target"); const f = event.dataTransfer.files && event.dataTransfer.files[0]; if (f) setQueuedReplacement(f); }); editorDialog.addEventListener("close", () => { setQueuedReplacement(null); }); editorEls.saveBtn.addEventListener("click", async () => { const filename = editorEls.filename.value.trim(); if (!filename) return; if (filename.includes("/")) { alert("Filename can't contain '/'. Use drag-to-move to relocate."); return; } const folder = editor.folder || ""; const newRel = joinPath(folder, filename); if (editor.creating) { // Text-flavor create → /save with no new_path. const r = await postJson(`${baseUrl}/files/save`, { path: newRel, content: editorEls.contentBox.value, }); if (r.ok) { editorDialog.close(); scheduleRefresh(folder); } else if (r.status === 409) { const action = await askConflict(newRel); if (action === "overwrite") { // Re-call /save (no overwrite flag — /save just writes); skip // the conflict by writing in-place which is what users want. // First delete the colliding entry to avoid the implicit // "destination is not a file" branch when it's a directory. // For files, a plain /save overwrite is fine. const r2 = await postJson(`${baseUrl}/files/save`, { path: newRel, content: editorEls.contentBox.value, }); if (r2.ok) { editorDialog.close(); scheduleRefresh(folder); } else { alert( (r2.body && r2.body.error) || `Save failed (HTTP ${r2.status}).` ); } } else if (action === "keep-both") { const altered = withCollisionSuffix(newRel); const r2 = await postJson(`${baseUrl}/files/save`, { path: altered, content: editorEls.contentBox.value, }); if (r2.ok) { editorDialog.close(); scheduleRefresh(folder); } } } else { alert((r.body && r.body.error) || `Save failed (HTTP ${r.status}).`); } return; } const renaming = newRel !== editor.originalPath; if (editor.mode === "text") { const payload = { path: editor.originalPath, content: editorEls.contentBox.value, }; if (renaming) payload.new_path = newRel; const r = await postJson(`${baseUrl}/files/save`, payload); if (r.ok) { editorDialog.close(); scheduleRefresh(folder); } else { alert( (r.body && r.body.error) || `Save failed (HTTP ${r.status}).` ); } return; } // Binary mode. const fd = new FormData(); fd.append("path", editor.originalPath); fd.append("csrf_token", csrfToken); if (renaming) fd.append("new_path", newRel); if (editor.queuedReplacement) { fd.append("file", editor.queuedReplacement); const r = await postForm(`${baseUrl}/files/replace`, fd); if (r.ok) { editorDialog.close(); scheduleRefresh(folder); } else { alert( (r.body && r.body.error) || `Replace failed (HTTP ${r.status}).` ); } } else if (renaming) { // Rename only via /move (no content change). const r = await postJson(`${baseUrl}/files/move`, { src: editor.originalPath, dst: newRel, }); if (r.ok) { editorDialog.close(); scheduleRefresh(folder); } else { alert( (r.body && r.body.error) || `Rename failed (HTTP ${r.status}).` ); } } }); editorEls.deleteBtn.addEventListener("click", async () => { if (!editor.originalPath) return; if (!confirm(`Delete ${editor.originalPath}?`)) return; const fd = new FormData(); fd.append("path", editor.originalPath); fd.append("csrf_token", csrfToken); const r = await postForm(`${baseUrl}/files/delete`, fd); if (r.ok) { editorDialog.close(); scheduleRefresh(parentOf(editor.originalPath)); } else { alert((r.body && r.body.error) || `Delete failed (HTTP ${r.status}).`); } }); // ---------- new-folder modal -------------------------------------------- function openNewFolder(targetFolder) { const folder = targetFolder || ""; newFolderDialog.querySelector(".files-new-folder-target").textContent = folder ? folder + "/" : "(overlay root)"; 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(); }); // ---------- 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 = `
queued
`; 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(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 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]"); if (!action) return; if (!manager.contains(action)) return; const op = action.dataset.action; const path = action.dataset.targetPath || ""; if (op === "new-file") { openEditorTextNew(path); } else if (op === "new-folder") { openNewFolder(path); } else if (op === "zip") { const url = `${baseUrl}/files/download_zip?path=${encodeURIComponent(path)}`; window.location.href = url; } else if (op === "edit") { const editable = action.dataset.editable === "1"; openEditorForFile(path, editable); } else if (op === "delete") { const kind = action.dataset.rowKind; const name = action.dataset.rowName || basename(path); openDelete(path, kind, name); } }); })();