import { encodeMsgpack, decodeMsgpack } from "./msgpack-helpers.js"; import { checkFeatureAccess } from "./feature-gate.js"; import { createFileHexHashManager } from "./filebrowser-hex-hash.js"; import { createFilePreviewModal } from "./filebrowser-preview-modal.js"; import { createTransferPanel } from "./filebrowser-transfer-panel.js"; import { KNOWN_BINARY_EXTS, PREVIEW_IMAGE_EXTS, escapeHtml, formatBytes, getFileExt, getHighlightLanguage, getParentPath, isPreviewable, shouldShowParentDirectory, } from "./filebrowser-utils.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 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; const MAC_PERMISSION_STORAGE_KEY = `filebrowser.macPermissionAllowed.${clientId}.v2`; const macPermissionAllowedPaths = new Set(loadMacPermissionAllowedPaths()); 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 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"); if (clientIdHeader) { clientIdHeader.innerHTML = `${escapeHtml(clientId)}`; } 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; statusEl.appendChild(document.createTextNode(` ${String(text ?? "")}`)); const stateClasses = { connected: "bg-emerald-500/10 text-emerald-300 border-emerald-500/30", error: "bg-red-500/10 text-red-300 border-red-500/30", disconnected: "bg-slate-800 text-slate-300 border-slate-700", connecting: "bg-sky-500/10 text-sky-300 border-sky-500/30", }; statusEl.className = `inline-flex self-start sm:self-auto items-center gap-2 px-3 py-1.5 rounded-full border text-xs font-medium ${stateClasses[state] || stateClasses.disconnected}`; } 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 "file_icon_result": handleFileIconResult(msg); break; case "file_thumb_result": handleFileThumbResult(msg); break; case "file_dirsize_result": handleFileDirsizeResult(msg); break; case "file_peek_result": handleFilePeekResult(msg); break; case "file_hash_result": handleFileHashResult(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); } } async function listFiles(path, socket = ws, options = {}) { const { resetHistory = false, skipHistory = false } = options; if (!(await confirmMacPermissionRisk(path, "open folder"))) { return false; } if (resetHistory) { pathHistory = []; } else if (!skipHistory && currentPath && currentPath !== path) { pathHistory.push(currentPath); } currentPath = path; send({ type: "file_list", path }, socket); updateBreadcrumb(path); updatePathInput(path); updateBackButton(); return true; } 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); } async function goBack() { if (pathHistory.length > 0) { const previousPath = pathHistory[pathHistory.length - 1]; const ok = await listFiles(previousPath, ws, { skipHistory: true }); if (ok) { pathHistory.pop(); } updateBackButton(); } } function loadMacPermissionAllowedPaths() { try { const raw = sessionStorage.getItem(MAC_PERMISSION_STORAGE_KEY); const parsed = raw ? JSON.parse(raw) : []; if (!Array.isArray(parsed)) return []; return parsed.filter((p) => typeof p === "string" && p.trim()); } catch { return []; } } function persistMacPermissionAllowedPaths() { try { sessionStorage.setItem( MAC_PERMISSION_STORAGE_KEY, JSON.stringify(Array.from(macPermissionAllowedPaths)), ); } catch {} } function normalizeMacPath(path) { const raw = String(path || "").trim(); if (!raw || raw === ".") return raw; let normalized = raw.replace(/\\/g, "/"); if (normalized === "~" && detectedHomePath) normalized = detectedHomePath; if (normalized.startsWith("~/") && detectedHomePath) { normalized = detectedHomePath + normalized.slice(1); } normalized = normalized.replace(/\/+/g, "/"); if (normalized.length > 1) normalized = normalized.replace(/\/+$/, ""); return normalized; } function isSameOrChildPath(path, root) { if (!path || !root) return false; return path === root || path.startsWith(root + "/"); } function markMacPermissionAllowed(path) { const risk = macProtectedLocationForPath(path); if (!risk) return; macPermissionAllowedPaths.add(risk.cachePath || risk.root || risk.path); persistMacPermissionAllowedPaths(); } function hasMacPermissionAllowedAncestor(path) { const normalized = normalizeMacPath(path); for (const allowed of macPermissionAllowedPaths) { if (isSameOrChildPath(normalized, allowed)) return true; } return false; } function macProtectedLocationForPath(path) { const normalized = normalizeMacPath(path); if (!normalized || normalized === ".") { return null; } const maybeMac = detectedOS === "mac" || (!detectedOS && normalized.startsWith("/Users/")); if (!maybeMac) return null; let home = normalizeMacPath(detectedHomePath); if (!home) { const homeMatch = normalized.match(/^(\/Users\/[^/]+)/); if (homeMatch) home = homeMatch[1]; } const protectedRoots = []; if (home) { for (const name of ["Desktop", "Documents", "Downloads", "Pictures", "Movies", "Music"]) { protectedRoots.push({ root: `${home}/${name}`, label: name }); } protectedRoots.push( { root: `${home}/Library/Mobile Documents`, label: "iCloud Drive" }, { root: `${home}/Library/Mail`, label: "Mail data" }, { root: `${home}/Library/Messages`, label: "Messages data" }, { root: `${home}/Library/Safari`, label: "Safari data" }, { root: `${home}/Library/Calendars`, label: "Calendar data" }, { root: `${home}/Library/Application Support/AddressBook`, label: "Contacts data" }, ); } protectedRoots.push( { root: "/Volumes", label: "removable or network volume" }, { root: "/Network", label: "network location" }, ); const match = protectedRoots.find((item) => isSameOrChildPath(normalized, item.root)); if (!match) return null; const cachePath = match.root === "/Volumes" || match.root === "/Network" ? normalized : match.root; return { ...match, path: normalized, cachePath }; } function macPermissionRiskForPath(path) { const risk = macProtectedLocationForPath(path); if (!risk) return null; if (hasMacPermissionAllowedAncestor(risk.path)) return null; return risk; } function showMacPermissionWarning(risk, operation) { return new Promise((resolve) => { const modal = document.createElement("div"); let onKeyDown = null; let settled = false; modal.className = "fixed inset-0 z-[2400] flex items-center justify-center bg-black/70 px-4"; modal.innerHTML = `
Confirm macOS Access Request

${escapeHtml(operation)} ${escapeHtml(risk.path)} may ask the person using this Mac to allow access to ${escapeHtml(risk.label)} for this app.

After you confirm here, the agent will try to open the folder on the Mac so macOS can show its permission prompt there.

`; const cleanup = (answer) => { if (settled) return; settled = true; if (onKeyDown) document.removeEventListener("keydown", onKeyDown); modal.remove(); resolve(answer); }; modal.querySelector("[data-mac-permission-cancel]")?.addEventListener("click", () => cleanup(false)); modal.querySelector("[data-mac-permission-continue]")?.addEventListener("click", () => cleanup(true)); modal.addEventListener("click", (e) => { if (e.target === modal) cleanup(false); }); onKeyDown = (e) => { if (e.key !== "Escape") return; cleanup(false); }; document.addEventListener("keydown", onKeyDown); document.body.appendChild(modal); modal.querySelector("[data-mac-permission-continue]")?.focus(); }); } async function confirmMacPermissionRisk(path, operation = "access") { const risk = macPermissionRiskForPath(path); if (!risk) return true; const ok = await showMacPermissionWarning(risk, operation); if (!ok) { notifyToast("Operation cancelled before macOS permission prompt", "info", 2500); return false; } return true; } function macPermissionLockedDirectory(entry) { if (!entry || !entry.isDir) return null; return macPermissionRiskForPath(entry.path); } function renderMacPermissionBadge(risk) { if (!risk) return ""; return ` Needs permission`; } function renderMacPermissionFolderIcon(risk) { if (!risk) return null; return ''; } function goHome() { listFiles(".", ws, { resetHistory: true }); } 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); }); } const filePreviewModalManager = createFilePreviewModal({ clientId, notifyToast, onDownload: (path) => downloadFile(path), beforeFileRead: (path, operation) => confirmMacPermissionRisk(path, operation), }); let openFilePreview = filePreviewModalManager.openFilePreview; const transferPanelManager = createTransferPanel({ onCancel: (transferId) => cancelTransfer(transferId), }); const { addTransferToUI, updateTransferProgress, } = transferPanelManager; 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", }; // Extensions whose icon depends on the actual file (embedded resources), // not just the registered shell association. const SELF_ICONNING_EXTS = new Set(["exe", "ico", "lnk", "dll", "msi", "cpl", "scr"]); // Extensions we'll request a Windows shell thumbnail for. Skip everything else. const THUMBNAIL_EXTS = new Set([ "jpg", "jpeg", "png", "gif", "bmp", "webp", "tif", "tiff", "heic", "heif", "mp4", "mkv", "mov", "avi", "webm", "m4v", "pdf", "docx", "xlsx", "pptx", ]); // key → { blobUrl?: string, failed?: boolean, pending?: boolean } const iconCache = new Map(); const thumbCache = new Map(); const MAX_ICON_CACHE_ENTRIES = 256; const MAX_THUMB_CACHE_ENTRIES = 128; function trimBlobCache(cache, maxEntries) { while (cache.size > maxEntries) { const oldestKey = cache.keys().next().value; const entry = cache.get(oldestKey); if (entry?.blobUrl) URL.revokeObjectURL(entry.blobUrl); cache.delete(oldestKey); } } function clearBlobCaches() { for (const cache of [iconCache, thumbCache]) { for (const entry of cache.values()) { if (entry?.blobUrl) URL.revokeObjectURL(entry.blobUrl); } cache.clear(); } } window.addEventListener("pagehide", clearBlobCaches, { once: true }); // Queues + batching const iconQueue = []; // {key, path?, ext?} const thumbQueue = []; // {key, path, size} const iconCommandsInFlight = new Set(); const thumbCommandsInFlight = new Set(); let iconFlushScheduled = false; let thumbFlushScheduled = false; const ICON_BATCH_SIZE = 32; const ICON_BATCH_DELAY_MS = 60; const THUMB_BATCH_SIZE = 8; const THUMB_BATCH_DELAY_MS = 80; const THUMB_EDGE = 96; const MAX_THUMB_INFLIGHT_COMMANDS = 2; function iconCacheKey(entry) { if (entry.isDir) return null; const ext = getFileExt(entry.name); if (!ext) return `ext:_noext`; if (SELF_ICONNING_EXTS.has(ext)) { return `path:${entry.path}|${entry.size}|${entry.modTime}`; } return `ext:${ext}`; } function thumbCacheKey(entry) { if (entry.isDir) return null; const ext = getFileExt(entry.name); if (!THUMBNAIL_EXTS.has(ext)) return null; // Skip absurdly large files — Windows providers may bog down. 256MB cap. if (entry.size > 256 * 1024 * 1024) return null; return `thumb:${entry.path}|${entry.size}|${entry.modTime}|${THUMB_EDGE}`; } function scheduleIconFlush() { if (iconFlushScheduled) return; iconFlushScheduled = true; setTimeout(flushIconQueue, ICON_BATCH_DELAY_MS); } function flushIconQueue() { iconFlushScheduled = false; if (iconQueue.length === 0) return; const batch = iconQueue.splice(0, ICON_BATCH_SIZE); const commandId = `icon-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; iconCommandsInFlight.add(commandId); send({ type: "command", commandType: "file_icon", id: commandId, payload: { items: batch }, }); if (iconQueue.length > 0) scheduleIconFlush(); } function scheduleThumbFlush() { if (thumbFlushScheduled) return; if (thumbCommandsInFlight.size >= MAX_THUMB_INFLIGHT_COMMANDS) return; thumbFlushScheduled = true; setTimeout(flushThumbQueue, THUMB_BATCH_DELAY_MS); } function flushThumbQueue() { thumbFlushScheduled = false; if (thumbQueue.length === 0) return; if (thumbCommandsInFlight.size >= MAX_THUMB_INFLIGHT_COMMANDS) return; const batch = thumbQueue.splice(0, THUMB_BATCH_SIZE); const commandId = `thumb-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; thumbCommandsInFlight.add(commandId); send({ type: "command", commandType: "file_thumb", id: commandId, payload: { items: batch }, }); if (thumbQueue.length > 0) scheduleThumbFlush(); } function requestIconFor(entry) { const key = iconCacheKey(entry); if (!key) return null; if (iconCache.has(key)) { const cached = iconCache.get(key); iconCache.delete(key); iconCache.set(key, cached); return key; } iconCache.set(key, { pending: true }); trimBlobCache(iconCache, MAX_ICON_CACHE_ENTRIES); const item = { key }; if (key.startsWith("path:")) { item.path = entry.path; } else { const ext = getFileExt(entry.name); if (ext) item.ext = ext; else item.ext = ""; } iconQueue.push(item); scheduleIconFlush(); return key; } function requestThumbFor(entry) { const key = thumbCacheKey(entry); if (!key) return null; if (thumbCache.has(key)) { const cached = thumbCache.get(key); thumbCache.delete(key); thumbCache.set(key, cached); return key; } thumbCache.set(key, { pending: true }); trimBlobCache(thumbCache, MAX_THUMB_CACHE_ENTRIES); thumbQueue.push({ key, path: entry.path, size: THUMB_EDGE }); scheduleThumbFlush(); return key; } function handleFileIconResult(msg) { if (msg.commandId) iconCommandsInFlight.delete(msg.commandId); const items = Array.isArray(msg.icons) ? msg.icons : []; for (const item of items) { if (!item || !item.key) continue; const entry = iconCache.get(item.key) || {}; entry.pending = false; if (item.png && item.png.length > 0) { if (entry.blobUrl) URL.revokeObjectURL(entry.blobUrl); const blob = new Blob([item.png], { type: "image/png" }); entry.blobUrl = URL.createObjectURL(blob); } else { entry.failed = true; } iconCache.delete(item.key); iconCache.set(item.key, entry); trimBlobCache(iconCache, MAX_ICON_CACHE_ENTRIES); applyIconToDom(item.key, entry); } } function handleFileThumbResult(msg) { if (msg.commandId) thumbCommandsInFlight.delete(msg.commandId); // Pump the next thumbnail batch now that there's capacity. if (thumbQueue.length > 0) scheduleThumbFlush(); const items = Array.isArray(msg.thumbs) ? msg.thumbs : []; for (const item of items) { if (!item || !item.key) continue; const entry = thumbCache.get(item.key) || {}; entry.pending = false; if (item.jpeg && item.jpeg.length > 0) { if (entry.blobUrl) URL.revokeObjectURL(entry.blobUrl); const blob = new Blob([item.jpeg], { type: "image/jpeg" }); entry.blobUrl = URL.createObjectURL(blob); entry.w = item.w || 0; entry.h = item.h || 0; } else { entry.failed = true; } thumbCache.delete(item.key); thumbCache.set(item.key, entry); trimBlobCache(thumbCache, MAX_THUMB_CACHE_ENTRIES); applyThumbToDom(item.key, entry); } } function applyIconToDom(key, entry) { if (!entry || !entry.blobUrl) return; const escaped = (typeof CSS !== "undefined" && CSS.escape) ? CSS.escape(key) : key.replace(/"/g, '\\"'); document.querySelectorAll(`[data-icon-key="${escaped}"]`).forEach((el) => { el.innerHTML = ``; }); } function applyThumbToDom(key, entry) { if (!entry || !entry.blobUrl) return; const escaped = (typeof CSS !== "undefined" && CSS.escape) ? CSS.escape(key) : key.replace(/"/g, '\\"'); // Thumbs are used by hover-preview popover; just update the popover if it's currently showing this key. if (currentPopoverKey === key) { renderQuickLookPopover(currentPopoverEntry, entry.blobUrl, entry.w, entry.h); } } // Windows FILE_ATTRIBUTE_* flags surfaced as compact badges next to the filename. const ATTR_READONLY = 0x00000001; const ATTR_HIDDEN = 0x00000002; const ATTR_SYSTEM = 0x00000004; const ATTR_REPARSE_POINT = 0x00000400; const ATTR_COMPRESSED = 0x00000800; const ATTR_ENCRYPTED = 0x00004000; function renderAttrBadges(attrs) { if (!attrs || typeof attrs !== "number") return ""; const pills = []; const pill = (label, title, cls) => `${label}`; if (attrs & ATTR_HIDDEN) pills.push(pill("H", "Hidden", "bg-slate-700 text-slate-300")); if (attrs & ATTR_SYSTEM) pills.push(pill("S", "System", "bg-red-900/60 text-red-200")); if (attrs & ATTR_REPARSE_POINT) pills.push(pill("L", "Reparse point / symlink", "bg-indigo-900/60 text-indigo-200")); if (attrs & ATTR_COMPRESSED) pills.push(pill("C", "NTFS compressed", "bg-blue-900/60 text-blue-200")); if (attrs & ATTR_ENCRYPTED) pills.push(pill("E", "NTFS encrypted (EFS)", "bg-emerald-900/60 text-emerald-200")); if (attrs & ATTR_READONLY) pills.push(pill("R", "Read-only", "bg-slate-700 text-slate-300")); if (pills.length === 0) return ""; return `${pills.join("")}`; } // ---- Hover-preview / spacebar quicklook popover -------------------------------- let currentPopoverKey = null; let currentPopoverEntry = null; let hoverPreviewTimer = null; let popoverEl = null; let lastHoveredRow = null; let lastHoveredEntry = null; const HOVER_DELAY_MS = 350; function ensurePopover() { if (popoverEl) return popoverEl; popoverEl = document.createElement("div"); popoverEl.id = "file-quicklook-popover"; popoverEl.style.cssText = "position:fixed;z-index:1500;display:none;pointer-events:none;background:#0f172a;border:1px solid #334155;border-radius:0.5rem;box-shadow:0 10px 25px rgba(0,0,0,.5);padding:0.5rem;max-width:280px;"; document.body.appendChild(popoverEl); return popoverEl; } function hideQuickLookPopover() { if (hoverPreviewTimer) { clearTimeout(hoverPreviewTimer); hoverPreviewTimer = null; } currentPopoverKey = null; currentPopoverEntry = null; if (popoverEl) popoverEl.style.display = "none"; } function renderQuickLookPopover(entry, thumbUrl, w, h) { if (!entry) return; const el = ensurePopover(); const ext = getFileExt(entry.name); const sizeStr = entry.isDir ? "" : formatBytes(entry.size); const modStr = new Date(entry.modTime * 1000).toLocaleString(); let imgHtml = ""; if (thumbUrl) { imgHtml = ``; } else { const iconKey = iconCacheKey(entry); const iconEntry = iconKey ? iconCache.get(iconKey) : null; if (iconEntry && iconEntry.blobUrl) { imgHtml = ``; } else { imgHtml = `
${getFileIcon(entry)}
`; } } el.innerHTML = ` ${imgHtml}
${escapeHtml(entry.name)}
${entry.isDir ? "Folder" : (ext ? ext.toUpperCase() + " · " : "") + sizeStr}
${modStr}
`; el.style.display = "block"; positionPopover(el); } function positionPopover(el) { if (!lastHoveredRow) return; const rect = lastHoveredRow.getBoundingClientRect(); const popoverWidth = el.offsetWidth || 280; const popoverHeight = el.offsetHeight || 320; let left = rect.right + 12; let top = rect.top; if (left + popoverWidth > window.innerWidth - 8) { left = rect.left - popoverWidth - 12; } if (left < 8) left = 8; if (top + popoverHeight > window.innerHeight - 8) { top = Math.max(8, window.innerHeight - popoverHeight - 8); } el.style.left = `${left}px`; el.style.top = `${top}px`; } function showQuickLookFor(row, entry, immediate) { if (!entry) return; lastHoveredRow = row; lastHoveredEntry = entry; const tKey = thumbCacheKey(entry); currentPopoverKey = tKey; currentPopoverEntry = entry; const cached = tKey ? thumbCache.get(tKey) : null; if (cached && cached.blobUrl) { renderQuickLookPopover(entry, cached.blobUrl, cached.w, cached.h); return; } // No thumb yet — still show the popover with icon + metadata. renderQuickLookPopover(entry, null, 0, 0); // Ensure thumb is queued (createFileRow already did this; no-op if so). if (tKey) requestThumbFor(entry); } function attachRowQuicklookEvents(row, entry) { if (entry.isDir) return; // folders: skip hover popover (would just be the icon) row.addEventListener("mouseenter", () => { lastHoveredRow = row; lastHoveredEntry = entry; if (hoverPreviewTimer) clearTimeout(hoverPreviewTimer); hoverPreviewTimer = setTimeout(() => showQuickLookFor(row, entry, false), HOVER_DELAY_MS); }); row.addEventListener("mouseleave", () => { hideQuickLookPopover(); }); } // Spacebar = open quicklook for last-hovered/focused row (Finder style). window.addEventListener("keydown", (e) => { if (e.key !== " ") return; const target = e.target; // Don't hijack space in inputs/textareas/editor. if (target && (target.tagName === "INPUT" || target.tagName === "TEXTAREA" || target.isContentEditable)) return; if (document.querySelector(".file-editor-modal.show")) return; if (document.querySelector(".file-preview-modal.show")) return; if (!lastHoveredRow || !lastHoveredEntry) return; e.preventDefault(); showQuickLookFor(lastHoveredRow, lastHoveredEntry, true); }); window.addEventListener("keyup", (e) => { if (e.key !== " ") return; hideQuickLookPopover(); }); window.addEventListener("scroll", () => hideQuickLookPopover(), { passive: true }); // ---- Folder size -------------------------------------------------------------- // commandId -> { path, toastId? } const folderSizeInFlight = new Map(); function requestFolderSize(entry) { if (!entry.isDir) return; const commandId = `dirsize-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; folderSizeInFlight.set(commandId, { path: entry.path, name: entry.name }); send({ type: "command", commandType: "file_dirsize", id: commandId, payload: { path: entry.path }, }); notifyToast(`Calculating size of ${entry.name}…`, "info", 2000); } function handleFileDirsizeResult(msg) { const tracked = msg.commandId ? folderSizeInFlight.get(msg.commandId) : null; if (!tracked) return; if (!msg.done) { // Progress tick — update status bar only (avoid toast spam). if (statusEl) { const label = `${tracked.name || msg.path}: ${formatBytes(msg.bytes || 0)} (${msg.files || 0} files)`; statusEl.innerHTML = ` ${escapeHtml(label)}`; } return; } folderSizeInFlight.delete(msg.commandId); updateStatus("connected", "Connected"); if (msg.error) { notifyToast(`Size of ${tracked.name}: error — ${msg.error}`, "error", 5000); return; } notifyToast( `${tracked.name}: ${formatBytes(msg.bytes || 0)} (${msg.files || 0} files, ${msg.dirs || 0} folders)`, "success", 8000, ); } function getFileIcon(entry) { if (entry.isDir) { return ''; } const ext = getFileExt(entry.name); const cls = FILE_ICON_MAP[ext]; const fallback = cls ? `` : ''; const key = iconCacheKey(entry); if (!key) return fallback; const cached = iconCache.get(key); if (cached && cached.blobUrl) { return ``; } return `${fallback}`; } 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(); currentPath = msg.path || currentPath; renderFileListError(msg); 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(); updatePinnedSidebar(); // If the previewed item was from another directory, clear it. if (currentPreviewEntry && !directoryEntries.some((e) => e.path === currentPreviewEntry.path)) { clearPreviewPane(); } markMacPermissionAllowed(currentPath); selectedFiles.clear(); updateSelectionUI(); renderCurrentDirectory(); } function isMacFolderAccessError(msg) { if (!msg || !msg.error) return false; if (msg.canRequestAccess || msg.accessDenied) return true; if (detectedOS !== "mac") return false; return /permission denied|operation not permitted|not permitted|access denied/i.test(msg.error); } function renderFileListError(msg) { const canRequestAccess = isMacFolderAccessError(msg); const helpText = msg.accessHelp || "macOS blocked this folder. Confirm the retry, approve the prompt on the Mac, then refresh if needed."; const actionHtml = canRequestAccess ? `
${escapeHtml(helpText)}
` : ""; fileListEl.innerHTML = `
${escapeHtml(msg.error)} ${actionHtml}
`; const requestBtn = document.getElementById("request-folder-access-btn"); if (requestBtn) { requestBtn.addEventListener("click", () => requestFolderAccess(msg.path || currentPath || ".")); } const retryBtn = document.getElementById("retry-folder-access-btn"); if (retryBtn) { retryBtn.addEventListener("click", () => listFiles(msg.path || currentPath || ".")); } } async function requestFolderAccess(path) { const targetPath = path || currentPath || "."; notifyToast("Trying to open the folder on the Mac...", "info", 3500); await listFiles(targetPath, ws, { skipHistory: true }); } 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"); const macPermissionRisk = macPermissionLockedDirectory(entry); row.className = "file-item grid grid-cols-12 gap-3 px-4 py-3 border border-transparent cursor-pointer transition-colors" + (macPermissionRisk ? " opacity-60 bg-slate-950/30 hover:bg-amber-900/10" : " hover:bg-slate-800/50"); row.dataset.path = entry.path; row.dataset.isDir = entry.isDir; if (macPermissionRisk) { row.dataset.macPermission = "required"; row.title = `macOS may ask the user to allow access to ${macPermissionRisk.label}.`; } // Kick off lazy fetches for the row's real icon and (if applicable) its thumbnail. // Both are throttled + batched; the DOM gets updated when results arrive. requestIconFor(entry); if (!entry.isDir) requestThumbFor(entry); const icon = renderMacPermissionFolderIcon(macPermissionRisk) || getFileIcon(entry); const badges = renderAttrBadges(entry.attrs); const macPermissionBadge = renderMacPermissionBadge(macPermissionRisk); const size = entry.isDir ? "-" : formatBytes(entry.size); const modTime = new Date(entry.modTime * 1000).toLocaleString(); const pinnedNow = entry.isDir && isPinned(entry.path); const pinTitle = pinnedNow ? "Unpin from sidebar" : "Pin to sidebar"; const pinIcon = pinnedNow ? "fa-star" : "fa-star"; const pinClass = pinnedNow ? "pin-star-btn pinned" : "pin-star-btn"; const pinBtn = entry.isDir ? `` : ""; row.innerHTML = `
${icon} ${escapeHtml(entry.name)} ${pinBtn} ${badges} ${macPermissionBadge}
${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") || e.target.closest(".pin-star-btn")) { return; } // Single-click always updates the preview pane. showPreviewForEntry(entry); if (entry.isDir) { listFiles(entry.path); } }; row.ondblclick = async (e) => { if (e.target.closest(".file-checkbox") || e.target.closest(".action-btn") || e.target.closest(".pin-star-btn")) { return; } if (entry.isDir) { listFiles(entry.path); } else if (isPreviewable(entry.name)) { await openFilePreview(entry.path, entry.size); } else if (KNOWN_BINARY_EXTS.has(getFileExt(entry.name))) { await openHexViewer(entry.path); } else { await 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); }; }); const pinToggleBtn = row.querySelector("[data-pin-toggle]"); if (pinToggleBtn) { pinToggleBtn.addEventListener("click", (e) => { e.stopPropagation(); const target = pinToggleBtn.dataset.pinToggle; if (isPinned(target)) { unpinPath(target); pinToggleBtn.classList.remove("pinned"); pinToggleBtn.title = "Pin to sidebar"; } else { pinPath(target, entry.name); pinToggleBtn.classList.add("pinned"); pinToggleBtn.title = "Unpin from sidebar"; } }); } row.oncontextmenu = (e) => { e.preventDefault(); showContextMenu(e.clientX, e.clientY, entry); }; attachRowQuicklookEvents(row, 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 "execute": executeFile(entry.path, false); break; case "silent_execute": executeFile(entry.path, true); break; case "delete": deleteFile(entry.path); break; case "dirsize": requestFolderSize(entry); break; case "pin": pinPath(entry.path, entry.name); notifyToast("Pinned", "success", 1800); break; case "unpin": unpinPath(entry.path); notifyToast("Unpinned", "info", 1800); break; case "hex_peek": if (!entry.isDir) openHexViewer(entry.path); break; case "hash": if (!entry.isDir) requestFileHash(entry.path, "context"); break; } } async function downloadFile(path) { if (!(await confirmMacPermissionRisk(path, "download file"))) return; 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; } let received = 0; let lastLoggedBytes = 0; const trackChunk = (size) => { received += size; 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); } }; const canStreamToDisk = typeof window.showSaveFilePicker === "function" && !!res.body; let usedStreamToDisk = false; if (canStreamToDisk) { let saveHandle = null; try { saveHandle = await window.showSaveFilePicker({ suggestedName: fileName }); } catch (pickerErr) { if (pickerErr && pickerErr.name === "AbortError") { transfer.cancelled = true; try { abortController.abort(); } catch {} removeTransfer(transferId); fileDownloads.delete(path); updateStatus("connected", "Connected"); return; } console.warn("[filebrowser] save picker failed, falling back to blob", pickerErr); saveHandle = null; } if (saveHandle) { const writable = await saveHandle.createWritable(); const progressTransform = new TransformStream({ transform(chunk, controller) { trackChunk(chunk.byteLength); controller.enqueue(chunk); }, }); try { await res.body.pipeThrough(progressTransform).pipeTo(writable, { signal: abortController.signal, }); } catch (pipeErr) { try { await writable.abort(pipeErr); } catch {} throw pipeErr; } usedStreamToDisk = true; } } if (!usedStreamToDisk) { const chunks = []; if (res.body) { const reader = res.body.getReader(); while (true) { const { done, value } = await reader.read(); if (done) break; if (value) { chunks.push(value); trackChunk(value.length); } if (transfer.cancelled) { try { await reader.cancel(); } catch {} break; } } } else { console.warn("[filebrowser] download response missing body", { path }); const blob = await res.blob(); const buf = new Uint8Array(await blob.arrayBuffer()); chunks.push(buf); trackChunk(buf.length); } if (transfer.cancelled) { removeTransfer(transferId); fileDownloads.delete(path); updateStatus("connected", "Connected"); return; } 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); } 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); } console.log("Download complete:", path, `${received} bytes`); console.debug("[filebrowser] download complete", { path, received, total: transfer.total, streamedToDisk: usedStreamToDisk, }); markMacPermissionAllowed(path); 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`); markMacPermissionAllowed(msg.path); removeTransfer(download.id); fileDownloads.delete(msg.path); updateStatus("connected", "Connected"); } } async function zipAndDownload(path) { if (!(await confirmMacPermissionRisk(path, "read folder"))) return; 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)}
`; notification.querySelector(".cancel-zip-operation")?.addEventListener("click", (event) => { event.stopPropagation(); cancelZipOperation(commandId); }); 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 executeFile(path, silent) { if (silent) { const commandId = `silent-exec-${Date.now()}`; send({ type: "command", commandType: "silent_exec", id: commandId, payload: { command: path, args: [], hideWindow: true }, }); trackCommandResult(commandId, { successMessage: "Silent execution started", errorPrefix: "Silent execution failed", }); } else { const commandId = `exec-${Date.now()}`; send({ type: "command", commandType: "file_execute", id: commandId, payload: { path }, }); trackCommandResult(commandId, { successMessage: "Execution started", errorPrefix: "Execution 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 = Math.max(transfer.sent || 0, 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"]'); const executeItem = contextMenu.querySelector('[data-action="execute"]'); const silentExecuteItem = contextMenu.querySelector('[data-action="silent_execute"]'); const dirsizeItem = contextMenu.querySelector('[data-action="dirsize"]'); const pinItem = contextMenu.querySelector('[data-action="pin"]'); const unpinItem = contextMenu.querySelector('[data-action="unpin"]'); const hexItem = contextMenu.querySelector('[data-action="hex_peek"]'); const hashItem = contextMenu.querySelector('[data-action="hash"]'); 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"; if (executeItem) executeItem.style.display = entry.isDir ? "none" : "block"; if (silentExecuteItem) silentExecuteItem.style.display = entry.isDir ? "none" : "block"; if (dirsizeItem) dirsizeItem.style.display = entry.isDir ? "block" : "none"; const pinned = isPinned(entry.path); if (pinItem) pinItem.style.display = pinned ? "none" : "block"; if (unpinItem) unpinItem.style.display = pinned ? "block" : "none"; if (hexItem) hexItem.style.display = entry.isDir ? "none" : "block"; if (hashItem) hashItem.style.display = entry.isDir ? "none" : "block"; } function hideContextMenu() { contextMenu.classList.remove("show"); } 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"); } let dropOverlayEl = null; function ensureDropOverlay() { if (dropOverlayEl || !fileListPanel) return dropOverlayEl; if (!fileListPanel.classList.contains("relative")) { fileListPanel.classList.add("relative"); } const overlay = document.createElement("div"); overlay.id = "file-drop-overlay"; overlay.className = "hidden absolute inset-0 z-30 pointer-events-none flex items-center justify-center bg-blue-500/15 border-2 border-dashed border-blue-400 rounded-lg backdrop-blur-sm"; overlay.innerHTML = `
Drop files to upload
`; fileListPanel.appendChild(overlay); dropOverlayEl = overlay; return overlay; } function setDropTargetActive(active) { if (!fileListPanel) return; const overlay = ensureDropOverlay(); if (overlay) { overlay.classList.toggle("hidden", !active); if (active) { const pathEl = overlay.querySelector(".drop-target-path"); if (pathEl) { pathEl.textContent = currentPath ? `into ${currentPath}` : ""; } } } 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); } function setupDragAndDropUpload() { if (!fileListPanel) return; ensureDropOverlay(); 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, agentNotified: !!uploadData.agentNotified, }); let commandId; let waitResult; if (uploadData.agentNotified && typeof uploadData.agentCommandId === "string") { commandId = uploadData.agentCommandId; waitResult = waitForCommandResult(commandId, HTTP_UPLOAD_AGENT_TIMEOUT_MS); } else { commandId = `upload-http-${Date.now()}-${Math.random()}`; waitResult = waitForCommandResult(commandId, HTTP_UPLOAD_AGENT_TIMEOUT_MS); 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; } const WS_UPLOAD_MAX_TOTAL = 8 * 1024 * 1024; const WS_UPLOAD_CHUNK_SIZE = 512 * 1024; const WS_UPLOAD_CONCURRENCY = 4; const WS_UPLOAD_ACK_TIMEOUT_MS = 90 * 1000; const HTTP_UPLOAD_AGENT_TIMEOUT_MS = 30 * 60 * 1000; const PREFER_HTTP_UPLOAD_PULL = true; async function uploadFileViaWsChunks(file, path, transfer) { const total = file.size; transfer.total = total; const pumpChunk = (offset, data) => { const ackPromise = new Promise((resolve, reject) => { const timeoutId = setTimeout(() => { transfer.pendingAcks.delete(offset); reject(new Error(`upload chunk timeout (offset ${offset})`)); }, WS_UPLOAD_ACK_TIMEOUT_MS); transfer.pendingAcks.set(offset, { resolve, reject, timeoutId }); }); send({ type: "file_upload", path, data, offset, total, transferId: transfer.transferId, }); transfer.sent = Math.min((transfer.sent || 0) + data.length, total); if (total > 0) { transfer.progress = Math.round((transfer.sent / total) * 100); updateTransferProgress(transfer.id, transfer.progress, transfer.sent, total); } return ackPromise; }; if (total === 0) { transfer.expectedChunks = 1; await pumpChunk(0, new Uint8Array(0)); transfer.completed = true; transfer.progress = 100; updateTransferProgress(transfer.id, 100, 0, 0); return; } const expectedChunks = Math.ceil(total / WS_UPLOAD_CHUNK_SIZE); transfer.expectedChunks = expectedChunks; const inFlight = new Map(); let nextOffset = 0; try { while (nextOffset < total || inFlight.size > 0) { while (inFlight.size < WS_UPLOAD_CONCURRENCY && nextOffset < total) { if (transfer.cancelled) throw new Error("Upload cancelled"); const start = nextOffset; const end = Math.min(start + WS_UPLOAD_CHUNK_SIZE, total); const buf = new Uint8Array(await file.slice(start, end).arrayBuffer()); nextOffset = end; const tracked = pumpChunk(start, buf).then(() => inFlight.delete(start)); inFlight.set(start, tracked); } if (inFlight.size > 0) { await Promise.race(inFlight.values()); } } transfer.completed = true; } catch (err) { transfer.pendingAcks.forEach((pending) => { clearTimeout(pending.timeoutId); try { pending.reject(err); } catch {} }); transfer.pendingAcks.clear(); throw err; } } 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 { if (PREFER_HTTP_UPLOAD_PULL) { await uploadFileViaHttpPull(file, path, transfer); } else if (file.size <= WS_UPLOAD_MAX_TOTAL) { await uploadFileViaWsChunks(file, path, transfer); } else { await uploadFileViaHttpPull(file, path, transfer); } finishUpload(transfer); } catch (err) { const canFallbackToWs = PREFER_HTTP_UPLOAD_PULL && file.size <= WS_UPLOAD_MAX_TOTAL && !transfer.cancelled; if (canFallbackToWs) { console.warn("[filebrowser] http upload failed, falling back to ws chunks", err); notifyToast("HTTP upload failed; retrying through WebSocket...", "warning", 3500); transfer.sent = 0; transfer.receivedBytes = 0; transfer.receivedChunks = 0; transfer.expectedChunks = 0; transfer.completed = false; transfer.ackedOffsets.clear(); transfer.pendingAcks.forEach((pending) => { clearTimeout(pending.timeoutId); try { pending.reject(new Error("switching upload method")); } catch {} }); transfer.pendingAcks.clear(); transfer.progress = 0; updateTransferProgress(transfer.id, 0, 0, transfer.total); try { await uploadFileViaWsChunks(file, path, transfer); finishUpload(transfer); return; } catch (fallbackErr) { err = fallbackErr; } } 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) { listFiles(path, ws, { resetHistory: true }); } }; pathInput.onkeydown = (e) => { if (e.key === "Enter") { const path = pathInput.value.trim(); if (path) { listFiles(path, ws, { resetHistory: true }); } } }; document.addEventListener("click", (e) => { if (!e.target.closest("#context-menu")) { hideContextMenu(); } }); setupDragAndDropUpload(); updateStatus("connecting", "Connecting..."); updateBackButton(); // ── Pinned paths (operator-scoped, localStorage) ──────────────────────────── const PINNED_STORAGE_KEY = "filebrowser.pinnedPaths.v1"; const sidebarPinned = document.getElementById("sidebar-pinned"); const sidebarPinCurrentBtn = document.getElementById("sidebar-pin-current-btn"); function loadPinnedPaths() { try { const raw = localStorage.getItem(PINNED_STORAGE_KEY); if (!raw) return []; const parsed = JSON.parse(raw); if (!Array.isArray(parsed)) return []; return parsed.filter((p) => p && typeof p.path === "string"); } catch { return []; } } function savePinnedPaths(list) { try { localStorage.setItem(PINNED_STORAGE_KEY, JSON.stringify(list)); } catch (err) { console.warn("Failed to persist pinned paths:", err); } } function isPinned(path) { return loadPinnedPaths().some((p) => p.path === path); } function pinPath(path, name) { if (!path) return; const list = loadPinnedPaths(); if (list.some((p) => p.path === path)) return; const fallback = path.split(/[\/\\]/).filter(Boolean).pop() || path; list.push({ path, name: name || fallback, addedAt: Date.now() }); savePinnedPaths(list); updatePinnedSidebar(); } function unpinPath(path) { if (!path) return; const list = loadPinnedPaths().filter((p) => p.path !== path); savePinnedPaths(list); updatePinnedSidebar(); } function updatePinnedSidebar() { if (!sidebarPinned) return; const list = loadPinnedPaths(); if (list.length === 0) { sidebarPinned.innerHTML = '
No pinned paths.
Right-click any folder.
'; return; } let html = ""; list.forEach((p) => { const active = currentPath && (currentPath === p.path || currentPath.startsWith(p.path + "/") || currentPath.startsWith(p.path + "\\")); html += ` `; }); sidebarPinned.innerHTML = html; sidebarPinned.querySelectorAll(".sidebar-item").forEach((row) => { row.addEventListener("click", (e) => { if (e.target.closest(".pin-remove-btn")) return; listFiles(row.dataset.path); }); }); sidebarPinned.querySelectorAll(".pin-remove-btn").forEach((btn) => { btn.addEventListener("click", (e) => { e.stopPropagation(); unpinPath(btn.dataset.path); }); }); } if (sidebarPinCurrentBtn) { sidebarPinCurrentBtn.addEventListener("click", () => { if (!currentPath || currentPath === ".") { notifyToast("Navigate into a folder first", "info", 2500); return; } if (isPinned(currentPath)) { unpinPath(currentPath); notifyToast("Unpinned", "info", 1800); } else { pinPath(currentPath); notifyToast("Pinned", "success", 1800); } }); } // ── Preview pane ──────────────────────────────────────────────────────────── const PREVIEW_PANE_STATE_KEY = "filebrowser.previewPaneVisible.v1"; const previewPaneHost = document.getElementById("preview-pane-host"); const previewPaneEl = document.getElementById("preview-pane"); const previewPaneToggle = document.getElementById("preview-pane-toggle"); const previewPaneShowBtnHost = document.getElementById("preview-pane-show-btn-host"); const previewPaneShowBtn = document.getElementById("preview-pane-show-btn"); const previewActiveEl = document.getElementById("preview-active"); const previewEmptyEl = previewPaneEl?.querySelector(".preview-empty"); const previewThumbBox = document.getElementById("preview-thumb-box"); const previewNameEl = document.getElementById("preview-name"); const previewPathEl = document.getElementById("preview-path"); const previewMetaList = document.getElementById("preview-meta-list"); let currentPreviewEntry = null; const fileHexHashManager = createFileHexHashManager({ send, notifyToast, getCurrentPreviewEntry: () => currentPreviewEntry, beforeFileRead: (path, operation) => confirmMacPermissionRisk(path, operation), }); const { handleFileHashResult, handleFilePeekResult, openHexViewer, requestFileHash, requestFilePeek, } = fileHexHashManager; function setPreviewPaneVisible(visible) { if (!previewPaneHost) return; if (visible) { previewPaneHost.classList.remove("hidden"); previewPaneShowBtnHost?.classList.add("hidden"); } else { previewPaneHost.classList.add("hidden"); previewPaneShowBtnHost?.classList.remove("hidden"); } try { localStorage.setItem(PREVIEW_PANE_STATE_KEY, visible ? "1" : "0"); } catch {} } previewPaneToggle?.addEventListener("click", () => setPreviewPaneVisible(false)); previewPaneShowBtn?.addEventListener("click", () => setPreviewPaneVisible(true)); (function initPreviewPaneVisibility() { let saved = "1"; try { saved = localStorage.getItem(PREVIEW_PANE_STATE_KEY) || "1"; } catch {} setPreviewPaneVisible(saved !== "0"); })(); function clearPreviewPane() { currentPreviewEntry = null; if (!previewPaneEl) return; previewPaneEl.classList.add("empty"); previewActiveEl?.classList.add("hidden"); if (previewThumbBox) previewThumbBox.innerHTML = ""; if (previewMetaList) previewMetaList.innerHTML = ""; fileHexHashManager.resetPreviewTextHead(); fileHexHashManager.resetPreviewHash(); } function renderPreviewMeta(entry) { if (!previewMetaList) return ""; const ext = getFileExt(entry.name); const fields = []; fields.push(["Type", entry.isDir ? "Folder" : (ext ? ext.toUpperCase() : "File")]); if (!entry.isDir) fields.push(["Size", formatBytes(entry.size)]); fields.push(["Modified", new Date(entry.modTime * 1000).toLocaleString()]); if (entry.mode) fields.push(["Mode", entry.mode]); if (entry.owner) fields.push(["Owner", entry.owner]); previewMetaList.innerHTML = fields.map(([k, v]) => `
${escapeHtml(k)}
${escapeHtml(String(v))}
` ).join(""); } function showPreviewForEntry(entry) { if (!entry || !previewPaneEl || previewPaneHost?.classList.contains("hidden")) return; currentPreviewEntry = entry; previewPaneEl.classList.remove("empty"); previewActiveEl?.classList.remove("hidden"); if (previewNameEl) previewNameEl.textContent = entry.name || ""; if (previewPathEl) previewPathEl.textContent = entry.path || ""; renderPreviewMeta(entry); // Thumbnail / icon if (previewThumbBox) { previewThumbBox.innerHTML = ""; const tKey = thumbCacheKey(entry); const cached = tKey ? thumbCache.get(tKey) : null; if (cached && cached.blobUrl) { const img = document.createElement("img"); img.src = cached.blobUrl; previewThumbBox.appendChild(img); } else { const iconKey = iconCacheKey(entry); const iconEntry = iconKey ? iconCache.get(iconKey) : null; if (iconEntry && iconEntry.blobUrl) { const img = document.createElement("img"); img.src = iconEntry.blobUrl; img.style.width = "64px"; img.style.height = "64px"; previewThumbBox.appendChild(img); } else { previewThumbBox.innerHTML = `
${getFileIcon(entry)}
`; } if (tKey) requestThumbFor(entry); } } // Text head — only for likely-text files under 4KB peek window fileHexHashManager.resetPreviewTextHead(); if (!entry.isDir) { const ext = getFileExt(entry.name); if (!KNOWN_BINARY_EXTS.has(ext) && !PREVIEW_IMAGE_EXTS.has(ext) && !PREVIEW_PDF_EXTS.has(ext)) { requestFilePeek(entry.path, "preview"); } } fileHexHashManager.resetPreviewHash(); } // ── 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 + "\\")); const macPermissionRisk = macPermissionRiskForPath(path); const lockHtml = macPermissionRisk ? '' : ""; const titleAttr = macPermissionRisk ? ` title="macOS may ask the user to allow access to ${escapeHtml(macPermissionRisk.label)}"` : ""; 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 driveItemHtml(e) { const label = e.name.match(/^[A-Za-z]:$/) ? e.name + "\\" : (e.name || e.path); const path = e.name.match(/^[A-Za-z]:$/) ? e.name + "\\" : e.path; const total = Number(e.totalBytes || 0); const free = Number(e.freeBytes || 0); let usageHtml = ""; let titleAttr = ""; if (total > 0) { const used = Math.max(0, total - free); const pct = Math.min(100, Math.round((used / total) * 100)); const cls = pct >= 90 ? "crit" : pct >= 75 ? "warn" : ""; usageHtml = `
${formatBytes(free)} free ${formatBytes(total)}
`; titleAttr = ` title="${escapeHtml(e.fsType || "")}${e.fsType ? " · " : ""}${formatBytes(free)} free of ${formatBytes(total)}"`; } return ` `; } 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 += driveItemHtml(e); } }); 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); }); } updatePinnedSidebar(); checkFeatureAccess("file_browser", clientId).then(ok => ok && connect()); function removeTransfer(transferId) { transferPanelManager.removeTransfer(transferId); activeTransfers.delete(transferId); } function cancelTransfer(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); } } window.cancelTransfer = cancelTransfer; 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 lineNumber = Number(result.line); const lineInfo = Number.isSafeInteger(lineNumber) && lineNumber > 0 ? ` (line ${lineNumber})` : ""; 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.querySelector(".search-result-download")?.addEventListener("click", (event) => { event.stopPropagation(); downloadFile(String(result.path || "")); }); row.onclick = async () => { const resultName = result.path.split(/[\/\\]/).pop() || ""; if (isPreviewable(resultName)) { await openFilePreview(result.path); } else { await openFileInEditor(result.path); } }; fileListEl.appendChild(row); }); } async function openFileInEditor(path) { if (!(await confirmMacPermissionRisk(path, "read file"))) return; 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; } markMacPermissionAllowed(msg.path); 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 MAX_HIGHLIGHT_CHARS = 512 * 1024; const displayedCode = code.length > MAX_HIGHLIGHT_CHARS ? `${code.slice(0, MAX_HIGHLIGHT_CHARS)}\n\n[Syntax preview truncated for performance]` : code; const codeElement = document.getElementById("editor-code"); const fileName = currentEditingFile?.split(/[/\\\\]/).pop() || ""; const requestedLanguage = getHighlightLanguage(fileName); const language = window.hljs?.getLanguage(requestedLanguage) ? requestedLanguage : "plaintext"; codeElement.className = language === "plaintext" ? "hljs" : `language-${language}`; codeElement.textContent = displayedCode; if (code.length > MAX_HIGHLIGHT_CHARS) { editorStatus.textContent = `Ready — syntax preview limited to ${Math.round(MAX_HIGHLIGHT_CHARS / 1024)} KB`; } delete codeElement.dataset.highlighted; if (window.hljs && language !== "plaintext") { 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); filePreviewModalManager.bindControls(); fileHexHashManager.bindControls(); 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); }); });