import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; import { checkFeatureAccess } from "./feature-gate.js"; const clientId = window.location.pathname.split("/")[1]; let ws = null; let currentPath = ""; let pathHistory = []; let selectedFiles = new Set(); let fileDownloads = new Map(); let fileUploads = new Map(); let fileUploadsById = new Map(); let activeTransfers = new Map(); let currentEditingFile = null; let currentPreviewBlobUrl = null; let currentPreviewPath = null; let lastSuccessfulResponse = 0; let pendingToast = null; const recentToasts = new Map(); const pendingCommandResults = new Map(); const pendingCommandWaiters = 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"); const fileListEl = document.getElementById("file-list"); const refreshBtn = document.getElementById("refresh-btn"); const uploadBtn = document.getElementById("upload-btn"); const mkdirBtn = document.getElementById("mkdir-btn"); const searchBtn = document.getElementById("search-btn"); const fileInput = document.getElementById("file-input"); const contextMenu = document.getElementById("context-menu"); const clientIdHeader = document.getElementById("client-id-header"); const backBtn = document.getElementById("back-btn"); const homeBtn = document.getElementById("home-btn"); 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"); const searchContentCheckbox = document.getElementById( "search-content-checkbox", ); const searchExecuteBtn = document.getElementById("search-execute-btn"); const searchCloseBtn = document.getElementById("search-close-btn"); const bulkActionsBar = document.getElementById("bulk-actions-bar"); const selectedCountEl = document.getElementById("selected-count"); const bulkDownloadBtn = document.getElementById("bulk-download-btn"); const bulkDeleteBtn = document.getElementById("bulk-delete-btn"); const bulkMoveBtn = document.getElementById("bulk-move-btn"); const bulkCopyBtn = document.getElementById("bulk-copy-btn"); const clearSelectionBtn = document.getElementById("clear-selection-btn"); const fileEditorModal = document.getElementById("file-editor-modal"); const editorTextarea = document.getElementById("editor-textarea"); const editorFileName = document.getElementById("editor-file-name"); const editorStatus = document.getElementById("editor-status"); const editorSaveBtn = document.getElementById("editor-save-btn"); const editorCancelBtn = document.getElementById("editor-cancel-btn"); const editorCloseBtn = document.getElementById("editor-close-btn"); const filePreviewModal = document.getElementById("file-preview-modal"); const previewFileNameEl = document.getElementById("preview-file-name"); const previewContent = document.getElementById("preview-content"); const previewCloseBtn = document.getElementById("preview-close-btn"); const previewDownloadBtn = document.getElementById("preview-download-btn"); const previewStatusEl = document.getElementById("preview-status"); 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`; const socket = new WebSocket(wsUrl); socket.binaryType = "arraybuffer"; ws = socket; socket.onopen = () => { console.log("File browser connected"); updateStatus("connected", "Connected"); enableControls(true); listFiles(currentPath || ".", socket); }; socket.onmessage = (event) => { const msg = decodeMsgpack(event.data); if (!msg) { console.error("Failed to decode message"); return; } handleMessage(msg); }; socket.onerror = (err) => { console.error("WebSocket error:", err); updateStatus("error", "Connection Error"); }; socket.onclose = () => { console.log("File browser disconnected"); updateStatus("disconnected", "Disconnected"); enableControls(false); if (ws === socket) { setTimeout(() => connect(), 3000); } }; } function updateStatus(state, text) { const icons = { connecting: '', connected: '', error: '', disconnected: '', }; statusEl.innerHTML = `${icons[state] || icons.disconnected} ${text}`; statusEl.className = state === "connected" ? "inline-flex items-center gap-2 px-3 py-2 rounded-full bg-green-900/40 text-green-100 border border-green-700/60" : "inline-flex items-center gap-2 px-3 py-2 rounded-full bg-slate-800 text-slate-300"; } function enableControls(enabled) { refreshBtn.disabled = !enabled; uploadBtn.disabled = !enabled; mkdirBtn.disabled = !enabled; } function send(msg, socket = ws) { if (socket && socket.readyState === WebSocket.OPEN) { console.log( "[DEBUG] Sending message:", msg.type, msg.commandType || "", "to server", ); socket.send(encodeMsgpack(msg)); } else { console.error( "[DEBUG] Cannot send - WebSocket not open. State:", socket?.readyState, ); } } function notifyToast(message, type = "info", duration = 4000) { if (typeof window.showToast !== "function") return; const key = `${type}:${message}`; const now = Date.now(); const last = recentToasts.get(key) || 0; if (now - last < 1000) return; recentToasts.set(key, now); if (document.visibilityState === "hidden") { if (!pendingToast) { pendingToast = { message, type, duration, count: 1 }; } else { pendingToast.message = message; pendingToast.type = type; pendingToast.duration = duration; pendingToast.count += 1; } return; } window.showToast(message, type, duration); } function trackCommandResult(commandId, options = {}) { if (!commandId) return; const { refreshOnSuccess = false, successMessage = null, errorPrefix = "Operation failed", } = options; pendingCommandResults.set(commandId, { refreshOnSuccess, successMessage, errorPrefix, }); } function waitForCommandResult(commandId, timeoutMs = 10 * 60 * 1000) { return new Promise((resolve, reject) => { if (!commandId) { reject(new Error("missing command id")); return; } const existing = pendingCommandWaiters.get(commandId); if (existing) { clearTimeout(existing.timeoutId); existing.reject(new Error("superseded command waiter")); } const timeoutId = setTimeout(() => { pendingCommandWaiters.delete(commandId); reject(new Error("command timed out")); }, timeoutMs); pendingCommandWaiters.set(commandId, { resolve, reject, timeoutId }); }); } document.addEventListener("visibilitychange", () => { if (document.visibilityState !== "visible") return; if (!pendingToast || typeof window.showToast !== "function") return; const { message, type, duration, count } = pendingToast; pendingToast = null; const summary = count > 1 ? `${message} (+${count - 1} more)` : message; window.showToast(summary, type, duration); }); function handleMessage(msg) { console.log("[DEBUG] Received message:", msg.type, msg); switch (msg.type) { case "ready": console.log("Session ready:", msg.sessionId); if (msg.clientUser && msg.clientOs) { applyClientInfo(msg.clientOs, msg.clientUser); } break; case "status": console.log("[DEBUG] Status message:", msg); if (msg.status === "offline") { const recentlyActive = Date.now() - lastSuccessfulResponse < 10_000; if (!recentlyActive) { updateStatus("error", "Client Offline"); enableControls(false); } } break; case "file_list_result": handleFileList(msg); break; case "file_download": handleFileDownload(msg); break; case "file_upload_result": handleFileUploadResult(msg); break; case "file_read_result": console.log("[DEBUG] Routing to handleFileReadResult"); handleFileReadResult(msg); break; case "file_search_result": handleFileSearchResult(msg); break; case "command_result": console.log("[DEBUG] Command result:", msg); handleCommandResult(msg); break; case "command_progress": console.log("[DEBUG] Command progress:", msg); handleCommandProgress(msg); break; default: console.log("[DEBUG] Unknown message type:", msg.type, msg); } } function listFiles(path, socket = ws) { if (currentPath && currentPath !== path) { pathHistory.push(currentPath); } currentPath = path; send({ type: "file_list", path }, socket); updateBreadcrumb(path); updatePathInput(path); updateBackButton(); } function updatePathInput(path) { pathInput.value = path || "."; } function updateBackButton() { backBtn.disabled = pathHistory.length === 0; backBtn.classList.toggle("opacity-50", pathHistory.length === 0); backBtn.classList.toggle("cursor-not-allowed", pathHistory.length === 0); } function goBack() { if (pathHistory.length > 0) { const previousPath = pathHistory.pop(); currentPath = previousPath; send({ type: "file_list", path: previousPath }); updateBreadcrumb(previousPath); updatePathInput(previousPath); updateBackButton(); } } function goHome() { pathHistory = []; listFiles("."); } function updateBreadcrumb(path) { const parts = path.split(/[\/\\]/).filter((p) => p && p !== "."); breadcrumbEl.innerHTML = ""; const root = document.createElement("span"); root.className = "breadcrumb-item hover:text-blue-400 transition-colors"; root.innerHTML = ' Drives'; root.onclick = () => listFiles("."); breadcrumbEl.appendChild(root); if (!path || path === ".") { return; } let accumulated = ""; parts.forEach((part, idx) => { accumulated += (accumulated ? "/" : "") + part; const pathSegment = accumulated; const separator = document.createElement("span"); separator.className = "text-slate-600 mx-1"; separator.innerHTML = ''; breadcrumbEl.appendChild(separator); const crumb = document.createElement("span"); crumb.className = "breadcrumb-item hover:text-blue-400 transition-colors"; crumb.textContent = part; crumb.onclick = () => listFiles(pathSegment); breadcrumbEl.appendChild(crumb); }); } function getFileExt(name = "") { const idx = name.lastIndexOf("."); if (idx < 0 || idx === name.length - 1) return ""; return name.slice(idx + 1).toLowerCase(); } const PREVIEW_IMAGE_EXTS = new Set(["jpg", "jpeg", "png", "gif", "webp", "bmp"]); const PREVIEW_PDF_EXTS = new Set(["pdf"]); const PREVIEW_MAX_BYTES = 50 * 1024 * 1024; // list of binary files from chatgpt (I can't think of anything else either) const KNOWN_BINARY_EXTS = new Set([ "mp4", "avi", "mkv", "mov", "wmv", "flv", "webm", "m4v", "mp3", "wav", "flac", "ogg", "aac", "wma", "m4a", "exe", "msi", "com", "app", "appimage", "dll", "so", "dylib", "lib", "zip", "rar", "7z", "tar", "gz", "bz2", "xz", "tgz", "db", "sqlite", "sqlite3", "mdb", "ttf", "otf", "woff", "woff2", "eot", "iso", "img", "vhd", "vmdk", ]); const IMAGE_MIME_MAP = { jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", bmp: "image/bmp", }; function isPreviewable(name) { const ext = getFileExt(name); return PREVIEW_IMAGE_EXTS.has(ext) || PREVIEW_PDF_EXTS.has(ext); } function getPreviewMimeType(name) { const ext = getFileExt(name); if (PREVIEW_PDF_EXTS.has(ext)) return "application/pdf"; return IMAGE_MIME_MAP[ext] || null; } function closePreview() { if (filePreviewModal) filePreviewModal.classList.remove("show"); if (currentPreviewBlobUrl) { URL.revokeObjectURL(currentPreviewBlobUrl); currentPreviewBlobUrl = null; } if (previewContent) previewContent.innerHTML = ""; if (previewStatusEl) previewStatusEl.textContent = ""; currentPreviewPath = null; } async function openFilePreview(path, knownSize) { const fileName = path.split(/[\/\\]/).pop() || ""; const mimeType = getPreviewMimeType(fileName); if (!mimeType) return; if (typeof knownSize === "number" && knownSize > PREVIEW_MAX_BYTES) { notifyToast(`File too large to preview (${formatBytes(knownSize)})`, "info", 4000); return; } currentPreviewPath = path; if (previewFileNameEl) previewFileNameEl.textContent = fileName; if (previewContent) previewContent.innerHTML = ''; if (previewStatusEl) previewStatusEl.textContent = "Loading..."; if (filePreviewModal) filePreviewModal.classList.add("show"); try { const requestRes = await fetch("/api/file/download/request", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ clientId, path }), }); if (!requestRes.ok) { const text = await requestRes.text(); if (currentPreviewPath !== path) return; if (previewContent) previewContent.innerHTML = `
${escapeHtml(text || "Failed to load preview")}
`; if (previewStatusEl) previewStatusEl.textContent = ""; return; } const requestData = await requestRes.json(); const downloadUrl = typeof requestData?.downloadUrl === "string" ? requestData.downloadUrl : (requestData?.downloadId ? `/api/file/download/${encodeURIComponent(requestData.downloadId)}` : ""); if (!downloadUrl) { if (currentPreviewPath !== path) return; if (previewContent) previewContent.innerHTML = `
Failed to load preview
`; if (previewStatusEl) previewStatusEl.textContent = ""; return; } const res = await fetch(downloadUrl, { method: "GET", credentials: "include", }); if (!res.ok) { const text = await res.text(); if (currentPreviewPath !== path) return; if (previewContent) previewContent.innerHTML = `
${escapeHtml(text || "Failed to load preview")}
`; if (previewStatusEl) previewStatusEl.textContent = ""; return; } // Reject oversized files before buffering to prevent OOM. const contentLength = Number(res.headers.get("Content-Length") || 0); if (contentLength > PREVIEW_MAX_BYTES) { if (currentPreviewPath !== path) return; if (previewContent) previewContent.innerHTML = `
File too large to preview (${escapeHtml(formatBytes(contentLength))})
`; if (previewStatusEl) previewStatusEl.textContent = ""; return; } const arrayBuffer = await res.arrayBuffer(); if (currentPreviewPath !== path) return; if (arrayBuffer.byteLength > PREVIEW_MAX_BYTES) { if (previewContent) previewContent.innerHTML = `
File too large to preview (${escapeHtml(formatBytes(arrayBuffer.byteLength))})
`; if (previewStatusEl) previewStatusEl.textContent = ""; return; } if (currentPreviewBlobUrl) { URL.revokeObjectURL(currentPreviewBlobUrl); currentPreviewBlobUrl = null; } const blob = new Blob([arrayBuffer], { type: mimeType }); currentPreviewBlobUrl = URL.createObjectURL(blob); if (previewContent) { previewContent.innerHTML = ""; if (PREVIEW_IMAGE_EXTS.has(getFileExt(fileName))) { const img = document.createElement("img"); img.src = currentPreviewBlobUrl; img.alt = ""; previewContent.appendChild(img); } else { const obj = document.createElement("object"); obj.data = currentPreviewBlobUrl; obj.type = "application/pdf"; previewContent.appendChild(obj); } } if (previewStatusEl) previewStatusEl.textContent = ""; } catch (err) { if (currentPreviewPath !== path) return; if (previewContent) previewContent.innerHTML = `
${escapeHtml(err.message || "Failed to load preview")}
`; if (previewStatusEl) previewStatusEl.textContent = ""; } } const FILE_ICON_MAP = { // Images jpg: "fa-file-image text-purple-400", jpeg: "fa-file-image text-purple-400", png: "fa-file-image text-purple-400", gif: "fa-file-image text-purple-400", webp: "fa-file-image text-purple-400", bmp: "fa-file-image text-purple-400", svg: "fa-file-image text-purple-400", ico: "fa-file-image text-purple-400", tiff: "fa-file-image text-purple-400", tif: "fa-file-image text-purple-400", // Video mp4: "fa-file-video text-pink-400", avi: "fa-file-video text-pink-400", mkv: "fa-file-video text-pink-400", mov: "fa-file-video text-pink-400", wmv: "fa-file-video text-pink-400", flv: "fa-file-video text-pink-400", webm: "fa-file-video text-pink-400", m4v: "fa-file-video text-pink-400", // Audio mp3: "fa-file-audio text-orange-400", wav: "fa-file-audio text-orange-400", flac: "fa-file-audio text-orange-400", ogg: "fa-file-audio text-orange-400", aac: "fa-file-audio text-orange-400", wma: "fa-file-audio text-orange-400", m4a: "fa-file-audio text-orange-400", // PDF pdf: "fa-file-pdf text-red-400", // Word doc: "fa-file-word text-blue-400", docx: "fa-file-word text-blue-400", odt: "fa-file-word text-blue-400", rtf: "fa-file-word text-blue-400", // Excel xls: "fa-file-excel text-green-400", xlsx: "fa-file-excel text-green-400", ods: "fa-file-excel text-green-400", csv: "fa-file-excel text-green-400", // PowerPoint ppt: "fa-file-powerpoint text-orange-500", pptx: "fa-file-powerpoint text-orange-500", odp: "fa-file-powerpoint text-orange-500", // Archives zip: "fa-file-zipper text-yellow-500", rar: "fa-file-zipper text-yellow-500", "7z": "fa-file-zipper text-yellow-500", tar: "fa-file-zipper text-yellow-500", gz: "fa-file-zipper text-yellow-500", bz2: "fa-file-zipper text-yellow-500", xz: "fa-file-zipper text-yellow-500", tgz: "fa-file-zipper text-yellow-500", // Code js: "fa-file-code text-yellow-300", ts: "fa-file-code text-blue-300", jsx: "fa-file-code text-cyan-300", tsx: "fa-file-code text-cyan-300", py: "fa-file-code text-yellow-300", go: "fa-file-code text-cyan-400", rs: "fa-file-code text-orange-300", c: "fa-file-code text-blue-300", cpp: "fa-file-code text-blue-300", h: "fa-file-code text-blue-300", java: "fa-file-code text-red-300", cs: "fa-file-code text-green-300", rb: "fa-file-code text-red-400", php: "fa-file-code text-indigo-300", swift: "fa-file-code text-orange-400", kt: "fa-file-code text-purple-300", // Web html: "fa-code text-orange-300", htm: "fa-code text-orange-300", css: "fa-code text-blue-300", scss: "fa-code text-pink-300", sass: "fa-code text-pink-300", less: "fa-code text-blue-300", // Config / Data json: "fa-file-code text-emerald-400", yaml: "fa-file-code text-emerald-400", yml: "fa-file-code text-emerald-400", xml: "fa-file-code text-emerald-400", toml: "fa-file-code text-emerald-400", ini: "fa-file-code text-emerald-400", cfg: "fa-file-code text-emerald-400", conf: "fa-file-code text-emerald-400", env: "fa-file-code text-emerald-400", // Text / Docs txt: "fa-file-lines text-slate-300", md: "fa-file-lines text-slate-300", log: "fa-file-lines text-slate-400", readme: "fa-file-lines text-slate-300", // Executables exe: "fa-gear text-green-400", msi: "fa-gear text-green-400", com: "fa-gear text-green-400", app: "fa-gear text-green-400", appimage: "fa-gear text-green-400", // Scripts bat: "fa-terminal text-green-300", cmd: "fa-terminal text-green-300", ps1: "fa-terminal text-blue-300", sh: "fa-terminal text-green-300", bash: "fa-terminal text-green-300", zsh: "fa-terminal text-green-300", // Libraries dll: "fa-puzzle-piece text-indigo-400", so: "fa-puzzle-piece text-indigo-400", dylib: "fa-puzzle-piece text-indigo-400", lib: "fa-puzzle-piece text-indigo-400", a: "fa-puzzle-piece text-indigo-400", // Databases db: "fa-database text-amber-400", sqlite: "fa-database text-amber-400", sqlite3: "fa-database text-amber-400", mdb: "fa-database text-amber-400", sql: "fa-database text-amber-400", // Fonts ttf: "fa-font text-teal-400", otf: "fa-font text-teal-400", woff: "fa-font text-teal-400", woff2: "fa-font text-teal-400", eot: "fa-font text-teal-400", // Keys/certs pem: "fa-key text-yellow-300", crt: "fa-key text-yellow-300", cer: "fa-key text-yellow-300", key: "fa-key text-yellow-300", pfx: "fa-key text-yellow-300", p12: "fa-key text-yellow-300", // Disk images iso: "fa-compact-disc text-slate-300", img: "fa-compact-disc text-slate-300", vhd: "fa-compact-disc text-slate-300", vmdk: "fa-compact-disc text-slate-300", }; function getFileIcon(entry) { if (entry.isDir) { return ''; } const ext = getFileExt(entry.name); const cls = FILE_ICON_MAP[ext]; if (cls) { return ``; } return ''; } 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)"; } const renderList = () => { fileListEl.innerHTML = ""; if (canGoUp) { fileListEl.appendChild(createParentRow(parentPath)); } if (entries.length === 0 && !canGoUp) { fileListEl.innerHTML = '
Empty directory
'; fileListEl.style.opacity = "1"; fileListEl.style.transform = "translateX(0)"; return; } entries.forEach((entry, index) => { const row = createFileRow(entry); if (!disableAnimations) { row.style.animationDelay = `${index * 0.02}s`; row.classList.add("card-animate"); } fileListEl.appendChild(row); }); 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 : []; // Sidebar: detect OS/home on first successful listing if (!detectedOS) { detectOSAndHome(currentPath); // If root listing with drive letters, detect as Windows if (!detectedOS && (!currentPath || currentPath === ".") && directoryEntries.some((e) => e.isDir && e.name.match(/^[A-Za-z]:$/))) { detectedOS = "windows"; updateSidebar(); } } else if (!detectedHomePath) { // Keep trying to detect home path on subsequent navigations detectOSAndHome(currentPath); } // Populate drives panel when at root if (!msg.path || msg.path === ".") { lastDriveEntries = directoryEntries; updateSidebarDrives(directoryEntries); } else if (lastDriveEntries.length === 0) { updateSidebarDrives([]); } highlightSidebarActive(); selectedFiles.clear(); updateSelectionUI(); renderCurrentDirectory(); } function shouldShowParentDirectory(path) { if (!path || path === ".") { return false; } return true; } function getParentPath(path) { const normalized = path.replace(/\\/g, "/"); const parts = normalized.split("/").filter((p) => p); if (parts.length === 1 && parts[0].match(/^[A-Za-z]:$/)) { return "."; } if (parts.length <= 1) { return "."; } parts.pop(); let parentPath = parts.join("/"); if (parentPath.match(/^[A-Za-z]:?$/)) { return parentPath.replace(/^([A-Za-z]):?$/, "$1:\\"); } return parentPath || "."; } function createParentRow(parentPath) { const row = document.createElement("div"); row.className = "file-item grid grid-cols-12 gap-3 px-4 py-3 border border-transparent cursor-pointer transition-colors hover:bg-slate-800/50"; row.dataset.path = parentPath; row.dataset.isDir = "true"; row.innerHTML = `
.. (parent directory)
-
-
`; row.ondblclick = () => listFiles(parentPath); row.onclick = () => listFiles(parentPath); return row; } function createFileRow(entry) { const row = document.createElement("div"); row.className = "file-item grid grid-cols-12 gap-3 px-4 py-3 border border-transparent cursor-pointer transition-colors"; row.dataset.path = entry.path; row.dataset.isDir = entry.isDir; const icon = getFileIcon(entry); const size = entry.isDir ? "-" : formatBytes(entry.size); const modTime = new Date(entry.modTime * 1000).toLocaleString(); row.innerHTML = `
${icon} ${escapeHtml(entry.name)}
${size}
${modTime}
${!entry.isDir ? '' : ""} ${entry.isDir ? '' : ""}
`; const nameDiv = row.querySelector(".col-span-6"); const mobileMetaDiv = document.createElement("div"); mobileMetaDiv.className = "file-meta"; mobileMetaDiv.innerHTML = `${size}·${modTime}`; nameDiv.appendChild(mobileMetaDiv); row.onclick = (e) => { if (e.target.closest(".file-checkbox") || e.target.closest(".action-btn")) { return; } if (entry.isDir) { listFiles(entry.path); } else if (isPreviewable(entry.name)) { openFilePreview(entry.path, entry.size); } else if (KNOWN_BINARY_EXTS.has(getFileExt(entry.name))) { notifyToast("Binary file — use Download to save it", "info", 3000); } else { openFileInEditor(entry.path); } }; const checkbox = row.querySelector(".file-checkbox"); checkbox.onclick = (e) => { e.stopPropagation(); }; checkbox.onchange = (e) => { if (e.target.checked) { selectedFiles.add(entry.path); row.classList.add("selected"); } else { selectedFiles.delete(entry.path); row.classList.remove("selected"); } updateSelectionUI(); }; row.querySelectorAll(".action-btn").forEach((btn) => { btn.onclick = (e) => { e.stopPropagation(); const action = btn.dataset.action; handleFileAction(action, entry); }; }); row.oncontextmenu = (e) => { e.preventDefault(); showContextMenu(e.clientX, e.clientY, entry); }; return row; } function toggleSelection(row, path) { const checkbox = row.querySelector(".file-checkbox"); if (selectedFiles.has(path)) { selectedFiles.delete(path); row.classList.remove("selected"); if (checkbox) checkbox.checked = false; } else { selectedFiles.add(path); row.classList.add("selected"); if (checkbox) checkbox.checked = true; } updateSelectionUI(); } function handleFileAction(action, entry) { switch (action) { case "edit": openFileInEditor(entry.path); break; case "download": downloadFile(entry.path); break; case "zip": zipAndDownload(entry.path); break; case "copy": const copyDest = prompt("Copy to:", entry.path + "_copy"); if (copyDest) { const commandId = `copy-${Date.now()}`; send({ type: "command", commandType: "file_copy", id: commandId, payload: { source: entry.path, dest: copyDest }, }); trackCommandResult(commandId, { refreshOnSuccess: true, successMessage: "Copy completed", errorPrefix: "Copy failed", }); } break; case "move": const moveDest = prompt("Move to:", entry.path); if (moveDest) { const commandId = `move-${Date.now()}`; send({ type: "command", commandType: "file_move", id: commandId, payload: { source: entry.path, dest: moveDest }, }); trackCommandResult(commandId, { refreshOnSuccess: true, successMessage: "Move completed", errorPrefix: "Move failed", }); } break; case "chmod": const mode = prompt( "Enter permissions (octal, e.g., 0755):", entry.mode || "0644", ); if (mode) { const commandId = `chmod-${Date.now()}`; send({ type: "command", commandType: "file_chmod", id: commandId, payload: { path: entry.path, mode }, }); trackCommandResult(commandId, { refreshOnSuccess: true, successMessage: "Permissions updated", errorPrefix: "Permissions update failed", }); } break; case "delete": deleteFile(entry.path); break; } } function downloadFile(path) { console.log("Requesting download:", path); const transferId = `download-${Date.now()}-${Math.random()}`; const fileName = path.split(/[\/\\]/).pop(); const abortController = new AbortController(); const transfer = { id: transferId, type: "download", path, fileName, progress: 0, total: 0, received: 0, receivedBytes: 0, receivedOffsets: new Map(), receivedChunks: new Set(), chunkSize: 0, expectedChunks: 0, buffer: null, chunks: [], cancelled: false, abortController, source: "http", expectedCommandId: null, }; fileDownloads.set(path, transfer); activeTransfers.set(transferId, transfer); addTransferToUI(transfer); updateStatus("connected", `Downloading ${fileName}...`); (async () => { try { console.debug("[filebrowser] download request", { path, clientId }); const requestRes = await fetch("/api/file/download/request", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", signal: abortController.signal, body: JSON.stringify({ clientId, path }), }); console.debug("[filebrowser] download request response", { ok: requestRes.ok, status: requestRes.status, }); if (!requestRes.ok) { const text = await requestRes.text(); notifyToast(text || "Download failed", "error", 5000); removeTransfer(transferId); fileDownloads.delete(path); updateStatus("connected", "Connected"); return; } const requestData = await requestRes.json(); const downloadUrl = typeof requestData?.downloadUrl === "string" ? requestData.downloadUrl : (requestData?.downloadId ? `/api/file/download/${encodeURIComponent(requestData.downloadId)}` : ""); if (!downloadUrl) { notifyToast("Download failed", "error", 5000); removeTransfer(transferId); fileDownloads.delete(path); updateStatus("connected", "Connected"); return; } console.debug("[filebrowser] download request accepted", { downloadUrl, }); console.debug("[filebrowser] download stream start", { downloadUrl, }); const res = await fetch(downloadUrl, { method: "GET", credentials: "include", signal: abortController.signal, }); console.debug("[filebrowser] download response", { ok: res.ok, status: res.status, contentType: res.headers.get("Content-Type"), contentLength: res.headers.get("Content-Length"), }); if (!res.ok) { const text = await res.text(); notifyToast(text || "Download failed", "error", 5000); removeTransfer(transferId); fileDownloads.delete(path); updateStatus("connected", "Connected"); return; } const total = Number(res.headers.get("Content-Length") || 0); if (Number.isFinite(total) && total > 0) { transfer.total = total; } const chunks = []; let received = 0; let lastLoggedBytes = 0; if (res.body) { const reader = res.body.getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; if (value) { chunks.push(value); received += value.length; transfer.received = received; if (received - lastLoggedBytes >= 5 * 1024 * 1024) { lastLoggedBytes = received; console.debug("[filebrowser] download stream", { path, received, total: transfer.total, }); } if (transfer.total > 0) { transfer.progress = Math.round((received / transfer.total) * 100); updateTransferProgress(transferId, transfer.progress, received, transfer.total); } } if (transfer.cancelled) { break; } } } else { console.warn("[filebrowser] download response missing body", { path }); const blob = await res.blob(); chunks.push(new Uint8Array(await blob.arrayBuffer())); received = chunks[0]?.length || 0; transfer.received = received; } if (transfer.cancelled) { removeTransfer(transferId); fileDownloads.delete(path); updateStatus("connected", "Connected"); return; } if (transfer.total === 0 && received > 0) { transfer.progress = 100; updateTransferProgress(transferId, transfer.progress, received, transfer.total); } const blob = new Blob(chunks); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = fileName; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); console.log("Download complete:", path, `${received} bytes`); console.debug("[filebrowser] download complete", { path, received, total: transfer.total, chunks: chunks.length, }); removeTransfer(transferId); fileDownloads.delete(path); updateStatus("connected", "Connected"); } catch (err) { if (transfer.cancelled) return; console.error("Download error:", err); notifyToast(`Download failed: ${err.message || err}`, "error", 5000); removeTransfer(transferId); fileDownloads.delete(path); updateStatus("connected", "Connected"); } })(); } function handleFileDownload(msg) { const toNumber = (value) => { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "bigint") { const asNumber = Number(value); if (Number.isSafeInteger(asNumber)) return asNumber; } return null; }; console.debug("[filebrowser] file_download", { path: msg.path, hasData: !!msg.data, dataLen: msg.data?.length ?? null, totalType: typeof msg.total, total: msg.total, chunkIndex: msg.chunkIndex, chunksTotal: msg.chunksTotal, offset: msg.offset, error: msg.error || null, }); if (msg.error) { alert(`Download failed: ${msg.error}`); const download = fileDownloads.get(msg.path); if (download) { removeTransfer(download.id); fileDownloads.delete(msg.path); } return; } let download = fileDownloads.get(msg.path); if (!download) { return; } if (download.source === "http") { return; } if (download.expectedCommandId) { if (!msg.commandId || msg.commandId !== download.expectedCommandId) { console.debug("[filebrowser] ignoring unsolicited download", { path: msg.path, commandId: msg.commandId || null, }); return; } } if (download.cancelled) { fileDownloads.delete(msg.path); return; } const total = toNumber(msg.total); if (total && total > 0) { if (!download.total) { download.total = total; console.debug("[filebrowser] download total set", { path: msg.path, total: download.total, }); } if (download.total > 0 && !download.buffer) { download.buffer = new Uint8Array(download.total); download.receivedBytes = 0; download.receivedOffsets = new Map(); download.receivedChunks = new Set(); download.chunkSize = 0; download.expectedChunks = 0; download.chunks = []; } } const chunkIndex = toNumber(msg.chunkIndex); const chunksTotal = toNumber(msg.chunksTotal); if (chunksTotal && !download.expectedChunks) { download.expectedChunks = chunksTotal; console.debug("[filebrowser] expected chunks set", { path: msg.path, expectedChunks: download.expectedChunks, }); } if (msg.data && msg.data.length > 0) { let data = msg.data; if (data instanceof ArrayBuffer) { data = new Uint8Array(data); } else if (typeof data === "string") { data = Uint8Array.from(atob(data), (c) => c.charCodeAt(0)); } if (data instanceof Uint8Array) { const chunkOffset = toNumber(msg.offset); if (download.total > 0 && !download.chunkSize && data.length > 0) { download.chunkSize = data.length; if (!download.expectedChunks) { download.expectedChunks = Math.ceil(download.total / download.chunkSize); console.debug("[filebrowser] inferred expected chunks", { path: msg.path, expectedChunks: download.expectedChunks, chunkSize: download.chunkSize, }); } } if (download.total > 0 && download.buffer && chunkOffset !== null) { const end = chunkOffset + data.length; if (chunkOffset >= 0 && end <= download.total) { const seen = chunkIndex !== null ? download.receivedChunks.has(chunkIndex) : download.receivedOffsets.has(chunkOffset); if (!seen) { download.buffer.set(data, chunkOffset); if (chunkIndex !== null) { download.receivedChunks.add(chunkIndex); } else { download.receivedOffsets.set(chunkOffset, data.length); } download.receivedBytes += data.length; } } download.received = Math.min(download.receivedBytes, download.total); } else { download.chunks.push(data); download.received += data.length; } } } if (download.total > 0) { download.progress = Math.round((download.received / download.total) * 100); updateTransferProgress( download.id, download.progress, download.received, download.total, ); console.debug("[filebrowser] download progress", { path: msg.path, progress: download.progress, received: download.received, total: download.total, }); } else if (download.expectedChunks > 0) { const chunkProgress = Math.round( (download.receivedChunks.size / download.expectedChunks) * 100, ); download.progress = Math.min(100, Math.max(0, chunkProgress)); updateTransferProgress( download.id, download.progress, download.received, download.total || 0, ); console.debug("[filebrowser] download chunk progress", { path: msg.path, progress: download.progress, receivedChunks: download.receivedChunks.size, expectedChunks: download.expectedChunks, received: download.received, }); } const receivedChunkCount = download.receivedChunks.size + download.receivedOffsets.size; const hasAllChunks = download.expectedChunks > 0 ? receivedChunkCount >= download.expectedChunks : download.received >= download.total; if ((download.total > 0 ? download.received >= download.total : hasAllChunks) && hasAllChunks) { console.debug("[filebrowser] download complete", { path: msg.path, received: download.received, total: download.total, expectedChunks: download.expectedChunks, receivedChunks: download.receivedChunks.size, }); let fullData = null; if (download.buffer) { fullData = download.buffer; } else { fullData = new Uint8Array(download.received); let offset = 0; download.chunks.forEach((chunk) => { fullData.set(chunk, offset); offset += chunk.length; }); } const blob = new Blob([fullData]); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = download.fileName; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); console.log("Download complete:", msg.path, `${download.received} bytes`); removeTransfer(download.id); fileDownloads.delete(msg.path); updateStatus("connected", "Connected"); } } function zipAndDownload(path) { console.log("Requesting zip:", path); const zipPath = path + ".zip"; const transferId = `download-zip-${Date.now()}-${Math.random()}`; const fileName = zipPath.split(/[\/\\]/).pop(); const transfer = { id: transferId, type: "download", path: zipPath, fileName, progress: 0, total: 0, received: 0, receivedBytes: 0, receivedOffsets: new Map(), receivedChunks: new Set(), chunkSize: 0, expectedChunks: 0, buffer: null, chunks: [], cancelled: false, source: "ws", expectedCommandId: null, }; fileDownloads.set(zipPath, transfer); const commandId = "zip_" + Date.now(); transfer.expectedCommandId = commandId; send({ type: "file_zip", path, commandId }); trackCommandResult(commandId, { refreshOnSuccess: true, successMessage: "Zip completed", errorPrefix: "Zip failed", }); showProgressNotification(commandId, "Starting zip operation...", path); } let activeProgressNotifications = new Map(); function showProgressNotification(commandId, message, path) { hideProgressNotification(commandId); const notification = document.createElement("div"); notification.id = `progress-${commandId}`; notification.className = "fixed bottom-4 right-4 bg-slate-800 border border-slate-700 rounded-lg shadow-lg p-4 min-w-[320px] z-50"; notification.innerHTML = `
Zipping Directory
${escapeHtml(message)}
${escapeHtml(path)}
`; document.body.appendChild(notification); activeProgressNotifications.set(commandId, notification); } function updateProgressNotification(commandId, message) { const messageEl = document.getElementById(`progress-message-${commandId}`); if (messageEl) { messageEl.textContent = message; } } function hideProgressNotification(commandId) { const notification = activeProgressNotifications.get(commandId); if (notification) { notification.remove(); activeProgressNotifications.delete(commandId); } } function cancelZipOperation(commandId) { if (confirm("Cancel this zip operation?")) { send({ type: "command_abort", commandId }); hideProgressNotification(commandId); updateStatus("connected", "Zip operation cancelled"); } } function handleCommandProgress(msg) { if (msg.commandId) { updateProgressNotification(msg.commandId, msg.message || "Processing..."); } } function deleteFile(path) { if (!confirm(`Are you sure you want to delete ${path}?`)) return; console.log("Deleting:", path); const commandId = `delete-${Date.now()}`; send({ type: "file_delete", path, commandId }); trackCommandResult(commandId, { refreshOnSuccess: true, successMessage: "Delete completed", errorPrefix: "Delete failed", }); } function handleFileUploadResult(msg) { const toNumber = (value) => { if (typeof value === "number" && Number.isFinite(value)) return value; if (typeof value === "bigint") { const asNumber = Number(value); if (Number.isSafeInteger(asNumber)) return asNumber; } return null; }; const transfer = msg.transferId ? fileUploadsById.get(msg.transferId) : (msg.path ? fileUploads.get(msg.path) : null); if (!msg.ok) { notifyToast(`Upload failed: ${msg.error}`, "error", 5000); updateStatus("connected", "Connected"); if (transfer) { removeTransfer(transfer.id); fileUploads.delete(transfer.path); fileUploadsById.delete(transfer.transferId); } return; } if (!transfer) { console.log("Upload ack received (no active transfer):", msg.path || msg.transferId); return; } const offset = toNumber(msg.offset); if (offset !== null) { const firstAckForOffset = !transfer.ackedOffsets.has(offset); if (firstAckForOffset) { transfer.ackedOffsets.add(offset); transfer.receivedChunks += 1; } if (Number.isFinite(msg.received)) { transfer.receivedBytes = Math.min(Number(msg.received), transfer.total); transfer.sent = transfer.receivedBytes; } if (transfer.total > 0) { transfer.progress = Math.round((transfer.sent / transfer.total) * 100); updateTransferProgress(transfer.id, transfer.progress, transfer.sent, transfer.total); } } const pendingOffset = offset !== null ? offset : (transfer.pendingAcks.has(0) ? 0 : null); if (pendingOffset !== null) { const pending = transfer.pendingAcks.get(pendingOffset); if (pending) { clearTimeout(pending.timeoutId); transfer.pendingAcks.delete(pendingOffset); pending.resolve(msg); } } if (transfer.completed && transfer.receivedChunks >= transfer.expectedChunks) { finishUpload(transfer); } } function handleCommandResult(msg) { if (msg.commandId && activeProgressNotifications.has(msg.commandId)) { setTimeout(() => hideProgressNotification(msg.commandId), 2000); } if (currentEditingFile && editorStatus.textContent === "Saving...") { if (msg.ok) { editorStatus.textContent = "Saved successfully!"; notifyToast("File saved successfully", "success", 5000); setTimeout(closeEditor, 1000); } else { editorStatus.textContent = `Error: ${msg.message || "Save failed"}`; notifyToast( `Save failed: ${msg.message || "Unknown error"}`, "error", 5000, ); editorSaveBtn.disabled = false; } if (msg.commandId) pendingCommandResults.delete(msg.commandId); return; } const tracked = msg.commandId ? pendingCommandResults.get(msg.commandId) : null; const waiter = msg.commandId ? pendingCommandWaiters.get(msg.commandId) : null; if (waiter) { clearTimeout(waiter.timeoutId); pendingCommandWaiters.delete(msg.commandId); if (msg.ok) { waiter.resolve(msg); } else { waiter.reject(new Error(msg.message || "operation failed")); } } if (!tracked) { return; } if (msg.commandId) pendingCommandResults.delete(msg.commandId); if (!msg.ok) { const errorText = msg.message ? `${tracked.errorPrefix}: ${msg.message}` : tracked.errorPrefix; notifyToast( errorText, "error", 5000, ); } else { notifyToast( tracked.successMessage || "Operation completed successfully", "success", 5000, ); if (tracked.refreshOnSuccess) { listFiles(currentPath); } } } function showContextMenu(x, y, entry) { contextMenu.style.left = `${x}px`; contextMenu.style.top = `${y}px`; contextMenu.classList.add("show"); contextMenu.dataset.path = entry.path; contextMenu.dataset.isDir = entry.isDir; const editItem = contextMenu.querySelector('[data-action="edit"]'); const zipItem = contextMenu.querySelector('[data-action="zip"]'); const chmodItem = contextMenu.querySelector('[data-action="chmod"]'); if (editItem) editItem.style.display = entry.isDir ? "none" : "block"; if (zipItem) zipItem.style.display = entry.isDir ? "block" : "none"; if (chmodItem) chmodItem.style.display = entry.mode ? "block" : "none"; } function hideContextMenu() { contextMenu.classList.remove("show"); } function formatBytes(bytes) { if (bytes === 0 || bytes === 0n) return "0 B"; const sizes = ["B", "KB", "MB", "GB", "TB"]; if (typeof bytes === "bigint") { const k = 1024n; let i = 0; let value = bytes; while (value >= k && i < sizes.length - 1) { value /= k; i += 1; } return `${value.toString()} ${sizes[i]}`; } const k = 1024; const i = Math.floor(Math.log(bytes) / Math.log(k)); return Math.round((bytes / Math.pow(k, i)) * 100) / 100 + " " + sizes[i]; } function escapeHtml(text) { const div = document.createElement("div"); div.textContent = text; return div.innerHTML; } refreshBtn.onclick = () => listFiles(currentPath); uploadBtn.onclick = () => fileInput.click(); fileInput.onchange = async (e) => { const files = Array.from(e.target.files); if (files.length === 0) return; for (const file of files) { await uploadFile(file); } fileInput.value = ""; 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 finishUpload(transfer) { console.log("Upload complete:", transfer.path); updateStatus("connected", "Connected"); notifyToast("File uploaded successfully", "success", 5000); removeTransfer(transfer.id); fileUploads.delete(transfer.path); fileUploadsById.delete(transfer.transferId); listFiles(currentPath); } async function uploadFileViaHttpPull(file, path, transfer) { console.debug("[filebrowser] upload request start", { clientId, path, fileName: file.name, size: file.size, }); const requestRes = await fetch("/api/file/upload/request", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "include", body: JSON.stringify({ clientId, path, fileName: file.name, }), }); if (!requestRes.ok) { const text = await requestRes.text(); throw new Error(text || "upload request failed"); } const requestData = await requestRes.json(); const uploadUrl = typeof requestData?.uploadUrl === "string" ? requestData.uploadUrl : (requestData?.uploadId ? `/api/file/upload/${encodeURIComponent(requestData.uploadId)}` : ""); if (!uploadUrl) { throw new Error("upload request failed"); } console.debug("[filebrowser] upload stage url", { uploadUrl }); const uploadData = await new Promise((resolve, reject) => { const xhr = new XMLHttpRequest(); transfer.uploadXhr = xhr; // Keep request-level timeout high for large WAN uploads. xhr.timeout = 30 * 60 * 1000; xhr.open("POST", uploadUrl, true); xhr.withCredentials = true; xhr.responseType = "text"; xhr.setRequestHeader("Content-Type", "application/octet-stream"); const onAbort = () => { try { xhr.abort(); } catch {} }; if (transfer.abortController?.signal) { transfer.abortController.signal.addEventListener("abort", onAbort, { once: true }); } xhr.upload.onprogress = (event) => { const total = event.total || file.size || transfer.total || 0; if (!total) return; const loaded = Math.min(event.loaded, total); // First 50% is browser -> server staging upload. const stageRatio = loaded / total; transfer.receivedBytes = loaded; transfer.sent = loaded; transfer.progress = Math.max(0, Math.min(50, Math.round(stageRatio * 50))); updateTransferProgress(transfer.id, transfer.progress, transfer.sent, transfer.total); }; xhr.onerror = () => { reject(new Error("upload staging failed")); }; xhr.ontimeout = () => { reject(new Error("upload staging timed out")); }; xhr.onabort = () => { reject(new Error("Upload cancelled")); }; xhr.onload = () => { const text = xhr.responseText || ""; if (xhr.status < 200 || xhr.status >= 300) { console.debug("[filebrowser] upload stage failed", { status: xhr.status, body: text, }); reject(new Error(text || "upload staging failed")); return; } try { resolve(text ? JSON.parse(text) : {}); } catch { reject(new Error("upload staging failed")); } }; try { xhr.send(file); } catch (err) { reject(err instanceof Error ? err : new Error("upload staging failed")); } }); if (!uploadData?.pullUrl) { throw new Error("upload staging failed"); } console.debug("[filebrowser] upload staged", { pullUrl: uploadData.pullUrl, size: uploadData.size, }); const commandId = `upload-http-${Date.now()}-${Math.random()}`; const waitResult = waitForCommandResult(commandId, 12 * 60 * 1000); send({ type: "command", commandType: "file_upload_http", id: commandId, payload: { path, url: uploadData.pullUrl, total: file.size, }, }); await waitResult; console.debug("[filebrowser] upload command completed", { path, size: file.size, }); transfer.receivedBytes = file.size; transfer.sent = file.size; transfer.progress = 100; transfer.receivedChunks = transfer.expectedChunks; updateTransferProgress(transfer.id, transfer.progress, transfer.sent, transfer.total); transfer.uploadXhr = null; } async function uploadFile(file) { const path = currentPath ? `${currentPath}/${file.name}` : file.name; const transferId = `upload-${Date.now()}-${Math.random()}`; console.log("Uploading:", path); const transfer = { id: transferId, type: "upload", path, fileName: file.name, progress: 0, total: file.size, sent: 0, cancelled: false, expectedChunks: 0, receivedChunks: 0, receivedBytes: 0, pendingAcks: new Map(), ackedOffsets: new Set(), transferId, completed: false, abortController: new AbortController(), uploadXhr: null, }; fileUploads.set(path, transfer); fileUploadsById.set(transferId, transfer); activeTransfers.set(transferId, transfer); addTransferToUI(transfer); try { await uploadFileViaHttpPull(file, path, transfer); finishUpload(transfer); } catch (err) { console.error("Upload error:", err); removeTransfer(transferId); fileUploads.delete(path); fileUploadsById.delete(transferId); alert(`Upload failed: ${err.message}`); } } mkdirBtn.onclick = () => { const name = prompt("Enter folder name:"); if (!name) return; const path = currentPath ? `${currentPath}/${name}` : name; console.log("Creating directory:", path); const commandId = `mkdir-${Date.now()}`; send({ type: "file_mkdir", path, commandId }); trackCommandResult(commandId, { refreshOnSuccess: true, successMessage: "Folder created", errorPrefix: "Create folder failed", }); }; backBtn.onclick = () => goBack(); homeBtn.onclick = () => goHome(); pathGoBtn.onclick = () => { const path = pathInput.value.trim(); if (path) { pathHistory = []; listFiles(path); } }; pathInput.onkeydown = (e) => { if (e.key === "Enter") { const path = pathInput.value.trim(); if (path) { pathHistory = []; listFiles(path); } } }; document.addEventListener("click", (e) => { if (!e.target.closest("#context-menu")) { hideContextMenu(); } }); setupDragAndDropUpload(); updateStatus("connecting", "Connecting..."); updateBackButton(); // ── Sidebar Quick Access ── let detectedHomePath = ""; let detectedOS = ""; const sidebarContent = document.getElementById("sidebar-content"); const sidebarDrives = document.getElementById("sidebar-drives"); let lastDriveEntries = []; function detectOSAndHome(path) { if (!path || path === ".") return; const winMatch = path.match(/^([A-Za-z]:\\Users\\[^\\]+)/i); if (winMatch) { detectedOS = "windows"; detectedHomePath = winMatch[1]; updateSidebar(); return; } if (path.match(/^[A-Za-z]:\\/)) { detectedOS = "windows"; updateSidebar(); return; } const macMatch = path.match(/^(\/Users\/[^\/]+)/); if (macMatch) { detectedOS = "mac"; detectedHomePath = macMatch[1]; updateSidebar(); return; } const linuxMatch = path.match(/^(\/home\/[^\/]+)/); if (linuxMatch) { detectedOS = "linux"; detectedHomePath = linuxMatch[1]; updateSidebar(); return; } if (path.startsWith("/root")) { detectedOS = "linux"; detectedHomePath = "/root"; updateSidebar(); return; } if (path.startsWith("/")) { detectedOS = "linux"; updateSidebar(); } } function applyClientInfo(osStr, userName) { if (detectedOS && detectedHomePath) return; const os = (osStr || "").toLowerCase(); const user = (userName || "").trim(); if (!user) return; if (os.includes("windows")) { detectedOS = "windows"; detectedHomePath = "C:\\Users\\" + user; } else if (os.includes("darwin") || os.includes("mac")) { detectedOS = "mac"; detectedHomePath = "/Users/" + user; } else { detectedOS = "linux"; detectedHomePath = user === "root" ? "/root" : "/home/" + user; } updateSidebar(); updateSidebarDrives(lastDriveEntries); } function sidebarItem(icon, label, path, color) { const active = currentPath && (currentPath === path || currentPath.startsWith(path + "/") || currentPath.startsWith(path + "\\")); return ``; } function updateSidebar() { if (!sidebarContent) return; let html = ""; if (detectedOS === "windows" && detectedHomePath) { const h = detectedHomePath; html += sidebarItem("fa-desktop", "Desktop", h + "\\Desktop", "text-blue-400"); html += sidebarItem("fa-download", "Downloads", h + "\\Downloads", "text-green-400"); html += sidebarItem("fa-file-lines", "Documents", h + "\\Documents", "text-yellow-400"); html += sidebarItem("fa-images", "Pictures", h + "\\Pictures", "text-purple-400"); html += sidebarItem("fa-music", "Music", h + "\\Music", "text-pink-400"); html += sidebarItem("fa-video", "Videos", h + "\\Videos", "text-red-400"); html += '
'; html += sidebarItem("fa-gear", "AppData", h + "\\AppData", "text-slate-400"); html += sidebarItem("fa-temperature-high", "Temp", h + "\\AppData\\Local\\Temp", "text-orange-400"); html += sidebarItem("fa-folder-open", "Program Files", "C:\\Program Files", "text-amber-400"); html += sidebarItem("fa-window-maximize", "Windows", "C:\\Windows", "text-blue-300"); } else if (detectedOS === "linux" || detectedOS === "mac") { if (detectedHomePath) { html += sidebarItem("fa-home", "Home", detectedHomePath, "text-blue-400"); html += sidebarItem("fa-desktop", "Desktop", detectedHomePath + "/Desktop", "text-blue-300"); html += sidebarItem("fa-download", "Downloads", detectedHomePath + "/Downloads", "text-green-400"); html += sidebarItem("fa-file-lines", "Documents", detectedHomePath + "/Documents", "text-yellow-400"); html += '
'; } html += sidebarItem("fa-gears", "/etc", "/etc", "text-slate-400"); html += sidebarItem("fa-database", "/var", "/var", "text-amber-400"); html += sidebarItem("fa-temperature-high", "/tmp", "/tmp", "text-orange-400"); html += sidebarItem("fa-cube", "/opt", "/opt", "text-teal-400"); html += sidebarItem("fa-user", "/usr", "/usr", "text-indigo-400"); } else { html = '
Navigate to detect paths
'; } sidebarContent.innerHTML = html; bindSidebarClicks(sidebarContent); } function updateSidebarDrives(entries) { if (!sidebarDrives) return; if (!entries || entries.length === 0) { if (detectedOS === "windows") { sidebarDrives.innerHTML = sidebarItem("fa-hard-drive", "This PC", ".", "text-slate-300"); } else { sidebarDrives.innerHTML = sidebarItem("fa-hard-drive", "Root /", "/", "text-slate-300"); } bindSidebarClicks(sidebarDrives); return; } let html = ""; entries.forEach((e) => { if (e.isDir && e.name.match(/^[A-Za-z]:$/)) { html += sidebarItem("fa-hard-drive", e.name + "\\", e.name + "\\", "text-slate-300"); } }); if (!html) { html = sidebarItem("fa-hard-drive", detectedOS === "windows" ? "This PC" : "Root /", detectedOS === "windows" ? "." : "/", "text-slate-300"); } sidebarDrives.innerHTML = html; bindSidebarClicks(sidebarDrives); } function bindSidebarClicks(container) { container.querySelectorAll(".sidebar-item").forEach((btn) => { btn.onclick = () => listFiles(btn.dataset.path); }); } function highlightSidebarActive() { document.querySelectorAll(".sidebar-item").forEach((btn) => { const p = btn.dataset.path; const active = currentPath && (currentPath === p || currentPath.startsWith(p + "/") || currentPath.startsWith(p + "\\")); btn.classList.toggle("active", active); }); } checkFeatureAccess("file_browser", clientId).then(ok => ok && connect()); function addTransferToUI(transfer) { const transferItem = document.createElement("div"); transferItem.id = `transfer-${transfer.id}`; transferItem.className = "transfer-item bg-slate-800/50 border border-slate-700 rounded-lg p-3"; const icon = transfer.type === "upload" ? "fa-upload" : "fa-download"; const color = transfer.type === "upload" ? "text-blue-400" : "text-green-400"; transferItem.innerHTML = `
${transfer.progress}% ${formatBytes(transfer.sent || transfer.received || 0)} / ${formatBytes(transfer.total)}
`; const nameEl = transferItem.querySelector(".transfer-name"); if (nameEl) { nameEl.textContent = transfer.fileName; } const cancelBtn = transferItem.querySelector(".cancel-btn"); if (cancelBtn) { cancelBtn.addEventListener("click", () => cancelTransfer(transfer.id)); } transferList.appendChild(transferItem); transferPanel.classList.remove("hidden"); } function updateTransferProgress(transferId, progress, current, total) { const transferItem = document.getElementById(`transfer-${transferId}`); if (!transferItem) return; const progressBar = transferItem.querySelector(".progress-bar"); const progressText = transferItem.querySelector(".progress-text"); const sizeText = transferItem.querySelector(".size-text"); if (progressBar) progressBar.style.width = `${progress}%`; if (progressText) progressText.textContent = `${progress}%`; if (sizeText) sizeText.textContent = `${formatBytes(current)} / ${formatBytes(total)}`; } function removeTransfer(transferId) { const transferItem = document.getElementById(`transfer-${transferId}`); if (transferItem) { transferItem.remove(); } activeTransfers.delete(transferId); if (transferList.children.length === 0) { transferPanel.classList.add("hidden"); } } window.cancelTransfer = function (transferId) { const transfer = activeTransfers.get(transferId); if (transfer) { transfer.cancelled = true; if (transfer.abortController) { transfer.abortController.abort(); } if (transfer.pendingAcks) { transfer.pendingAcks.forEach((pending) => { clearTimeout(pending.timeoutId); pending.reject(new Error("Upload cancelled")); }); transfer.pendingAcks.clear(); } removeTransfer(transferId); if (transfer.type === "upload") { fileUploads.delete(transfer.path); fileUploadsById.delete(transfer.transferId); } else { fileDownloads.delete(transfer.path); } console.log("Transfer cancelled:", transferId); } }; function updateSelectionUI() { const count = selectedFiles.size; selectedCountEl.textContent = count; if (count > 0) { bulkActionsBar.classList.remove("hidden"); } else { bulkActionsBar.classList.add("hidden"); } document.querySelectorAll(".file-item").forEach((row) => { const path = row.dataset.path; if (selectedFiles.has(path)) { row.classList.add("selected"); } else { row.classList.remove("selected"); } }); } function clearSelection() { selectedFiles.clear(); updateSelectionUI(); } searchBtn.addEventListener("click", () => { searchBar.classList.toggle("hidden"); if (!searchBar.classList.contains("hidden")) { searchInput.focus(); } }); searchCloseBtn.addEventListener("click", () => { searchBar.classList.add("hidden"); searchInput.value = ""; listFiles(currentPath); }); searchExecuteBtn.addEventListener("click", () => { const query = searchInput.value.trim(); if (!query) return; const searchContent = searchContentCheckbox.checked; performSearch(query, searchContent); }); searchInput.addEventListener("keypress", (e) => { if (e.key === "Enter") { searchExecuteBtn.click(); } }); function performSearch(pattern, searchContent) { clearVirtualizedListMode(); updateDirectorySummaryAndPaging(0, 0); const searchId = `search-${Date.now()}`; const cmdId = `search-cmd-${Date.now()}`; const msg = { type: "command", commandType: "file_search", id: cmdId, payload: { searchId, path: currentPath || ".", pattern, searchContent, maxResults: 500, }, }; send(msg); fileListEl.innerHTML = '
Searching...
'; } function handleFileSearchResult(msg) { clearVirtualizedListMode(); updateDirectorySummaryAndPaging(0, 0); if (msg.error) { fileListEl.innerHTML = `
${escapeHtml(msg.error)}
`; return; } const results = msg.results || []; if (results.length === 0) { fileListEl.innerHTML = '
No results found
'; return; } fileListEl.innerHTML = ""; results.forEach((result) => { const row = document.createElement("div"); row.className = "file-item px-4 py-3 border border-slate-700 rounded cursor-pointer hover:bg-slate-800/50 mb-2"; const fileName = result.path.split(/[\/\\]/).pop(); const lineInfo = result.line ? ` (line ${result.line})` : ""; const matchPreview = result.match ? `
${escapeHtml(result.match.substring(0, 100))}
` : ""; const searchIcon = getFileIcon({ isDir: false, name: fileName }); row.innerHTML = `
${searchIcon}
${escapeHtml(fileName)}${lineInfo}
${escapeHtml(result.path)}
${matchPreview}
`; row.onclick = () => { const resultName = result.path.split(/[\/\\]/).pop() || ""; if (isPreviewable(resultName)) { openFilePreview(result.path); } else { openFileInEditor(result.path); } }; fileListEl.appendChild(row); }); } function openFileInEditor(path) { const cmdId = `file-read-${Date.now()}`; const msg = { type: "command", commandType: "file_read", id: cmdId, payload: { path, maxSize: 10 * 1024 * 1024, }, }; console.log("[DEBUG] Opening file in editor:", path); console.log( "[DEBUG] Sending file_read command:", JSON.stringify(msg, null, 2), ); console.log( "[DEBUG] WebSocket state:", ws?.readyState, "OPEN=", WebSocket.OPEN, ); send(msg); currentEditingFile = path; editorFileName.textContent = path.split(/[/\\\\]/).pop(); editorStatus.textContent = "Loading..."; fileEditorModal.classList.add("show"); } function handleFileReadResult(msg) { console.log("[DEBUG] handleFileReadResult called:", { path: msg.path, hasError: !!msg.error, isBinary: msg.isBinary, contentLength: msg.content?.length, }); if (msg.error) { alert(`Error reading file: ${escapeHtml(msg.error)}`); closeEditor(); return; } if (msg.isBinary) { notifyToast("Binary file — use Download to save it", "info", 3000); closeEditor(); return; } console.log("[DEBUG] Setting editor content, length:", msg.content?.length); editorTextarea.value = msg.content || ""; editorStatus.textContent = "Ready"; applySyntaxHighlighting(); editorTextarea.classList.add("hidden"); editorPreview.classList.remove("hidden"); editorPreviewTab.classList.add("bg-blue-600"); editorPreviewTab.classList.remove("bg-slate-700", "hover:bg-slate-600"); editorEditTab.classList.remove("bg-blue-600"); editorEditTab.classList.add("bg-slate-700", "hover:bg-slate-600"); } function saveFileFromEditor() { if (!currentEditingFile) return; const content = editorTextarea.value; const cmdId = `file-write-${Date.now()}`; const msg = { type: "command", commandType: "file_write", id: cmdId, payload: { path: currentEditingFile, content, }, }; send(msg); editorStatus.textContent = "Saving..."; editorSaveBtn.disabled = true; } function closeEditor() { fileEditorModal.classList.remove("show"); editorTextarea.value = ""; currentEditingFile = null; editorStatus.textContent = "Ready"; editorSaveBtn.disabled = false; } function applySyntaxHighlighting() { const code = editorTextarea.value; const codeElement = document.getElementById("editor-code"); const fileName = currentEditingFile?.split(/[/\\\\]/).pop() || ""; const ext = fileName.split(".").pop()?.toLowerCase(); const languageMap = { js: "javascript", jsx: "javascript", ts: "typescript", tsx: "typescript", py: "python", rb: "ruby", java: "java", cpp: "cpp", c: "c", cs: "csharp", php: "php", go: "go", rs: "rust", sh: "bash", bash: "bash", bat: "powershell", cmd: "powershell", ps1: "powershell", json: "json", xml: "xml", html: "html", css: "css", scss: "scss", sql: "sql", yaml: "yaml", yml: "yaml", md: "markdown", txt: "plaintext", }; const language = languageMap[ext] || "plaintext"; codeElement.className = `language-${language}`; codeElement.textContent = code; delete codeElement.dataset.highlighted; if (window.hljs) { hljs.highlightElement(codeElement); } } const editorEditTab = document.getElementById("editor-edit-tab"); const editorPreviewTab = document.getElementById("editor-preview-tab"); const editorPreview = document.getElementById("editor-preview"); editorEditTab.addEventListener("click", () => { editorTextarea.classList.remove("hidden"); editorPreview.classList.add("hidden"); editorEditTab.classList.add("bg-blue-600"); editorEditTab.classList.remove("bg-slate-700", "hover:bg-slate-600"); editorPreviewTab.classList.remove("bg-blue-600"); editorPreviewTab.classList.add("bg-slate-700", "hover:bg-slate-600"); }); editorPreviewTab.addEventListener("click", () => { applySyntaxHighlighting(); editorTextarea.classList.add("hidden"); editorPreview.classList.remove("hidden"); editorPreviewTab.classList.add("bg-blue-600"); editorPreviewTab.classList.remove("bg-slate-700", "hover:bg-slate-600"); editorEditTab.classList.remove("bg-blue-600"); editorEditTab.classList.add("bg-slate-700", "hover:bg-slate-600"); }); editorSaveBtn.addEventListener("click", saveFileFromEditor); editorCancelBtn.addEventListener("click", closeEditor); editorCloseBtn.addEventListener("click", closeEditor); if (previewCloseBtn) previewCloseBtn.addEventListener("click", closePreview); if (previewDownloadBtn) previewDownloadBtn.addEventListener("click", () => { if (currentPreviewPath) downloadFile(currentPreviewPath); }); if (filePreviewModal) { // Close on backdrop click. filePreviewModal.addEventListener("click", (e) => { if (e.target === filePreviewModal) closePreview(); }); } document.addEventListener("keydown", (e) => { if (e.key === "Escape" && filePreviewModal && filePreviewModal.classList.contains("show")) { closePreview(); } }); const editorRunBtn = document.getElementById("editor-run-btn"); editorRunBtn.addEventListener("click", () => { if (!currentEditingFile) return; const ext = currentEditingFile.split(".").pop()?.toLowerCase(); let command = ""; const isWindows = currentPath.includes(":\\"); if (isWindows) { switch (ext) { case "bat": case "cmd": command = currentEditingFile; break; case "ps1": command = `powershell.exe -ExecutionPolicy Bypass -File "${currentEditingFile}"`; break; case "exe": case "com": command = `"${currentEditingFile}"`; break; case "py": command = `python "${currentEditingFile}"`; break; case "js": command = `node "${currentEditingFile}"`; break; default: command = `"${currentEditingFile}"`; } } else { switch (ext) { case "sh": case "bash": command = `bash "${currentEditingFile}"`; break; case "py": command = `python3 "${currentEditingFile}"`; break; case "rb": command = `ruby "${currentEditingFile}"`; break; case "js": command = `node "${currentEditingFile}"`; break; case "pl": command = `perl "${currentEditingFile}"`; break; default: command = `"${currentEditingFile}"`; } } window.open( `/${clientId}/console?cmd=${encodeURIComponent(command)}`, "_blank", ); }); bulkDownloadBtn.addEventListener("click", () => { selectedFiles.forEach((path) => downloadFile(path)); }); bulkDeleteBtn.addEventListener("click", () => { if (!confirm(`Delete ${selectedFiles.size} selected items?`)) return; selectedFiles.forEach((path) => { send({ type: "file_delete", path }); }); clearSelection(); setTimeout(() => listFiles(currentPath), 500); }); bulkMoveBtn.addEventListener("click", () => { const dest = prompt("Enter destination path:"); if (!dest) return; selectedFiles.forEach((path) => { const fileName = path.split(/[\/\\]/).pop(); const destPath = `${dest}/${fileName}`; send({ type: "file_move", source: path, dest: destPath }); }); clearSelection(); setTimeout(() => listFiles(currentPath), 500); }); bulkCopyBtn.addEventListener("click", () => { const dest = prompt("Enter destination path:"); if (!dest) return; selectedFiles.forEach((path) => { const fileName = path.split(/[\/\\]/).pop(); const destPath = `${dest}/${fileName}`; send({ type: "file_copy", source: path, dest: destPath }); }); clearSelection(); setTimeout(() => listFiles(currentPath), 500); }); clearSelectionBtn.addEventListener("click", clearSelection); contextMenu.querySelectorAll(".context-menu-item").forEach((item) => { item.addEventListener("click", () => { const action = item.dataset.action; const path = contextMenu.dataset.path; const isDir = contextMenu.dataset.isDir === "true"; const entry = { path, isDir }; contextMenu.classList.remove("show"); handleFileAction(action, entry); }); });