Three bugs surfaced in browser testing, plus one UX request:
1. The Uploads panel and the binary-mode editor sub-panels stayed
visible after `el.hidden = true` because their `display: flex/grid`
rules in components.css have the same specificity as the UA's
`[hidden]{display:none}` and come later in cascade. Add a targeted
`[hidden]!important` rule for the affected classes.
2. Clicking a folder toggle inside a `files` overlay did nothing.
`file-tree.js` looked for `.file-tree-children` via
`button.nextElementSibling`, but the files-overlay row template
inserts a per-row action span between the toggle and the children
div. Switch to `closest('.file-tree-row').querySelector(':scope >
.file-tree-children')` so both row variants resolve correctly.
3. Pressing Enter on the new-folder dialog did nothing — the keydown
handler was attached with `{once:true}` inside `openNewFolder`,
so the first letter the user typed consumed the listener and Enter
never fired. Move the listener to module init so it survives
subsequent keystrokes and dialog reopenings.
UX: render the overlay root as a row inside the tree (label
"(overlay root)") rather than as a separate toolbar. The root row
carries the same `+ new file · + new folder · ⬇ zip` hover-action
column as every other folder row, so drop-on-row, hover-reveal, and
data-target-path semantics are uniform across the tree.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
984 lines
35 KiB
JavaScript
984 lines
35 KiB
JavaScript
// 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:
|
|
//
|
|
// * Per-row hover actions: + new file, + new folder, ⬇ zip, ✕ on
|
|
// folders; edit, ✕ on files (download is a regular <a>).
|
|
// * 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/<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 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 = `
|
|
<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(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);
|
|
}
|
|
});
|
|
})();
|