diff --git a/Overlord-Server/public/assets/filebrowser.js b/Overlord-Server/public/assets/filebrowser.js index 1dc02b8..df78e23 100644 --- a/Overlord-Server/public/assets/filebrowser.js +++ b/Overlord-Server/public/assets/filebrowser.js @@ -14,6 +14,16 @@ let lastSuccessfulResponse = 0; let pendingToast = null; const recentToasts = new Map(); const pendingCommandResults = new Map(); +const VIRTUALIZATION_THRESHOLD = 400; +const VIRTUAL_ROW_HEIGHT = 58; +const VIRTUAL_OVERSCAN = 8; + +let directoryEntries = []; +let filteredDirectoryEntries = []; +let virtualScrollHandler = null; +let virtualResizeHandler = null; +let virtualRenderRaf = null; +let isVirtualizedList = false; const statusEl = document.getElementById("status-indicator"); const breadcrumbEl = document.getElementById("breadcrumb"); @@ -31,6 +41,11 @@ const pathInput = document.getElementById("path-input"); const pathGoBtn = document.getElementById("path-go-btn"); const transferPanel = document.getElementById("transfer-panel"); const transferList = document.getElementById("transfer-list"); +const fileListPanel = document.getElementById("file-list-panel"); +const sortFieldEl = document.getElementById("sort-field"); +const sortOrderBtn = document.getElementById("sort-order-btn"); +const filterTypeEl = document.getElementById("filter-type"); +const fileCountSummaryEl = document.getElementById("file-count-summary"); const searchBar = document.getElementById("search-bar"); const searchInput = document.getElementById("search-input"); @@ -58,6 +73,11 @@ if (clientIdHeader) { clientIdHeader.textContent = `${clientId} - File Browser`; } +let sortField = localStorage.getItem("filebrowser.sortField") || "name"; +let sortOrder = localStorage.getItem("filebrowser.sortOrder") || "asc"; +let filterType = localStorage.getItem("filebrowser.filterType") || "all"; +let dragDepth = 0; + function connect() { const protocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const wsUrl = `${protocol}//${window.location.host}/api/clients/${clientId}/files/ws`; @@ -297,33 +317,117 @@ function updateBreadcrumb(path) { }); } -function handleFileList(msg) { - if (msg.error) { - fileListEl.innerHTML = `
${escapeHtml(msg.error)}
`; - return; +function getFileExt(name = "") { + const idx = name.lastIndexOf("."); + if (idx < 0 || idx === name.length - 1) return ""; + return name.slice(idx + 1).toLowerCase(); +} + +function entryMatchesFilter(entry, mode) { + if (mode === "all") return true; + if (mode === "dirs") return !!entry.isDir; + if (mode === "files") return !entry.isDir; + if (entry.isDir) return false; + + const ext = getFileExt(entry.name); + const imageExt = new Set(["jpg", "jpeg", "png", "gif", "webp", "bmp", "svg", "ico"]); + const docExt = new Set(["txt", "md", "pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "csv", "json", "xml", "yaml", "yml"]); + const archiveExt = new Set(["zip", "rar", "7z", "tar", "gz", "bz2", "xz"]); + const execExt = new Set(["exe", "msi", "bat", "cmd", "ps1", "sh", "appimage", "bin", "com"]); + + if (mode === "images") return imageExt.has(ext); + if (mode === "docs") return docExt.has(ext); + if (mode === "archives") return archiveExt.has(ext); + if (mode === "executables") return execExt.has(ext); + return true; +} + +function sortEntries(entries, field, order) { + const dirRank = (entry) => (entry.isDir ? 0 : 1); + const factor = order === "desc" ? -1 : 1; + const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: "base" }); + + return [...entries].sort((a, b) => { + const dirDiff = dirRank(a) - dirRank(b); + if (dirDiff !== 0) return dirDiff; + + let valueA = a.name || ""; + let valueB = b.name || ""; + + if (field === "size") { + valueA = Number(a.size || 0); + valueB = Number(b.size || 0); + } else if (field === "modified") { + valueA = Number(a.modTime || 0); + valueB = Number(b.modTime || 0); + } else if (field === "type") { + valueA = a.isDir ? "" : getFileExt(a.name); + valueB = b.isDir ? "" : getFileExt(b.name); + } + + if (typeof valueA === "number" && typeof valueB === "number") { + if (valueA === valueB) { + return factor * collator.compare(a.name || "", b.name || ""); + } + return factor * (valueA - valueB); + } + + const diff = collator.compare(String(valueA), String(valueB)); + if (diff !== 0) return factor * diff; + return factor * collator.compare(a.name || "", b.name || ""); + }); +} + +function applySortAndFilterEntries(entries) { + const filtered = entries.filter((entry) => entryMatchesFilter(entry, filterType)); + return sortEntries(filtered, sortField, sortOrder); +} + +function updateSortOrderButton() { + if (!sortOrderBtn) return; + sortOrderBtn.textContent = sortOrder === "asc" ? "Asc" : "Desc"; +} + +function updateDirectorySummaryAndPaging(totalCount, shownCount) { + if (fileCountSummaryEl) { + fileCountSummaryEl.textContent = `${totalCount} items`; + } +} + +function clearVirtualizedListMode() { + if (virtualRenderRaf) { + cancelAnimationFrame(virtualRenderRaf); + virtualRenderRaf = null; + } + if (virtualScrollHandler) { + window.removeEventListener("scroll", virtualScrollHandler); + virtualScrollHandler = null; + } + if (virtualResizeHandler) { + window.removeEventListener("resize", virtualResizeHandler); + virtualResizeHandler = null; + } + isVirtualizedList = false; + fileListEl.classList.add("divide-y", "divide-slate-800"); +} + +function renderDirectoryStandard(entries, canGoUp, parentPath, disableAnimations) { + clearVirtualizedListMode(); + + if (!disableAnimations) { + fileListEl.style.opacity = "0"; + fileListEl.style.transform = "translateX(20px)"; + } else { + fileListEl.style.transition = "none"; + fileListEl.style.opacity = "1"; + fileListEl.style.transform = "translateX(0)"; } - lastSuccessfulResponse = Date.now(); - updateStatus("connected", "Connected"); - enableControls(true); - - currentPath = msg.path; - const entries = msg.entries || []; - - selectedFiles.clear(); - updateSelectionUI(); - - fileListEl.style.opacity = "0"; - fileListEl.style.transform = "translateX(20px)"; - - setTimeout(() => { + const renderList = () => { fileListEl.innerHTML = ""; - const canGoUp = shouldShowParentDirectory(currentPath); if (canGoUp) { - const parentPath = getParentPath(currentPath); - const parentRow = createParentRow(parentPath); - fileListEl.appendChild(parentRow); + fileListEl.appendChild(createParentRow(parentPath)); } if (entries.length === 0 && !canGoUp) { @@ -334,24 +438,127 @@ function handleFileList(msg) { return; } - entries.sort((a, b) => { - if (a.isDir && !b.isDir) return -1; - if (!a.isDir && b.isDir) return 1; - return a.name.localeCompare(b.name); - }); - entries.forEach((entry, index) => { const row = createFileRow(entry); - row.style.animationDelay = `${index * 0.02}s`; - row.classList.add("card-animate"); + if (!disableAnimations) { + row.style.animationDelay = `${index * 0.02}s`; + row.classList.add("card-animate"); + } fileListEl.appendChild(row); }); - fileListEl.style.transition = - "opacity 0.3s ease-out, transform 0.3s ease-out"; - fileListEl.style.opacity = "1"; - fileListEl.style.transform = "translateX(0)"; - }, 150); + if (!disableAnimations) { + fileListEl.style.transition = + "opacity 0.3s ease-out, transform 0.3s ease-out"; + fileListEl.style.opacity = "1"; + fileListEl.style.transform = "translateX(0)"; + } + }; + + if (disableAnimations) { + renderList(); + } else { + setTimeout(renderList, 150); + } +} + +function renderDirectoryVirtualized(entries, canGoUp, parentPath) { + clearVirtualizedListMode(); + isVirtualizedList = true; + fileListEl.style.transition = "none"; + fileListEl.style.opacity = "1"; + fileListEl.style.transform = "translateX(0)"; + fileListEl.classList.remove("divide-y", "divide-slate-800"); + fileListEl.innerHTML = ""; + + if (canGoUp) { + fileListEl.appendChild(createParentRow(parentPath)); + } + + const host = document.createElement("div"); + const topSpacer = document.createElement("div"); + const rowsContainer = document.createElement("div"); + const bottomSpacer = document.createElement("div"); + + host.className = "virtualized-list-host"; + host.appendChild(topSpacer); + host.appendChild(rowsContainer); + host.appendChild(bottomSpacer); + fileListEl.appendChild(host); + + const renderWindow = () => { + const hostTop = host.getBoundingClientRect().top + window.scrollY; + const viewportTop = window.scrollY; + const viewportBottom = viewportTop + window.innerHeight; + const relativeTop = Math.max(0, viewportTop - hostTop); + const relativeBottom = Math.max(0, viewportBottom - hostTop); + + let start = Math.floor(relativeTop / VIRTUAL_ROW_HEIGHT) - VIRTUAL_OVERSCAN; + let end = Math.ceil(relativeBottom / VIRTUAL_ROW_HEIGHT) + VIRTUAL_OVERSCAN; + + start = Math.max(0, start); + end = Math.min(entries.length, Math.max(start + 1, end)); + + topSpacer.style.height = `${start * VIRTUAL_ROW_HEIGHT}px`; + bottomSpacer.style.height = `${Math.max(0, (entries.length - end) * VIRTUAL_ROW_HEIGHT)}px`; + + rowsContainer.innerHTML = ""; + for (let i = start; i < end; i += 1) { + rowsContainer.appendChild(createFileRow(entries[i])); + } + }; + + const scheduleRender = () => { + if (virtualRenderRaf) return; + virtualRenderRaf = requestAnimationFrame(() => { + virtualRenderRaf = null; + renderWindow(); + }); + }; + + virtualScrollHandler = () => scheduleRender(); + virtualResizeHandler = () => scheduleRender(); + window.addEventListener("scroll", virtualScrollHandler, { passive: true }); + window.addEventListener("resize", virtualResizeHandler); + + renderWindow(); +} + +function renderCurrentDirectory() { + filteredDirectoryEntries = applySortAndFilterEntries(directoryEntries); + const visibleEntries = filteredDirectoryEntries; + updateDirectorySummaryAndPaging(visibleEntries.length, visibleEntries.length); + + const canGoUp = shouldShowParentDirectory(currentPath); + const parentPath = canGoUp ? getParentPath(currentPath) : "."; + + if (visibleEntries.length > VIRTUALIZATION_THRESHOLD) { + renderDirectoryVirtualized(visibleEntries, canGoUp, parentPath); + return; + } + + const disableAnimations = visibleEntries.length > 50; + renderDirectoryStandard(visibleEntries, canGoUp, parentPath, disableAnimations); +} + +function handleFileList(msg) { + if (msg.error) { + clearVirtualizedListMode(); + fileListEl.innerHTML = `
${escapeHtml(msg.error)}
`; + updateDirectorySummaryAndPaging(0, 0); + return; + } + + lastSuccessfulResponse = Date.now(); + updateStatus("connected", "Connected"); + enableControls(true); + + currentPath = msg.path; + directoryEntries = Array.isArray(msg.entries) ? msg.entries : []; + + selectedFiles.clear(); + updateSelectionUI(); + renderCurrentDirectory(); } function shouldShowParentDirectory(path) { @@ -1232,6 +1439,95 @@ fileInput.onchange = async (e) => { listFiles(currentPath); }; +async function uploadMultipleFiles(files) { + for (const file of files) { + await uploadFile(file); + } + listFiles(currentPath); +} + +function hasFileDrag(event) { + const types = Array.from(event.dataTransfer?.types || []); + return types.includes("Files"); +} + +function setDropTargetActive(active) { + if (!fileListPanel) return; + fileListPanel.classList.toggle("ring-2", active); + fileListPanel.classList.toggle("ring-blue-500", active); + fileListPanel.classList.toggle("ring-offset-2", active); + fileListPanel.classList.toggle("ring-offset-slate-950", active); + fileListPanel.classList.toggle("bg-blue-500/5", active); +} + +function setupDragAndDropUpload() { + if (!fileListPanel) return; + + fileListPanel.addEventListener("dragenter", (e) => { + if (!hasFileDrag(e)) return; + e.preventDefault(); + dragDepth += 1; + setDropTargetActive(true); + }); + + fileListPanel.addEventListener("dragover", (e) => { + if (!hasFileDrag(e)) return; + e.preventDefault(); + e.dataTransfer.dropEffect = "copy"; + setDropTargetActive(true); + }); + + fileListPanel.addEventListener("dragleave", (e) => { + if (!hasFileDrag(e)) return; + e.preventDefault(); + dragDepth = Math.max(0, dragDepth - 1); + if (dragDepth === 0) { + setDropTargetActive(false); + } + }); + + fileListPanel.addEventListener("drop", async (e) => { + if (!hasFileDrag(e)) return; + e.preventDefault(); + dragDepth = 0; + setDropTargetActive(false); + + const files = Array.from(e.dataTransfer?.files || []); + if (files.length === 0) return; + + notifyToast(`Uploading ${files.length} file(s)...`, "info", 3000); + await uploadMultipleFiles(files); + }); +} + +if (sortFieldEl) { + sortFieldEl.value = sortField; + sortFieldEl.addEventListener("change", () => { + sortField = sortFieldEl.value; + localStorage.setItem("filebrowser.sortField", sortField); + renderCurrentDirectory(); + }); +} + +if (filterTypeEl) { + filterTypeEl.value = filterType; + filterTypeEl.addEventListener("change", () => { + filterType = filterTypeEl.value; + localStorage.setItem("filebrowser.filterType", filterType); + renderCurrentDirectory(); + }); +} + +if (sortOrderBtn) { + updateSortOrderButton(); + sortOrderBtn.addEventListener("click", () => { + sortOrder = sortOrder === "asc" ? "desc" : "asc"; + localStorage.setItem("filebrowser.sortOrder", sortOrder); + updateSortOrderButton(); + renderCurrentDirectory(); + }); +} + function waitForUploadAck(transfer, offset, timeoutMs) { return new Promise((resolve, reject) => { const existing = transfer.pendingAcks.get(offset); @@ -1450,6 +1746,7 @@ document.addEventListener("click", (e) => { } }); +setupDragAndDropUpload(); updateStatus("connecting", "Connecting..."); updateBackButton(); connect(); @@ -1602,6 +1899,8 @@ searchInput.addEventListener("keypress", (e) => { }); function performSearch(pattern, searchContent) { + clearVirtualizedListMode(); + updateDirectorySummaryAndPaging(0, 0); const searchId = `search-${Date.now()}`; const cmdId = `search-cmd-${Date.now()}`; const msg = { @@ -1624,6 +1923,8 @@ function performSearch(pattern, searchContent) { } function handleFileSearchResult(msg) { + clearVirtualizedListMode(); + updateDirectorySummaryAndPaging(0, 0); if (msg.error) { fileListEl.innerHTML = `
${escapeHtml(msg.error)}
`; return; diff --git a/Overlord-Server/public/filebrowser.html b/Overlord-Server/public/filebrowser.html index 6271edb..ddff47d 100644 --- a/Overlord-Server/public/filebrowser.html +++ b/Overlord-Server/public/filebrowser.html @@ -461,8 +461,56 @@ + +
+
+ + + + + + +
+ +
+ 0 items +
+
+