Files
webmanager-mvp/webui/html/app.js
T

4490 lines
141 KiB
JavaScript

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 monacoState = {
module: null,
loadPromise: null,
editor: null,
model: null,
resizeHandler: null,
};
let moveState = {
source: null,
destination: "",
};
let renameState = {
source: null,
name: "",
};
let deleteConfirmState = {
pane: "left",
items: [],
recursivePaths: [],
};
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,
taskId: null,
cancelRequested: false,
};
let folderUploadPlanState = {
targetPane: "left",
targetPath: "",
rootFolderName: "",
entries: [],
fileCount: 0,
subfolderCount: 0,
};
let folderUploadPickerInput = null;
let settingsState = {
activeTab: "general",
logsLoaded: false,
showThumbnails: false,
preferredStartupPathLeft: null,
preferredStartupPathRight: null,
selectedTheme: "default",
selectedColorMode: "dark",
zipDownloadLimits: null,
};
const VALID_THEME_FAMILIES = [
"default",
"macos-soft",
"midnight",
"graphite",
"windows11",
"commander-electric",
"nord-arctic",
"catppuccin-soft",
"fluent-neon",
];
const VALID_COLOR_MODES = ["dark", "light"];
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 setStatus(msg) {
document.getElementById("status").textContent = msg;
}
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"),
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"),
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"),
target: document.getElementById("context-menu-target"),
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"),
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 isZipDownloadSelection(items) {
return items.length > 1 || (items.length === 1 && items[0].kind === "directory");
}
function zipDownloadRequestKey(paths) {
return paths.join("\n");
}
function selectedItemCountLabel(totalItems) {
return `${totalItems} selected item${totalItems === 1 ? "" : "s"}`;
}
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.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.archiveLabel = "ZIP archive";
downloadProgressState.totalItems = selectedItems.length;
downloadProgressState.requestKey = zipDownloadRequestKey(requestPaths);
downloadProgressState.taskId = null;
downloadProgressState.cancelRequested = false;
setDownloadModalVisible(true);
updateDownloadModalDisplay({
active: true,
targetText: "Preparing download...",
currentFileText: `Selection: ${selectedItemCountLabel(selectedItems.length)}`,
countText: "Preparing zip download",
statusText: "Preparing download...",
percent: 20,
cancelVisible: true,
cancelDisabled: true,
});
requestAnimationFrame(() => {
if (!downloadProgressState.active) {
return;
}
updateDownloadModalDisplay({
active: true,
targetText: "Preparing download...",
currentFileText: `Packaging ${selectedItemCountLabel(downloadProgressState.totalItems)}`,
countText: "Zip preflight and packaging",
statusText: "Preparing download...",
percent: 55,
cancelVisible: true,
cancelDisabled: !downloadProgressState.taskId || downloadProgressState.cancelRequested,
});
});
}
function markZipDownloadReady(fileName) {
downloadProgressState.active = false;
downloadProgressState.cancelRequested = false;
downloadProgressState.archiveLabel = fileName || "ZIP archive";
updateDownloadModalDisplay({
active: false,
targetText: `Download started: ${downloadProgressState.archiveLabel}`,
currentFileText: `Prepared ${selectedItemCountLabel(downloadProgressState.totalItems)}`,
countText: "Browser download started",
statusText: "Download started",
percent: 100,
cancelVisible: false,
});
window.setTimeout(closeDownloadModal, 480);
}
function markZipDownloadFailed(err) {
downloadProgressState.active = false;
downloadProgressState.cancelRequested = false;
updateDownloadModalDisplay({
active: false,
targetText: "Preparing download...",
currentFileText: `Selection: ${selectedItemCountLabel(downloadProgressState.totalItems)}`,
countText: "Zip download failed",
statusText: err.message || "Download failed",
percent: 0,
cancelVisible: false,
});
}
function markZipDownloadCancelled() {
downloadProgressState.active = false;
downloadProgressState.cancelRequested = false;
updateDownloadModalDisplay({
active: false,
targetText: "Download cancelled",
currentFileText: `Selection: ${selectedItemCountLabel(downloadProgressState.totalItems)}`,
countText: "Zip download cancelled",
statusText: "Download cancelled",
percent: 0,
cancelVisible: false,
});
}
function updateZipDownloadTaskProgress(task) {
if (!downloadProgressState.active) {
return;
}
updateDownloadModalDisplay({
active: true,
targetText: "Preparing download...",
currentFileText: task.current_item ? `Current: ${task.current_item}` : `Selection: ${selectedItemCountLabel(downloadProgressState.totalItems)}`,
countText: task.total_items ? `${task.done_items || 0}/${task.total_items} top-level items` : "Preparing zip download",
statusText: downloadProgressState.cancelRequested ? "Cancelling download..." : task.status === "ready" ? "Download started" : "Preparing download...",
percent: task.status === "ready" ? 100 : 55,
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.archiveLabel = "";
downloadProgressState.totalItems = 0;
downloadProgressState.requestKey = null;
downloadProgressState.taskId = null;
downloadProgressState.cancelRequested = false;
updateDownloadModalDisplay({
active: false,
targetText: "",
currentFileText: "",
countText: "",
statusText: "",
percent: 0,
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.target.textContent = "";
}
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)];
contextMenuState.open = true;
contextMenuState.pane = pane;
contextMenuState.items = items;
contextMenuState.anchorPath = entry.path;
const isMulti = items.length > 1;
const openableSingle = items.length === 1 && isOpenableSelection(items[0]);
const editableSingle = items.length === 1 && isEditableSelection(items[0]);
const downloadableSelection = items.length > 0;
elements.scope.textContent = isMulti ? "Multi-selection" : "Single item";
elements.target.textContent = isMulti ? `${items.length} selected items` : entry.name;
elements.openButton.classList.toggle("hidden", isMulti);
elements.openButton.disabled = !openableSingle;
elements.editButton.classList.toggle("hidden", isMulti || items.length !== 1 || items[0].kind !== "file");
elements.editButton.disabled = !editableSingle;
elements.downloadButton.classList.remove("hidden");
elements.downloadButton.disabled = !downloadableSelection;
elements.renameButton.classList.toggle("hidden", isMulti);
elements.copyButton.classList.remove("hidden");
elements.copyButton.disabled = items.length === 0;
elements.moveButton.classList.remove("hidden");
elements.deleteButton.classList.remove("hidden");
elements.propertiesButton.classList.remove("hidden");
elements.propertiesButton.disabled = items.length === 0;
const menuWidth = 220;
const menuHeight = 120;
const x = Math.min(event.clientX, window.innerWidth - menuWidth - 12);
const y = Math.min(event.clientY, window.innerHeight - menuHeight - 12);
elements.menu.style.left = `${Math.max(8, x)}px`;
elements.menu.style.top = `${Math.max(8, y)}px`;
elements.menu.classList.remove("hidden");
}
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();
}
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 requestKey = zipDownloadRequestKey(selectedPaths);
if (zipDownload && downloadProgressState.active && downloadProgressState.requestKey === requestKey) {
setStatus("Preparing download...");
return;
}
if (zipDownload) {
openZipDownloadModal(selectedItems);
setStatus("Preparing download...");
}
try {
const selected = selectedItems[0];
if (zipDownload) {
const created = await createArchiveDownloadTask(selectedPaths);
downloadProgressState.taskId = created.task_id;
updateZipDownloadTaskProgress({
status: "preparing",
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;
}
const { blob, fileName } = await downloadFileRequest(selectedPaths);
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = fileName || selected.name;
document.body.append(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(url);
setStatus(`Download started: ${anchor.download}`);
} catch (err) {
if (zipDownload) {
if (err.code === "download_cancelled") {
markZipDownloadCancelled();
setStatus("Download cancelled");
} else {
markZipDownloadFailed(err);
setStatus("Download failed");
}
} else {
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"),
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,
};
}
async function createArchiveDownloadTask(paths) {
return apiRequest("POST", "/api/files/download/archive-prepare", { 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");
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 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: '<svg viewBox="0 0 24 24" class="entry-media-svg is-filled" aria-hidden="true"><path d="M3.5 7.5a2 2 0 0 1 2-2h4l2 2h7a2 2 0 0 1 2 2v7a2.5 2.5 0 0 1-2.5 2.5H6a2.5 2.5 0 0 1-2.5-2.5z"/><path d="M3.5 9h17" class="entry-media-detail"/></svg>',
file: '<svg viewBox="0 0 24 24" class="entry-media-svg is-filled" aria-hidden="true"><path d="M7 3.5h6l4 4v12a1.5 1.5 0 0 1-1.5 1.5h-8A1.5 1.5 0 0 1 6 19.5V5A1.5 1.5 0 0 1 7.5 3.5z"/><path d="M13 3.5V8h4.5" class="entry-media-detail"/></svg>',
image: '<svg viewBox="0 0 24 24" class="entry-media-svg is-filled" aria-hidden="true"><rect x="4" y="5" width="16" height="14" rx="2"/><circle cx="9" cy="10" r="1.6" class="entry-media-detail-solid"/><path d="M6.5 17l4.2-4.3 2.8 2.8 1.9-2L18.5 17z" class="entry-media-detail"/></svg>',
video: '<svg viewBox="0 0 24 24" class="entry-media-svg is-filled" aria-hidden="true"><rect x="4" y="6" width="11" height="12" rx="2"/><path d="M16 10.2l4-2.2v8l-4-2.2z"/><path d="M9.2 10l3.8 2.1-3.8 2.1z" class="entry-media-detail-solid"/></svg>',
pdf: '<svg viewBox="0 0 24 24" class="entry-media-svg is-filled" aria-hidden="true"><path d="M7 3.5h6l4 4v12a1.5 1.5 0 0 1-1.5 1.5h-8A1.5 1.5 0 0 1 6 19.5V5A1.5 1.5 0 0 1 7.5 3.5z"/><path d="M8 16h8M8.2 12.4h2.1a1.4 1.4 0 1 0 0-2.8H8.2zM12.2 9.6v5.6h1.6a1.9 1.9 0 0 0 0-3.8h-1.6M17 9.6h-2.6v5.6M14.8 12.2h1.7" class="entry-media-detail"/></svg>',
text: '<svg viewBox="0 0 24 24" class="entry-media-svg" aria-hidden="true"><path d="M7 3.5h6l4 4v12a1.5 1.5 0 0 1-1.5 1.5h-8A1.5 1.5 0 0 1 6 19.5V5A1.5 1.5 0 0 1 7.5 3.5z"/><path d="M9 10h6"/><path d="M9 13h6"/><path d="M9 16h4"/></svg>',
markdown: '<svg viewBox="0 0 24 24" class="entry-media-svg" aria-hidden="true"><path d="M7 3.5h6l4 4v12a1.5 1.5 0 0 1-1.5 1.5h-8A1.5 1.5 0 0 1 6 19.5V5A1.5 1.5 0 0 1 7.5 3.5z"/><path d="M8.5 15v-4l1.8 2 1.7-2v4"/><path d="M13.6 11.2h2.6"/><path d="M14.9 10.2v5.2"/><path d="M13.9 14.5l1 1 1-1"/></svg>',
json: '<svg viewBox="0 0 24 24" class="entry-media-svg" aria-hidden="true"><path d="M7 3.5h6l4 4v12a1.5 1.5 0 0 1-1.5 1.5h-8A1.5 1.5 0 0 1 6 19.5V5A1.5 1.5 0 0 1 7.5 3.5z"/><path d="M10 9c-.9.6-1.3 1.5-1.3 3s.4 2.4 1.3 3"/><path d="M14 9c.9.6 1.3 1.5 1.3 3s-.4 2.4-1.3 3"/><circle cx="12" cy="12" r=".8" fill="currentColor" stroke="none"/></svg>',
yaml: '<svg viewBox="0 0 24 24" class="entry-media-svg" aria-hidden="true"><path d="M7 3.5h6l4 4v12a1.5 1.5 0 0 1-1.5 1.5h-8A1.5 1.5 0 0 1 6 19.5V5A1.5 1.5 0 0 1 7.5 3.5z"/><path d="M8.5 9.5l2 2.5v3"/><path d="M12 9.5l2 2.5"/><path d="M14.5 12.8V15"/><path d="M8.5 16h7"/></svg>',
css: '<svg viewBox="0 0 24 24" class="entry-media-svg" aria-hidden="true"><path d="M7 3.5h6l4 4v12a1.5 1.5 0 0 1-1.5 1.5h-8A1.5 1.5 0 0 1 6 19.5V5A1.5 1.5 0 0 1 7.5 3.5z"/><path d="M14.5 9.8a2.4 2.4 0 1 0 0 4.8"/><path d="M9.5 9.8a2.4 2.4 0 1 1 0 4.8"/></svg>',
javascript: '<svg viewBox="0 0 24 24" class="entry-media-svg" aria-hidden="true"><path d="M7 3.5h6l4 4v12a1.5 1.5 0 0 1-1.5 1.5h-8A1.5 1.5 0 0 1 6 19.5V5A1.5 1.5 0 0 1 7.5 3.5z"/><path d="M10.4 9.7v4.8c0 1-.5 1.5-1.4 1.5"/><path d="M13.8 9.8c1 0 1.8.4 1.8 1.3 0 .8-.6 1.1-1.5 1.3-.8.2-1.4.4-1.4 1.2 0 .7.6 1.2 1.6 1.2"/></svg>',
typescript: '<svg viewBox="0 0 24 24" class="entry-media-svg" aria-hidden="true"><path d="M7 3.5h6l4 4v12a1.5 1.5 0 0 1-1.5 1.5h-8A1.5 1.5 0 0 1 6 19.5V5A1.5 1.5 0 0 1 7.5 3.5z"/><path d="M8.5 9.8h4"/><path d="M10.5 9.8v5.4"/><path d="M14.2 9.8c.9 0 1.7.4 1.7 1.3 0 .8-.6 1.1-1.4 1.3-.8.2-1.4.4-1.4 1.2 0 .7.6 1.2 1.6 1.2"/></svg>',
html: '<svg viewBox="0 0 24 24" class="entry-media-svg" aria-hidden="true"><path d="M7 3.5h6l4 4v12a1.5 1.5 0 0 1-1.5 1.5h-8A1.5 1.5 0 0 1 6 19.5V5A1.5 1.5 0 0 1 7.5 3.5z"/><path d="M9.5 10.2l-1.7 1.8 1.7 1.8"/><path d="M14.5 10.2l1.7 1.8-1.7 1.8"/><path d="M13.1 9.5l-2.2 5"/></svg>',
xml: '<svg viewBox="0 0 24 24" class="entry-media-svg" aria-hidden="true"><path d="M7 3.5h6l4 4v12a1.5 1.5 0 0 1-1.5 1.5h-8A1.5 1.5 0 0 1 6 19.5V5A1.5 1.5 0 0 1 7.5 3.5z"/><path d="M9.4 10.3l-1.5 1.7 1.5 1.7"/><path d="M14.6 10.3l1.5 1.7-1.5 1.7"/><path d="M11.8 9.5l.7 5"/></svg>',
shell: '<svg viewBox="0 0 24 24" class="entry-media-svg" aria-hidden="true"><rect x="4" y="6" width="16" height="12" rx="2"/><path d="M8 10l2 2-2 2"/><path d="M12 15h4"/></svg>',
python: '<svg viewBox="0 0 24 24" class="entry-media-svg" aria-hidden="true"><path d="M12 5c-3.8 0-3.6 1.7-3.6 1.7V9h7.3V6.7S15.8 5 12 5z"/><circle cx="10" cy="7" r=".8" fill="currentColor" stroke="none"/><path d="M12 19c3.8 0 3.6-1.7 3.6-1.7V15H8.3v2.3S8.2 19 12 19z"/><circle cx="14" cy="17" r=".8" fill="currentColor" stroke="none"/><path d="M8.3 9v3H7c-1.8 0-1.8 1.7-1.8 1.7S5 17 8.7 17"/><path d="M15.7 15v-3H17c1.8 0 1.8-1.7 1.8-1.7S19 7 15.3 7"/></svg>',
docker: '<svg viewBox="0 0 24 24" class="entry-media-svg" aria-hidden="true"><path d="M7 10h2v2H7z"/><path d="M10 10h2v2h-2z"/><path d="M13 10h2v2h-2z"/><path d="M10 7.5h2V10h-2z"/><path d="M13 7.5h2V10h-2z"/><path d="M16.2 10.7c.4-1 .3-2-.3-2.8.9-.1 1.8.2 2.4.8.6.6.9 1.4.8 2.3.7.1 1.3.7 1.6 1.3-.4.4-1 .7-1.6.8-.5 2.3-2.3 3.8-5 3.8H9.2c-2.2 0-4-1.8-4-4v-1.2h11z"/></svg>',
};
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");
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 = !hasSelection;
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", ".py", ".css", ".html"].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") {
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;
}
model.pendingSelectionPath = childPath;
navigateTo(pane, parentPath);
}
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 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;
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) {
closeContextMenu();
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);
}
}
function closeDeleteConfirmModal() {
const elements = deleteConfirmElements();
deleteConfirmState.pane = "left";
deleteConfirmState.items = [];
deleteConfirmState.recursivePaths = [];
elements.error.textContent = "";
elements.overlay.classList.add("hidden");
}
function openDeleteConfirmModal(pane, items, recursivePaths) {
const elements = deleteConfirmElements();
deleteConfirmState.pane = pane;
deleteConfirmState.items = items.map((item) => ({ ...item }));
deleteConfirmState.recursivePaths = Array.from(recursivePaths);
elements.error.textContent = "";
if (items.length === 1) {
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 = items[0].path;
} else {
elements.title.textContent = "Delete selected items and folder contents?";
elements.message.textContent = `This will permanently delete ${items.length} selected items, including all files and subfolders inside the selected folders.`;
elements.path.textContent = `${items.length} selected items`;
}
elements.overlay.classList.remove("hidden");
}
async function executeDeleteItems(pane, items, recursivePaths) {
let successes = 0;
let failures = 0;
let firstError = null;
for (const item of items) {
try {
await apiRequest("POST", "/api/files/delete", {
path: item.path,
recursive: recursivePaths.has(item.path),
});
successes += 1;
} catch (err) {
if (err.code === "directory_not_empty" && recursivePaths.has(item.path)) {
try {
await apiRequest("POST", "/api/files/delete", {
path: item.path,
recursive: true,
});
successes += 1;
continue;
} catch (retryErr) {
err = retryErr;
}
}
failures += 1;
if (!firstError) {
firstError = `${item.path}: ${err.message}`;
}
}
}
setSelectedItem(pane, null);
await loadBrowsePane(pane);
showActionSummary("Delete", successes, failures, firstError);
}
async function submitDeleteConfirmModal() {
const elements = deleteConfirmElements();
if (!deleteConfirmState.items.length) {
return;
}
elements.error.textContent = "";
try {
const pane = deleteConfirmState.pane;
const items = [...deleteConfirmState.items];
const recursivePaths = new Set(deleteConfirmState.recursivePaths);
closeDeleteConfirmModal();
await executeDeleteItems(pane, items, recursivePaths);
} catch (err) {
elements.error.textContent = err.message;
}
}
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) {
openDeleteConfirmModal(pane, selectedItems, recursivePaths);
return;
}
if (!window.confirm(`Delete ${selectedItems.length} selected item(s)?`)) {
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", "");
let successes = 0;
let failures = 0;
let firstError = null;
for (const item of selectedItems) {
const destination = defaultDestination(item.path, baseDestination);
try {
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 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: "",
};
}
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 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 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 = '<div class="popup-meta">Loading...</div>';
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}`);
}
}
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() {
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 loadHistoryForSettings();
}
(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,
};
}
function attemptCloseEditor() {
if (editorIsDirty() && !window.confirm("Discard unsaved changes?")) {
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 (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 (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;
}
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;
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.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.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 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 = () => setSettingsTab("general");
settings.interfaceTab.onclick = () => setSettingsTab("interface");
settings.downloadsTab.onclick = () => setSettingsTab("downloads");
settings.logsTab.onclick = async () => {
setSettingsTab("logs");
await loadHistoryForSettings();
};
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");
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");
}
}
init();