let state = { panes: { left: { currentPath: "/Volumes", showHidden: false, selectedItem: null, selectedItems: [], visibleItems: [], currentRowIndex: -1, selectionAnchorIndex: null, pendingSelectionPath: null, }, right: { currentPath: "/Volumes", showHidden: false, selectedItem: null, selectedItems: [], visibleItems: [], currentRowIndex: -1, selectionAnchorIndex: null, pendingSelectionPath: null, }, }, activePane: "left", selectedTaskId: null, lastTaskCount: 0, }; const ROW_JUMP_STEP = 10; let wildcardDialogMode = "select"; let editorState = { path: null, originalContent: "", modified: null, }; let moveState = { source: null, destination: "", }; let renameState = { source: null, name: "", }; let batchMoveState = { destinationBase: "", count: 0, }; let settingsState = { activeTab: "general", logsLoaded: false, showThumbnails: false, }; let searchState = { pane: "left", path: "/Volumes", query: "", }; const THEME_STORAGE_KEY = "webmanager-theme"; function preferredTheme() { const stored = window.localStorage.getItem(THEME_STORAGE_KEY); if (stored === "light" || stored === "dark") { return stored; } if (window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches) { return "light"; } return "dark"; } function applyTheme(theme) { const nextTheme = theme === "light" ? "light" : "dark"; document.documentElement.dataset.theme = nextTheme; const icon = document.getElementById("theme-toggle-icon"); const button = document.getElementById("theme-toggle"); if (icon) { icon.textContent = nextTheme === "dark" ? "☾" : "☀"; } if (button) { button.setAttribute("aria-label", `Switch to ${nextTheme === "dark" ? "light" : "dark"} mode`); button.setAttribute("title", `Switch to ${nextTheme === "dark" ? "light" : "dark"} mode`); } } function toggleTheme() { const current = document.documentElement.dataset.theme === "light" ? "light" : "dark"; const next = current === "dark" ? "light" : "dark"; applyTheme(next); window.localStorage.setItem(THEME_STORAGE_KEY, next); } function paneState(pane) { return state.panes[pane]; } function otherPane(pane) { return pane === "left" ? "right" : "left"; } function activePaneState() { return paneState(state.activePane); } function setStatus(msg) { document.getElementById("status").textContent = msg; } function setError(id, msg) { 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"), content: document.getElementById("editor-content"), 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 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"), 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 settingsElements() { return { overlay: document.getElementById("settings-modal"), closeButton: document.getElementById("settings-close-btn"), generalTab: document.getElementById("settings-general-tab"), logsTab: document.getElementById("settings-logs-tab"), generalPanel: document.getElementById("settings-general-panel"), showThumbnailsInput: document.getElementById("settings-show-thumbnails"), logsPanel: document.getElementById("settings-logs-panel"), 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"), closeButton: document.getElementById("info-close-btn"), error: document.getElementById("info-error"), grid: document.getElementById("info-grid"), }; } 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) { const error = data.error || {}; throw new Error(error.message || `HTTP ${response.status}`); } return data; } async function refreshTasksSnapshot() { try { const data = await apiRequest("GET", "/api/tasks"); state.lastTaskCount = Array.isArray(data.items) ? data.items.length : state.lastTaskCount; } 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 setActivePane(pane) { state.activePane = pane; document.getElementById("active-pane-label").textContent = 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 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"); icon.className = `entry-media-icon ${entry.kind === "directory" ? "folder" : "file"}`; icon.setAttribute("aria-hidden", "true"); slot.append(icon); return slot; } async function loadSettings() { const data = await apiRequest("GET", "/api/settings"); settingsState.showThumbnails = !!data.show_thumbnails; const elements = settingsElements(); if (elements.showThumbnailsInput) { elements.showThumbnailsInput.checked = settingsState.showThumbnails; } } async function saveSettings(update) { const data = await apiRequest("POST", "/api/settings", update); settingsState.showThumbnails = !!data.show_thumbnails; const elements = settingsElements(); if (elements.showThumbnailsInput) { elements.showThumbnailsInput.checked = settingsState.showThumbnails; } renderPaneItems("left"); renderPaneItems("right"); } 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"); document.getElementById("view-btn").disabled = !exactlyOne || !allFiles; document.getElementById("edit-btn").disabled = !exactlyOne || !allFiles || !isEditableSelection(selectedItems[0] || null); document.getElementById("rename-btn").disabled = !exactlyOne; document.getElementById("delete-btn").disabled = !hasSelection; document.getElementById("copy-btn").disabled = !allFiles; document.getElementById("move-btn").disabled = !hasSelection; } 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", ".css", ".html"].some((suffix) => lower.endsWith(suffix)); } function isVideoSelection(item) { if (!item || item.kind !== "file") { return false; } const lower = (item.name || "").toLowerCase(); return lower.endsWith(".mp4") || lower.endsWith(".mkv"); } function currentParentPath(path) { const normalized = (path || "").trim(); if (!normalized) { return null; } if (normalized === "/Volumes") { 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 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 rootCrumb = createButton("/", () => { setActivePane(pane); navigateTo(pane, "/Volumes"); }); rootCrumb.type = "button"; rootCrumb.onclick = (ev) => { ev.preventDefault(); ev.stopPropagation(); setActivePane(pane); navigateTo(pane, "/Volumes"); }; 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 createBrowseItem(pane, entry, kind) { 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(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" ? "-" : String(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 renderPaneItems(pane) { const model = paneState(pane); const items = document.getElementById(`${pane}-items`); items.innerHTML = ""; if (!Array.isArray(model.visibleItems) || model.visibleItems.length === 0) { model.currentRowIndex = -1; 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); }; const upNameCell = document.createElement("span"); upNameCell.className = "entry-name entry-dir"; const upMedia = document.createElement("span"); upMedia.className = "entry-media-slot"; upNameCell.append(upMedia); const upName = document.createElement("button"); upName.type = "button"; upName.className = "dir-link"; upName.textContent = "../"; upName.onclick = (ev) => { ev.stopPropagation(); setActivePane(pane); navigateTo(pane, entry.path); }; 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); 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 span"); 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); }; 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(); }; } items.append(row); }); 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; document.getElementById(`${pane}-current-path`).textContent = data.path; 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); scrollCurrentRowIntoView(pane); setStatus(`Loaded ${pane}: ${data.path}`); } catch (err) { setError(`${pane}-browse-error`, `Browse: ${err.message}`); } } function navigateTo(pane, path) { const model = paneState(pane); model.currentPath = path; model.currentRowIndex = 0; clearSelectionAnchor(pane); setSelectedItem(pane, null); loadBrowsePane(pane); } async function createFolderForPane(pane) { setActivePane(pane); const name = window.prompt("Folder name"); 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; } const selected = selectedItems[0]; const newName = window.prompt("New name", selected.name); if (!newName) { return; } setError("actions-error", ""); try { await apiRequest("POST", "/api/files/rename", { path: selected.path, new_name: newName, }); setSelectedItem(pane, null); await loadBrowsePane(pane); } catch (err) { setActionError("Rename", err); } } async function deleteSelected() { const pane = state.activePane; const selectedItems = [...paneState(pane).selectedItems]; if (selectedItems.length === 0) { return; } if (!window.confirm(`Delete ${selectedItems.length} selected item(s)?`)) { return; } setError("actions-error", ""); let successes = 0; let failures = 0; let firstError = null; for (const item of selectedItems) { try { await apiRequest("POST", "/api/files/delete", { path: item.path }); successes += 1; } catch (err) { failures += 1; if (!firstError) { firstError = `${item.path}: ${err.message}`; } } } setSelectedItem(pane, null); await loadBrowsePane(pane); showActionSummary("Delete", successes, failures, firstError); } 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", ""); let successes = 0; let failures = 0; let firstError = null; for (const item of selectedItems) { const destination = defaultDestination(item.path, baseDestination); try { if (item.kind !== "file") { throw new Error("Only files are supported for copy"); } const result = await apiRequest("POST", "/api/files/copy", { source: item.path, destination, }); state.selectedTaskId = result.task_id; await refreshTasksSnapshot(); successes += 1; } catch (err) { failures += 1; if (!firstError) { firstError = `${item.path}: ${err.message}`; } } } await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]); showActionSummary("Copy", successes, failures, firstError); } 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; } const allFiles = selectedItems.every((item) => item.kind === "file"); setError("actions-error", ""); if (!allFiles) { 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: batch started"); return; } let successes = 0; let failures = 0; let firstError = null; for (const item of selectedItems) { const destination = defaultDestination(item.path, baseDestination); try { if (item.kind !== "file") { throw new Error("Only files are supported for move"); } const result = await apiRequest("POST", "/api/files/move", { source: item.path, destination, }); state.selectedTaskId = result.task_id; await refreshTasksSnapshot(); successes += 1; } catch (err) { failures += 1; if (!firstError) { firstError = `${item.path}: ${err.message}`; } } } setSelectedItem(sourcePane, null); await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]); showActionSummary("Move", successes, failures, firstError); } async function addBookmark() { const pane = state.activePane; const path = paneState(pane).currentPath; const label = window.prompt("Bookmark label", path); 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 isViewerOpen() { return !viewerElements().overlay.classList.contains("hidden"); } function isVideoOpen() { return !videoElements().overlay.classList.contains("hidden"); } function isEditorOpen() { return !editorElements().overlay.classList.contains("hidden"); } 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: "", }; } function closeRenamePopup() { const elements = renameElements(); elements.overlay.classList.add("hidden"); elements.error.textContent = ""; elements.input.value = ""; resetRenameState(); } function openRenamePopup() { const selectedItems = activePaneState().selectedItems; if (selectedItems.length !== 1) { return false; } const source = selectedItems[0]; const elements = renameElements(); renameState.source = source; renameState.name = source.name; elements.input.value = source.name; elements.error.textContent = ""; elements.overlay.classList.remove("hidden"); elements.input.focus(); elements.input.select(); return true; } 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(); const source = renameState.source; if (!source) { return; } const newName = elements.input.value.trim(); elements.error.textContent = ""; if (!newName) { elements.error.textContent = "Name is required"; return; } if (newName === source.name) { elements.error.textContent = "Name must differ from current name"; return; } if (newName.includes("/")) { elements.error.textContent = "Name cannot contain /"; return; } if (newName === "." || newName === "..") { elements.error.textContent = "Invalid name"; return; } try { await apiRequest("POST", "/api/files/rename", { path: source.path, new_name: newName, }); closeRenamePopup(); setSelectedItem(state.activePane, null); await loadBrowsePane(state.activePane); setStatus(`Renamed ${source.path}`); } catch (err) { elements.error.textContent = err.message; } } 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 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 !== 1) { return; } const selected = selectedItems[0]; const elements = infoElements(); elements.overlay.classList.remove("hidden"); elements.error.textContent = ""; elements.grid.innerHTML = ""; 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); } 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" : "general"; const isGeneral = settingsState.activeTab === "general"; elements.generalTab.classList.toggle("is-active", isGeneral); elements.generalTab.setAttribute("aria-selected", isGeneral ? "true" : "false"); elements.logsTab.classList.toggle("is-active", !isGeneral); elements.logsTab.setAttribute("aria-selected", !isGeneral ? "true" : "false"); elements.generalPanel.classList.toggle("hidden", !isGeneral); elements.logsPanel.classList.toggle("hidden", isGeneral); } 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 renderHistoryItems(items) { const elements = settingsElements(); 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); 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); } } async function loadHistoryForSettings() { const elements = settingsElements(); elements.logsError.textContent = ""; elements.logsList.innerHTML = ''; try { const data = await apiRequest("GET", "/api/history"); renderHistoryItems(data.items || []); settingsState.logsLoaded = true; } catch (err) { elements.logsList.innerHTML = ""; elements.logsError.textContent = err.message; } } 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}`); } } function closeSettings() { settingsElements().overlay.classList.add("hidden"); } async function openSettings(tab = "general") { const elements = settingsElements(); elements.overlay.classList.remove("hidden"); setSettingsTab(tab); if (settingsState.activeTab === "logs") { await loadHistoryForSettings(); } (settingsState.activeTab === "logs" ? elements.logsTab : elements.generalTab).focus(); } function editorIsDirty() { return editorElements().content.value !== editorState.originalContent; } function resetEditorState() { editorState = { path: null, originalContent: "", modified: null, }; } function attemptCloseEditor() { if (editorIsDirty() && !window.confirm("Discard unsaved changes?")) { return; } closeEditor(); } function closeEditor() { const editor = editorElements(); editor.overlay.classList.add("hidden"); editor.error.textContent = ""; editor.content.value = ""; resetEditorState(); } async function openViewer() { 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; } } 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.content.value = ""; editor.content.disabled = true; 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; editor.content.value = data.content; editor.content.disabled = false; editor.saveButton.disabled = false; editorState.path = data.path; editorState.originalContent = data.content; editorState.modified = data.modified; editor.content.focus(); } catch (err) { editor.error.textContent = err.message; } } 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: editor.content.value, expected_modified: editorState.modified, }); editorState.originalContent = editor.content.value; 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 (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 (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 (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 (isVideoOpen()) { if (event.key === "Escape") { event.preventDefault(); closeVideoViewer(); } return; } if (isViewerOpen()) { if (event.key === "Escape") { event.preventDefault(); closeViewer(); } return; } if (isWildcardPopupOpen()) { return; } if (!shouldHandleShortcut(event.target)) { 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 === "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; 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; 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 = () => setSettingsTab("general"); settings.logsTab.onclick = async () => { setSettingsTab("logs"); await loadHistoryForSettings(); }; settings.showThumbnailsInput.onchange = handleShowThumbnailsChange; settings.overlay.onclick = (event) => { if (event.target === settings.overlay) { closeSettings(); } }; 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 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 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(preferredTheme()); setActivePane("left"); setupEvents(); await loadSettings(); await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]); } init();