let state = { panes: { left: { currentPath: "/Volumes", showHidden: false, selectedItem: null, selectedItems: [], visibleItems: [], currentRowIndex: -1, selectionAnchorIndex: null, pendingSelectionPath: null, returnFocusName: null, }, right: { currentPath: "/Volumes", showHidden: false, selectedItem: null, selectedItems: [], visibleItems: [], currentRowIndex: -1, selectionAnchorIndex: null, pendingSelectionPath: null, returnFocusName: null, }, }, activePane: "left", selectedTaskId: null, lastTaskCount: 0, }; const ROW_JUMP_STEP = 10; let wildcardDialogMode = "select"; let editorState = { path: null, originalContent: "", modified: null, }; let monacoState = { module: null, loadPromise: null, editor: null, model: null, resizeHandler: null, }; let moveState = { source: null, destination: "", }; let renameState = { source: null, name: "", submitAction: null, }; let deleteConfirmState = { resolver: null, }; let contextMenuState = { open: false, pane: "left", items: [], anchorPath: null, }; let batchMoveState = { destinationBase: "", count: 0, }; let imageViewerState = { scale: 1, fitScale: 1, path: null, resizeHandler: null, }; let uploadState = { active: false, targetPath: "", files: [], index: 0, overwriteAll: false, skipAll: false, successfulCount: 0, skippedCount: 0, cancelled: false, conflictResolver: null, cancelRequested: false, }; let downloadProgressState = { active: false, archiveLabel: "", totalItems: 0, requestKey: null, mode: null, taskId: null, cancelRequested: false, }; let folderUploadPlanState = { targetPane: "left", targetPath: "", rootFolderName: "", entries: [], fileCount: 0, subfolderCount: 0, }; let folderUploadPickerInput = null; let settingsState = { activeTab: "general", logsLoaded: false, tasksLoaded: false, logsPollTimer: null, lastHistoryRenderKey: "", lastTasksRenderKey: "", showThumbnails: false, preferredStartupPathLeft: null, preferredStartupPathRight: null, selectedTheme: "default", selectedColorMode: "dark", zipDownloadLimits: null, }; let headerTaskState = { activeItems: [], visibleItems: [], recentItems: [], popoverOpen: false, pollTimer: null, lastRenderKey: "", knownStatuses: {}, recentExpiryMs: 4000, paneRefreshPromise: null, }; // The header chip/popover reflects user-visible file operations, not every task-backed file action. const ACTIVE_OPERATION_OPERATIONS = new Set(["copy", "move", "duplicate", "delete"]); const ACTIVE_TASK_STATUSES = new Set(["queued", "running", "cancelling"]); const TERMINAL_OPERATION_STATUSES = new Set(["completed", "cancelled", "failed"]); const VALID_THEME_FAMILIES = [ "default", "macos-soft", "midnight", "graphite", "windows11", "commander-electric", "nord-arctic", "catppuccin-soft", "fluent-neon", ]; const VALID_COLOR_MODES = ["dark", "light"]; const VIRTUAL_SOURCES = [ { path: "/Volumes", label: "Volumes" }, { path: "/Clients", label: "Clients" }, ]; let searchState = { pane: "left", path: "/Volumes", query: "", }; function effectiveThemeKey(theme, colorMode) { const family = VALID_THEME_FAMILIES.includes(theme) ? theme : "default"; const mode = VALID_COLOR_MODES.includes(colorMode) ? colorMode : "dark"; return `${family}-${mode}`; } function currentColorMode() { return document.documentElement.dataset.colorMode === "light" ? "light" : "dark"; } function applyTheme(theme, colorMode) { const family = VALID_THEME_FAMILIES.includes(theme) ? theme : "default"; const mode = VALID_COLOR_MODES.includes(colorMode) ? colorMode : "dark"; const nextTheme = effectiveThemeKey(family, mode); document.documentElement.dataset.themeFamily = family; document.documentElement.dataset.colorMode = mode; document.documentElement.dataset.theme = nextTheme; const icon = document.getElementById("theme-toggle-icon"); const button = document.getElementById("theme-toggle"); if (icon) { icon.textContent = mode === "dark" ? "☾" : "☀"; } if (button) { button.setAttribute("aria-label", `Switch to ${mode === "dark" ? "light" : "dark"} mode`); button.setAttribute("title", `Switch to ${mode === "dark" ? "light" : "dark"} mode`); } if (monacoState.module) { monacoState.module.editor.setTheme(mode === "light" ? "vs" : "vs-dark"); } } async function toggleTheme() { const current = settingsState.selectedColorMode === "light" ? "light" : "dark"; const next = current === "dark" ? "light" : "dark"; try { const data = await saveSettings({ selected_color_mode: next }); applyTheme(data.selected_theme, data.selected_color_mode); } catch (err) { setError("actions-error", `Theme: ${err.message}`); } } function paneState(pane) { return state.panes[pane]; } function otherPane(pane) { return pane === "left" ? "right" : "left"; } function activePaneState() { return paneState(state.activePane); } function sourceRootForPath(path) { const normalized = (path || "").trim(); if (normalized === "/Clients" || normalized.startsWith("/Clients/")) { return "/Clients"; } return "/Volumes"; } function isRemoteBrowsePath(path) { return sourceRootForPath(path) === "/Clients"; } function syncSourceSwitchers() { ["left", "right"].forEach((pane) => { const container = document.getElementById(`${pane}-source-switcher`); if (!container) { return; } const activeSource = sourceRootForPath(paneState(pane).currentPath); [...container.querySelectorAll("button[data-source-path]")].forEach((button) => { const isActive = button.dataset.sourcePath === activeSource; button.disabled = isActive; button.setAttribute("aria-pressed", isActive ? "true" : "false"); }); }); } function ensureSourceSwitchers() { ["left", "right"].forEach((pane) => { const toolbar = document.querySelector(`#${pane}-pane .pane-topbar`); if (!toolbar || document.getElementById(`${pane}-source-switcher`)) { return; } const container = document.createElement("div"); container.id = `${pane}-source-switcher`; container.className = "pane-source-switcher"; VIRTUAL_SOURCES.forEach((source) => { const button = createButton(source.label, () => { setActivePane(pane); navigateTo(pane, source.path); }); button.type = "button"; button.dataset.sourcePath = source.path; container.append(button); }); toolbar.prepend(container); }); syncSourceSwitchers(); } function setStatus(msg) { document.getElementById("status").textContent = msg; } function headerTaskElements() { return { container: document.getElementById("header-task-chip-container"), chipButton: document.getElementById("header-task-chip-btn"), chipLabel: document.getElementById("header-task-chip-label"), popover: document.getElementById("header-task-popover"), popoverList: document.getElementById("header-task-popover-list"), logsButton: document.getElementById("header-task-logs-btn"), }; } function setError(id, msg) { if (id === "actions-error") { document.getElementById(id).textContent = ""; if (msg) { openFeedbackModal(msg); } else { closeFeedbackModal(); } return; } document.getElementById(id).textContent = msg || ""; } function setActionError(action, err) { setError("actions-error", `${action}: ${err.message}`); } function showActionSummary(action, successes, failures, firstError) { const base = `${action}: ${successes} success, ${failures} failed`; if (firstError) { setError("actions-error", `${base}. First error: ${firstError}`); } else { setError("actions-error", ""); } setStatus(base); } function viewerElements() { return { overlay: document.getElementById("viewer-modal"), title: document.getElementById("viewer-title"), fileName: document.getElementById("viewer-file-name"), filePath: document.getElementById("viewer-file-path"), error: document.getElementById("viewer-error"), content: document.getElementById("viewer-content"), closeButton: document.getElementById("viewer-close-btn"), }; } function editorElements() { return { overlay: document.getElementById("editor-modal"), title: document.getElementById("editor-title"), fileName: document.getElementById("editor-file-name"), filePath: document.getElementById("editor-file-path"), error: document.getElementById("editor-error"), host: document.getElementById("editor-host"), closeButton: document.getElementById("editor-close-btn"), saveButton: document.getElementById("editor-save-btn"), cancelButton: document.getElementById("editor-cancel-btn"), }; } function videoElements() { return { overlay: document.getElementById("video-modal"), title: document.getElementById("video-title"), fileName: document.getElementById("video-file-name"), filePath: document.getElementById("video-file-path"), error: document.getElementById("video-error"), player: document.getElementById("video-player"), closeButton: document.getElementById("video-close-btn"), }; } function pdfElements() { return { overlay: document.getElementById("pdf-modal"), title: document.getElementById("pdf-title"), fileName: document.getElementById("pdf-file-name"), filePath: document.getElementById("pdf-file-path"), error: document.getElementById("pdf-error"), frame: document.getElementById("pdf-frame"), closeButton: document.getElementById("pdf-close-btn"), }; } function imageElements() { return { overlay: document.getElementById("image-modal"), title: document.getElementById("image-title"), fileName: document.getElementById("image-file-name"), filePath: document.getElementById("image-file-path"), error: document.getElementById("image-error"), viewport: document.getElementById("image-viewport"), image: document.getElementById("image-viewer-img"), closeButton: document.getElementById("image-close-btn"), zoomInButton: document.getElementById("image-zoom-in-btn"), zoomOutButton: document.getElementById("image-zoom-out-btn"), resetButton: document.getElementById("image-reset-btn"), }; } function moveElements() { return { overlay: document.getElementById("move-popup"), source: document.getElementById("move-source"), input: document.getElementById("move-input"), error: document.getElementById("move-error"), applyButton: document.getElementById("move-apply-btn"), cancelButton: document.getElementById("move-cancel-btn"), closeButton: document.getElementById("move-close-btn"), }; } function renameElements() { return { overlay: document.getElementById("rename-popup"), title: document.getElementById("rename-title"), label: document.getElementById("rename-label"), input: document.getElementById("rename-input"), error: document.getElementById("rename-error"), applyButton: document.getElementById("rename-apply-btn"), cancelButton: document.getElementById("rename-cancel-btn"), closeButton: document.getElementById("rename-close-btn"), }; } function batchMoveElements() { return { overlay: document.getElementById("batch-move-popup"), count: document.getElementById("batch-move-count"), destination: document.getElementById("batch-move-destination"), error: document.getElementById("batch-move-error"), applyButton: document.getElementById("batch-move-apply-btn"), cancelButton: document.getElementById("batch-move-cancel-btn"), }; } function deleteConfirmElements() { return { overlay: document.getElementById("delete-confirm-modal"), title: document.getElementById("delete-confirm-title"), message: document.getElementById("delete-confirm-message"), path: document.getElementById("delete-confirm-path"), error: document.getElementById("delete-confirm-error"), applyButton: document.getElementById("delete-confirm-apply-btn"), cancelButton: document.getElementById("delete-confirm-cancel-btn"), }; } function feedbackElements() { return { overlay: document.getElementById("feedback-modal"), message: document.getElementById("feedback-message"), closeButton: document.getElementById("feedback-close-btn"), }; } function downloadModalElements() { return { overlay: document.getElementById("download-modal"), target: document.getElementById("download-modal-target"), currentFile: document.getElementById("download-modal-current-file"), count: document.getElementById("download-modal-count"), progressBar: document.getElementById("download-modal-progress-bar"), status: document.getElementById("download-modal-status"), logsButton: document.getElementById("download-modal-logs-btn"), cancelButton: document.getElementById("download-modal-cancel-btn"), closeButton: document.getElementById("download-modal-close-btn"), }; } function contextMenuElements() { return { menu: document.getElementById("context-menu"), scope: document.getElementById("context-menu-scope"), openButton: document.getElementById("context-menu-open-btn"), editButton: document.getElementById("context-menu-edit-btn"), downloadButton: document.getElementById("context-menu-download-btn"), renameButton: document.getElementById("context-menu-rename-btn"), duplicateButton: document.getElementById("context-menu-duplicate-btn"), copyButton: document.getElementById("context-menu-copy-btn"), moveButton: document.getElementById("context-menu-move-btn"), deleteButton: document.getElementById("context-menu-delete-btn"), propertiesButton: document.getElementById("context-menu-properties-btn"), }; } function isOpenableSelection(item) { if (!item) { return false; } if (item.kind === "directory") { return true; } return isImageSelection(item) || isVideoSelection(item); } function isTextPreviewSelection(item) { if (!item || item.kind !== "file") { return false; } const lower = (item.name || "").toLowerCase(); if (lower === "dockerfile" || lower === "containerfile") { return true; } return [".txt", ".log", ".ini", ".cfg", ".conf", ".md", ".yml", ".yaml", ".json", ".js", ".py", ".css", ".html"].some((suffix) => lower.endsWith(suffix) ); } function isRemoteViewableSelection(item) { return isImageSelection(item) || isTextPreviewSelection(item); } function isZipDownloadSelection(items) { return items.length > 1 || (items.length === 1 && items[0].kind === "directory"); } function zipDownloadRequestKey(paths) { return paths.join("\n"); } function singleFileDownloadRequestKey(path) { return path; } function selectedItemCountLabel(totalItems) { return `${totalItems} selected item${totalItems === 1 ? "" : "s"}`; } function archiveTaskStatusLabel(status) { switch (status) { case "requested": return "Requested"; case "preparing": return "Preparing"; case "ready": return "Ready"; case "failed": return "Failed"; case "cancelled": return "Cancelled"; default: return "Preparing"; } } function archiveTaskCountText(task) { if (typeof task.done_items === "number" && typeof task.total_items === "number") { return `${task.done_items}/${task.total_items} top-level items`; } if (typeof task.total_items === "number") { return `0/${task.total_items} top-level items`; } if (task.status === "requested") { return "Waiting for archive worker"; } return "Preparing archive"; } function archiveTaskCurrentItemText(task) { if (task.current_item) { return `Current: ${task.current_item}`; } if (task.status === "requested") { return `Selection: ${selectedItemCountLabel(downloadProgressState.totalItems)}`; } if (task.status === "ready") { return `Prepared ${selectedItemCountLabel(downloadProgressState.totalItems)}`; } return `Selection: ${selectedItemCountLabel(downloadProgressState.totalItems)}`; } function archiveTaskProgressPercent(task) { if (task.status === "ready") { return 100; } if (typeof task.done_items === "number" && typeof task.total_items === "number" && task.total_items > 0) { return Math.max(0, Math.min(100, Math.round((task.done_items / task.total_items) * 100))); } return 0; } function isDownloadModalOpen() { return !downloadModalElements().overlay.classList.contains("hidden"); } function setDownloadModalVisible(visible) { const elements = downloadModalElements(); if (!elements.overlay) { return; } elements.overlay.classList.toggle("hidden", !visible); } function updateDownloadModalDisplay(info) { const elements = downloadModalElements(); if (!elements.overlay) { return; } elements.target.textContent = info.targetText || ""; elements.currentFile.textContent = info.currentFileText || ""; elements.count.textContent = info.countText || ""; elements.status.textContent = info.statusText || ""; elements.progressBar.style.width = `${Math.max(0, Math.min(100, info.percent || 0))}%`; elements.logsButton.classList.toggle("hidden", !info.logsVisible); elements.cancelButton.disabled = !!info.cancelDisabled; elements.cancelButton.classList.toggle("hidden", !info.cancelVisible); elements.closeButton.disabled = !!info.active; elements.closeButton.classList.toggle("hidden", !!info.active); } function openZipDownloadModal(selectedItems) { const requestPaths = selectedItems.map((item) => item.path); downloadProgressState.active = true; downloadProgressState.mode = "archive"; downloadProgressState.archiveLabel = "ZIP archive"; downloadProgressState.totalItems = selectedItems.length; downloadProgressState.requestKey = zipDownloadRequestKey(requestPaths); downloadProgressState.taskId = null; downloadProgressState.cancelRequested = false; setDownloadModalVisible(true); updateDownloadModalDisplay({ active: true, targetText: "Archive download requested", currentFileText: `Selection: ${selectedItemCountLabel(selectedItems.length)}`, countText: "Waiting for archive task", statusText: "Requested", percent: 0, logsVisible: true, cancelVisible: true, cancelDisabled: true, }); } function openSingleFileDownloadModal(selectedItem) { downloadProgressState.active = true; downloadProgressState.mode = "single_file"; downloadProgressState.archiveLabel = selectedItem.name; downloadProgressState.totalItems = 1; downloadProgressState.requestKey = singleFileDownloadRequestKey(selectedItem.path); downloadProgressState.taskId = null; downloadProgressState.cancelRequested = false; setDownloadModalVisible(true); updateDownloadModalDisplay({ active: true, targetText: `File download requested: ${selectedItem.name}`, currentFileText: `File: ${selectedItem.path}`, countText: "Direct file download", statusText: "Requesting download...", percent: 0, logsVisible: true, cancelVisible: false, }); } function markZipDownloadReady(fileName) { downloadProgressState.active = false; downloadProgressState.cancelRequested = false; downloadProgressState.archiveLabel = fileName || "ZIP archive"; updateDownloadModalDisplay({ active: false, targetText: `Archive ready: ${downloadProgressState.archiveLabel}`, currentFileText: `Prepared ${selectedItemCountLabel(downloadProgressState.totalItems)}`, countText: "Browser download requested", statusText: "Ready", percent: 100, logsVisible: true, cancelVisible: false, }); window.setTimeout(closeDownloadModal, 480); } function markZipDownloadFailed(err) { downloadProgressState.active = false; downloadProgressState.cancelRequested = false; updateDownloadModalDisplay({ active: false, targetText: "Archive prepare failed", currentFileText: `Selection: ${selectedItemCountLabel(downloadProgressState.totalItems)}`, countText: "Archive task failed", statusText: `Failed: ${err.message || "Archive download failed"}`, percent: 0, logsVisible: true, cancelVisible: false, }); } function markZipDownloadCancelled() { downloadProgressState.active = false; downloadProgressState.cancelRequested = false; updateDownloadModalDisplay({ active: false, targetText: "Archive prepare cancelled", currentFileText: `Selection: ${selectedItemCountLabel(downloadProgressState.totalItems)}`, countText: "Archive task cancelled", statusText: "Cancelled", percent: 0, logsVisible: true, cancelVisible: false, }); } function markSingleFileDownloadRequested(fileName, path) { downloadProgressState.active = false; updateDownloadModalDisplay({ active: false, targetText: `File download requested: ${fileName}`, currentFileText: `File: ${path}`, countText: "Browser download requested", statusText: "Download requested", percent: 0, logsVisible: true, cancelVisible: false, }); window.setTimeout(closeDownloadModal, 480); } function markSingleFileDownloadFailed(err, selectedItem) { downloadProgressState.active = false; updateDownloadModalDisplay({ active: false, targetText: "File download failed", currentFileText: `File: ${selectedItem.path}`, countText: "Direct file download", statusText: `Failed: ${err.message || "Download failed"}`, percent: 0, logsVisible: true, cancelVisible: false, }); } function updateZipDownloadTaskProgress(task) { if (!downloadProgressState.active) { return; } updateDownloadModalDisplay({ active: true, targetText: "Archive download task", currentFileText: archiveTaskCurrentItemText(task), countText: archiveTaskCountText(task), statusText: downloadProgressState.cancelRequested ? "Cancelling download..." : archiveTaskStatusLabel(task.status), percent: archiveTaskProgressPercent(task), logsVisible: true, cancelVisible: true, cancelDisabled: !downloadProgressState.taskId || downloadProgressState.cancelRequested, }); } function sleep(ms) { return new Promise((resolve) => window.setTimeout(resolve, ms)); } async function waitForArchiveDownloadReady(taskId) { while (true) { const task = await getTaskRequest(taskId); if (task.status === "ready") { return task; } if (task.status === "cancelled") { const err = new Error("Archive download was cancelled"); err.code = "download_cancelled"; throw err; } if (task.status === "failed") { const err = new Error(task.error_message || "Archive download failed"); err.code = task.error_code || null; throw err; } updateZipDownloadTaskProgress(task); await sleep(250); } } function closeDownloadModal() { if (downloadProgressState.active) { return; } downloadProgressState.mode = null; downloadProgressState.archiveLabel = ""; downloadProgressState.totalItems = 0; downloadProgressState.requestKey = null; downloadProgressState.taskId = null; downloadProgressState.cancelRequested = false; updateDownloadModalDisplay({ active: false, targetText: "", currentFileText: "", countText: "", statusText: "", percent: 0, logsVisible: false, cancelVisible: false, }); setDownloadModalVisible(false); } function isContextMenuOpen() { return contextMenuState.open && !contextMenuElements().menu.classList.contains("hidden"); } function closeContextMenu() { const elements = contextMenuElements(); contextMenuState.open = false; contextMenuState.pane = "left"; contextMenuState.items = []; contextMenuState.anchorPath = null; if (!elements.menu) { return; } elements.menu.classList.add("hidden"); elements.scope.textContent = ""; elements.menu.style.left = ""; elements.menu.style.top = ""; } function openContextMenu(pane, entry, event) { if (!entry || entry.isParent) { return; } const elements = contextMenuElements(); const selectedItems = paneState(pane).selectedItems || []; const selectedPathsSet = new Set(selectedItems.map((item) => item.path)); const items = selectedPathsSet.has(entry.path) ? selectedItems.map((item) => ({ ...item })) : [selectedEntryFromItem(entry)]; const remoteSelection = items.some((item) => isRemoteBrowsePath(item.path)); contextMenuState.open = true; contextMenuState.pane = pane; contextMenuState.items = items; contextMenuState.anchorPath = entry.path; const isMulti = items.length > 1; const openableSingle = items.length === 1 && (remoteSelection ? items[0].kind === "directory" || isRemoteViewableSelection(items[0]) : isOpenableSelection(items[0])); const editableSingle = items.length === 1 && !remoteSelection && isEditableSelection(items[0]); const downloadableSelection = items.length === 1 && items[0].kind === "file"; elements.scope.textContent = isMulti ? "Multi-selection" : "Single item"; elements.openButton.classList.toggle("hidden", isMulti); elements.openButton.disabled = !openableSingle; elements.editButton.classList.toggle("hidden", isMulti || items.length !== 1 || items[0].kind !== "file" || remoteSelection); elements.editButton.disabled = !editableSingle; elements.downloadButton.classList.remove("hidden"); elements.downloadButton.disabled = !downloadableSelection; elements.renameButton.classList.toggle("hidden", isMulti || remoteSelection); elements.duplicateButton.classList.remove("hidden"); elements.duplicateButton.disabled = remoteSelection || items.length === 0; elements.copyButton.classList.remove("hidden"); elements.copyButton.disabled = remoteSelection || items.length === 0; elements.moveButton.classList.remove("hidden"); elements.moveButton.disabled = remoteSelection || items.length === 0; elements.deleteButton.classList.remove("hidden"); elements.deleteButton.disabled = remoteSelection || items.length === 0; elements.propertiesButton.classList.remove("hidden"); elements.propertiesButton.disabled = items.length === 0; elements.menu.classList.remove("hidden"); positionContextMenu(elements.menu, event.currentTarget, event); } function positionContextMenu(menu, rowElement, event) { if (!menu) { return; } const paneElement = rowElement instanceof Element ? rowElement.closest(".pane") : null; const paneRect = paneElement ? paneElement.getBoundingClientRect() : null; const rowRect = rowElement instanceof Element ? rowElement.getBoundingClientRect() : null; const menuRect = menu.getBoundingClientRect(); const viewportPadding = 8; const panePadding = 8; const minLeft = paneRect ? Math.max(viewportPadding, paneRect.left + panePadding) : viewportPadding; const maxLeft = paneRect ? Math.max(minLeft, Math.min(window.innerWidth - viewportPadding - menuRect.width, paneRect.right - panePadding - menuRect.width)) : Math.max(minLeft, window.innerWidth - viewportPadding - menuRect.width); const preferredLeft = rowRect ? rowRect.left + 12 : event.clientX; const left = Math.max(minLeft, Math.min(maxLeft, preferredLeft)); const paneTop = paneRect ? paneRect.top + panePadding : viewportPadding; const paneBottom = paneRect ? paneRect.bottom - panePadding : window.innerHeight - viewportPadding; const rowTop = rowRect ? rowRect.top : event.clientY; const rowBottom = rowRect ? rowRect.bottom : event.clientY; const spaceBelow = paneBottom - rowBottom; const spaceAbove = rowTop - paneTop; let top; if (spaceBelow >= menuRect.height || spaceBelow >= spaceAbove) { top = rowBottom; } else if (spaceAbove >= menuRect.height) { top = rowTop - menuRect.height; } else { top = Math.max(paneTop, Math.min(paneBottom - menuRect.height, rowBottom)); } top = Math.max(paneTop, Math.min(top, paneBottom - menuRect.height)); menu.style.left = `${left}px`; menu.style.top = `${top}px`; } function applyContextMenuSelection() { if (!contextMenuState.items.length) { return false; } const pane = contextMenuState.pane; const model = paneState(pane); setActivePane(pane); model.selectedItems = contextMenuState.items.map((item) => ({ ...item })); model.selectedItem = model.selectedItems.length > 0 ? model.selectedItems[model.selectedItems.length - 1] : null; const anchorPath = contextMenuState.anchorPath; if (anchorPath) { const currentIndex = model.visibleItems.findIndex((item) => !item.isParent && item.path === anchorPath); if (currentIndex >= 0) { model.currentRowIndex = currentIndex; setSelectionAnchor(pane, currentIndex); } } renderPaneItems(pane); return true; } function startContextMenuRename() { if (!applyContextMenuSelection()) { closeContextMenu(); return; } closeContextMenu(); openRenamePopup(); } function startContextMenuDelete() { if (!applyContextMenuSelection()) { closeContextMenu(); return; } closeContextMenu(); deleteSelected(); } function startContextMenuMove() { if (!applyContextMenuSelection()) { closeContextMenu(); return; } closeContextMenu(); openF6Flow(); } function startContextMenuCopy() { if (contextMenuElements().copyButton?.disabled) { return; } if (!applyContextMenuSelection()) { closeContextMenu(); return; } closeContextMenu(); startCopySelected(); } async function startDuplicateSelected() { const sourcePane = state.activePane; const selectedItems = [...paneState(sourcePane).selectedItems]; if (selectedItems.length === 0) { return; } setError("actions-error", ""); try { const result = await createDuplicateTask(selectedItems.map((item) => item.path)); state.selectedTaskId = result.task_id; await refreshTasksSnapshot(); await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]); showActionSummary("Duplicate", 1, 0, null); } catch (err) { showActionSummary("Duplicate", 0, 1, err.message); } } function startContextMenuDuplicate() { if (contextMenuElements().duplicateButton?.disabled) { return; } if (!applyContextMenuSelection()) { closeContextMenu(); return; } closeContextMenu(); startDuplicateSelected(); } function startContextMenuOpen() { if (contextMenuElements().openButton?.disabled) { return; } if (!applyContextMenuSelection()) { closeContextMenu(); return; } closeContextMenu(); openCurrentDirectory(); } function startContextMenuEdit() { if (contextMenuElements().editButton?.disabled) { return; } if (!applyContextMenuSelection()) { closeContextMenu(); return; } closeContextMenu(); openEditor(); } async function startDownloadSelected() { const selectedItems = activePaneState().selectedItems; if (selectedItems.length === 0) { return; } const zipDownload = isZipDownloadSelection(selectedItems); const selectedPaths = selectedItems.map((item) => item.path); const selected = selectedItems[0]; const requestKey = zipDownload ? zipDownloadRequestKey(selectedPaths) : singleFileDownloadRequestKey(selected.path); if (downloadProgressState.active && downloadProgressState.requestKey === requestKey) { setStatus(zipDownload ? "Preparing download..." : "Requesting download..."); return; } if (zipDownload) { openZipDownloadModal(selectedItems); setStatus("Preparing download..."); } else { openSingleFileDownloadModal(selected); setStatus("Requesting download..."); } try { if (zipDownload) { const created = await createArchiveDownloadTask(selectedPaths); downloadProgressState.taskId = created.task_id; updateZipDownloadTaskProgress({ status: created.status || "requested", current_item: null, done_items: 0, total_items: selectedItems.length, }); const task = await waitForArchiveDownloadReady(created.task_id); startArchiveDownload(task.id, task.destination); markZipDownloadReady(task.destination); setStatus(`Download started: ${task.destination}`); return; } let fileName = selected.name; if (isRemoteBrowsePath(selected.path)) { fileName = startDirectSingleFileDownload(selected.path, selected.name).fileName || selected.name; } else { const response = await downloadFileRequest(selectedPaths); const url = URL.createObjectURL(response.blob); const anchor = document.createElement("a"); anchor.href = url; anchor.download = response.fileName || selected.name; document.body.append(anchor); anchor.click(); anchor.remove(); URL.revokeObjectURL(url); fileName = anchor.download || selected.name; } markSingleFileDownloadRequested(fileName, selected.path); setStatus(`Download requested: ${fileName}`); } catch (err) { if (zipDownload) { if (err.code === "download_cancelled") { markZipDownloadCancelled(); setStatus("Download cancelled"); } else { markZipDownloadFailed(err); setStatus("Download failed"); } } else { markSingleFileDownloadFailed(err, selected); setActionError("Download", err); } } } function startContextMenuDownload() { if (contextMenuElements().downloadButton?.disabled) { return; } if (!applyContextMenuSelection()) { closeContextMenu(); return; } closeContextMenu(); startDownloadSelected(); } function startContextMenuProperties() { if (contextMenuElements().propertiesButton?.disabled) { return; } if (!applyContextMenuSelection()) { closeContextMenu(); return; } closeContextMenu(); openInfo(); } function settingsElements() { return { overlay: document.getElementById("settings-modal"), closeButton: document.getElementById("settings-close-btn"), generalTab: document.getElementById("settings-general-tab"), interfaceTab: document.getElementById("settings-interface-tab"), downloadsTab: document.getElementById("settings-downloads-tab"), logsTab: document.getElementById("settings-logs-tab"), generalPanel: document.getElementById("settings-general-panel"), interfacePanel: document.getElementById("settings-interface-panel"), downloadsPanel: document.getElementById("settings-downloads-panel"), showThumbnailsInput: document.getElementById("settings-show-thumbnails"), startupPathLeftInput: document.getElementById("settings-startup-path-left"), startupPathRightInput: document.getElementById("settings-startup-path-right"), generalError: document.getElementById("settings-general-error"), generalSaveButton: document.getElementById("settings-general-save-btn"), selectedThemeInput: document.getElementById("settings-selected-theme"), interfaceError: document.getElementById("settings-interface-error"), interfaceSaveButton: document.getElementById("settings-interface-save-btn"), downloadMaxItems: document.getElementById("settings-download-max-items"), downloadMaxTotalSize: document.getElementById("settings-download-max-total-size"), downloadMaxFileSize: document.getElementById("settings-download-max-file-size"), downloadScanTimeout: document.getElementById("settings-download-scan-timeout"), downloadSymlinkPolicy: document.getElementById("settings-download-symlink-policy"), logsPanel: document.getElementById("settings-logs-panel"), tasksList: document.getElementById("settings-tasks-list"), logsList: document.getElementById("settings-logs-list"), logsError: document.getElementById("settings-logs-error"), }; } function searchElements() { return { overlay: document.getElementById("search-modal"), closeButton: document.getElementById("search-close-btn"), context: document.getElementById("search-context"), input: document.getElementById("search-input"), error: document.getElementById("search-error"), results: document.getElementById("search-results"), }; } function infoElements() { return { overlay: document.getElementById("info-modal"), title: document.getElementById("info-title"), closeButton: document.getElementById("info-close-btn"), error: document.getElementById("info-error"), grid: document.getElementById("info-grid"), }; } function uploadElements() { return { menu: document.getElementById("upload-menu"), button: document.getElementById("upload-btn"), menuToggle: document.getElementById("upload-menu-toggle"), menuPopup: document.getElementById("upload-menu-popup"), folderButton: document.getElementById("upload-folder-btn"), input: document.getElementById("upload-input"), }; } function uploadModalElements() { return { overlay: document.getElementById("upload-modal"), target: document.getElementById("upload-modal-target"), currentFile: document.getElementById("upload-modal-current-file"), count: document.getElementById("upload-modal-count"), progressBar: document.getElementById("upload-modal-progress-bar"), status: document.getElementById("upload-modal-status"), cancelButton: document.getElementById("upload-modal-cancel-btn"), }; } function isFeedbackModalOpen() { return !feedbackElements().overlay.classList.contains("hidden"); } function openFeedbackModal(message) { const elements = feedbackElements(); if (!elements.overlay) { return; } elements.message.textContent = message || ""; elements.overlay.classList.remove("hidden"); } function closeFeedbackModal() { const elements = feedbackElements(); if (!elements.overlay) { return; } elements.message.textContent = ""; elements.overlay.classList.add("hidden"); } function setUploadModalVisible(visible) { const elements = uploadModalElements(); if (!elements.overlay) { return; } elements.overlay.classList.toggle("hidden", !visible); } function updateUploadModalDisplay(info) { const elements = uploadModalElements(); if (!elements.overlay) { return; } const total = info.total || 0; elements.target.textContent = `Uploading to: ${info.targetPath}`; elements.currentFile.textContent = info.currentFileName ? `Current file: ${info.currentFileName}` : info.rootFolderName ? `Folder: ${info.rootFolderName}` : "Preparing files"; elements.count.textContent = total ? `${Math.min(info.index, total)}/${total} files` : ""; const percent = total ? Math.min(100, Math.round((info.index / total) * 100)) : 0; elements.progressBar.style.width = `${percent}%`; if (info.statusText) { elements.status.textContent = info.statusText; } else if (!elements.status.textContent) { elements.status.textContent = ""; } elements.cancelButton.disabled = !uploadState.active; } function setUploadModalStatus(msg) { const elements = uploadModalElements(); if (!elements.overlay) { return; } elements.status.textContent = msg || ""; } function requestUploadCancel() { uploadState.cancelRequested = true; const elements = uploadModalElements(); if (elements.cancelButton) { elements.cancelButton.disabled = true; } setUploadModalStatus("Cancel requested; finishing current file..."); } function ensureFolderUploadPicker() { if (folderUploadPickerInput) { return folderUploadPickerInput; } const input = document.createElement("input"); input.type = "file"; input.multiple = true; input.hidden = true; input.setAttribute("webkitdirectory", ""); input.setAttribute("directory", ""); input.onchange = handleFolderSelection; document.body.append(input); folderUploadPickerInput = input; return folderUploadPickerInput; } function uploadConflictElements() { return { overlay: document.getElementById("upload-conflict-modal"), title: document.getElementById("upload-conflict-title"), target: document.getElementById("upload-conflict-target"), fileName: document.getElementById("upload-conflict-file-name"), message: document.getElementById("upload-conflict-message"), overwriteButton: document.getElementById("upload-conflict-overwrite-btn"), overwriteAllButton: document.getElementById("upload-conflict-overwrite-all-btn"), skipButton: document.getElementById("upload-conflict-skip-btn"), skipAllButton: document.getElementById("upload-conflict-skip-all-btn"), cancelButton: document.getElementById("upload-conflict-cancel-btn"), }; } function ensureUploadConflictModal() { if (document.getElementById("upload-conflict-modal")) { return uploadConflictElements(); } const overlay = document.createElement("div"); overlay.id = "upload-conflict-modal"; overlay.className = "popup-overlay hidden"; const card = document.createElement("div"); card.className = "popup-card"; card.setAttribute("role", "dialog"); card.setAttribute("aria-modal", "true"); card.setAttribute("aria-labelledby", "upload-conflict-title"); const title = document.createElement("h3"); title.id = "upload-conflict-title"; title.textContent = "Upload conflict"; const target = document.createElement("div"); target.id = "upload-conflict-target"; target.className = "popup-meta"; const fileName = document.createElement("div"); fileName.id = "upload-conflict-file-name"; fileName.className = "popup-meta"; const message = document.createElement("div"); message.id = "upload-conflict-message"; message.className = "popup-meta"; const actions = document.createElement("div"); actions.className = "popup-actions"; const overwriteButton = createButton("Overwrite", () => resolveUploadConflict("overwrite")); overwriteButton.id = "upload-conflict-overwrite-btn"; const overwriteAllButton = createButton("Overwrite all", () => resolveUploadConflict("overwrite_all")); overwriteAllButton.id = "upload-conflict-overwrite-all-btn"; const skipButton = createButton("Skip", () => resolveUploadConflict("skip")); skipButton.id = "upload-conflict-skip-btn"; const skipAllButton = createButton("Skip all", () => resolveUploadConflict("skip_all")); skipAllButton.id = "upload-conflict-skip-all-btn"; const cancelButton = createButton("Cancel", () => resolveUploadConflict("cancel")); cancelButton.id = "upload-conflict-cancel-btn"; actions.append(overwriteButton, overwriteAllButton, skipButton, skipAllButton, cancelButton); card.append(title, target, fileName, message, actions); overlay.append(card); document.body.append(overlay); return uploadConflictElements(); } async function apiRequest(method, url, body) { const options = { method, headers: {} }; if (body !== undefined) { options.headers["Content-Type"] = "application/json"; options.body = JSON.stringify(body); } const response = await fetch(url, options); const data = await response.json().catch(() => ({})); if (!response.ok) { throw createApiError(response, data); } return data; } function createApiError(response, data) { const error = data.error || {}; const err = new Error(error.message || `HTTP ${response.status}`); err.code = error.code || null; err.status = response.status; err.details = error.details || {}; return err; } async function downloadFileRequest(paths) { const params = new URLSearchParams(); for (const path of paths) { params.append("path", path); } const response = await fetch(`/api/files/download?${params.toString()}`); if (!response.ok) { const data = await response.json().catch(() => ({})); throw createApiError(response, data); } const disposition = response.headers.get("content-disposition") || ""; const match = disposition.match(/filename=\"([^\"]+)\"/); return { blob: await response.blob(), fileName: match ? match[1] : null, }; } function startDirectSingleFileDownload(path, fallbackName) { const anchor = document.createElement("a"); anchor.href = `/api/files/download?${new URLSearchParams({ path }).toString()}`; anchor.download = fallbackName || ""; document.body.append(anchor); anchor.click(); anchor.remove(); return { fileName: anchor.download || fallbackName || null, }; } async function createArchiveDownloadTask(paths) { return apiRequest("POST", "/api/files/download/archive-prepare", { paths }); } async function createDuplicateTask(paths) { return apiRequest("POST", "/api/files/duplicate", { paths }); } async function getTaskRequest(taskId) { return apiRequest("GET", `/api/tasks/${encodeURIComponent(taskId)}`); } async function cancelArchiveDownloadTask(taskId) { return apiRequest("POST", `/api/files/download/archive/${encodeURIComponent(taskId)}/cancel`); } function startArchiveDownload(taskId, fileName) { const anchor = document.createElement("a"); anchor.href = `/api/files/download/archive/${encodeURIComponent(taskId)}`; anchor.download = fileName || ""; document.body.append(anchor); anchor.click(); anchor.remove(); } async function requestArchiveDownloadCancel() { if (!downloadProgressState.active || !downloadProgressState.taskId || downloadProgressState.cancelRequested) { return; } downloadProgressState.cancelRequested = true; updateDownloadModalDisplay({ active: true, targetText: "Preparing download...", currentFileText: `Selection: ${selectedItemCountLabel(downloadProgressState.totalItems)}`, countText: "Zip download cancellation requested", statusText: "Cancelling download...", percent: 55, cancelVisible: true, cancelDisabled: true, }); try { await cancelArchiveDownloadTask(downloadProgressState.taskId); } catch (err) { if (err.code !== "download_not_cancellable") { downloadProgressState.cancelRequested = false; throw err; } } } async function uploadFileRequest(targetPath, file, overwrite = false) { const formData = new FormData(); formData.append("target_path", targetPath); formData.append("overwrite", overwrite ? "true" : "false"); formData.append("file", file, file.name); const response = await fetch("/api/files/upload", { method: "POST", body: formData, }); const data = await response.json().catch(() => ({})); if (!response.ok) { throw createApiError(response, data); } return data; } async function refreshTasksSnapshot() { try { const data = await apiRequest("GET", "/api/tasks"); applyTaskSnapshot(data.items); } catch (_) { // Task list panel is not visible in current UI; silently keep flow stable. } } function createButton(text, onClick) { const button = document.createElement("button"); button.textContent = text; button.onclick = onClick; return button; } function closeUploadMenu() { const elements = uploadElements(); if (!elements.menuPopup || !elements.menuToggle) { return; } elements.menuPopup.classList.add("hidden"); elements.menuToggle.setAttribute("aria-expanded", "false"); } function toggleUploadMenu() { const elements = uploadElements(); if (!elements.menuPopup || !elements.menuToggle) { return; } const nextOpen = elements.menuPopup.classList.contains("hidden"); elements.menuPopup.classList.toggle("hidden", !nextOpen); elements.menuToggle.setAttribute("aria-expanded", nextOpen ? "true" : "false"); } function resetUploadProgress() { const elements = uploadElements(); uploadState.active = false; uploadState.targetPath = ""; uploadState.files = []; uploadState.index = 0; uploadState.overwriteAll = false; uploadState.skipAll = false; uploadState.successfulCount = 0; uploadState.skippedCount = 0; uploadState.cancelled = false; uploadState.conflictResolver = null; uploadState.cancelRequested = false; elements.button.disabled = false; setUploadModalVisible(false); setUploadModalStatus(""); } function showFolderUploadPlan(plan) { folderUploadPlanState = plan; updateUploadModalDisplay({ targetPath: `${plan.targetPath}/${plan.rootFolderName}`, rootFolderName: plan.rootFolderName, total: plan.fileCount, index: 0, }); setUploadModalVisible(true); setUploadModalStatus("Preparing folder upload..."); } function updateUploadProgress() { const elements = uploadElements(); const total = uploadState.files.length; const currentFile = uploadState.files[uploadState.index] || null; elements.target.textContent = `Upload to: ${uploadState.targetPath}`; elements.currentFile.textContent = currentFile ? `Uploading ${total} file${total === 1 ? "" : "s"} - Current file: ${currentFile.name}` : `Uploading ${total} file${total === 1 ? "" : "s"}`; elements.count.textContent = total > 0 ? `${Math.min(uploadState.index + 1, total)}/${total} files` : ""; elements.button.disabled = uploadState.active; setUploadProgressVisible(uploadState.active); } function openUploadPicker() { if (uploadState.active) { return; } closeUploadMenu(); uploadState.targetPath = activePaneState().currentPath; const elements = uploadElements(); elements.input.value = ""; elements.input.click(); } function openFolderPicker() { if (uploadState.active) { return; } closeUploadMenu(); folderUploadPlanState = { targetPane: state.activePane, targetPath: activePaneState().currentPath, rootFolderName: "", entries: [], fileCount: 0, subfolderCount: 0, }; const input = ensureFolderUploadPicker(); input.value = ""; input.click(); } function isUploadConflictOpen() { const overlay = document.getElementById("upload-conflict-modal"); return Boolean(overlay) && !overlay.classList.contains("hidden"); } function resolveUploadConflict(choice) { const resolver = uploadState.conflictResolver; if (!resolver) { return; } uploadState.conflictResolver = null; const elements = uploadConflictElements(); elements.overlay.classList.add("hidden"); resolver(choice); } function promptUploadConflict(fileName, targetPath, message) { const elements = ensureUploadConflictModal(); elements.title.textContent = "Upload conflict"; elements.target.textContent = `Upload to: ${targetPath}`; elements.fileName.textContent = `File: ${fileName}`; elements.message.textContent = message || "Target path already exists."; elements.overlay.classList.remove("hidden"); return new Promise((resolve) => { uploadState.conflictResolver = resolve; }); } function countPlannedSubfolders(relativePaths) { const folderPaths = new Set(); relativePaths.forEach((relativePath) => { const parts = relativePath.split("/").filter(Boolean); if (parts.length <= 1) { return; } let current = ""; for (let index = 0; index < parts.length - 1; index += 1) { current = current ? `${current}/${parts[index]}` : parts[index]; folderPaths.add(current); } }); return folderPaths.size; } function buildFolderUploadPlan(files, targetPath) { if (!files.length) { return null; } const plannedEntries = files.map((file) => { const webkitRelativePath = String(file.webkitRelativePath || "").replace(/\\/g, "/"); const parts = webkitRelativePath.split("/").filter(Boolean); return { file, webkitRelativePath, rootFolderName: parts[0] || "", relativePath: parts.length > 1 ? parts.slice(1).join("/") : file.name, }; }); const rootFolderName = plannedEntries[0].rootFolderName; if (!rootFolderName) { throw new Error("Folder picker did not return a usable folder structure"); } if (plannedEntries.some((entry) => entry.rootFolderName !== rootFolderName || !entry.relativePath)) { throw new Error("Folder picker returned multiple roots or invalid relative paths"); } return { targetPane: folderUploadPlanState.targetPane || state.activePane, targetPath, rootFolderName, entries: plannedEntries.map((entry) => ({ file: entry.file, name: entry.file.name, size: entry.file.size, relativePath: entry.relativePath, })), fileCount: plannedEntries.length, subfolderCount: countPlannedSubfolders(plannedEntries.map((entry) => entry.relativePath)), }; } function folderDirectoryPaths(plan) { const paths = new Set([`${plan.targetPath}/${plan.rootFolderName}`]); plan.entries.forEach((entry) => { const parts = entry.relativePath.split("/").filter(Boolean); if (parts.length <= 1) { return; } let current = `${plan.targetPath}/${plan.rootFolderName}`; for (let index = 0; index < parts.length - 1; index += 1) { current = `${current}/${parts[index]}`; paths.add(current); } }); return Array.from(paths).sort((left, right) => left.split("/").length - right.split("/").length); } async function ensureFolderDirectoryExists(path) { const segments = path.split("/"); const name = segments.pop(); const parentPath = segments.join("/"); try { await apiRequest("POST", "/api/files/mkdir", { parent_path: parentPath, name, }); return; } catch (err) { if (err.code !== "already_exists") { throw err; } } await apiRequest("GET", `/api/browse?${new URLSearchParams({ path }).toString()}`); } async function executeFolderUploadPlan(plan) { uploadState.active = true; uploadState.targetPath = `${plan.targetPath}/${plan.rootFolderName}`; uploadState.overwriteAll = false; uploadState.skipAll = false; uploadState.successfulCount = 0; uploadState.skippedCount = 0; uploadState.cancelled = false; uploadState.files = plan.entries.map((entry) => entry.file); uploadState.index = 0; setError("actions-error", ""); showFolderUploadPlan(plan); try { const directories = folderDirectoryPaths(plan); for (const directoryPath of directories) { if (uploadState.cancelRequested) { uploadState.cancelled = true; break; } await ensureFolderDirectoryExists(directoryPath); } setUploadModalStatus(""); outer: for (let index = 0; index < plan.entries.length; index += 1) { if (uploadState.cancelRequested) { uploadState.cancelled = true; break; } const entry = plan.entries[index]; const relativeParts = entry.relativePath.split("/").filter(Boolean); const fileName = relativeParts[relativeParts.length - 1]; const parentSegments = relativeParts.slice(0, -1); const targetPath = parentSegments.length ? `${plan.targetPath}/${plan.rootFolderName}/${parentSegments.join("/")}` : `${plan.targetPath}/${plan.rootFolderName}`; uploadState.index = index; updateUploadModalDisplay({ targetPath: `${plan.targetPath}/${plan.rootFolderName}`, rootFolderName: plan.rootFolderName, total: plan.fileCount, index: index + 1, currentFileName: entry.relativePath, }); let overwrite = uploadState.overwriteAll; while (true) { try { await uploadFileRequest(targetPath, entry.file, overwrite); uploadState.successfulCount += 1; break; } catch (err) { if (err.code !== "already_exists") { throw err; } if (uploadState.skipAll) { uploadState.skippedCount += 1; break; } const choice = await promptUploadConflict(fileName, targetPath, err.message); if (choice === "overwrite") { overwrite = true; continue; } if (choice === "overwrite_all") { uploadState.overwriteAll = true; overwrite = true; continue; } if (choice === "skip") { uploadState.skippedCount += 1; break; } if (choice === "skip_all") { uploadState.skipAll = true; uploadState.skippedCount += 1; break; } uploadState.cancelRequested = true; uploadState.cancelled = true; break outer; } } } if (uploadState.successfulCount > 0 || uploadState.skippedCount > 0) { await loadBrowsePane(plan.targetPane); } if (uploadState.cancelled) { setStatus(`Folder upload: ${uploadState.successfulCount}/${plan.fileCount} file${plan.fileCount === 1 ? "" : "s"} uploaded before cancel`); } else if (uploadState.skippedCount > 0) { setStatus(`Folder upload: ${uploadState.successfulCount} uploaded, ${uploadState.skippedCount} skipped`); } else { setStatus(`Folder upload: ${uploadState.successfulCount} file${uploadState.successfulCount === 1 ? "" : "s"} uploaded`); } } catch (err) { if (uploadState.successfulCount > 0 || uploadState.skippedCount > 0) { await loadBrowsePane(plan.targetPane); } setActionError("Folder upload", err); } finally { resetUploadProgress(); } } async function handleFolderSelection(event) { const files = Array.from(event.target.files || []); event.target.value = ""; if (files.length === 0) { return; } try { const targetPath = folderUploadPlanState.targetPath || activePaneState().currentPath; const plan = buildFolderUploadPlan(files, targetPath); if (!plan) { return; } showFolderUploadPlan(plan); setError("actions-error", ""); setStatus(`Folder upload: preparing ${plan.rootFolderName} (${plan.fileCount} file${plan.fileCount === 1 ? "" : "s"})`); await executeFolderUploadPlan(plan); } catch (err) { setUploadProgressVisible(false); setActionError("Folder upload", err); } } async function handleUploadSelection(event) { const files = Array.from(event.target.files || []); event.target.value = ""; if (files.length === 0) { return; } const targetPath = uploadState.targetPath || activePaneState().currentPath; uploadState.active = true; uploadState.targetPath = targetPath; uploadState.files = files; uploadState.index = 0; uploadState.overwriteAll = false; uploadState.skipAll = false; uploadState.successfulCount = 0; uploadState.skippedCount = 0; uploadState.cancelled = false; uploadState.cancelRequested = false; setError("actions-error", ""); setUploadModalVisible(true); updateUploadModalDisplay({ targetPath, rootFolderName: "", total: files.length, index: 0, }); try { outer: for (let index = 0; index < files.length; index += 1) { if (uploadState.cancelRequested) { uploadState.cancelled = true; break; } uploadState.index = index; updateUploadModalDisplay({ targetPath, rootFolderName: "", total: files.length, index: index + 1, currentFileName: files[index].name, }); let overwrite = uploadState.overwriteAll; while (true) { try { await uploadFileRequest(targetPath, files[index], overwrite); uploadState.successfulCount += 1; break; } catch (err) { if (err.code !== "already_exists") { throw err; } if (uploadState.skipAll) { uploadState.skippedCount += 1; break; } const choice = await promptUploadConflict(files[index].name, targetPath, err.message); if (choice === "overwrite") { overwrite = true; continue; } if (choice === "overwrite_all") { uploadState.overwriteAll = true; overwrite = true; continue; } if (choice === "skip") { uploadState.skippedCount += 1; break; } if (choice === "skip_all") { uploadState.skipAll = true; uploadState.skippedCount += 1; break; } uploadState.cancelRequested = true; uploadState.cancelled = true; break outer; } } } if (uploadState.successfulCount > 0) { await loadBrowsePane(state.activePane); } if (uploadState.cancelled) { setStatus(`Upload: ${uploadState.successfulCount}/${files.length} file${files.length === 1 ? "" : "s"} uploaded before cancel`); } else if (uploadState.skippedCount > 0) { setStatus(`Upload: ${uploadState.successfulCount} uploaded, ${uploadState.skippedCount} skipped`); } else { setStatus(`Upload: ${uploadState.successfulCount} file${uploadState.successfulCount === 1 ? "" : "s"} uploaded`); } } catch (err) { if (uploadState.successfulCount > 0) { await loadBrowsePane(state.activePane); } setActionError("Upload", err); setUploadModalStatus(err.message); } finally { resetUploadProgress(); } } function setActivePane(pane) { state.activePane = pane; document.getElementById("left-pane").classList.toggle("active-pane", pane === "left"); document.getElementById("right-pane").classList.toggle("active-pane", pane === "right"); updateActionButtons(); } function setSelectedItem(pane, item) { const model = paneState(pane); model.selectedItem = item; model.selectedItems = item ? [item] : []; updateActionButtons(); } function clearSelectionAnchor(pane) { paneState(pane).selectionAnchorIndex = null; } function setSelectionAnchor(pane, index) { paneState(pane).selectionAnchorIndex = Number.isInteger(index) ? index : null; } function selectedPaths(pane) { return paneState(pane).selectedItems.map((item) => item.path); } function setSingleSelection(pane, item) { setSelectedItem(pane, item); } function setSingleSelectionAtIndex(pane, item, index) { setSingleSelection(pane, item); setSelectionAnchor(pane, index); } function toggleSelection(pane, item) { const model = paneState(pane); const index = model.selectedItems.findIndex((selected) => selected.path === item.path); if (index >= 0) { const removed = model.selectedItems[index]; model.selectedItems.splice(index, 1); if (model.selectedItem && model.selectedItem.path === removed.path) { model.selectedItem = model.selectedItems.length > 0 ? model.selectedItems[model.selectedItems.length - 1] : null; } } else { model.selectedItems.push(item); model.selectedItem = item; } updateActionButtons(); } function toggleSelectionAtIndex(pane, item, index) { toggleSelection(pane, item); setSelectionAnchor(pane, index); } function selectedEntryFromItem(entry) { return { path: entry.path, name: entry.name, kind: entry.kind }; } function setRangeSelection(pane, anchorIndex, currentIndex) { const model = paneState(pane); if (!Array.isArray(model.visibleItems) || model.visibleItems.length === 0) { return; } const start = Math.max(0, Math.min(anchorIndex, currentIndex)); const end = Math.min(model.visibleItems.length - 1, Math.max(anchorIndex, currentIndex)); model.selectedItems = model.visibleItems .slice(start, end + 1) .filter((entry) => !entry.isParent) .map((entry) => selectedEntryFromItem(entry)); const current = model.visibleItems[currentIndex]; model.selectedItem = current && !current.isParent ? selectedEntryFromItem(current) : (model.selectedItems[model.selectedItems.length - 1] || null); updateActionButtons(); } function isMacLike() { const platform = navigator.platform || navigator.userAgent || ""; return /Mac|iPhone|iPad|iPod/i.test(platform); } function isToggleModifierClick(event) { if (isMacLike()) { return !!event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey; } return !!event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey; } function currentRowItem(pane) { const model = paneState(pane); if (!Array.isArray(model.visibleItems) || model.visibleItems.length === 0) { return null; } if (model.currentRowIndex < 0 || model.currentRowIndex >= model.visibleItems.length) { return null; } return model.visibleItems[model.currentRowIndex]; } function thumbnailsEnabled() { return !!settingsState.showThumbnails; } function isThumbnailCandidate(entry) { if (!entry || entry.kind !== "file") { return false; } const lower = (entry.name || "").toLowerCase(); return lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".png") || lower.endsWith(".webp"); } function iconTypeForEntry(entry) { if (!entry) { return "file"; } if (entry.kind === "directory") { return "folder"; } const name = entry.name || ""; const lower = name.toLowerCase(); if (lower === "dockerfile" || lower === "containerfile") { return "docker"; } if (["jpg", "jpeg", "png", "webp", "gif", "bmp", "avif"].some((ext) => lower.endsWith(`.${ext}`))) { return "image"; } if (["mp4", "mkv", "mov", "avi", "webm"].some((ext) => lower.endsWith(`.${ext}`))) { return "video"; } if (lower.endsWith(".pdf")) { return "pdf"; } if (["md", "markdown"].some((ext) => lower.endsWith(`.${ext}`))) { return "markdown"; } if (lower.endsWith(".json")) { return "json"; } if (["yaml", "yml"].some((ext) => lower.endsWith(`.${ext}`))) { return "yaml"; } if (lower.endsWith(".css")) { return "css"; } if (["js", "mjs", "cjs"].some((ext) => lower.endsWith(`.${ext}`))) { return "javascript"; } if (["ts", "tsx"].some((ext) => lower.endsWith(`.${ext}`))) { return "typescript"; } if (["html", "htm"].some((ext) => lower.endsWith(`.${ext}`))) { return "html"; } if (lower.endsWith(".xml")) { return "xml"; } if (["sh", "bash", "zsh", "fish"].some((ext) => lower.endsWith(`.${ext}`))) { return "shell"; } if (lower.endsWith(".py")) { return "python"; } if (["txt", "log", "ini", "cfg", "conf"].some((ext) => lower.endsWith(`.${ext}`))) { return "text"; } return "file"; } function mediaIconSvg(type) { const icons = { folder: '', file: '', image: '', video: '', pdf: '', text: '', markdown: '', json: '', yaml: '', css: '', javascript: '', typescript: '', html: '', xml: '', shell: '', python: '', docker: '', }; return icons[type] || icons.file; } function createMediaSlot(entry) { const slot = document.createElement("span"); slot.className = "entry-media-slot"; if (thumbnailsEnabled() && isThumbnailCandidate(entry)) { const image = document.createElement("img"); image.className = "entry-thumbnail"; image.loading = "lazy"; image.alt = ""; image.src = `/api/files/thumbnail?${new URLSearchParams({ path: entry.path }).toString()}`; slot.append(image); return slot; } const icon = document.createElement("span"); const iconType = iconTypeForEntry(entry); icon.className = `entry-media-icon ${iconType}`; icon.setAttribute("aria-hidden", "true"); icon.innerHTML = mediaIconSvg(iconType); slot.append(icon); return slot; } function createSelectionSlot(pane, entry, index) { const slot = document.createElement("span"); slot.className = "entry-select-slot"; if (entry.isParent) { const placeholder = document.createElement("span"); placeholder.className = "entry-select-toggle is-disabled"; placeholder.setAttribute("aria-hidden", "true"); const indicator = document.createElement("span"); indicator.className = "entry-select-indicator"; placeholder.append(indicator); slot.append(placeholder); return slot; } const button = document.createElement("button"); button.type = "button"; button.className = "entry-select-toggle"; const selected = selectedPaths(pane).includes(entry.path); if (selected) { button.classList.add("is-selected"); } button.setAttribute("aria-label", `${selected ? "Deselect" : "Select"} ${entry.name}`); button.setAttribute("aria-pressed", selected ? "true" : "false"); const indicator = document.createElement("span"); indicator.className = "entry-select-indicator"; button.append(indicator); button.onclick = (event) => { event.preventDefault(); event.stopPropagation(); setActivePane(pane); paneState(pane).currentRowIndex = index; toggleSelectionAtIndex(pane, selectedEntryFromItem(entry), index); renderPaneItems(pane); }; slot.append(button); return slot; } async function loadSettings() { const data = await apiRequest("GET", "/api/settings"); settingsState.showThumbnails = !!data.show_thumbnails; settingsState.preferredStartupPathLeft = data.preferred_startup_path_left || null; settingsState.preferredStartupPathRight = data.preferred_startup_path_right || null; settingsState.selectedTheme = VALID_THEME_FAMILIES.includes(data.selected_theme) ? data.selected_theme : "default"; settingsState.selectedColorMode = VALID_COLOR_MODES.includes(data.selected_color_mode) ? data.selected_color_mode : "dark"; settingsState.zipDownloadLimits = data.zip_download_limits || null; const elements = settingsElements(); if (elements.showThumbnailsInput) { elements.showThumbnailsInput.checked = settingsState.showThumbnails; } if (elements.startupPathLeftInput) { elements.startupPathLeftInput.value = settingsState.preferredStartupPathLeft || ""; } if (elements.startupPathRightInput) { elements.startupPathRightInput.value = settingsState.preferredStartupPathRight || ""; } if (elements.selectedThemeInput) { elements.selectedThemeInput.value = settingsState.selectedTheme; } renderDownloadSettings(); } async function saveSettings(update) { const data = await apiRequest("POST", "/api/settings", update); settingsState.showThumbnails = !!data.show_thumbnails; settingsState.preferredStartupPathLeft = data.preferred_startup_path_left || null; settingsState.preferredStartupPathRight = data.preferred_startup_path_right || null; settingsState.selectedTheme = VALID_THEME_FAMILIES.includes(data.selected_theme) ? data.selected_theme : "default"; settingsState.selectedColorMode = VALID_COLOR_MODES.includes(data.selected_color_mode) ? data.selected_color_mode : "dark"; settingsState.zipDownloadLimits = data.zip_download_limits || null; const elements = settingsElements(); if (elements.showThumbnailsInput) { elements.showThumbnailsInput.checked = settingsState.showThumbnails; } if (elements.startupPathLeftInput) { elements.startupPathLeftInput.value = settingsState.preferredStartupPathLeft || ""; } if (elements.startupPathRightInput) { elements.startupPathRightInput.value = settingsState.preferredStartupPathRight || ""; } if (elements.selectedThemeInput) { elements.selectedThemeInput.value = settingsState.selectedTheme; } renderDownloadSettings(); applyTheme(settingsState.selectedTheme, settingsState.selectedColorMode); renderPaneItems("left"); renderPaneItems("right"); return data; } function updateActionButtons() { const selectedItems = activePaneState().selectedItems; const count = selectedItems.length; const hasSelection = count > 0; const exactlyOne = count === 1; const allFiles = hasSelection && selectedItems.every((item) => item.kind === "file"); const remoteBrowse = isRemoteBrowsePath(activePaneState().currentPath); const remoteViewable = exactlyOne && isRemoteViewableSelection(selectedItems[0] || null); document.getElementById("view-btn").disabled = remoteBrowse ? !remoteViewable : !exactlyOne || !allFiles; document.getElementById("edit-btn").disabled = remoteBrowse || !exactlyOne || !allFiles || !isEditableSelection(selectedItems[0] || null); document.getElementById("rename-btn").disabled = remoteBrowse || !exactlyOne; document.getElementById("delete-btn").disabled = remoteBrowse || !hasSelection; document.getElementById("copy-btn").disabled = remoteBrowse || !hasSelection; document.getElementById("move-btn").disabled = remoteBrowse || !hasSelection; document.getElementById("mkdir-btn").disabled = remoteBrowse; document.getElementById("upload-btn").disabled = remoteBrowse; document.getElementById("upload-menu-toggle").disabled = remoteBrowse; document.getElementById("upload-folder-btn").disabled = remoteBrowse; } function isEditableSelection(item) { if (!item || item.kind !== "file") { return false; } const name = item.name || ""; const lower = name.toLowerCase(); if (lower === "dockerfile" || lower === "containerfile") { return true; } return [".txt", ".log", ".md", ".yml", ".yaml", ".json", ".js", ".py", ".css", ".html", ".conf"].some((suffix) => lower.endsWith(suffix)); } function formatBinarySize(bytes) { const value = Number(bytes); if (!Number.isFinite(value) || value < 0) { return "-"; } if (value < 1024) { return `${value} B`; } const units = ["KiB", "MiB", "GiB", "TiB"]; let scaled = value; let unitIndex = -1; do { scaled /= 1024; unitIndex += 1; } while (scaled >= 1024 && unitIndex < units.length - 1); const digits = scaled >= 10 || unitIndex === 0 ? 0 : 1; return `${scaled.toFixed(digits)} ${units[unitIndex]}`; } function formatSeconds(seconds) { const value = Number(seconds); if (!Number.isFinite(value) || value < 0) { return "-"; } return `${value % 1 === 0 ? value.toFixed(0) : value.toFixed(1)} seconds`; } function formatSymlinkPolicy(policy) { return policy === "not_allowed" ? "Rejected / not allowed" : (policy || "-"); } function renderDownloadSettings() { const elements = settingsElements(); const limits = settingsState.zipDownloadLimits || {}; if (elements.downloadMaxItems) { elements.downloadMaxItems.textContent = limits.max_items ? `${limits.max_items} items` : "-"; } if (elements.downloadMaxTotalSize) { elements.downloadMaxTotalSize.textContent = formatBinarySize(limits.max_total_input_bytes); } if (elements.downloadMaxFileSize) { elements.downloadMaxFileSize.textContent = formatBinarySize(limits.max_individual_file_bytes); } if (elements.downloadScanTimeout) { elements.downloadScanTimeout.textContent = formatSeconds(limits.scan_timeout_seconds); } if (elements.downloadSymlinkPolicy) { elements.downloadSymlinkPolicy.textContent = formatSymlinkPolicy(limits.symlink_policy); } } function monacoLanguageForName(name) { const lower = (name || "").toLowerCase(); if (lower === "dockerfile" || lower === "containerfile") { return "dockerfile"; } if ([".js", ".mjs", ".cjs"].some((suffix) => lower.endsWith(suffix))) { return "javascript"; } if ([".ts", ".tsx"].some((suffix) => lower.endsWith(suffix))) { return "typescript"; } if (lower.endsWith(".json")) { return "json"; } if (lower.endsWith(".css")) { return "css"; } if ([".html", ".htm"].some((suffix) => lower.endsWith(suffix))) { return "html"; } if (lower.endsWith(".xml")) { return "xml"; } if ([".yml", ".yaml"].some((suffix) => lower.endsWith(suffix))) { return "yaml"; } if (lower.endsWith(".py")) { return "python"; } if ([".sh", ".bash", ".zsh", ".fish"].some((suffix) => lower.endsWith(suffix))) { return "shell"; } if ([".md", ".markdown"].some((suffix) => lower.endsWith(suffix))) { return "markdown"; } if ([".txt", ".log", ".ini", ".cfg", ".conf"].some((suffix) => lower.endsWith(suffix))) { return "plaintext"; } return "plaintext"; } function isVideoSelection(item) { if (!item || item.kind !== "file") { return false; } const lower = (item.name || "").toLowerCase(); return lower.endsWith(".mp4") || lower.endsWith(".mkv"); } function isPdfSelection(item) { if (!item || item.kind !== "file") { return false; } return (item.name || "").toLowerCase().endsWith(".pdf"); } function isImageSelection(item) { if (!item || item.kind !== "file") { return false; } const lower = (item.name || "").toLowerCase(); return [".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp", ".avif"].some((suffix) => lower.endsWith(suffix)); } function currentImageScale() { return Number.isFinite(imageViewerState.scale) ? imageViewerState.scale : 1; } function applyImageScale() { const image = imageElements().image; image.style.transform = `scale(${currentImageScale()})`; } function resetImageViewerState() { imageViewerState = { scale: 1, fitScale: 1, path: null, resizeHandler: null, }; } function currentParentPath(path) { const normalized = (path || "").trim(); if (!normalized) { return null; } if (normalized === "/Volumes" || normalized === "/Clients") { return null; } if (normalized.startsWith("/")) { const segments = normalized.split("/").filter(Boolean); if (segments.length <= 1) { return null; } if (segments.length === 2) { return `/${segments[0]}`; } return `/${segments.slice(0, -1).join("/")}`; } if (!normalized.includes("/")) { return null; } const segments = normalized.split("/"); if (segments.length === 2) { return segments[0]; } return segments.slice(0, -1).join("/"); } function navigateToParent(pane) { const model = paneState(pane); const childPath = model.currentPath; const parentPath = currentParentPath(childPath); if (!parentPath) { return; } prepareParentReturnRestore(pane, childPath); navigateTo(pane, parentPath); } function prepareParentReturnRestore(pane, childPath) { const model = paneState(pane); model.returnFocusName = baseName(childPath); } function baseName(path) { const index = path.lastIndexOf("/"); return index >= 0 ? path.slice(index + 1) : path; } function rootKeyFromPath(path) { const normalized = (path || "").trim(); if (!normalized) { return null; } if (normalized.startsWith("/")) { const segments = normalized.split("/").filter(Boolean); if (segments.length < 2) { return normalized; } return `/${segments[0]}/${segments[1]}`; } return normalized.split("/")[0]; } function isNestedPath(sourcePath, destinationPath) { const source = (sourcePath || "").replace(/\/+$/, ""); const destination = (destinationPath || "").replace(/\/+$/, ""); if (!source || !destination) { return false; } return destination.startsWith(`${source}/`); } function uniqueRootKeysForItems(items) { return [...new Set(items.map((item) => rootKeyFromPath(item.path)).filter(Boolean))]; } function renderBreadcrumbs(pane, path) { const nav = document.getElementById(`${pane}-breadcrumbs`); nav.innerHTML = ""; const normalized = (path || "").trim(); const isHostPath = normalized.startsWith("/"); const parts = normalized.split("/").filter(Boolean); if (isHostPath) { const rootTarget = parts.length > 0 ? `/${parts[0]}` : "/Volumes"; const rootCrumb = createButton("/", () => { setActivePane(pane); navigateTo(pane, rootTarget); }); rootCrumb.type = "button"; rootCrumb.onclick = (ev) => { ev.preventDefault(); ev.stopPropagation(); setActivePane(pane); navigateTo(pane, rootTarget); }; nav.append(rootCrumb); if (parts.length > 0) { const sep = document.createElement("span"); sep.textContent = "/"; nav.append(sep); } } let aggregate = isHostPath ? "" : ""; for (let i = 0; i < parts.length; i += 1) { aggregate = isHostPath ? `/${parts.slice(0, i + 1).join("/")}` : (i === 0 ? parts[i] : `${aggregate}/${parts[i]}`); const crumbPath = aggregate; const crumb = createButton(parts[i], () => { setActivePane(pane); navigateTo(pane, crumbPath); }); crumb.type = "button"; crumb.onclick = (ev) => { ev.preventDefault(); ev.stopPropagation(); setActivePane(pane); navigateTo(pane, crumbPath); }; nav.append(crumb); if (i < parts.length - 1) { const sep = document.createElement("span"); sep.textContent = "/"; nav.append(sep); } } } function formatModified(isoString) { if (!isoString) { return "-"; } const d = new Date(isoString); if (Number.isNaN(d.getTime())) { return isoString; } const date = d.toLocaleDateString(undefined, { year: "2-digit", month: "2-digit", day: "2-digit" }); const time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); return `${date} ${time}`; } function formatFileSize(bytes) { if (typeof bytes !== "number" || !Number.isFinite(bytes) || bytes < 0) { return "-"; } if (bytes < 1024) { return `${bytes} B`; } if (bytes < 1024 ** 2) { return `${Math.round(bytes / 1024)} KB`; } if (bytes < 1024 ** 3) { return `${(bytes / (1024 ** 2)).toFixed(1)} MB`; } if (bytes < 1024 ** 4) { return `${(bytes / (1024 ** 3)).toFixed(1)} GB`; } return `${(bytes / (1024 ** 4)).toFixed(1)} TB`; } function createBrowseItem(pane, entry, kind, index) { const li = document.createElement("li"); li.className = "selectable"; li.dataset.path = entry.path; const paths = selectedPaths(pane); const model = paneState(pane); if (paths.includes(entry.path)) { li.classList.add("is-selected"); } if (model.currentRowIndex >= 0 && model.visibleItems[model.currentRowIndex]?.path === entry.path) { li.classList.add("is-current-row"); } li.onclick = () => { setActivePane(pane); setSingleSelection(pane, { path: entry.path, name: entry.name, kind }); loadBrowsePane(pane); }; const name = document.createElement("span"); name.className = `entry-name ${kind === "directory" ? "entry-dir" : "entry-file"}`; name.append(createSelectionSlot(pane, { ...entry, kind }, index)); name.append(createMediaSlot({ ...entry, kind })); if (kind === "directory") { const open = document.createElement("button"); open.className = "dir-link"; open.textContent = `${entry.name}/`; open.type = "button"; open.onclick = (ev) => { ev.stopPropagation(); setActivePane(pane); navigateTo(pane, entry.path); }; open.classList.add("entry-label"); name.append(open); } else { const fileName = document.createElement("span"); fileName.textContent = entry.name; fileName.className = "entry-label"; fileName.onclick = (ev) => { ev.stopPropagation(); setActivePane(pane); setSingleSelection(pane, { path: entry.path, name: entry.name, kind }); loadBrowsePane(pane); }; name.append(fileName); } li.append(name); const size = document.createElement("span"); size.className = "entry-size"; size.textContent = kind === "directory" ? "-" : formatFileSize(entry.size); li.append(size); const modified = document.createElement("span"); modified.className = "entry-modified"; modified.textContent = formatModified(entry.modified); li.append(modified); return li; } function scrollCurrentRowIntoView(pane) { const model = paneState(pane); if (model.currentRowIndex < 0) { return; } const list = document.getElementById(`${pane}-items`); const row = list.querySelector(`li[data-row-index="${model.currentRowIndex}"]`); if (row) { row.scrollIntoView({ block: "nearest" }); } } function updatePaneFocusLine(pane) { const model = paneState(pane); const focusLine = document.getElementById(`${pane}-focus-line`); const nameEl = document.getElementById(`${pane}-focus-name`); const selectedEl = document.getElementById(`${pane}-focus-selected`); if (!focusLine || !nameEl || !selectedEl) { return; } let label = "—"; if (!Array.isArray(model.visibleItems) || model.currentRowIndex < 0 || model.currentRowIndex >= model.visibleItems.length) { label = "—"; } else { const item = model.visibleItems[model.currentRowIndex]; if (item) { label = item.isParent ? "../" : (item.name || "—"); } } nameEl.textContent = label; const selectedCount = Array.isArray(model.selectedItems) ? model.selectedItems.length : 0; if (selectedCount > 0) { selectedEl.textContent = `Selected: ${selectedCount} ${selectedCount === 1 ? "item" : "items"}`; selectedEl.classList.remove("hidden"); } else { selectedEl.textContent = ""; selectedEl.classList.add("hidden"); } } function renderPaneItems(pane) { closeContextMenu(); const model = paneState(pane); const items = document.getElementById(`${pane}-items`); items.innerHTML = ""; if (!Array.isArray(model.visibleItems) || model.visibleItems.length === 0) { model.currentRowIndex = -1; updatePaneFocusLine(pane); updateActionButtons(); return; } if (model.currentRowIndex < 0 || model.currentRowIndex >= model.visibleItems.length) { model.currentRowIndex = 0; } model.visibleItems.forEach((entry, index) => { if (entry.isParent) { const up = document.createElement("li"); up.className = "selectable"; up.dataset.rowIndex = String(index); up.dataset.path = entry.path; if (index === model.currentRowIndex) { up.classList.add("is-current-row"); } up.onclick = () => { setActivePane(pane); model.currentRowIndex = index; clearSelectionAnchor(pane); renderPaneItems(pane); }; up.oncontextmenu = (event) => { event.preventDefault(); }; const upNameCell = document.createElement("span"); upNameCell.className = "entry-name entry-dir"; upNameCell.append(createSelectionSlot(pane, { ...entry, isParent: true }, index)); upNameCell.append(createMediaSlot({ name: "..", path: entry.path, kind: "directory" })); const upName = document.createElement("button"); upName.type = "button"; upName.className = "dir-link"; upName.textContent = "../"; upName.onclick = (ev) => { ev.stopPropagation(); setActivePane(pane); navigateToParent(pane); }; upName.classList.add("entry-label"); upNameCell.append(upName); up.append(upNameCell); const upSize = document.createElement("span"); upSize.className = "entry-size"; upSize.textContent = "-"; up.append(upSize); const upModified = document.createElement("span"); upModified.className = "entry-modified"; upModified.textContent = "-"; up.append(upModified); items.append(up); return; } const row = createBrowseItem(pane, entry, entry.kind, index); row.dataset.rowIndex = String(index); if (index === model.currentRowIndex) { row.classList.add("is-current-row"); } row.onclick = () => { setActivePane(pane); model.currentRowIndex = index; setSingleSelectionAtIndex(pane, { path: entry.path, name: entry.name, kind: entry.kind }, index); renderPaneItems(pane); }; const dirLink = row.querySelector(".dir-link"); if (dirLink) { dirLink.onclick = (ev) => { ev.stopPropagation(); setActivePane(pane); navigateTo(pane, entry.path); }; } const fileName = row.querySelector(".entry-file .entry-label"); if (fileName) { fileName.onclick = (ev) => { ev.stopPropagation(); setActivePane(pane); model.currentRowIndex = index; if (isToggleModifierClick(ev)) { toggleSelectionAtIndex(pane, { path: entry.path, name: entry.name, kind: entry.kind }, index); } else { setSingleSelectionAtIndex(pane, { path: entry.path, name: entry.name, kind: entry.kind }, index); } renderPaneItems(pane); }; } row.onclick = (ev) => { setActivePane(pane); model.currentRowIndex = index; if (isToggleModifierClick(ev)) { toggleSelectionAtIndex(pane, { path: entry.path, name: entry.name, kind: entry.kind }, index); } else { setSingleSelectionAtIndex(pane, { path: entry.path, name: entry.name, kind: entry.kind }, index); } renderPaneItems(pane); }; row.oncontextmenu = (event) => { event.preventDefault(); event.stopPropagation(); setActivePane(pane); openContextMenu(pane, entry, event); }; if (entry.kind === "file" && isImageSelection({ path: entry.path, name: entry.name, kind: entry.kind })) { row.ondblclick = (ev) => { ev.stopPropagation(); setActivePane(pane); model.currentRowIndex = index; setSingleSelectionAtIndex(pane, { path: entry.path, name: entry.name, kind: entry.kind }, index); renderPaneItems(pane); openImageViewer(); }; } else if (entry.kind === "file" && isVideoSelection({ path: entry.path, name: entry.name, kind: entry.kind })) { row.ondblclick = (ev) => { ev.stopPropagation(); setActivePane(pane); model.currentRowIndex = index; setSingleSelectionAtIndex(pane, { path: entry.path, name: entry.name, kind: entry.kind }, index); renderPaneItems(pane); openVideoViewer(); }; } else if (entry.kind === "file" && isPdfSelection({ path: entry.path, name: entry.name, kind: entry.kind })) { row.ondblclick = (ev) => { ev.stopPropagation(); setActivePane(pane); model.currentRowIndex = index; setSingleSelectionAtIndex(pane, { path: entry.path, name: entry.name, kind: entry.kind }, index); renderPaneItems(pane); openPdfViewer(); }; } items.append(row); }); updatePaneFocusLine(pane); updateActionButtons(); } async function loadBrowsePane(pane) { setError(`${pane}-browse-error`, ""); try { const model = paneState(pane); const query = new URLSearchParams({ path: model.currentPath, show_hidden: String(model.showHidden), }); const data = await apiRequest("GET", `/api/browse?${query.toString()}`); model.currentPath = data.path; syncSourceSwitchers(); renderBreadcrumbs(pane, data.path); const visibleItems = []; const parent = currentParentPath(data.path); if (parent) { visibleItems.push({ path: parent, name: "..", kind: "directory", isParent: true }); } for (const entry of data.directories) { visibleItems.push({ ...entry, kind: "directory" }); } for (const entry of data.files) { visibleItems.push({ ...entry, kind: "file" }); } model.visibleItems = visibleItems; const visiblePaths = new Set(visibleItems.filter((item) => !item.isParent).map((item) => item.path)); model.selectedItems = model.selectedItems.filter((item) => visiblePaths.has(item.path)); if (model.selectedItem && !visiblePaths.has(model.selectedItem.path)) { model.selectedItem = model.selectedItems.length > 0 ? model.selectedItems[model.selectedItems.length - 1] : null; } if (model.pendingSelectionPath) { const pendingIndex = visibleItems.findIndex((item) => !item.isParent && item.path === model.pendingSelectionPath); if (pendingIndex >= 0) { const pendingItem = visibleItems[pendingIndex]; model.currentRowIndex = pendingIndex; setSingleSelectionAtIndex(pane, selectedEntryFromItem(pendingItem), pendingIndex); } model.pendingSelectionPath = null; } renderPaneItems(pane); restoreParentReturnFocus(pane, visibleItems); scrollCurrentRowIntoView(pane); setStatus(`Loaded ${pane}: ${data.path}`); } catch (err) { setError(`${pane}-browse-error`, `Browse: ${err.message}`); } } function restoreParentReturnFocus(pane, visibleItems) { const model = paneState(pane); const returningFromChildName = model.returnFocusName; model.returnFocusName = null; if (!returningFromChildName) { return; } const returnIndex = visibleItems.findIndex((item) => !item.isParent && item.name === returningFromChildName); if (returnIndex < 0) { return; } const returnItem = visibleItems[returnIndex]; model.currentRowIndex = returnIndex; renderPaneItems(pane); } function navigateTo(pane, path) { closeContextMenu(); const model = paneState(pane); model.currentPath = path; model.currentRowIndex = 0; clearSelectionAnchor(pane); setSelectedItem(pane, null); syncSourceSwitchers(); updateActionButtons(); loadBrowsePane(pane); } async function createFolderForPane(pane) { setActivePane(pane); const name = await new Promise((resolve) => { openTextInputModal({ title: "Create Folder", label: "Folder name", applyText: "Create", initialValue: "", onSubmit: async (rawValue, elements, cancelled) => { if (cancelled) { resolve(""); return true; } const value = rawValue.trim(); elements.error.textContent = ""; if (!value) { elements.error.textContent = "Folder name is required"; return false; } resolve(value); return true; }, }); }); if (!name) { return; } setError(`${pane}-browse-error`, ""); try { await apiRequest("POST", "/api/files/mkdir", { parent_path: paneState(pane).currentPath, name, }); await loadBrowsePane(pane); } catch (err) { setError(`${pane}-browse-error`, `Create folder: ${err.message}`); } } async function createFolderForActivePane() { await createFolderForPane(state.activePane); } async function renameSelected() { const pane = state.activePane; const selectedItems = paneState(pane).selectedItems; if (selectedItems.length !== 1) { return; } openRenamePopup(); } function closeDeleteConfirmModal() { const elements = deleteConfirmElements(); const resolver = deleteConfirmState.resolver; deleteConfirmState.resolver = null; elements.error.textContent = ""; elements.title.textContent = "Delete folder and contents?"; elements.message.textContent = "This will permanently delete the folder and all files and subfolders inside it."; elements.path.textContent = ""; elements.applyButton.textContent = "Delete"; elements.overlay.classList.add("hidden"); if (typeof resolver === "function") { resolver(false); } } function openConfirmModal({ title, message, path, applyText = "Confirm" }) { const elements = deleteConfirmElements(); elements.error.textContent = ""; elements.title.textContent = title; elements.message.textContent = message; elements.path.textContent = path || ""; elements.applyButton.textContent = applyText; elements.overlay.classList.remove("hidden"); elements.applyButton.focus(); return new Promise((resolve) => { deleteConfirmState.resolver = resolve; }); } async function executeDeleteItems(pane, items, recursivePaths) { try { let result; if (items.length > 1) { result = await apiRequest("POST", "/api/files/delete", { paths: items.map((item) => item.path), recursive_paths: Array.from(recursivePaths), }); setStatus("Delete: operation started"); } else { const item = items[0]; result = await apiRequest("POST", "/api/files/delete", { path: item.path, recursive: recursivePaths.has(item.path), }); setStatus("Delete: started"); } state.selectedTaskId = result.task_id; await refreshTasksSnapshot(); } catch (err) { setActionError("Delete", err); return; } setSelectedItem(pane, null); await loadBrowsePane(pane); } async function submitDeleteConfirmModal() { const resolver = deleteConfirmState.resolver; if (typeof resolver !== "function") { return; } deleteConfirmState.resolver = null; const elements = deleteConfirmElements(); elements.error.textContent = ""; elements.title.textContent = "Delete folder and contents?"; elements.message.textContent = "This will permanently delete the folder and all files and subfolders inside it."; elements.path.textContent = ""; elements.applyButton.textContent = "Delete"; elements.overlay.classList.add("hidden"); resolver(true); } async function collectDeleteRecursivePaths(selectedItems) { const recursivePaths = new Set(); for (const item of selectedItems) { if (item.kind !== "directory") { continue; } const query = new URLSearchParams({ path: item.path, show_hidden: "true", }); const data = await apiRequest("GET", `/api/browse?${query.toString()}`); if ((data.directories && data.directories.length > 0) || (data.files && data.files.length > 0)) { recursivePaths.add(item.path); } } return recursivePaths; } async function deleteSelected() { const pane = state.activePane; const selectedItems = [...paneState(pane).selectedItems]; if (selectedItems.length === 0) { return; } setError("actions-error", ""); try { const recursivePaths = await collectDeleteRecursivePaths(selectedItems); if (recursivePaths.size > 0) { const confirmed = await openConfirmModal({ title: selectedItems.length === 1 ? "Delete folder and contents?" : "Delete selected items and folder contents?", message: selectedItems.length === 1 ? "This will permanently delete the folder and all files and subfolders inside it." : `This will permanently delete ${selectedItems.length} selected items, including all files and subfolders inside the selected folders.`, path: selectedItems.length === 1 ? selectedItems[0].path : `${selectedItems.length} selected items`, applyText: "Delete", }); if (!confirmed) { return; } await executeDeleteItems(pane, selectedItems, recursivePaths); return; } const confirmed = await openConfirmModal({ title: selectedItems.length === 1 ? "Delete item?" : "Delete selected items?", message: selectedItems.length === 1 ? "This will permanently delete the selected item." : `This will permanently delete ${selectedItems.length} selected items.`, path: selectedItems.length === 1 ? selectedItems[0].path : `${selectedItems.length} selected items`, applyText: "Delete", }); if (!confirmed) { return; } await executeDeleteItems(pane, selectedItems, new Set()); } catch (err) { setActionError("Delete", err); } } function defaultDestination(sourcePath, targetBasePath) { const sourceName = baseName(sourcePath); return `${targetBasePath}/${sourceName}`; } async function startCopySelected() { const sourcePane = state.activePane; const destinationPane = otherPane(sourcePane); const selectedItems = [...paneState(sourcePane).selectedItems]; if (selectedItems.length === 0) { return; } const baseDestination = paneState(destinationPane).currentPath; setError("actions-error", ""); try { let result; if (selectedItems.length > 1) { result = await apiRequest("POST", "/api/files/copy", { sources: selectedItems.map((item) => item.path), destination_base: baseDestination, }); setStatus("Copy: operation started"); } else { const item = selectedItems[0]; const destination = defaultDestination(item.path, baseDestination); result = await apiRequest("POST", "/api/files/copy", { source: item.path, destination, }); setStatus("Copy: started"); } state.selectedTaskId = result.task_id; await refreshTasksSnapshot(); } catch (err) { setActionError("Copy", err); return; } await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]); } async function startMoveSelected() { await executeMoveSelection(paneState(otherPane(state.activePane)).currentPath); } async function executeMoveSelection(baseDestination) { const sourcePane = state.activePane; const selectedItems = [...paneState(sourcePane).selectedItems]; if (selectedItems.length === 0) { return; } setError("actions-error", ""); if (selectedItems.length > 1) { const result = await apiRequest("POST", "/api/files/move", { sources: selectedItems.map((item) => item.path), destination_base: baseDestination, }); state.selectedTaskId = result.task_id; await refreshTasksSnapshot(); setSelectedItem(sourcePane, null); await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]); setStatus("Move: operation started"); return; } const item = selectedItems[0]; const destination = defaultDestination(item.path, baseDestination); if (item.kind !== "file") { setActionError("Move", new Error("Only files are supported for single-item move")); return; } const result = await apiRequest("POST", "/api/files/move", { source: item.path, destination, }); state.selectedTaskId = result.task_id; await refreshTasksSnapshot(); setSelectedItem(sourcePane, null); await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]); setStatus("Move: started"); } async function addBookmark() { const pane = state.activePane; const path = paneState(pane).currentPath; const label = await new Promise((resolve) => { openTextInputModal({ title: "Add Bookmark", label: "Bookmark label", applyText: "Add", initialValue: path, onSubmit: async (rawValue, elements, cancelled) => { if (cancelled) { resolve(""); return true; } const value = rawValue.trim(); elements.error.textContent = ""; if (!value) { elements.error.textContent = "Bookmark label is required"; return false; } resolve(value); return true; }, }); }); if (!label) { return; } setError("actions-error", ""); try { await apiRequest("POST", "/api/bookmarks", { path, label }); setStatus(`Bookmark added for ${path}`); } catch (err) { setActionError("Add bookmark", err); } } function shouldHandleShortcut(target) { if (!target || !(target instanceof Element)) { return true; } if (target.closest("[contenteditable='true']")) { return false; } const control = target.closest("input, textarea, select, button"); if (!control) { return true; } if (control.tagName === "INPUT" && control.type === "checkbox") { return false; } return false; } function actionButton(id) { return document.getElementById(id); } function triggerActionButton(id) { const button = actionButton(id); if (!button || button.disabled) { return false; } button.click(); return true; } function actionShortcutHandled(event) { const altOnly = event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey; const noModifiers = !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey; if (noModifiers) { if (event.key === "F1") { return triggerActionButton("settings-btn"); } if (event.key === "F2") { return triggerActionButton("rename-btn"); } if (event.key === "F3") { return triggerActionButton("view-btn"); } if (event.key === "F4") { return triggerActionButton("edit-btn"); } if (event.key === "F5") { return triggerActionButton("copy-btn"); } if (event.key === "F6") { return openF6Flow(); } if (event.key === "F7") { return triggerActionButton("mkdir-btn"); } if (event.key === "F8") { return triggerActionButton("delete-btn"); } } if (altOnly) { const key = event.key.toLowerCase(); if (key === "3") { return triggerActionButton("view-btn"); } if (key === "4") { return triggerActionButton("edit-btn"); } if (key === "5") { return triggerActionButton("copy-btn"); } if (key === "6") { return openF6Flow(); } if (key === "7") { return triggerActionButton("mkdir-btn"); } if (key === "8") { return triggerActionButton("delete-btn"); } } return false; } function wildcardPopupElements() { return { overlay: document.getElementById("wildcard-popup"), title: document.getElementById("wildcard-popup-title"), meta: document.getElementById("wildcard-popup-meta"), input: document.getElementById("wildcard-pattern-input"), error: document.getElementById("wildcard-popup-error"), applyButton: document.getElementById("wildcard-apply-btn"), cancelButton: document.getElementById("wildcard-cancel-btn"), }; } function isSettingsOpen() { return !settingsElements().overlay.classList.contains("hidden"); } function isWildcardPopupOpen() { return !wildcardPopupElements().overlay.classList.contains("hidden"); } function isMovePopupOpen() { return !moveElements().overlay.classList.contains("hidden"); } function isRenamePopupOpen() { return !renameElements().overlay.classList.contains("hidden"); } function isBatchMovePopupOpen() { return !batchMoveElements().overlay.classList.contains("hidden"); } function isDeleteConfirmModalOpen() { return !deleteConfirmElements().overlay.classList.contains("hidden"); } function isUploadConflictModalOpen() { return isUploadConflictOpen(); } function isViewerOpen() { return !viewerElements().overlay.classList.contains("hidden"); } function isVideoOpen() { return !videoElements().overlay.classList.contains("hidden"); } function isEditorOpen() { return !editorElements().overlay.classList.contains("hidden"); } async function loadMonacoModule() { if (monacoState.module) { return monacoState.module; } if (!monacoState.loadPromise) { monacoState.loadPromise = import("https://cdn.jsdelivr.net/npm/monaco-editor@0.52.2/+esm") .then((module) => { monacoState.module = module; module.editor.setTheme(currentColorMode() === "light" ? "vs" : "vs-dark"); return module; }); } return monacoState.loadPromise; } function disposeMonacoEditor() { if (typeof monacoState.resizeHandler === "function") { window.removeEventListener("resize", monacoState.resizeHandler); monacoState.resizeHandler = null; } if (monacoState.editor) { monacoState.editor.dispose(); monacoState.editor = null; } if (monacoState.model) { monacoState.model.dispose(); monacoState.model = null; } } function nextAnimationFrame() { return new Promise((resolve) => window.requestAnimationFrame(() => resolve())); } async function ensureMonacoEditor(path, content) { const editor = editorElements(); const monaco = await loadMonacoModule(); await nextAnimationFrame(); await nextAnimationFrame(); disposeMonacoEditor(); editor.host.textContent = ""; const model = monaco.editor.createModel(content, monacoLanguageForName(baseName(path))); const instance = monaco.editor.create(editor.host, { model, theme: currentColorMode() === "light" ? "vs" : "vs-dark", language: monacoLanguageForName(baseName(path)), automaticLayout: false, lineNumbers: "on", minimap: { enabled: false }, scrollBeyondLastLine: false, renderLineHighlight: "line", wordWrap: "on", fontSize: 13, roundedSelection: false, readOnly: false, }); const resizeHandler = () => { if (monacoState.editor) { monacoState.editor.layout(); } }; window.addEventListener("resize", resizeHandler); monacoState.model = model; monacoState.editor = instance; monacoState.resizeHandler = resizeHandler; instance.layout(); instance.focus(); } function currentEditorContent() { if (monacoState.editor) { return monacoState.editor.getValue(); } return ""; } function escapeRegExp(text) { return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&"); } function globToRegExp(pattern) { const escaped = pattern .split("*") .map((part) => escapeRegExp(part)) .join(".*"); return new RegExp(`^${escaped}$`, "i"); } function applyWildcardSelection(mode, pattern) { const pane = state.activePane; const model = paneState(pane); const matcher = globToRegExp(pattern); const candidates = model.visibleItems.filter((entry) => !entry.isParent); const matches = candidates.filter((entry) => matcher.test(entry.name)); const matchPaths = new Set(matches.map((entry) => entry.path)); let changed = 0; if (mode === "select") { const existing = new Set(model.selectedItems.map((item) => item.path)); for (const entry of matches) { if (existing.has(entry.path)) { continue; } model.selectedItems.push({ path: entry.path, name: entry.name, kind: entry.kind }); changed += 1; } if (matches.length > 0) { const last = matches[matches.length - 1]; model.selectedItem = { path: last.path, name: last.name, kind: last.kind }; } } else { const before = model.selectedItems.length; model.selectedItems = model.selectedItems.filter((item) => !matchPaths.has(item.path)); changed = before - model.selectedItems.length; if (!model.selectedItem || matchPaths.has(model.selectedItem.path)) { model.selectedItem = model.selectedItems.length > 0 ? model.selectedItems[model.selectedItems.length - 1] : null; } } renderPaneItems(pane); setStatus(`Wildcard ${mode}: ${matches.length} matched, ${changed} changed`); } function closeWildcardPopup() { const elements = wildcardPopupElements(); elements.overlay.classList.add("hidden"); elements.error.textContent = ""; elements.input.value = ""; } function showDirectoryMoveNotSupported() { const message = "Directory move is not supported in v1"; setError("actions-error", message); setStatus(message); } function showBatchDirectoryMoveNotSupported() { const message = "Batch directory move is not supported in v1"; setError("actions-error", message); setStatus(message); } function resetMoveState() { moveState = { source: null, destination: "", }; } function resetRenameState() { renameState = { source: null, name: "", submitAction: null, }; } function settleRenamePopup(value = null, cancelled = false, notify = true) { const elements = renameElements(); const submitAction = renameState.submitAction; elements.overlay.classList.add("hidden"); elements.error.textContent = ""; elements.input.value = ""; elements.title.textContent = "Rename"; elements.label.textContent = "Name"; elements.applyButton.textContent = "Rename"; resetRenameState(); if (notify && typeof submitAction === "function") { return submitAction(value, null, cancelled); } return undefined; } function closeRenamePopup() { settleRenamePopup(null, true); } function openTextInputModal({ title, label, applyText, initialValue = "", onSubmit }) { const elements = renameElements(); renameState.source = null; renameState.name = initialValue; renameState.submitAction = onSubmit; elements.title.textContent = title; elements.label.textContent = label; elements.applyButton.textContent = applyText; elements.input.value = initialValue; elements.error.textContent = ""; elements.overlay.classList.remove("hidden"); elements.input.focus(); elements.input.select(); } function openRenamePopup() { const selectedItems = activePaneState().selectedItems; if (selectedItems.length !== 1) { return false; } const source = selectedItems[0]; return openTextInputModal({ title: "Rename", label: "Name", applyText: "Rename", initialValue: source.name, onSubmit: async (rawValue, elements, cancelled) => { if (cancelled) { return true; } const newName = rawValue.trim(); elements.error.textContent = ""; if (!newName) { elements.error.textContent = "Name is required"; return false; } if (newName === source.name) { elements.error.textContent = "Name must differ from current name"; return false; } if (newName.includes("/")) { elements.error.textContent = "Name cannot contain /"; return false; } if (newName === "." || newName === "..") { elements.error.textContent = "Invalid name"; return false; } try { await apiRequest("POST", "/api/files/rename", { path: source.path, new_name: newName, }); setSelectedItem(state.activePane, null); await loadBrowsePane(state.activePane); setStatus(`Renamed ${source.path}`); return true; } catch (err) { elements.error.textContent = err.message; return false; } }, }); } function resetBatchMoveState() { batchMoveState = { destinationBase: "", count: 0, }; } function closeMovePopup() { const elements = moveElements(); elements.overlay.classList.add("hidden"); elements.error.textContent = ""; elements.input.value = ""; resetMoveState(); } function closeBatchMovePopup() { const elements = batchMoveElements(); elements.overlay.classList.add("hidden"); elements.error.textContent = ""; resetBatchMoveState(); } function openMovePopup() { const selectedItems = activePaneState().selectedItems; if (selectedItems.length !== 1) { return false; } const source = selectedItems[0]; const destination = defaultDestination(source.path, paneState(otherPane(state.activePane)).currentPath); const elements = moveElements(); moveState.source = source; moveState.destination = destination; elements.source.textContent = `Source: ${source.path}`; elements.input.value = destination; elements.error.textContent = ""; elements.overlay.classList.remove("hidden"); elements.input.focus(); elements.input.select(); return true; } function openBatchMovePopup(selectedItems) { if (selectedItems.length === 0) { return false; } const destinationBase = paneState(otherPane(state.activePane)).currentPath; const elements = batchMoveElements(); batchMoveState.destinationBase = destinationBase; batchMoveState.count = selectedItems.length; elements.count.textContent = `${selectedItems.length} selected item(s)`; elements.destination.textContent = `Destination: ${destinationBase}`; elements.error.textContent = ""; elements.overlay.classList.remove("hidden"); elements.applyButton.focus(); return true; } function openF6Flow() { const selectedItems = activePaneState().selectedItems; if (selectedItems.length === 0) { return false; } if (selectedItems.length === 1) { return openMovePopup(); } if (selectedItems.some((item) => item.kind !== "file")) { const roots = uniqueRootKeysForItems(selectedItems); if (roots.length > 1) { const message = "Batch move requires all selected items to be in the same root"; setError("actions-error", message); setStatus(message); return true; } return openBatchMovePopup(selectedItems); } return openBatchMovePopup(selectedItems); } async function submitRenamePopup() { const elements = renameElements(); if (typeof renameState.submitAction !== "function") { return; } const shouldClose = await renameState.submitAction(elements.input.value, elements, false); if (shouldClose !== false) { settleRenamePopup("", false, false); } } async function submitMovePopup() { const elements = moveElements(); const source = moveState.source; if (!source) { return; } const destination = elements.input.value.trim(); const sourceParent = currentParentPath(source.path); const destinationParent = currentParentPath(destination); elements.error.textContent = ""; if (!destination) { elements.error.textContent = "Target path is required"; return; } if (destination === source.path) { elements.error.textContent = "Destination must differ from source"; return; } if (source.kind === "directory") { if (isNestedPath(source.path, destination)) { elements.error.textContent = "Destination cannot be inside source"; return; } if (destinationParent !== sourceParent) { if (rootKeyFromPath(destination) !== rootKeyFromPath(source.path)) { elements.error.textContent = "Cross-root directory move is not supported in v1"; return; } } } try { const result = await apiRequest("POST", "/api/files/move", { source: source.path, destination, }); state.selectedTaskId = result.task_id; await refreshTasksSnapshot(); closeMovePopup(); setSelectedItem(state.activePane, null); await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]); setStatus(`Move: 1 success, 0 failed`); } catch (err) { elements.error.textContent = err.message; } } async function submitBatchMovePopup() { const elements = batchMoveElements(); elements.error.textContent = ""; try { await executeMoveSelection(batchMoveState.destinationBase); closeBatchMovePopup(); } catch (err) { elements.error.textContent = err.message; } } function submitWildcardPopup() { const elements = wildcardPopupElements(); const pattern = elements.input.value.trim(); if (!pattern) { elements.error.textContent = "Pattern is required"; return; } try { applyWildcardSelection(wildcardDialogMode, pattern); closeWildcardPopup(); } catch (err) { elements.error.textContent = `Wildcard: ${err.message}`; } } function openWildcardPopup(mode) { wildcardDialogMode = mode; const pane = state.activePane; const elements = wildcardPopupElements(); elements.title.textContent = mode === "select" ? "Wildcard Select" : "Wildcard Deselect"; elements.meta.textContent = `Active pane: ${pane} (visible items only, case-insensitive)`; elements.applyButton.textContent = mode === "select" ? "Select" : "Deselect"; elements.error.textContent = ""; elements.input.value = ""; elements.overlay.classList.remove("hidden"); elements.input.focus(); } function closeViewer() { const viewer = viewerElements(); viewer.overlay.classList.add("hidden"); viewer.error.textContent = ""; viewer.content.textContent = ""; } function closeVideoViewer() { const video = videoElements(); video.overlay.classList.add("hidden"); video.error.textContent = ""; video.player.pause(); video.player.removeAttribute("src"); video.player.load(); } function isPdfOpen() { return !pdfElements().overlay.classList.contains("hidden"); } function closePdfViewer() { const pdf = pdfElements(); pdf.overlay.classList.add("hidden"); pdf.error.textContent = ""; pdf.frame.removeAttribute("src"); } function isImageOpen() { return !imageElements().overlay.classList.contains("hidden"); } function fitImageToViewport() { const image = imageElements().image; const viewport = imageElements().viewport; if (!image.naturalWidth || !image.naturalHeight) { return false; } if (!viewport.clientWidth || !viewport.clientHeight) { return false; } const widthScale = viewport.clientWidth / image.naturalWidth; const heightScale = viewport.clientHeight / image.naturalHeight; const fitScale = Math.min(widthScale, heightScale, 1); if (!Number.isFinite(fitScale) || fitScale <= 0) { return false; } imageViewerState.fitScale = fitScale; imageViewerState.scale = imageViewerState.fitScale; applyImageScale(); return true; } function adjustImageZoom(multiplier) { if (!isImageOpen()) { return; } const minScale = Math.max(imageViewerState.fitScale * 0.5, 0.1); const maxScale = Math.max(imageViewerState.fitScale * 6, 1.5); imageViewerState.scale = Math.min(maxScale, Math.max(minScale, currentImageScale() * multiplier)); applyImageScale(); } function resetImageZoom() { if (!isImageOpen()) { return; } imageViewerState.scale = imageViewerState.fitScale; applyImageScale(); } function closeImageViewer() { const image = imageElements(); image.overlay.classList.add("hidden"); image.error.textContent = ""; image.image.removeAttribute("src"); image.image.removeAttribute("alt"); image.image.onload = null; image.image.onerror = null; if (imageViewerState.resizeHandler) { window.removeEventListener("resize", imageViewerState.resizeHandler); } resetImageViewerState(); } function isInfoOpen() { return !infoElements().overlay.classList.contains("hidden"); } function closeInfo() { const elements = infoElements(); elements.overlay.classList.add("hidden"); elements.error.textContent = ""; elements.grid.innerHTML = ""; } function renderInfoField(label, value) { const grid = infoElements().grid; const labelNode = document.createElement("div"); labelNode.className = "info-label"; labelNode.textContent = label; const valueNode = document.createElement("div"); valueNode.className = "info-value"; valueNode.textContent = value == null || value === "" ? "-" : String(value); grid.append(labelNode, valueNode); } async function openInfo() { const selectedItems = activePaneState().selectedItems; if (selectedItems.length === 0) { return; } const elements = infoElements(); elements.overlay.classList.remove("hidden"); elements.title.textContent = "Properties"; elements.error.textContent = ""; elements.grid.innerHTML = ""; if (selectedItems.length > 1) { const fileCount = selectedItems.filter((item) => item.kind === "file").length; const directoryCount = selectedItems.filter((item) => item.kind === "directory").length; renderInfoField("Selected items", selectedItems.length); renderInfoField("Files", fileCount); renderInfoField("Directories", directoryCount); return; } const selected = selectedItems[0]; try { const data = await apiRequest("GET", `/api/files/info?${new URLSearchParams({ path: selected.path }).toString()}`); renderInfoField("Name", data.name); renderInfoField("Path", data.path); renderInfoField("Type", data.type); renderInfoField("Size", data.size); renderInfoField("Modified", formatModified(data.modified)); renderInfoField("Root", data.root); renderInfoField("Extension", data.extension); renderInfoField("Content type", data.content_type); renderInfoField("Owner", data.owner); renderInfoField("Group", data.group); renderInfoField("Width", data.width); renderInfoField("Height", data.height); } catch (err) { elements.error.textContent = err.message; } } function isSearchOpen() { return !searchElements().overlay.classList.contains("hidden"); } function closeSearch() { const elements = searchElements(); elements.overlay.classList.add("hidden"); elements.error.textContent = ""; } function renderSearchResults(items) { const elements = searchElements(); elements.results.innerHTML = ""; if (!Array.isArray(items) || items.length === 0) { const empty = document.createElement("div"); empty.className = "popup-meta"; empty.textContent = "No matches found."; elements.results.append(empty); return; } for (const item of items) { const row = document.createElement("button"); row.type = "button"; row.className = "search-result"; row.onclick = () => activateSearchResult(item); const name = document.createElement("div"); name.className = "search-result-name"; name.textContent = item.name; const path = document.createElement("div"); path.className = "search-result-path"; path.textContent = item.parent_path; const meta = document.createElement("div"); meta.className = "search-result-meta"; meta.textContent = `${item.type} · ${item.root}`; row.append(name, path, meta); elements.results.append(row); } } function activateSearchResult(item) { const pane = state.activePane; closeSearch(); if (item.type === "directory") { navigateTo(pane, item.path); return; } paneState(pane).pendingSelectionPath = item.path; navigateTo(pane, item.parent_path); } function openSearch() { const pane = state.activePane; const elements = searchElements(); searchState.pane = pane; searchState.path = paneState(pane).currentPath; searchState.query = ""; elements.context.textContent = `Searching under: ${searchState.path}`; elements.input.value = ""; elements.error.textContent = ""; elements.results.innerHTML = ""; elements.overlay.classList.remove("hidden"); elements.input.focus(); elements.input.select(); } async function submitSearch() { const elements = searchElements(); const query = elements.input.value.trim(); searchState.query = query; elements.error.textContent = ""; elements.results.innerHTML = ""; try { const data = await apiRequest("GET", `/api/search?${new URLSearchParams({ path: searchState.path, query, }).toString()}`); renderSearchResults(data.items); if (data.truncated) { elements.error.textContent = "Result limit reached. Showing first matches."; } } catch (err) { elements.error.textContent = err.message; } } function setSettingsTab(tab) { const elements = settingsElements(); settingsState.activeTab = tab === "logs" ? "logs" : (tab === "downloads" ? "downloads" : (tab === "interface" ? "interface" : "general")); const isGeneral = settingsState.activeTab === "general"; const isInterface = settingsState.activeTab === "interface"; const isDownloads = settingsState.activeTab === "downloads"; const isLogs = settingsState.activeTab === "logs"; elements.generalTab.classList.toggle("is-active", isGeneral); elements.generalTab.setAttribute("aria-selected", isGeneral ? "true" : "false"); elements.interfaceTab.classList.toggle("is-active", isInterface); elements.interfaceTab.setAttribute("aria-selected", isInterface ? "true" : "false"); elements.downloadsTab.classList.toggle("is-active", isDownloads); elements.downloadsTab.setAttribute("aria-selected", isDownloads ? "true" : "false"); elements.logsTab.classList.toggle("is-active", isLogs); elements.logsTab.setAttribute("aria-selected", isLogs ? "true" : "false"); elements.generalPanel.classList.toggle("hidden", !isGeneral); elements.interfacePanel.classList.toggle("hidden", !isInterface); elements.downloadsPanel.classList.toggle("hidden", !isDownloads); elements.logsPanel.classList.toggle("hidden", !isLogs); } function formatHistoryLine(item) { const timestamp = item.finished_at || item.created_at || ""; const when = formatModified(timestamp); const primaryPath = item.path || [item.source, item.destination].filter(Boolean).join(" -> "); return { title: `${item.operation} · ${item.status}`, path: primaryPath || "-", meta: when, error: item.status === "failed" ? (item.error_message || item.error_code || "") : "", }; } function formatTaskStatusLabel(task) { if (task.operation === "download") { switch (task.status) { case "requested": return "Requested"; case "preparing": return "Preparing"; case "ready": return "Ready for download"; case "failed": return "Failed"; case "cancelled": return "Cancelled"; default: return task.status; } } switch (task.status) { case "queued": return "Queued"; case "running": return "Running"; case "cancelling": return "Cancelling"; case "cancelled": return "Cancelled"; case "completed": return "Completed"; case "failed": return "Failed"; default: return task.status; } } function inferDownloadTaskContext(task) { if (task.operation !== "download") { return null; } if (typeof task.destination === "string" && /^kodidownload-\d{8}-\d{6}\.zip$/.test(task.destination)) { return "Multi-item ZIP"; } return "Directory ZIP"; } function formatTaskLine(task) { const when = formatModified(task.finished_at || task.created_at || ""); const details = []; const downloadContext = inferDownloadTaskContext(task); if (downloadContext) { details.push(downloadContext); } if (typeof task.done_items === "number" && typeof task.total_items === "number") { details.push(`${task.done_items}/${task.total_items} items`); } if (task.current_item) { details.push(`Current: ${task.current_item}`); } return { title: `${task.operation} · ${formatTaskStatusLabel(task)}`, path: task.destination ? `${task.destination} · ${task.source}` : task.source || "-", meta: [when, ...details].filter(Boolean).join(" · "), error: task.status === "failed" ? (task.error_message || task.error_code || "") : "", }; } function isActiveTask(task) { return Boolean(task) && ACTIVE_OPERATION_OPERATIONS.has(task.operation) && ACTIVE_TASK_STATUSES.has(task.status); } function activeTasksFromItems(items) { return Array.isArray(items) ? items.filter((task) => isActiveTask(task)) : []; } function isTerminalOperationTask(task) { return Boolean(task) && ACTIVE_OPERATION_OPERATIONS.has(task.operation) && TERMINAL_OPERATION_STATUSES.has(task.status); } function statusBadgeLabel(task) { switch (task?.status) { case "queued": return "Queued"; case "running": return "Running"; case "cancelling": return "Cancelling"; case "completed": return "Completed"; case "cancelled": return "Cancelled"; case "failed": return "Failed"; default: return formatTaskStatusLabel(task); } } function terminalOperationChipLabel(items) { const count = Array.isArray(items) ? items.length : 0; if (count <= 0) { return ""; } return `${count} recent operation${count === 1 ? "" : "s"}`; } function visibleOperationSortKey(task) { const statusOrder = { running: 0, cancelling: 1, queued: 2, completed: 3, cancelled: 4, failed: 5, }; return statusOrder[task?.status] ?? 9; } function sortVisibleOperations(items) { return [...items].sort((left, right) => { const statusDelta = visibleOperationSortKey(left) - visibleOperationSortKey(right); if (statusDelta !== 0) { return statusDelta; } const leftTime = left.finished_at || left.created_at || ""; const rightTime = right.finished_at || right.created_at || ""; return rightTime.localeCompare(leftTime); }); } async function refreshOperationPanes() { if (headerTaskState.paneRefreshPromise) { return headerTaskState.paneRefreshPromise; } headerTaskState.paneRefreshPromise = Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]) .catch((err) => { setError("actions-error", `Refresh panes: ${err.message}`); }) .finally(() => { headerTaskState.paneRefreshPromise = null; }); return headerTaskState.paneRefreshPromise; } function taskIsCancellable(task) { return Boolean(task) && ACTIVE_OPERATION_OPERATIONS.has(task.operation) && ["queued", "running"].includes(task.status); } async function cancelTaskRequest(taskId) { return apiRequest("POST", `/api/tasks/${encodeURIComponent(taskId)}/cancel`); } function formatTaskOperationLabel(task) { const operation = String(task?.operation || ""); if (!operation) { return "Task"; } return operation.charAt(0).toUpperCase() + operation.slice(1); } function hasMeaningfulItemProgress(task) { return typeof task?.done_items === "number" && typeof task?.total_items === "number" && task.total_items > 0; } function canShowChipItemProgress(task) { if (!hasMeaningfulItemProgress(task)) { return false; } return task.operation === "copy" || task.operation === "duplicate" || task.operation === "delete"; } function compactTaskCurrentItem(task) { if (!task?.current_item) { return ""; } const value = String(task.current_item).replace(/\\/g, "/"); if (value.length <= 44) { return value; } const parts = value.split("/").filter(Boolean); if (parts.length >= 2) { const shortened = `.../${parts.slice(-2).join("/")}`; if (shortened.length <= 44) { return shortened; } } return `...${value.slice(-41)}`; } function activeTaskChipLabel(items) { const count = Array.isArray(items) ? items.length : 0; if (count !== 1) { return `${count} active operation${count === 1 ? "" : "s"}`; } const task = items[0]; const action = formatTaskOperationLabel(task); if (task.status === "cancelling") { return `${action} cancelling`; } if (canShowChipItemProgress(task)) { return `${action} ${task.done_items}/${task.total_items}`; } if (task.status === "queued") { return `${action} queued`; } return `${action} running`; } function taskProgressText(task) { if (!hasMeaningfulItemProgress(task)) { return ""; } return `${task.done_items}/${task.total_items}`; } function taskProgressSubtext(task) { if (task?.status === "cancelling") { return "Stopping after current item..."; } const progress = taskProgressText(task); if (progress) { return `${progress} items processed`; } return ""; } function headerTaskRenderKey(items) { return JSON.stringify( Array.isArray(items) ? items.map((task) => ({ id: task.id || "", operation: task.operation || "", status: task.status || "", source: task.source || "", destination: task.destination || "", done_items: task.done_items, total_items: task.total_items, current_item: task.current_item || "", })) : [] ); } function shouldPollHeaderTasks() { return headerTaskState.popoverOpen || headerTaskState.activeItems.length > 0 || headerTaskState.recentItems.length > 0; } function stopHeaderTaskPolling() { if (headerTaskState.pollTimer) { window.clearTimeout(headerTaskState.pollTimer); headerTaskState.pollTimer = null; } } function scheduleHeaderTaskPolling() { stopHeaderTaskPolling(); if (!shouldPollHeaderTasks()) { return; } headerTaskState.pollTimer = window.setTimeout(async () => { await refreshTasksSnapshot(); scheduleHeaderTaskPolling(); }, 1500); } function setHeaderTaskPopoverOpen(nextOpen) { const elements = headerTaskElements(); const open = Boolean(nextOpen) && headerTaskState.visibleItems.length > 0; headerTaskState.popoverOpen = open; if (elements.chipButton) { elements.chipButton.setAttribute("aria-expanded", open ? "true" : "false"); } if (elements.popover) { elements.popover.classList.toggle("hidden", !open); } scheduleHeaderTaskPolling(); } function renderHeaderTaskPopover(items) { const elements = headerTaskElements(); if (!elements.popoverList) { return; } const renderKey = headerTaskRenderKey(items); if (headerTaskState.lastRenderKey === renderKey) { return; } const scrollTop = elements.popoverList.scrollTop; elements.popoverList.innerHTML = ""; if (!Array.isArray(items) || items.length === 0) { const empty = document.createElement("div"); empty.className = "header-task-item-empty"; empty.textContent = "No active operations right now."; elements.popoverList.append(empty); headerTaskState.lastRenderKey = renderKey; return; } for (const task of items) { const line = formatTaskLine(task); const row = document.createElement("div"); row.className = `header-task-item status-${task.status}`; const heading = document.createElement("div"); heading.className = "header-task-item-heading"; const title = document.createElement("div"); title.className = "header-task-item-title"; title.textContent = formatTaskOperationLabel(task); const badge = document.createElement("span"); badge.className = `header-task-status-badge status-${task.status}`; badge.textContent = statusBadgeLabel(task); heading.append(title, badge); const path = document.createElement("div"); path.className = "header-task-item-path"; path.textContent = line.path; const meta = document.createElement("div"); meta.className = "header-task-item-meta"; meta.textContent = line.meta; row.append(heading, path, meta); const progressText = taskProgressText(task); if (progressText) { const progress = document.createElement("div"); progress.className = "header-task-item-progress"; progress.textContent = progressText; row.append(progress); } const currentItem = compactTaskCurrentItem(task); if (currentItem) { const current = document.createElement("div"); current.className = "header-task-item-current"; current.textContent = currentItem; current.title = String(task.current_item); row.append(current); } const subtext = taskProgressSubtext(task); if (subtext) { const note = document.createElement("div"); note.className = "header-task-item-subtext"; note.textContent = subtext; row.append(note); } if (taskIsCancellable(task) || task.status === "cancelling") { const actions = document.createElement("div"); actions.className = "header-task-item-actions"; const cancelButton = document.createElement("button"); cancelButton.type = "button"; cancelButton.className = "header-task-item-action"; cancelButton.textContent = task.status === "cancelling" ? "Stopping..." : "Stop"; cancelButton.disabled = task.status === "cancelling"; if (!cancelButton.disabled) { cancelButton.onclick = async () => { cancelButton.disabled = true; try { await cancelTaskRequest(task.id); await refreshTasksSnapshot(); } catch (err) { cancelButton.disabled = false; setError("actions-error", `Stop task: ${err.message}`); } }; } actions.append(cancelButton); row.append(actions); } elements.popoverList.append(row); } headerTaskState.lastRenderKey = renderKey; elements.popoverList.scrollTop = scrollTop; } function renderHeaderTaskChip(items) { const elements = headerTaskElements(); if (!elements.container || !elements.chipLabel) { return; } const activeItems = Array.isArray(items) ? items : []; const recentItems = Array.isArray(headerTaskState.recentItems) ? headerTaskState.recentItems : []; const visibleItems = activeItems.length > 0 ? activeItems : recentItems; const hasVisibleItems = visibleItems.length > 0; elements.container.classList.toggle("hidden", !hasVisibleItems); elements.chipLabel.textContent = activeItems.length > 0 ? activeTaskChipLabel(activeItems) : terminalOperationChipLabel(recentItems); if (!hasVisibleItems) { headerTaskState.lastRenderKey = ""; setHeaderTaskPopoverOpen(false); return; } renderHeaderTaskPopover(visibleItems); } function updateHeaderTaskState(taskItems) { const items = Array.isArray(taskItems) ? taskItems : []; headerTaskState.activeItems = activeTasksFromItems(items); headerTaskState.visibleItems = sortVisibleOperations([...headerTaskState.activeItems, ...headerTaskState.recentItems]); renderHeaderTaskChip(headerTaskState.activeItems); scheduleHeaderTaskPolling(); } function applyTaskSnapshot(taskItems) { const items = Array.isArray(taskItems) ? taskItems : []; const now = Date.now(); const activeById = new Set(); const nextKnownStatuses = {}; const nextRecentItems = []; let shouldRefreshPanes = false; for (const task of items) { if (!task?.id) { continue; } nextKnownStatuses[task.id] = task.status || ""; const previousStatus = headerTaskState.knownStatuses[task.id] || ""; if (isActiveTask(task)) { activeById.add(task.id); } if (isTerminalOperationTask(task)) { if (previousStatus && !TERMINAL_OPERATION_STATUSES.has(previousStatus)) { shouldRefreshPanes = true; nextRecentItems.push({ ...task, _recent_until: now + headerTaskState.recentExpiryMs }); } } } for (const recentTask of headerTaskState.recentItems) { if (!recentTask?.id || activeById.has(recentTask.id)) { continue; } if ((recentTask._recent_until || 0) > now) { nextRecentItems.push(recentTask); } } headerTaskState.recentItems = sortVisibleOperations( nextRecentItems.filter((task, index, collection) => collection.findIndex((entry) => entry.id === task.id) === index) ); headerTaskState.knownStatuses = nextKnownStatuses; state.lastTaskCount = items.length; updateHeaderTaskState(items); if (shouldRefreshPanes) { void refreshOperationPanes(); } return items; } function renderHistoryItems(items) { const elements = settingsElements(); const renderKey = JSON.stringify(Array.isArray(items) ? items : []); if (settingsState.lastHistoryRenderKey === renderKey) { return; } const scrollTop = elements.logsList.scrollTop; elements.logsList.innerHTML = ""; if (!Array.isArray(items) || items.length === 0) { const empty = document.createElement("div"); empty.className = "popup-meta"; empty.textContent = "No history entries yet."; elements.logsList.append(empty); settingsState.lastHistoryRenderKey = renderKey; return; } for (const item of items) { const line = formatHistoryLine(item); const row = document.createElement("div"); row.className = `settings-log-item status-${item.status}`; const title = document.createElement("div"); title.className = "settings-log-title"; title.textContent = line.title; const path = document.createElement("div"); path.className = "settings-log-path"; path.textContent = line.path; const meta = document.createElement("div"); meta.className = "settings-log-meta"; meta.textContent = line.meta; row.append(title, path, meta); if (line.error) { const error = document.createElement("div"); error.className = "settings-log-error"; error.textContent = line.error; row.append(error); } elements.logsList.append(row); } settingsState.lastHistoryRenderKey = renderKey; elements.logsList.scrollTop = scrollTop; } function renderTaskItems(items) { const elements = settingsElements(); const renderKey = JSON.stringify(Array.isArray(items) ? items : []); if (settingsState.lastTasksRenderKey === renderKey) { return; } const scrollTop = elements.tasksList.scrollTop; elements.tasksList.innerHTML = ""; if (!Array.isArray(items) || items.length === 0) { const empty = document.createElement("div"); empty.className = "popup-meta"; empty.textContent = "No tasks yet."; elements.tasksList.append(empty); settingsState.lastTasksRenderKey = renderKey; return; } for (const task of items) { const line = formatTaskLine(task); const row = document.createElement("div"); row.className = `settings-log-item status-${task.status}`; const title = document.createElement("div"); title.className = "settings-log-title"; title.textContent = line.title; const path = document.createElement("div"); path.className = "settings-log-path"; path.textContent = line.path; const meta = document.createElement("div"); meta.className = "settings-log-meta"; meta.textContent = line.meta; row.append(title, path, meta); if (line.error) { const error = document.createElement("div"); error.className = "settings-log-error"; error.textContent = line.error; row.append(error); } elements.tasksList.append(row); } settingsState.lastTasksRenderKey = renderKey; elements.tasksList.scrollTop = scrollTop; } async function loadHistoryForSettings() { const data = await apiRequest("GET", "/api/history"); renderHistoryItems(data.items || []); settingsState.logsLoaded = true; } async function loadTasksForSettings() { const data = await apiRequest("GET", "/api/tasks"); renderTaskItems(applyTaskSnapshot(data.items)); settingsState.tasksLoaded = true; } async function loadLogsAndTasksForSettings() { const elements = settingsElements(); elements.logsError.textContent = ""; if (!settingsState.tasksLoaded) { elements.tasksList.innerHTML = ''; } if (!settingsState.logsLoaded) { elements.logsList.innerHTML = ''; } try { await Promise.all([loadTasksForSettings(), loadHistoryForSettings()]); } catch (err) { elements.tasksList.innerHTML = ""; elements.logsList.innerHTML = ""; elements.logsError.textContent = err.message; } } function stopSettingsLogsPolling() { if (settingsState.logsPollTimer) { window.clearTimeout(settingsState.logsPollTimer); settingsState.logsPollTimer = null; } } function scheduleSettingsLogsPolling() { stopSettingsLogsPolling(); if (settingsState.activeTab !== "logs" || settingsElements().overlay.classList.contains("hidden")) { return; } settingsState.logsPollTimer = window.setTimeout(async () => { await loadLogsAndTasksForSettings(); scheduleSettingsLogsPolling(); }, 1500); } async function handleShowThumbnailsChange(event) { const input = event.target; try { await saveSettings({ show_thumbnails: !!input.checked }); } catch (err) { input.checked = settingsState.showThumbnails; settingsElements().logsError.textContent = ""; setError("actions-error", `Settings: ${err.message}`); } } async function handlePreferredStartupPathSave() { const settings = settingsElements(); const leftValue = settings.startupPathLeftInput ? settings.startupPathLeftInput.value : ""; const rightValue = settings.startupPathRightInput ? settings.startupPathRightInput.value : ""; settings.generalError.textContent = ""; try { await saveSettings({ preferred_startup_path_left: leftValue, preferred_startup_path_right: rightValue, }); setStatus("Preferred startup paths saved"); } catch (err) { settings.generalError.textContent = err.message; } } async function handleInterfaceSave() { const settings = settingsElements(); const themeValue = settings.selectedThemeInput ? settings.selectedThemeInput.value : "default"; settings.interfaceError.textContent = ""; try { await saveSettings({ selected_theme: themeValue }); setStatus("Theme saved"); } catch (err) { settings.interfaceError.textContent = err.message; } } function closeSettings() { stopSettingsLogsPolling(); settingsElements().overlay.classList.add("hidden"); } async function openSettings(tab = "general") { const elements = settingsElements(); elements.overlay.classList.remove("hidden"); elements.generalError.textContent = ""; setSettingsTab(tab); if (settingsState.activeTab === "logs") { await loadLogsAndTasksForSettings(); scheduleSettingsLogsPolling(); } else { stopSettingsLogsPolling(); } (settingsState.activeTab === "logs" ? elements.logsTab : settingsState.activeTab === "downloads" ? elements.downloadsTab : settingsState.activeTab === "interface" ? elements.interfaceTab : elements.generalTab).focus(); } function editorIsDirty() { return currentEditorContent() !== editorState.originalContent; } function resetEditorState() { editorState = { path: null, originalContent: "", modified: null, }; } async function attemptCloseEditor() { if (editorIsDirty()) { const discard = await openConfirmModal({ title: "Discard unsaved changes?", message: "Your unsaved editor changes will be lost.", path: editorState.path || "", applyText: "Discard", }); if (!discard) { return; } } closeEditor(); } function closeEditor() { const editor = editorElements(); disposeMonacoEditor(); editor.overlay.classList.add("hidden"); editor.error.textContent = ""; editor.host.textContent = ""; resetEditorState(); } async function openTextViewer() { const selectedItems = activePaneState().selectedItems; if (selectedItems.length !== 1 || selectedItems[0].kind !== "file") { return; } const selected = selectedItems[0]; const viewer = viewerElements(); viewer.overlay.classList.remove("hidden"); viewer.title.textContent = "View"; viewer.fileName.textContent = selected.name; viewer.filePath.textContent = selected.path; viewer.error.textContent = ""; viewer.content.textContent = "Loading..."; try { const data = await apiRequest("GET", `/api/files/view?${new URLSearchParams({ path: selected.path }).toString()}`); viewer.fileName.textContent = data.name; viewer.filePath.textContent = data.path; viewer.content.textContent = data.content; if (data.truncated) { viewer.error.textContent = "Preview truncated for safety"; } } catch (err) { viewer.content.textContent = ""; viewer.error.textContent = err.message; } } async function openPdfViewer() { const selectedItems = activePaneState().selectedItems; if (selectedItems.length !== 1 || !isPdfSelection(selectedItems[0])) { return; } const selected = selectedItems[0]; const pdf = pdfElements(); const pdfUrl = `/api/files/pdf?${new URLSearchParams({ path: selected.path }).toString()}`; pdf.overlay.classList.remove("hidden"); pdf.title.textContent = "PDF"; pdf.fileName.textContent = selected.name; pdf.filePath.textContent = selected.path; pdf.error.textContent = ""; pdf.frame.src = pdfUrl; } async function openImageViewer() { const selectedItems = activePaneState().selectedItems; if (selectedItems.length !== 1 || !isImageSelection(selectedItems[0])) { return; } const selected = selectedItems[0]; const image = imageElements(); const imageUrl = `/api/files/image?${new URLSearchParams({ path: selected.path }).toString()}`; closeImageViewer(); image.overlay.classList.remove("hidden"); image.title.textContent = "Image"; image.fileName.textContent = selected.name; image.filePath.textContent = selected.path; image.error.textContent = ""; image.image.alt = selected.name; image.image.onload = () => { requestAnimationFrame(() => { if (!fitImageToViewport()) { requestAnimationFrame(() => { fitImageToViewport(); }); } }); image.image.onload = null; }; image.image.onerror = () => { image.error.textContent = "Image could not be displayed in this browser."; image.image.onerror = null; }; imageViewerState.path = selected.path; imageViewerState.resizeHandler = () => fitImageToViewport(); window.addEventListener("resize", imageViewerState.resizeHandler); image.image.src = imageUrl; } function videoPlaybackMessage(item) { const lower = (item.name || "").toLowerCase(); if (lower.endsWith(".mkv")) { return "MKV playback is best-effort and depends on browser codec support."; } return ""; } async function openVideoViewer() { const selectedItems = activePaneState().selectedItems; if (selectedItems.length !== 1 || !isVideoSelection(selectedItems[0])) { return; } const selected = selectedItems[0]; const video = videoElements(); const streamUrl = `/api/files/video?${new URLSearchParams({ path: selected.path }).toString()}`; video.overlay.classList.remove("hidden"); video.title.textContent = "Video"; video.fileName.textContent = selected.name; video.filePath.textContent = selected.path; video.error.textContent = videoPlaybackMessage(selected); video.player.pause(); video.player.src = streamUrl; video.player.load(); } async function openEditor() { const selectedItems = activePaneState().selectedItems; if (selectedItems.length !== 1 || !isEditableSelection(selectedItems[0])) { return; } const selected = selectedItems[0]; const editor = editorElements(); editor.overlay.classList.remove("hidden"); editor.title.textContent = "Edit"; editor.fileName.textContent = selected.name; editor.filePath.textContent = selected.path; editor.error.textContent = ""; editor.host.textContent = "Loading editor..."; editor.saveButton.disabled = true; try { const data = await apiRequest("GET", `/api/files/view?${new URLSearchParams({ path: selected.path, for_edit: "true" }).toString()}`); editor.fileName.textContent = data.name; editor.filePath.textContent = data.path; await ensureMonacoEditor(data.path, data.content); editor.saveButton.disabled = false; editorState.path = data.path; editorState.originalContent = data.content; editorState.modified = data.modified; } catch (err) { disposeMonacoEditor(); editor.host.textContent = ""; editor.error.textContent = err.message; } } function openViewer() { const selectedItems = activePaneState().selectedItems; if (selectedItems.length !== 1 || selectedItems[0].kind !== "file") { return; } const selected = selectedItems[0]; if (isRemoteBrowsePath(selected.path)) { if (isImageSelection(selected)) { openImageViewer(); return; } openTextViewer(); return; } if (isImageSelection(selected)) { openImageViewer(); return; } if (isVideoSelection(selected)) { openVideoViewer(); return; } if (isPdfSelection(selected)) { openPdfViewer(); return; } openTextViewer(); } async function saveEditor() { if (!editorState.path) { return; } const editor = editorElements(); editor.error.textContent = ""; try { const response = await apiRequest("POST", "/api/files/save", { path: editorState.path, content: currentEditorContent(), expected_modified: editorState.modified, }); editorState.originalContent = currentEditorContent(); editorState.modified = response.modified; setStatus(`Saved ${response.path}`); closeEditor(); await loadBrowsePane(state.activePane); } catch (err) { editor.error.textContent = err.message; } } function moveCurrentRow(delta) { const pane = state.activePane; const model = paneState(pane); if (!Array.isArray(model.visibleItems) || model.visibleItems.length === 0) { model.currentRowIndex = -1; return; } if (model.currentRowIndex < 0) { model.currentRowIndex = 0; } else { const maxIndex = model.visibleItems.length - 1; model.currentRowIndex = Math.max(0, Math.min(maxIndex, model.currentRowIndex + delta)); } renderPaneItems(pane); scrollCurrentRowIntoView(pane); } function extendSelectionByRow(delta) { const pane = state.activePane; const model = paneState(pane); if (!Array.isArray(model.visibleItems) || model.visibleItems.length === 0) { model.currentRowIndex = -1; return; } const originalIndex = model.currentRowIndex < 0 ? 0 : model.currentRowIndex; if (model.currentRowIndex < 0) { model.currentRowIndex = 0; } if (!Number.isInteger(model.selectionAnchorIndex)) { model.selectionAnchorIndex = originalIndex; } const maxIndex = model.visibleItems.length - 1; model.currentRowIndex = Math.max(0, Math.min(maxIndex, model.currentRowIndex + delta)); setRangeSelection(pane, model.selectionAnchorIndex, model.currentRowIndex); renderPaneItems(pane); scrollCurrentRowIntoView(pane); } function jumpCurrentRow(edge) { const pane = state.activePane; const model = paneState(pane); if (!Array.isArray(model.visibleItems) || model.visibleItems.length === 0) { model.currentRowIndex = -1; return; } model.currentRowIndex = edge === "start" ? 0 : model.visibleItems.length - 1; renderPaneItems(pane); scrollCurrentRowIntoView(pane); } function openCurrentDirectory() { const pane = state.activePane; const item = currentRowItem(pane); if (!item) { return; } if (item.kind === "directory") { navigateTo(pane, item.path); return; } if (isImageSelection(item)) { openImageViewer(); return; } if (!isRemoteBrowsePath(item.path) && isVideoSelection(item)) { openVideoViewer(); } } function toggleCurrentSelection() { const pane = state.activePane; const item = currentRowItem(pane); if (!item || item.isParent) { return; } setSelectionAnchor(pane, paneState(pane).currentRowIndex); toggleSelection(pane, { path: item.path, name: item.name, kind: item.kind }); renderPaneItems(pane); } function clearSelectionForActivePane() { const pane = state.activePane; clearSelectionAnchor(pane); setSelectedItem(pane, null); renderPaneItems(pane); } function handleKeyboardShortcuts(event) { if (isContextMenuOpen()) { if (event.key === "Escape") { event.preventDefault(); closeContextMenu(); } return; } if (event.key === "Escape" && !uploadElements().menuPopup.classList.contains("hidden")) { event.preventDefault(); closeUploadMenu(); return; } if (isFeedbackModalOpen()) { if (event.key === "Escape" || event.key === "Enter") { event.preventDefault(); closeFeedbackModal(); } return; } if (isDownloadModalOpen()) { if (event.key === "Escape" && !downloadProgressState.active) { event.preventDefault(); closeDownloadModal(); } return; } if (isInfoOpen()) { if (event.key === "Escape") { event.preventDefault(); closeInfo(); } return; } if (isSearchOpen()) { if (event.key === "Escape") { event.preventDefault(); closeSearch(); return; } if (event.key === "Enter") { event.preventDefault(); submitSearch(); return; } return; } if (isRenamePopupOpen()) { if (event.key === "Escape") { event.preventDefault(); closeRenamePopup(); return; } if (event.key === "Enter") { event.preventDefault(); submitRenamePopup(); return; } return; } if (isSettingsOpen()) { if (event.key === "Escape") { event.preventDefault(); closeSettings(); return; } return; } if (isBatchMovePopupOpen()) { if (event.key === "Escape") { event.preventDefault(); closeBatchMovePopup(); return; } if (event.key === "Enter") { event.preventDefault(); submitBatchMovePopup(); return; } return; } if (isDeleteConfirmModalOpen()) { if (event.key === "Escape") { event.preventDefault(); closeDeleteConfirmModal(); return; } if (event.key === "Enter") { event.preventDefault(); submitDeleteConfirmModal(); return; } return; } if (isUploadConflictModalOpen()) { if (event.key === "Escape") { event.preventDefault(); resolveUploadConflict("cancel"); } return; } if (isMovePopupOpen()) { if (event.key === "Escape") { event.preventDefault(); closeMovePopup(); return; } if (event.key === "Enter") { event.preventDefault(); submitMovePopup(); } return; } if (isEditorOpen()) { if (event.key === "Escape") { event.preventDefault(); attemptCloseEditor(); } return; } if (isImageOpen()) { if (event.key === "Escape") { event.preventDefault(); closeImageViewer(); } return; } if (isVideoOpen()) { if (event.key === "Escape") { event.preventDefault(); closeVideoViewer(); } return; } if (isPdfOpen()) { if (event.key === "Escape") { event.preventDefault(); closePdfViewer(); } return; } if (isViewerOpen()) { if (event.key === "Escape") { event.preventDefault(); closeViewer(); } return; } if (isWildcardPopupOpen()) { return; } if (!shouldHandleShortcut(event.target)) { return; } if (event.key === "Escape" && headerTaskState.popoverOpen) { event.preventDefault(); setHeaderTaskPopoverOpen(false); return; } const isInfoShortcut = event.key === "Enter" && !event.shiftKey && !event.altKey && (event.metaKey || event.ctrlKey); if (isInfoShortcut) { event.preventDefault(); openInfo(); return; } const isSearchShortcut = event.key.toLowerCase() === "f" && event.shiftKey && !event.altKey && (event.metaKey || event.ctrlKey); if (isSearchShortcut) { event.preventDefault(); openSearch(); return; } if (actionShortcutHandled(event)) { event.preventDefault(); return; } if (event.shiftKey && event.key === "+") { event.preventDefault(); openWildcardPopup("select"); return; } if (event.shiftKey && event.key === "_") { event.preventDefault(); openWildcardPopup("deselect"); return; } if (event.metaKey && event.key === "ArrowUp") { event.preventDefault(); jumpCurrentRow("start"); return; } if (event.metaKey && event.key === "ArrowDown") { event.preventDefault(); jumpCurrentRow("end"); return; } if (event.altKey && event.key === "ArrowUp") { event.preventDefault(); moveCurrentRow(-ROW_JUMP_STEP); return; } if (event.altKey && event.key === "ArrowDown") { event.preventDefault(); moveCurrentRow(ROW_JUMP_STEP); return; } if (event.key === "Tab") { event.preventDefault(); setActivePane(otherPane(state.activePane)); return; } if (event.key === "Backspace") { event.preventDefault(); navigateToParent(state.activePane); return; } if (event.key === "ArrowUp") { if (event.shiftKey) { event.preventDefault(); extendSelectionByRow(-1); return; } event.preventDefault(); moveCurrentRow(-1); return; } if (event.key === "ArrowDown") { if (event.shiftKey) { event.preventDefault(); extendSelectionByRow(1); return; } event.preventDefault(); moveCurrentRow(1); return; } if (event.key === "Enter") { event.preventDefault(); openCurrentDirectory(); return; } if (event.key === " " || event.code === "Space") { event.preventDefault(); toggleCurrentSelection(); return; } if (event.key === "Escape") { event.preventDefault(); clearSelectionForActivePane(); } } function setupPaneEvents(pane) { document.getElementById(`${pane}-pane`).onclick = () => setActivePane(pane); document.getElementById(`${pane}-hidden-toggle`).onchange = (ev) => { setActivePane(pane); paneState(pane).showHidden = ev.target.checked; loadBrowsePane(pane); }; } function setupEvents() { setupPaneEvents("left"); setupPaneEvents("right"); document.addEventListener("keydown", handleKeyboardShortcuts); document.getElementById("theme-toggle").onclick = toggleTheme; const headerTasks = headerTaskElements(); if (headerTasks.chipButton) { headerTasks.chipButton.onclick = (event) => { event.stopPropagation(); setHeaderTaskPopoverOpen(!headerTaskState.popoverOpen); }; } if (headerTasks.logsButton) { headerTasks.logsButton.onclick = () => { setHeaderTaskPopoverOpen(false); openSettings("logs"); }; } document.getElementById("upload-btn").onclick = openUploadPicker; document.getElementById("upload-menu-toggle").onclick = (event) => { event.stopPropagation(); toggleUploadMenu(); }; document.getElementById("upload-folder-btn").onclick = openFolderPicker; document.getElementById("settings-btn").onclick = () => openSettings("general"); document.getElementById("view-btn").onclick = openViewer; document.getElementById("edit-btn").onclick = openEditor; document.getElementById("rename-btn").onclick = openRenamePopup; document.getElementById("delete-btn").onclick = deleteSelected; document.getElementById("copy-btn").onclick = startCopySelected; document.getElementById("move-btn").onclick = openF6Flow; document.getElementById("mkdir-btn").onclick = createFolderForActivePane; uploadElements().input.onchange = handleUploadSelection; const modalCancel = uploadModalElements().cancelButton; if (modalCancel) { modalCancel.onclick = requestUploadCancel; } const feedback = feedbackElements(); if (feedback.closeButton) { feedback.closeButton.onclick = closeFeedbackModal; } if (feedback.overlay) { feedback.overlay.onclick = (event) => { if (event.target === feedback.overlay) { closeFeedbackModal(); } }; } const downloadModal = downloadModalElements(); if (downloadModal.logsButton) { downloadModal.logsButton.onclick = () => { openSettings("logs"); }; } if (downloadModal.cancelButton) { downloadModal.cancelButton.onclick = () => { requestArchiveDownloadCancel().catch((err) => { markZipDownloadFailed(err); setStatus("Download failed"); }); }; } if (downloadModal.closeButton) { downloadModal.closeButton.onclick = closeDownloadModal; } if (downloadModal.overlay) { downloadModal.overlay.onclick = (event) => { if (event.target === downloadModal.overlay) { closeDownloadModal(); } }; } const contextMenu = contextMenuElements(); if (contextMenu.renameButton) { contextMenu.renameButton.onclick = startContextMenuRename; } if (contextMenu.openButton) { contextMenu.openButton.onclick = startContextMenuOpen; } if (contextMenu.editButton) { contextMenu.editButton.onclick = startContextMenuEdit; } if (contextMenu.downloadButton) { contextMenu.downloadButton.onclick = startContextMenuDownload; } if (contextMenu.duplicateButton) { contextMenu.duplicateButton.onclick = startContextMenuDuplicate; } if (contextMenu.copyButton) { contextMenu.copyButton.onclick = startContextMenuCopy; } if (contextMenu.moveButton) { contextMenu.moveButton.onclick = startContextMenuMove; } if (contextMenu.deleteButton) { contextMenu.deleteButton.onclick = startContextMenuDelete; } if (contextMenu.propertiesButton) { contextMenu.propertiesButton.onclick = startContextMenuProperties; } document.addEventListener("click", (event) => { const elements = uploadElements(); if (!elements.menu || elements.menu.contains(event.target)) { } else { closeUploadMenu(); } const headerTaskContainer = headerTaskElements().container; if (headerTaskContainer && !headerTaskContainer.contains(event.target)) { setHeaderTaskPopoverOpen(false); } const contextMenu = contextMenuElements().menu; if (contextMenu && !contextMenu.contains(event.target)) { closeContextMenu(); } }); document.addEventListener("contextmenu", (event) => { const contextMenu = contextMenuElements().menu; if (contextMenu && contextMenu.contains(event.target)) { event.preventDefault(); return; } const row = event.target instanceof Element ? event.target.closest("li[data-row-index]") : null; if (!row) { closeContextMenu(); } }); const rename = renameElements(); rename.closeButton.onclick = closeRenamePopup; rename.cancelButton.onclick = closeRenamePopup; rename.applyButton.onclick = submitRenamePopup; rename.input.onkeydown = (event) => { if (event.key === "Enter") { event.preventDefault(); submitRenamePopup(); return; } if (event.key === "Escape") { event.preventDefault(); closeRenamePopup(); } }; rename.overlay.onclick = (event) => { if (event.target === rename.overlay) { closeRenamePopup(); } }; const settings = settingsElements(); settings.closeButton.onclick = closeSettings; settings.generalTab.onclick = () => { stopSettingsLogsPolling(); setSettingsTab("general"); }; settings.interfaceTab.onclick = () => { stopSettingsLogsPolling(); setSettingsTab("interface"); }; settings.downloadsTab.onclick = () => { stopSettingsLogsPolling(); setSettingsTab("downloads"); }; settings.logsTab.onclick = async () => { await openSettings("logs"); }; settings.showThumbnailsInput.onchange = handleShowThumbnailsChange; settings.generalSaveButton.onclick = handlePreferredStartupPathSave; settings.interfaceSaveButton.onclick = handleInterfaceSave; settings.overlay.onclick = (event) => { if (event.target === settings.overlay) { closeSettings(); } }; const image = imageElements(); image.closeButton.onclick = closeImageViewer; image.zoomInButton.onclick = () => adjustImageZoom(1.2); image.zoomOutButton.onclick = () => adjustImageZoom(1 / 1.2); image.resetButton.onclick = resetImageZoom; const search = searchElements(); search.closeButton.onclick = closeSearch; search.overlay.onclick = (event) => { if (event.target === search.overlay) { closeSearch(); } }; const info = infoElements(); info.closeButton.onclick = closeInfo; info.overlay.onclick = (event) => { if (event.target === info.overlay) { closeInfo(); } }; const pdf = pdfElements(); pdf.closeButton.onclick = closePdfViewer; const wildcard = wildcardPopupElements(); wildcard.cancelButton.onclick = closeWildcardPopup; wildcard.applyButton.onclick = submitWildcardPopup; wildcard.input.onkeydown = (event) => { if (event.key === "Enter") { event.preventDefault(); submitWildcardPopup(); return; } if (event.key === "Escape") { event.preventDefault(); closeWildcardPopup(); } }; wildcard.overlay.onclick = (event) => { if (event.target === wildcard.overlay) { closeWildcardPopup(); } }; const move = moveElements(); move.closeButton.onclick = closeMovePopup; move.cancelButton.onclick = closeMovePopup; move.applyButton.onclick = submitMovePopup; move.input.onkeydown = (event) => { if (event.key === "Enter") { event.preventDefault(); submitMovePopup(); return; } if (event.key === "Escape") { event.preventDefault(); closeMovePopup(); } }; move.overlay.onclick = (event) => { if (event.target === move.overlay) { closeMovePopup(); } }; const batchMove = batchMoveElements(); batchMove.cancelButton.onclick = closeBatchMovePopup; batchMove.applyButton.onclick = submitBatchMovePopup; batchMove.overlay.onclick = (event) => { if (event.target === batchMove.overlay) { closeBatchMovePopup(); } }; const deleteConfirm = deleteConfirmElements(); deleteConfirm.cancelButton.onclick = closeDeleteConfirmModal; deleteConfirm.applyButton.onclick = submitDeleteConfirmModal; deleteConfirm.overlay.onclick = (event) => { if (event.target === deleteConfirm.overlay) { closeDeleteConfirmModal(); } }; const viewer = viewerElements(); viewer.closeButton.onclick = closeViewer; viewer.overlay.onclick = (event) => { if (event.target === viewer.overlay) { closeViewer(); } }; const video = videoElements(); video.closeButton.onclick = closeVideoViewer; video.player.onerror = () => { video.error.textContent = "Playback failed in this browser for this file."; }; video.overlay.onclick = (event) => { if (event.target === video.overlay) { closeVideoViewer(); } }; const editor = editorElements(); editor.closeButton.onclick = attemptCloseEditor; editor.cancelButton.onclick = attemptCloseEditor; editor.saveButton.onclick = saveEditor; editor.overlay.onclick = (event) => { if (event.target === editor.overlay) { attemptCloseEditor(); } }; } async function init() { setError("actions-error", ""); applyTheme("default", "dark"); setActivePane("left"); ensureSourceSwitchers(); setupEvents(); await loadSettings(); applyTheme(settingsState.selectedTheme, settingsState.selectedColorMode); paneState("left").currentPath = settingsState.preferredStartupPathLeft || "/Volumes"; paneState("right").currentPath = settingsState.preferredStartupPathRight || "/Volumes"; await loadBrowsePane("left"); if (paneState("left").currentPath !== "/Volumes" && document.getElementById("left-browse-error").textContent) { setError("left-browse-error", ""); paneState("left").currentPath = "/Volumes"; await loadBrowsePane("left"); } await loadBrowsePane("right"); if (paneState("right").currentPath !== "/Volumes" && document.getElementById("right-browse-error").textContent) { setError("right-browse-error", ""); paneState("right").currentPath = "/Volumes"; await loadBrowsePane("right"); } await refreshTasksSnapshot(); } init();