2398 lines
70 KiB
JavaScript
2398 lines
70 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 moveState = {
|
|
source: null,
|
|
destination: "",
|
|
};
|
|
let renameState = {
|
|
source: null,
|
|
name: "",
|
|
};
|
|
let batchMoveState = {
|
|
destinationBase: "",
|
|
count: 0,
|
|
};
|
|
let settingsState = {
|
|
activeTab: "general",
|
|
logsLoaded: false,
|
|
showThumbnails: false,
|
|
preferredStartupPathLeft: null,
|
|
preferredStartupPathRight: null,
|
|
};
|
|
let searchState = {
|
|
pane: "left",
|
|
path: "/Volumes",
|
|
query: "",
|
|
};
|
|
const THEME_STORAGE_KEY = "webmanager-theme";
|
|
|
|
function preferredTheme() {
|
|
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
|
|
if (stored === "light" || stored === "dark") {
|
|
return stored;
|
|
}
|
|
if (window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches) {
|
|
return "light";
|
|
}
|
|
return "dark";
|
|
}
|
|
|
|
function applyTheme(theme) {
|
|
const nextTheme = theme === "light" ? "light" : "dark";
|
|
document.documentElement.dataset.theme = nextTheme;
|
|
const icon = document.getElementById("theme-toggle-icon");
|
|
const button = document.getElementById("theme-toggle");
|
|
if (icon) {
|
|
icon.textContent = nextTheme === "dark" ? "☾" : "☀";
|
|
}
|
|
if (button) {
|
|
button.setAttribute("aria-label", `Switch to ${nextTheme === "dark" ? "light" : "dark"} mode`);
|
|
button.setAttribute("title", `Switch to ${nextTheme === "dark" ? "light" : "dark"} mode`);
|
|
}
|
|
}
|
|
|
|
function toggleTheme() {
|
|
const current = document.documentElement.dataset.theme === "light" ? "light" : "dark";
|
|
const next = current === "dark" ? "light" : "dark";
|
|
applyTheme(next);
|
|
window.localStorage.setItem(THEME_STORAGE_KEY, next);
|
|
}
|
|
|
|
function paneState(pane) {
|
|
return state.panes[pane];
|
|
}
|
|
|
|
function otherPane(pane) {
|
|
return pane === "left" ? "right" : "left";
|
|
}
|
|
|
|
function activePaneState() {
|
|
return paneState(state.activePane);
|
|
}
|
|
|
|
function setStatus(msg) {
|
|
document.getElementById("status").textContent = msg;
|
|
}
|
|
|
|
function setError(id, msg) {
|
|
document.getElementById(id).textContent = msg || "";
|
|
}
|
|
|
|
function setActionError(action, err) {
|
|
setError("actions-error", `${action}: ${err.message}`);
|
|
}
|
|
|
|
function showActionSummary(action, successes, failures, firstError) {
|
|
const base = `${action}: ${successes} success, ${failures} failed`;
|
|
if (firstError) {
|
|
setError("actions-error", `${base}. First error: ${firstError}`);
|
|
} else {
|
|
setError("actions-error", "");
|
|
}
|
|
setStatus(base);
|
|
}
|
|
|
|
function viewerElements() {
|
|
return {
|
|
overlay: document.getElementById("viewer-modal"),
|
|
title: document.getElementById("viewer-title"),
|
|
fileName: document.getElementById("viewer-file-name"),
|
|
filePath: document.getElementById("viewer-file-path"),
|
|
error: document.getElementById("viewer-error"),
|
|
content: document.getElementById("viewer-content"),
|
|
closeButton: document.getElementById("viewer-close-btn"),
|
|
};
|
|
}
|
|
|
|
function editorElements() {
|
|
return {
|
|
overlay: document.getElementById("editor-modal"),
|
|
title: document.getElementById("editor-title"),
|
|
fileName: document.getElementById("editor-file-name"),
|
|
filePath: document.getElementById("editor-file-path"),
|
|
error: document.getElementById("editor-error"),
|
|
content: document.getElementById("editor-content"),
|
|
closeButton: document.getElementById("editor-close-btn"),
|
|
saveButton: document.getElementById("editor-save-btn"),
|
|
cancelButton: document.getElementById("editor-cancel-btn"),
|
|
};
|
|
}
|
|
|
|
function videoElements() {
|
|
return {
|
|
overlay: document.getElementById("video-modal"),
|
|
title: document.getElementById("video-title"),
|
|
fileName: document.getElementById("video-file-name"),
|
|
filePath: document.getElementById("video-file-path"),
|
|
error: document.getElementById("video-error"),
|
|
player: document.getElementById("video-player"),
|
|
closeButton: document.getElementById("video-close-btn"),
|
|
};
|
|
}
|
|
|
|
function 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 moveElements() {
|
|
return {
|
|
overlay: document.getElementById("move-popup"),
|
|
source: document.getElementById("move-source"),
|
|
input: document.getElementById("move-input"),
|
|
error: document.getElementById("move-error"),
|
|
applyButton: document.getElementById("move-apply-btn"),
|
|
cancelButton: document.getElementById("move-cancel-btn"),
|
|
closeButton: document.getElementById("move-close-btn"),
|
|
};
|
|
}
|
|
|
|
function renameElements() {
|
|
return {
|
|
overlay: document.getElementById("rename-popup"),
|
|
input: document.getElementById("rename-input"),
|
|
error: document.getElementById("rename-error"),
|
|
applyButton: document.getElementById("rename-apply-btn"),
|
|
cancelButton: document.getElementById("rename-cancel-btn"),
|
|
closeButton: document.getElementById("rename-close-btn"),
|
|
};
|
|
}
|
|
|
|
function batchMoveElements() {
|
|
return {
|
|
overlay: document.getElementById("batch-move-popup"),
|
|
count: document.getElementById("batch-move-count"),
|
|
destination: document.getElementById("batch-move-destination"),
|
|
error: document.getElementById("batch-move-error"),
|
|
applyButton: document.getElementById("batch-move-apply-btn"),
|
|
cancelButton: document.getElementById("batch-move-cancel-btn"),
|
|
};
|
|
}
|
|
|
|
function settingsElements() {
|
|
return {
|
|
overlay: document.getElementById("settings-modal"),
|
|
closeButton: document.getElementById("settings-close-btn"),
|
|
generalTab: document.getElementById("settings-general-tab"),
|
|
logsTab: document.getElementById("settings-logs-tab"),
|
|
generalPanel: document.getElementById("settings-general-panel"),
|
|
showThumbnailsInput: document.getElementById("settings-show-thumbnails"),
|
|
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"),
|
|
logsPanel: document.getElementById("settings-logs-panel"),
|
|
logsList: document.getElementById("settings-logs-list"),
|
|
logsError: document.getElementById("settings-logs-error"),
|
|
};
|
|
}
|
|
|
|
function searchElements() {
|
|
return {
|
|
overlay: document.getElementById("search-modal"),
|
|
closeButton: document.getElementById("search-close-btn"),
|
|
context: document.getElementById("search-context"),
|
|
input: document.getElementById("search-input"),
|
|
error: document.getElementById("search-error"),
|
|
results: document.getElementById("search-results"),
|
|
};
|
|
}
|
|
|
|
function infoElements() {
|
|
return {
|
|
overlay: document.getElementById("info-modal"),
|
|
closeButton: document.getElementById("info-close-btn"),
|
|
error: document.getElementById("info-error"),
|
|
grid: document.getElementById("info-grid"),
|
|
};
|
|
}
|
|
|
|
async function apiRequest(method, url, body) {
|
|
const options = { method, headers: {} };
|
|
if (body !== undefined) {
|
|
options.headers["Content-Type"] = "application/json";
|
|
options.body = JSON.stringify(body);
|
|
}
|
|
const response = await fetch(url, options);
|
|
const data = await response.json().catch(() => ({}));
|
|
if (!response.ok) {
|
|
const error = data.error || {};
|
|
throw new Error(error.message || `HTTP ${response.status}`);
|
|
}
|
|
return data;
|
|
}
|
|
|
|
async function refreshTasksSnapshot() {
|
|
try {
|
|
const data = await apiRequest("GET", "/api/tasks");
|
|
state.lastTaskCount = Array.isArray(data.items) ? data.items.length : state.lastTaskCount;
|
|
} catch (_) {
|
|
// Task list panel is not visible in current UI; silently keep flow stable.
|
|
}
|
|
}
|
|
|
|
function createButton(text, onClick) {
|
|
const button = document.createElement("button");
|
|
button.textContent = text;
|
|
button.onclick = onClick;
|
|
return button;
|
|
}
|
|
|
|
function setActivePane(pane) {
|
|
state.activePane = pane;
|
|
document.getElementById("active-pane-label").textContent = pane;
|
|
document.getElementById("left-pane").classList.toggle("active-pane", pane === "left");
|
|
document.getElementById("right-pane").classList.toggle("active-pane", pane === "right");
|
|
updateActionButtons();
|
|
}
|
|
|
|
function setSelectedItem(pane, item) {
|
|
const model = paneState(pane);
|
|
model.selectedItem = item;
|
|
model.selectedItems = item ? [item] : [];
|
|
updateActionButtons();
|
|
}
|
|
|
|
function clearSelectionAnchor(pane) {
|
|
paneState(pane).selectionAnchorIndex = null;
|
|
}
|
|
|
|
function setSelectionAnchor(pane, index) {
|
|
paneState(pane).selectionAnchorIndex = Number.isInteger(index) ? index : null;
|
|
}
|
|
|
|
function selectedPaths(pane) {
|
|
return paneState(pane).selectedItems.map((item) => item.path);
|
|
}
|
|
|
|
function setSingleSelection(pane, item) {
|
|
setSelectedItem(pane, item);
|
|
}
|
|
|
|
function setSingleSelectionAtIndex(pane, item, index) {
|
|
setSingleSelection(pane, item);
|
|
setSelectionAnchor(pane, index);
|
|
}
|
|
|
|
function toggleSelection(pane, item) {
|
|
const model = paneState(pane);
|
|
const index = model.selectedItems.findIndex((selected) => selected.path === item.path);
|
|
if (index >= 0) {
|
|
const removed = model.selectedItems[index];
|
|
model.selectedItems.splice(index, 1);
|
|
if (model.selectedItem && model.selectedItem.path === removed.path) {
|
|
model.selectedItem = model.selectedItems.length > 0 ? model.selectedItems[model.selectedItems.length - 1] : null;
|
|
}
|
|
} else {
|
|
model.selectedItems.push(item);
|
|
model.selectedItem = item;
|
|
}
|
|
updateActionButtons();
|
|
}
|
|
|
|
function toggleSelectionAtIndex(pane, item, index) {
|
|
toggleSelection(pane, item);
|
|
setSelectionAnchor(pane, index);
|
|
}
|
|
|
|
function selectedEntryFromItem(entry) {
|
|
return { path: entry.path, name: entry.name, kind: entry.kind };
|
|
}
|
|
|
|
function setRangeSelection(pane, anchorIndex, currentIndex) {
|
|
const model = paneState(pane);
|
|
if (!Array.isArray(model.visibleItems) || model.visibleItems.length === 0) {
|
|
return;
|
|
}
|
|
const start = Math.max(0, Math.min(anchorIndex, currentIndex));
|
|
const end = Math.min(model.visibleItems.length - 1, Math.max(anchorIndex, currentIndex));
|
|
model.selectedItems = model.visibleItems
|
|
.slice(start, end + 1)
|
|
.filter((entry) => !entry.isParent)
|
|
.map((entry) => selectedEntryFromItem(entry));
|
|
const current = model.visibleItems[currentIndex];
|
|
model.selectedItem = current && !current.isParent ? selectedEntryFromItem(current) : (model.selectedItems[model.selectedItems.length - 1] || null);
|
|
updateActionButtons();
|
|
}
|
|
|
|
function isMacLike() {
|
|
const platform = navigator.platform || navigator.userAgent || "";
|
|
return /Mac|iPhone|iPad|iPod/i.test(platform);
|
|
}
|
|
|
|
function isToggleModifierClick(event) {
|
|
if (isMacLike()) {
|
|
return !!event.metaKey && !event.ctrlKey && !event.shiftKey && !event.altKey;
|
|
}
|
|
return !!event.ctrlKey && !event.metaKey && !event.shiftKey && !event.altKey;
|
|
}
|
|
|
|
function currentRowItem(pane) {
|
|
const model = paneState(pane);
|
|
if (!Array.isArray(model.visibleItems) || model.visibleItems.length === 0) {
|
|
return null;
|
|
}
|
|
if (model.currentRowIndex < 0 || model.currentRowIndex >= model.visibleItems.length) {
|
|
return null;
|
|
}
|
|
return model.visibleItems[model.currentRowIndex];
|
|
}
|
|
|
|
function thumbnailsEnabled() {
|
|
return !!settingsState.showThumbnails;
|
|
}
|
|
|
|
function isThumbnailCandidate(entry) {
|
|
if (!entry || entry.kind !== "file") {
|
|
return false;
|
|
}
|
|
const lower = (entry.name || "").toLowerCase();
|
|
return lower.endsWith(".jpg") || lower.endsWith(".jpeg") || lower.endsWith(".png") || lower.endsWith(".webp");
|
|
}
|
|
|
|
function createMediaSlot(entry) {
|
|
const slot = document.createElement("span");
|
|
slot.className = "entry-media-slot";
|
|
if (thumbnailsEnabled() && isThumbnailCandidate(entry)) {
|
|
const image = document.createElement("img");
|
|
image.className = "entry-thumbnail";
|
|
image.loading = "lazy";
|
|
image.alt = "";
|
|
image.src = `/api/files/thumbnail?${new URLSearchParams({ path: entry.path }).toString()}`;
|
|
slot.append(image);
|
|
return slot;
|
|
}
|
|
|
|
const icon = document.createElement("span");
|
|
icon.className = `entry-media-icon ${entry.kind === "directory" ? "folder" : "file"}`;
|
|
icon.setAttribute("aria-hidden", "true");
|
|
slot.append(icon);
|
|
return slot;
|
|
}
|
|
|
|
async function loadSettings() {
|
|
const data = await apiRequest("GET", "/api/settings");
|
|
settingsState.showThumbnails = !!data.show_thumbnails;
|
|
settingsState.preferredStartupPathLeft = data.preferred_startup_path_left || null;
|
|
settingsState.preferredStartupPathRight = data.preferred_startup_path_right || 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 || "";
|
|
}
|
|
}
|
|
|
|
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;
|
|
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 || "";
|
|
}
|
|
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 = !allFiles;
|
|
document.getElementById("move-btn").disabled = !hasSelection;
|
|
}
|
|
|
|
function isEditableSelection(item) {
|
|
if (!item || item.kind !== "file") {
|
|
return false;
|
|
}
|
|
const name = item.name || "";
|
|
const lower = name.toLowerCase();
|
|
if (lower === "dockerfile" || lower === "containerfile") {
|
|
return true;
|
|
}
|
|
return [".txt", ".log", ".md", ".yml", ".yaml", ".json", ".js", ".css", ".html"].some((suffix) => lower.endsWith(suffix));
|
|
}
|
|
|
|
function isVideoSelection(item) {
|
|
if (!item || item.kind !== "file") {
|
|
return false;
|
|
}
|
|
const lower = (item.name || "").toLowerCase();
|
|
return lower.endsWith(".mp4") || lower.endsWith(".mkv");
|
|
}
|
|
|
|
function isPdfSelection(item) {
|
|
if (!item || item.kind !== "file") {
|
|
return false;
|
|
}
|
|
return (item.name || "").toLowerCase().endsWith(".pdf");
|
|
}
|
|
|
|
function currentParentPath(path) {
|
|
const normalized = (path || "").trim();
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
if (normalized === "/Volumes") {
|
|
return null;
|
|
}
|
|
if (normalized.startsWith("/")) {
|
|
const segments = normalized.split("/").filter(Boolean);
|
|
if (segments.length <= 1) {
|
|
return null;
|
|
}
|
|
if (segments.length === 2) {
|
|
return `/${segments[0]}`;
|
|
}
|
|
return `/${segments.slice(0, -1).join("/")}`;
|
|
}
|
|
if (!normalized.includes("/")) {
|
|
return null;
|
|
}
|
|
const segments = normalized.split("/");
|
|
if (segments.length === 2) {
|
|
return segments[0];
|
|
}
|
|
return segments.slice(0, -1).join("/");
|
|
}
|
|
|
|
function baseName(path) {
|
|
const index = path.lastIndexOf("/");
|
|
return index >= 0 ? path.slice(index + 1) : path;
|
|
}
|
|
|
|
function rootKeyFromPath(path) {
|
|
const normalized = (path || "").trim();
|
|
if (!normalized) {
|
|
return null;
|
|
}
|
|
if (normalized.startsWith("/")) {
|
|
const segments = normalized.split("/").filter(Boolean);
|
|
if (segments.length < 2) {
|
|
return normalized;
|
|
}
|
|
return `/${segments[0]}/${segments[1]}`;
|
|
}
|
|
return normalized.split("/")[0];
|
|
}
|
|
|
|
function isNestedPath(sourcePath, destinationPath) {
|
|
const source = (sourcePath || "").replace(/\/+$/, "");
|
|
const destination = (destinationPath || "").replace(/\/+$/, "");
|
|
if (!source || !destination) {
|
|
return false;
|
|
}
|
|
return destination.startsWith(`${source}/`);
|
|
}
|
|
|
|
function uniqueRootKeysForItems(items) {
|
|
return [...new Set(items.map((item) => rootKeyFromPath(item.path)).filter(Boolean))];
|
|
}
|
|
|
|
function renderBreadcrumbs(pane, path) {
|
|
const nav = document.getElementById(`${pane}-breadcrumbs`);
|
|
nav.innerHTML = "";
|
|
const normalized = (path || "").trim();
|
|
const isHostPath = normalized.startsWith("/");
|
|
const parts = normalized.split("/").filter(Boolean);
|
|
if (isHostPath) {
|
|
const rootCrumb = createButton("/", () => {
|
|
setActivePane(pane);
|
|
navigateTo(pane, "/Volumes");
|
|
});
|
|
rootCrumb.type = "button";
|
|
rootCrumb.onclick = (ev) => {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
setActivePane(pane);
|
|
navigateTo(pane, "/Volumes");
|
|
};
|
|
nav.append(rootCrumb);
|
|
if (parts.length > 0) {
|
|
const sep = document.createElement("span");
|
|
sep.textContent = "/";
|
|
nav.append(sep);
|
|
}
|
|
}
|
|
let aggregate = isHostPath ? "" : "";
|
|
for (let i = 0; i < parts.length; i += 1) {
|
|
aggregate = isHostPath ? `/${parts.slice(0, i + 1).join("/")}` : (i === 0 ? parts[i] : `${aggregate}/${parts[i]}`);
|
|
const crumbPath = aggregate;
|
|
const crumb = createButton(parts[i], () => {
|
|
setActivePane(pane);
|
|
navigateTo(pane, crumbPath);
|
|
});
|
|
crumb.type = "button";
|
|
crumb.onclick = (ev) => {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
setActivePane(pane);
|
|
navigateTo(pane, crumbPath);
|
|
};
|
|
nav.append(crumb);
|
|
if (i < parts.length - 1) {
|
|
const sep = document.createElement("span");
|
|
sep.textContent = "/";
|
|
nav.append(sep);
|
|
}
|
|
}
|
|
}
|
|
|
|
function formatModified(isoString) {
|
|
if (!isoString) {
|
|
return "-";
|
|
}
|
|
const d = new Date(isoString);
|
|
if (Number.isNaN(d.getTime())) {
|
|
return isoString;
|
|
}
|
|
const date = d.toLocaleDateString(undefined, { year: "2-digit", month: "2-digit", day: "2-digit" });
|
|
const time = d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
|
|
return `${date} ${time}`;
|
|
}
|
|
|
|
function createBrowseItem(pane, entry, kind) {
|
|
const li = document.createElement("li");
|
|
li.className = "selectable";
|
|
li.dataset.path = entry.path;
|
|
const paths = selectedPaths(pane);
|
|
const model = paneState(pane);
|
|
if (paths.includes(entry.path)) {
|
|
li.classList.add("is-selected");
|
|
}
|
|
if (model.currentRowIndex >= 0 && model.visibleItems[model.currentRowIndex]?.path === entry.path) {
|
|
li.classList.add("is-current-row");
|
|
}
|
|
|
|
li.onclick = () => {
|
|
setActivePane(pane);
|
|
setSingleSelection(pane, { path: entry.path, name: entry.name, kind });
|
|
loadBrowsePane(pane);
|
|
};
|
|
|
|
const name = document.createElement("span");
|
|
name.className = `entry-name ${kind === "directory" ? "entry-dir" : "entry-file"}`;
|
|
name.append(createMediaSlot({ ...entry, kind }));
|
|
|
|
if (kind === "directory") {
|
|
const open = document.createElement("button");
|
|
open.className = "dir-link";
|
|
open.textContent = `${entry.name}/`;
|
|
open.type = "button";
|
|
open.onclick = (ev) => {
|
|
ev.stopPropagation();
|
|
setActivePane(pane);
|
|
navigateTo(pane, entry.path);
|
|
};
|
|
open.classList.add("entry-label");
|
|
name.append(open);
|
|
} else {
|
|
const fileName = document.createElement("span");
|
|
fileName.textContent = entry.name;
|
|
fileName.className = "entry-label";
|
|
fileName.onclick = (ev) => {
|
|
ev.stopPropagation();
|
|
setActivePane(pane);
|
|
setSingleSelection(pane, { path: entry.path, name: entry.name, kind });
|
|
loadBrowsePane(pane);
|
|
};
|
|
name.append(fileName);
|
|
}
|
|
li.append(name);
|
|
|
|
const size = document.createElement("span");
|
|
size.className = "entry-size";
|
|
size.textContent = kind === "directory" ? "-" : String(entry.size);
|
|
li.append(size);
|
|
|
|
const modified = document.createElement("span");
|
|
modified.className = "entry-modified";
|
|
modified.textContent = formatModified(entry.modified);
|
|
li.append(modified);
|
|
return li;
|
|
}
|
|
|
|
function scrollCurrentRowIntoView(pane) {
|
|
const model = paneState(pane);
|
|
if (model.currentRowIndex < 0) {
|
|
return;
|
|
}
|
|
const list = document.getElementById(`${pane}-items`);
|
|
const row = list.querySelector(`li[data-row-index="${model.currentRowIndex}"]`);
|
|
if (row) {
|
|
row.scrollIntoView({ block: "nearest" });
|
|
}
|
|
}
|
|
|
|
function renderPaneItems(pane) {
|
|
const model = paneState(pane);
|
|
const items = document.getElementById(`${pane}-items`);
|
|
items.innerHTML = "";
|
|
|
|
if (!Array.isArray(model.visibleItems) || model.visibleItems.length === 0) {
|
|
model.currentRowIndex = -1;
|
|
updateActionButtons();
|
|
return;
|
|
}
|
|
if (model.currentRowIndex < 0 || model.currentRowIndex >= model.visibleItems.length) {
|
|
model.currentRowIndex = 0;
|
|
}
|
|
|
|
model.visibleItems.forEach((entry, index) => {
|
|
if (entry.isParent) {
|
|
const up = document.createElement("li");
|
|
up.className = "selectable";
|
|
up.dataset.rowIndex = String(index);
|
|
up.dataset.path = entry.path;
|
|
if (index === model.currentRowIndex) {
|
|
up.classList.add("is-current-row");
|
|
}
|
|
up.onclick = () => {
|
|
setActivePane(pane);
|
|
model.currentRowIndex = index;
|
|
clearSelectionAnchor(pane);
|
|
renderPaneItems(pane);
|
|
};
|
|
const upNameCell = document.createElement("span");
|
|
upNameCell.className = "entry-name entry-dir";
|
|
const upMedia = document.createElement("span");
|
|
upMedia.className = "entry-media-slot";
|
|
upNameCell.append(upMedia);
|
|
const upName = document.createElement("button");
|
|
upName.type = "button";
|
|
upName.className = "dir-link";
|
|
upName.textContent = "../";
|
|
upName.onclick = (ev) => {
|
|
ev.stopPropagation();
|
|
setActivePane(pane);
|
|
navigateTo(pane, entry.path);
|
|
};
|
|
upName.classList.add("entry-label");
|
|
upNameCell.append(upName);
|
|
up.append(upNameCell);
|
|
const upSize = document.createElement("span");
|
|
upSize.className = "entry-size";
|
|
upSize.textContent = "-";
|
|
up.append(upSize);
|
|
const upModified = document.createElement("span");
|
|
upModified.className = "entry-modified";
|
|
upModified.textContent = "-";
|
|
up.append(upModified);
|
|
items.append(up);
|
|
return;
|
|
}
|
|
|
|
const row = createBrowseItem(pane, entry, entry.kind);
|
|
row.dataset.rowIndex = String(index);
|
|
if (index === model.currentRowIndex) {
|
|
row.classList.add("is-current-row");
|
|
}
|
|
row.onclick = () => {
|
|
setActivePane(pane);
|
|
model.currentRowIndex = index;
|
|
setSingleSelectionAtIndex(pane, { path: entry.path, name: entry.name, kind: entry.kind }, index);
|
|
renderPaneItems(pane);
|
|
};
|
|
const dirLink = row.querySelector(".dir-link");
|
|
if (dirLink) {
|
|
dirLink.onclick = (ev) => {
|
|
ev.stopPropagation();
|
|
setActivePane(pane);
|
|
navigateTo(pane, entry.path);
|
|
};
|
|
}
|
|
const fileName = row.querySelector(".entry-file span");
|
|
if (fileName) {
|
|
fileName.onclick = (ev) => {
|
|
ev.stopPropagation();
|
|
setActivePane(pane);
|
|
model.currentRowIndex = index;
|
|
if (isToggleModifierClick(ev)) {
|
|
toggleSelectionAtIndex(pane, { path: entry.path, name: entry.name, kind: entry.kind }, index);
|
|
} else {
|
|
setSingleSelectionAtIndex(pane, { path: entry.path, name: entry.name, kind: entry.kind }, index);
|
|
}
|
|
renderPaneItems(pane);
|
|
};
|
|
}
|
|
row.onclick = (ev) => {
|
|
setActivePane(pane);
|
|
model.currentRowIndex = index;
|
|
if (isToggleModifierClick(ev)) {
|
|
toggleSelectionAtIndex(pane, { path: entry.path, name: entry.name, kind: entry.kind }, index);
|
|
} else {
|
|
setSingleSelectionAtIndex(pane, { path: entry.path, name: entry.name, kind: entry.kind }, index);
|
|
}
|
|
renderPaneItems(pane);
|
|
};
|
|
if (entry.kind === "file" && isVideoSelection({ path: entry.path, name: entry.name, kind: entry.kind })) {
|
|
row.ondblclick = (ev) => {
|
|
ev.stopPropagation();
|
|
setActivePane(pane);
|
|
model.currentRowIndex = index;
|
|
setSingleSelectionAtIndex(pane, { path: entry.path, name: entry.name, kind: entry.kind }, index);
|
|
renderPaneItems(pane);
|
|
openVideoViewer();
|
|
};
|
|
} 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);
|
|
});
|
|
updateActionButtons();
|
|
}
|
|
|
|
async function loadBrowsePane(pane) {
|
|
setError(`${pane}-browse-error`, "");
|
|
try {
|
|
const model = paneState(pane);
|
|
const query = new URLSearchParams({
|
|
path: model.currentPath,
|
|
show_hidden: String(model.showHidden),
|
|
});
|
|
const data = await apiRequest("GET", `/api/browse?${query.toString()}`);
|
|
model.currentPath = data.path;
|
|
document.getElementById(`${pane}-current-path`).textContent = data.path;
|
|
renderBreadcrumbs(pane, data.path);
|
|
|
|
const visibleItems = [];
|
|
const parent = currentParentPath(data.path);
|
|
if (parent) {
|
|
visibleItems.push({ path: parent, name: "..", kind: "directory", isParent: true });
|
|
}
|
|
for (const entry of data.directories) {
|
|
visibleItems.push({ ...entry, kind: "directory" });
|
|
}
|
|
for (const entry of data.files) {
|
|
visibleItems.push({ ...entry, kind: "file" });
|
|
}
|
|
model.visibleItems = visibleItems;
|
|
|
|
const visiblePaths = new Set(visibleItems.filter((item) => !item.isParent).map((item) => item.path));
|
|
model.selectedItems = model.selectedItems.filter((item) => visiblePaths.has(item.path));
|
|
if (model.selectedItem && !visiblePaths.has(model.selectedItem.path)) {
|
|
model.selectedItem = model.selectedItems.length > 0 ? model.selectedItems[model.selectedItems.length - 1] : null;
|
|
}
|
|
|
|
if (model.pendingSelectionPath) {
|
|
const pendingIndex = visibleItems.findIndex((item) => !item.isParent && item.path === model.pendingSelectionPath);
|
|
if (pendingIndex >= 0) {
|
|
const pendingItem = visibleItems[pendingIndex];
|
|
model.currentRowIndex = pendingIndex;
|
|
setSingleSelectionAtIndex(pane, selectedEntryFromItem(pendingItem), pendingIndex);
|
|
}
|
|
model.pendingSelectionPath = null;
|
|
}
|
|
|
|
renderPaneItems(pane);
|
|
scrollCurrentRowIntoView(pane);
|
|
setStatus(`Loaded ${pane}: ${data.path}`);
|
|
} catch (err) {
|
|
setError(`${pane}-browse-error`, `Browse: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
function navigateTo(pane, path) {
|
|
const model = paneState(pane);
|
|
model.currentPath = path;
|
|
model.currentRowIndex = 0;
|
|
clearSelectionAnchor(pane);
|
|
setSelectedItem(pane, null);
|
|
loadBrowsePane(pane);
|
|
}
|
|
|
|
async function createFolderForPane(pane) {
|
|
setActivePane(pane);
|
|
const name = window.prompt("Folder name");
|
|
if (!name) {
|
|
return;
|
|
}
|
|
setError(`${pane}-browse-error`, "");
|
|
try {
|
|
await apiRequest("POST", "/api/files/mkdir", {
|
|
parent_path: paneState(pane).currentPath,
|
|
name,
|
|
});
|
|
await loadBrowsePane(pane);
|
|
} catch (err) {
|
|
setError(`${pane}-browse-error`, `Create folder: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
async function createFolderForActivePane() {
|
|
await createFolderForPane(state.activePane);
|
|
}
|
|
|
|
async function renameSelected() {
|
|
const pane = state.activePane;
|
|
const selectedItems = paneState(pane).selectedItems;
|
|
if (selectedItems.length !== 1) {
|
|
return;
|
|
}
|
|
const selected = selectedItems[0];
|
|
const newName = window.prompt("New name", selected.name);
|
|
if (!newName) {
|
|
return;
|
|
}
|
|
setError("actions-error", "");
|
|
try {
|
|
await apiRequest("POST", "/api/files/rename", {
|
|
path: selected.path,
|
|
new_name: newName,
|
|
});
|
|
setSelectedItem(pane, null);
|
|
await loadBrowsePane(pane);
|
|
} catch (err) {
|
|
setActionError("Rename", err);
|
|
}
|
|
}
|
|
|
|
async function deleteSelected() {
|
|
const pane = state.activePane;
|
|
const selectedItems = [...paneState(pane).selectedItems];
|
|
if (selectedItems.length === 0) {
|
|
return;
|
|
}
|
|
if (!window.confirm(`Delete ${selectedItems.length} selected item(s)?`)) {
|
|
return;
|
|
}
|
|
setError("actions-error", "");
|
|
let successes = 0;
|
|
let failures = 0;
|
|
let firstError = null;
|
|
for (const item of selectedItems) {
|
|
try {
|
|
await apiRequest("POST", "/api/files/delete", { path: item.path });
|
|
successes += 1;
|
|
} catch (err) {
|
|
failures += 1;
|
|
if (!firstError) {
|
|
firstError = `${item.path}: ${err.message}`;
|
|
}
|
|
}
|
|
}
|
|
setSelectedItem(pane, null);
|
|
await loadBrowsePane(pane);
|
|
showActionSummary("Delete", successes, failures, firstError);
|
|
}
|
|
|
|
function defaultDestination(sourcePath, targetBasePath) {
|
|
const sourceName = baseName(sourcePath);
|
|
return `${targetBasePath}/${sourceName}`;
|
|
}
|
|
|
|
async function startCopySelected() {
|
|
const sourcePane = state.activePane;
|
|
const destinationPane = otherPane(sourcePane);
|
|
const selectedItems = [...paneState(sourcePane).selectedItems];
|
|
if (selectedItems.length === 0) {
|
|
return;
|
|
}
|
|
const baseDestination = paneState(destinationPane).currentPath;
|
|
setError("actions-error", "");
|
|
let successes = 0;
|
|
let failures = 0;
|
|
let firstError = null;
|
|
for (const item of selectedItems) {
|
|
const destination = defaultDestination(item.path, baseDestination);
|
|
try {
|
|
if (item.kind !== "file") {
|
|
throw new Error("Only files are supported for copy");
|
|
}
|
|
const result = await apiRequest("POST", "/api/files/copy", {
|
|
source: item.path,
|
|
destination,
|
|
});
|
|
state.selectedTaskId = result.task_id;
|
|
await refreshTasksSnapshot();
|
|
successes += 1;
|
|
} catch (err) {
|
|
failures += 1;
|
|
if (!firstError) {
|
|
firstError = `${item.path}: ${err.message}`;
|
|
}
|
|
}
|
|
}
|
|
await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]);
|
|
showActionSummary("Copy", successes, failures, firstError);
|
|
}
|
|
|
|
async function startMoveSelected() {
|
|
await executeMoveSelection(paneState(otherPane(state.activePane)).currentPath);
|
|
}
|
|
|
|
async function executeMoveSelection(baseDestination) {
|
|
const sourcePane = state.activePane;
|
|
const selectedItems = [...paneState(sourcePane).selectedItems];
|
|
if (selectedItems.length === 0) {
|
|
return;
|
|
}
|
|
const allFiles = selectedItems.every((item) => item.kind === "file");
|
|
setError("actions-error", "");
|
|
|
|
if (!allFiles) {
|
|
const result = await apiRequest("POST", "/api/files/move", {
|
|
sources: selectedItems.map((item) => item.path),
|
|
destination_base: baseDestination,
|
|
});
|
|
state.selectedTaskId = result.task_id;
|
|
await refreshTasksSnapshot();
|
|
setSelectedItem(sourcePane, null);
|
|
await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]);
|
|
setStatus("Move: batch started");
|
|
return;
|
|
}
|
|
|
|
let successes = 0;
|
|
let failures = 0;
|
|
let firstError = null;
|
|
for (const item of selectedItems) {
|
|
const destination = defaultDestination(item.path, baseDestination);
|
|
try {
|
|
if (item.kind !== "file") {
|
|
throw new Error("Only files are supported for move");
|
|
}
|
|
const result = await apiRequest("POST", "/api/files/move", {
|
|
source: item.path,
|
|
destination,
|
|
});
|
|
state.selectedTaskId = result.task_id;
|
|
await refreshTasksSnapshot();
|
|
successes += 1;
|
|
} catch (err) {
|
|
failures += 1;
|
|
if (!firstError) {
|
|
firstError = `${item.path}: ${err.message}`;
|
|
}
|
|
}
|
|
}
|
|
setSelectedItem(sourcePane, null);
|
|
await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]);
|
|
showActionSummary("Move", successes, failures, firstError);
|
|
}
|
|
|
|
async function addBookmark() {
|
|
const pane = state.activePane;
|
|
const path = paneState(pane).currentPath;
|
|
const label = window.prompt("Bookmark label", path);
|
|
if (!label) {
|
|
return;
|
|
}
|
|
setError("actions-error", "");
|
|
try {
|
|
await apiRequest("POST", "/api/bookmarks", { path, label });
|
|
setStatus(`Bookmark added for ${path}`);
|
|
} catch (err) {
|
|
setActionError("Add bookmark", err);
|
|
}
|
|
}
|
|
|
|
function shouldHandleShortcut(target) {
|
|
if (!target || !(target instanceof Element)) {
|
|
return true;
|
|
}
|
|
if (target.closest("[contenteditable='true']")) {
|
|
return false;
|
|
}
|
|
const control = target.closest("input, textarea, select, button");
|
|
if (!control) {
|
|
return true;
|
|
}
|
|
if (control.tagName === "INPUT" && control.type === "checkbox") {
|
|
return false;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function actionButton(id) {
|
|
return document.getElementById(id);
|
|
}
|
|
|
|
function triggerActionButton(id) {
|
|
const button = actionButton(id);
|
|
if (!button || button.disabled) {
|
|
return false;
|
|
}
|
|
button.click();
|
|
return true;
|
|
}
|
|
|
|
function actionShortcutHandled(event) {
|
|
const altOnly = event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey;
|
|
const noModifiers = !event.altKey && !event.ctrlKey && !event.metaKey && !event.shiftKey;
|
|
|
|
if (noModifiers) {
|
|
if (event.key === "F1") {
|
|
return triggerActionButton("settings-btn");
|
|
}
|
|
if (event.key === "F2") {
|
|
return triggerActionButton("rename-btn");
|
|
}
|
|
if (event.key === "F3") {
|
|
return triggerActionButton("view-btn");
|
|
}
|
|
if (event.key === "F4") {
|
|
return triggerActionButton("edit-btn");
|
|
}
|
|
if (event.key === "F5") {
|
|
return triggerActionButton("copy-btn");
|
|
}
|
|
if (event.key === "F6") {
|
|
return openF6Flow();
|
|
}
|
|
if (event.key === "F7") {
|
|
return triggerActionButton("mkdir-btn");
|
|
}
|
|
if (event.key === "F8") {
|
|
return triggerActionButton("delete-btn");
|
|
}
|
|
}
|
|
|
|
if (altOnly) {
|
|
const key = event.key.toLowerCase();
|
|
if (key === "3") {
|
|
return triggerActionButton("view-btn");
|
|
}
|
|
if (key === "4") {
|
|
return triggerActionButton("edit-btn");
|
|
}
|
|
if (key === "5") {
|
|
return triggerActionButton("copy-btn");
|
|
}
|
|
if (key === "6") {
|
|
return openF6Flow();
|
|
}
|
|
if (key === "7") {
|
|
return triggerActionButton("mkdir-btn");
|
|
}
|
|
if (key === "8") {
|
|
return triggerActionButton("delete-btn");
|
|
}
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
function wildcardPopupElements() {
|
|
return {
|
|
overlay: document.getElementById("wildcard-popup"),
|
|
title: document.getElementById("wildcard-popup-title"),
|
|
meta: document.getElementById("wildcard-popup-meta"),
|
|
input: document.getElementById("wildcard-pattern-input"),
|
|
error: document.getElementById("wildcard-popup-error"),
|
|
applyButton: document.getElementById("wildcard-apply-btn"),
|
|
cancelButton: document.getElementById("wildcard-cancel-btn"),
|
|
};
|
|
}
|
|
|
|
function isSettingsOpen() {
|
|
return !settingsElements().overlay.classList.contains("hidden");
|
|
}
|
|
|
|
function isWildcardPopupOpen() {
|
|
return !wildcardPopupElements().overlay.classList.contains("hidden");
|
|
}
|
|
|
|
function isMovePopupOpen() {
|
|
return !moveElements().overlay.classList.contains("hidden");
|
|
}
|
|
|
|
function isRenamePopupOpen() {
|
|
return !renameElements().overlay.classList.contains("hidden");
|
|
}
|
|
|
|
function isBatchMovePopupOpen() {
|
|
return !batchMoveElements().overlay.classList.contains("hidden");
|
|
}
|
|
|
|
function isViewerOpen() {
|
|
return !viewerElements().overlay.classList.contains("hidden");
|
|
}
|
|
|
|
function isVideoOpen() {
|
|
return !videoElements().overlay.classList.contains("hidden");
|
|
}
|
|
|
|
function isEditorOpen() {
|
|
return !editorElements().overlay.classList.contains("hidden");
|
|
}
|
|
|
|
function escapeRegExp(text) {
|
|
return text.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
|
|
}
|
|
|
|
function globToRegExp(pattern) {
|
|
const escaped = pattern
|
|
.split("*")
|
|
.map((part) => escapeRegExp(part))
|
|
.join(".*");
|
|
return new RegExp(`^${escaped}$`, "i");
|
|
}
|
|
|
|
function applyWildcardSelection(mode, pattern) {
|
|
const pane = state.activePane;
|
|
const model = paneState(pane);
|
|
const matcher = globToRegExp(pattern);
|
|
const candidates = model.visibleItems.filter((entry) => !entry.isParent);
|
|
const matches = candidates.filter((entry) => matcher.test(entry.name));
|
|
const matchPaths = new Set(matches.map((entry) => entry.path));
|
|
|
|
let changed = 0;
|
|
if (mode === "select") {
|
|
const existing = new Set(model.selectedItems.map((item) => item.path));
|
|
for (const entry of matches) {
|
|
if (existing.has(entry.path)) {
|
|
continue;
|
|
}
|
|
model.selectedItems.push({ path: entry.path, name: entry.name, kind: entry.kind });
|
|
changed += 1;
|
|
}
|
|
if (matches.length > 0) {
|
|
const last = matches[matches.length - 1];
|
|
model.selectedItem = { path: last.path, name: last.name, kind: last.kind };
|
|
}
|
|
} else {
|
|
const before = model.selectedItems.length;
|
|
model.selectedItems = model.selectedItems.filter((item) => !matchPaths.has(item.path));
|
|
changed = before - model.selectedItems.length;
|
|
if (!model.selectedItem || matchPaths.has(model.selectedItem.path)) {
|
|
model.selectedItem = model.selectedItems.length > 0 ? model.selectedItems[model.selectedItems.length - 1] : null;
|
|
}
|
|
}
|
|
|
|
renderPaneItems(pane);
|
|
setStatus(`Wildcard ${mode}: ${matches.length} matched, ${changed} changed`);
|
|
}
|
|
|
|
function closeWildcardPopup() {
|
|
const elements = wildcardPopupElements();
|
|
elements.overlay.classList.add("hidden");
|
|
elements.error.textContent = "";
|
|
elements.input.value = "";
|
|
}
|
|
|
|
function showDirectoryMoveNotSupported() {
|
|
const message = "Directory move is not supported in v1";
|
|
setError("actions-error", message);
|
|
setStatus(message);
|
|
}
|
|
|
|
function showBatchDirectoryMoveNotSupported() {
|
|
const message = "Batch directory move is not supported in v1";
|
|
setError("actions-error", message);
|
|
setStatus(message);
|
|
}
|
|
|
|
function resetMoveState() {
|
|
moveState = {
|
|
source: null,
|
|
destination: "",
|
|
};
|
|
}
|
|
|
|
function resetRenameState() {
|
|
renameState = {
|
|
source: null,
|
|
name: "",
|
|
};
|
|
}
|
|
|
|
function closeRenamePopup() {
|
|
const elements = renameElements();
|
|
elements.overlay.classList.add("hidden");
|
|
elements.error.textContent = "";
|
|
elements.input.value = "";
|
|
resetRenameState();
|
|
}
|
|
|
|
function openRenamePopup() {
|
|
const selectedItems = activePaneState().selectedItems;
|
|
if (selectedItems.length !== 1) {
|
|
return false;
|
|
}
|
|
const source = selectedItems[0];
|
|
const elements = renameElements();
|
|
renameState.source = source;
|
|
renameState.name = source.name;
|
|
elements.input.value = source.name;
|
|
elements.error.textContent = "";
|
|
elements.overlay.classList.remove("hidden");
|
|
elements.input.focus();
|
|
elements.input.select();
|
|
return true;
|
|
}
|
|
|
|
function resetBatchMoveState() {
|
|
batchMoveState = {
|
|
destinationBase: "",
|
|
count: 0,
|
|
};
|
|
}
|
|
|
|
function closeMovePopup() {
|
|
const elements = moveElements();
|
|
elements.overlay.classList.add("hidden");
|
|
elements.error.textContent = "";
|
|
elements.input.value = "";
|
|
resetMoveState();
|
|
}
|
|
|
|
function closeBatchMovePopup() {
|
|
const elements = batchMoveElements();
|
|
elements.overlay.classList.add("hidden");
|
|
elements.error.textContent = "";
|
|
resetBatchMoveState();
|
|
}
|
|
|
|
function openMovePopup() {
|
|
const selectedItems = activePaneState().selectedItems;
|
|
if (selectedItems.length !== 1) {
|
|
return false;
|
|
}
|
|
const source = selectedItems[0];
|
|
const destination = defaultDestination(source.path, paneState(otherPane(state.activePane)).currentPath);
|
|
const elements = moveElements();
|
|
moveState.source = source;
|
|
moveState.destination = destination;
|
|
elements.source.textContent = `Source: ${source.path}`;
|
|
elements.input.value = destination;
|
|
elements.error.textContent = "";
|
|
elements.overlay.classList.remove("hidden");
|
|
elements.input.focus();
|
|
elements.input.select();
|
|
return true;
|
|
}
|
|
|
|
function openBatchMovePopup(selectedItems) {
|
|
if (selectedItems.length === 0) {
|
|
return false;
|
|
}
|
|
const destinationBase = paneState(otherPane(state.activePane)).currentPath;
|
|
const elements = batchMoveElements();
|
|
batchMoveState.destinationBase = destinationBase;
|
|
batchMoveState.count = selectedItems.length;
|
|
elements.count.textContent = `${selectedItems.length} selected item(s)`;
|
|
elements.destination.textContent = `Destination: ${destinationBase}`;
|
|
elements.error.textContent = "";
|
|
elements.overlay.classList.remove("hidden");
|
|
elements.applyButton.focus();
|
|
return true;
|
|
}
|
|
|
|
function openF6Flow() {
|
|
const selectedItems = activePaneState().selectedItems;
|
|
if (selectedItems.length === 0) {
|
|
return false;
|
|
}
|
|
if (selectedItems.length === 1) {
|
|
return openMovePopup();
|
|
}
|
|
if (selectedItems.some((item) => item.kind !== "file")) {
|
|
const roots = uniqueRootKeysForItems(selectedItems);
|
|
if (roots.length > 1) {
|
|
const message = "Batch move requires all selected items to be in the same root";
|
|
setError("actions-error", message);
|
|
setStatus(message);
|
|
return true;
|
|
}
|
|
return openBatchMovePopup(selectedItems);
|
|
}
|
|
return openBatchMovePopup(selectedItems);
|
|
}
|
|
|
|
async function submitRenamePopup() {
|
|
const elements = renameElements();
|
|
const source = renameState.source;
|
|
if (!source) {
|
|
return;
|
|
}
|
|
const newName = elements.input.value.trim();
|
|
elements.error.textContent = "";
|
|
if (!newName) {
|
|
elements.error.textContent = "Name is required";
|
|
return;
|
|
}
|
|
if (newName === source.name) {
|
|
elements.error.textContent = "Name must differ from current name";
|
|
return;
|
|
}
|
|
if (newName.includes("/")) {
|
|
elements.error.textContent = "Name cannot contain /";
|
|
return;
|
|
}
|
|
if (newName === "." || newName === "..") {
|
|
elements.error.textContent = "Invalid name";
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await apiRequest("POST", "/api/files/rename", {
|
|
path: source.path,
|
|
new_name: newName,
|
|
});
|
|
closeRenamePopup();
|
|
setSelectedItem(state.activePane, null);
|
|
await loadBrowsePane(state.activePane);
|
|
setStatus(`Renamed ${source.path}`);
|
|
} catch (err) {
|
|
elements.error.textContent = err.message;
|
|
}
|
|
}
|
|
|
|
async function submitMovePopup() {
|
|
const elements = moveElements();
|
|
const source = moveState.source;
|
|
if (!source) {
|
|
return;
|
|
}
|
|
const destination = elements.input.value.trim();
|
|
const sourceParent = currentParentPath(source.path);
|
|
const destinationParent = currentParentPath(destination);
|
|
|
|
elements.error.textContent = "";
|
|
if (!destination) {
|
|
elements.error.textContent = "Target path is required";
|
|
return;
|
|
}
|
|
if (destination === source.path) {
|
|
elements.error.textContent = "Destination must differ from source";
|
|
return;
|
|
}
|
|
if (source.kind === "directory") {
|
|
if (isNestedPath(source.path, destination)) {
|
|
elements.error.textContent = "Destination cannot be inside source";
|
|
return;
|
|
}
|
|
if (destinationParent !== sourceParent) {
|
|
if (rootKeyFromPath(destination) !== rootKeyFromPath(source.path)) {
|
|
elements.error.textContent = "Cross-root directory move is not supported in v1";
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
try {
|
|
const result = await apiRequest("POST", "/api/files/move", {
|
|
source: source.path,
|
|
destination,
|
|
});
|
|
state.selectedTaskId = result.task_id;
|
|
await refreshTasksSnapshot();
|
|
closeMovePopup();
|
|
setSelectedItem(state.activePane, null);
|
|
await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]);
|
|
setStatus(`Move: 1 success, 0 failed`);
|
|
} catch (err) {
|
|
elements.error.textContent = err.message;
|
|
}
|
|
}
|
|
|
|
async function submitBatchMovePopup() {
|
|
const elements = batchMoveElements();
|
|
elements.error.textContent = "";
|
|
try {
|
|
await executeMoveSelection(batchMoveState.destinationBase);
|
|
closeBatchMovePopup();
|
|
} catch (err) {
|
|
elements.error.textContent = err.message;
|
|
}
|
|
}
|
|
|
|
function submitWildcardPopup() {
|
|
const elements = wildcardPopupElements();
|
|
const pattern = elements.input.value.trim();
|
|
if (!pattern) {
|
|
elements.error.textContent = "Pattern is required";
|
|
return;
|
|
}
|
|
try {
|
|
applyWildcardSelection(wildcardDialogMode, pattern);
|
|
closeWildcardPopup();
|
|
} catch (err) {
|
|
elements.error.textContent = `Wildcard: ${err.message}`;
|
|
}
|
|
}
|
|
|
|
function openWildcardPopup(mode) {
|
|
wildcardDialogMode = mode;
|
|
const pane = state.activePane;
|
|
const elements = wildcardPopupElements();
|
|
elements.title.textContent = mode === "select" ? "Wildcard Select" : "Wildcard Deselect";
|
|
elements.meta.textContent = `Active pane: ${pane} (visible items only, case-insensitive)`;
|
|
elements.applyButton.textContent = mode === "select" ? "Select" : "Deselect";
|
|
elements.error.textContent = "";
|
|
elements.input.value = "";
|
|
elements.overlay.classList.remove("hidden");
|
|
elements.input.focus();
|
|
}
|
|
|
|
function closeViewer() {
|
|
const viewer = viewerElements();
|
|
viewer.overlay.classList.add("hidden");
|
|
viewer.error.textContent = "";
|
|
viewer.content.textContent = "";
|
|
}
|
|
|
|
function closeVideoViewer() {
|
|
const video = videoElements();
|
|
video.overlay.classList.add("hidden");
|
|
video.error.textContent = "";
|
|
video.player.pause();
|
|
video.player.removeAttribute("src");
|
|
video.player.load();
|
|
}
|
|
|
|
function 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 isInfoOpen() {
|
|
return !infoElements().overlay.classList.contains("hidden");
|
|
}
|
|
|
|
function closeInfo() {
|
|
const elements = infoElements();
|
|
elements.overlay.classList.add("hidden");
|
|
elements.error.textContent = "";
|
|
elements.grid.innerHTML = "";
|
|
}
|
|
|
|
function renderInfoField(label, value) {
|
|
const grid = infoElements().grid;
|
|
const labelNode = document.createElement("div");
|
|
labelNode.className = "info-label";
|
|
labelNode.textContent = label;
|
|
const valueNode = document.createElement("div");
|
|
valueNode.className = "info-value";
|
|
valueNode.textContent = value == null || value === "" ? "-" : String(value);
|
|
grid.append(labelNode, valueNode);
|
|
}
|
|
|
|
async function openInfo() {
|
|
const selectedItems = activePaneState().selectedItems;
|
|
if (selectedItems.length !== 1) {
|
|
return;
|
|
}
|
|
const selected = selectedItems[0];
|
|
const elements = infoElements();
|
|
elements.overlay.classList.remove("hidden");
|
|
elements.error.textContent = "";
|
|
elements.grid.innerHTML = "";
|
|
try {
|
|
const data = await apiRequest("GET", `/api/files/info?${new URLSearchParams({ path: selected.path }).toString()}`);
|
|
renderInfoField("Name", data.name);
|
|
renderInfoField("Path", data.path);
|
|
renderInfoField("Type", data.type);
|
|
renderInfoField("Size", data.size);
|
|
renderInfoField("Modified", formatModified(data.modified));
|
|
renderInfoField("Root", data.root);
|
|
renderInfoField("Extension", data.extension);
|
|
renderInfoField("Content type", data.content_type);
|
|
renderInfoField("Owner", data.owner);
|
|
renderInfoField("Group", data.group);
|
|
} catch (err) {
|
|
elements.error.textContent = err.message;
|
|
}
|
|
}
|
|
|
|
function isSearchOpen() {
|
|
return !searchElements().overlay.classList.contains("hidden");
|
|
}
|
|
|
|
function closeSearch() {
|
|
const elements = searchElements();
|
|
elements.overlay.classList.add("hidden");
|
|
elements.error.textContent = "";
|
|
}
|
|
|
|
function renderSearchResults(items) {
|
|
const elements = searchElements();
|
|
elements.results.innerHTML = "";
|
|
if (!Array.isArray(items) || items.length === 0) {
|
|
const empty = document.createElement("div");
|
|
empty.className = "popup-meta";
|
|
empty.textContent = "No matches found.";
|
|
elements.results.append(empty);
|
|
return;
|
|
}
|
|
|
|
for (const item of items) {
|
|
const row = document.createElement("button");
|
|
row.type = "button";
|
|
row.className = "search-result";
|
|
row.onclick = () => activateSearchResult(item);
|
|
|
|
const name = document.createElement("div");
|
|
name.className = "search-result-name";
|
|
name.textContent = item.name;
|
|
|
|
const path = document.createElement("div");
|
|
path.className = "search-result-path";
|
|
path.textContent = item.parent_path;
|
|
|
|
const meta = document.createElement("div");
|
|
meta.className = "search-result-meta";
|
|
meta.textContent = `${item.type} · ${item.root}`;
|
|
|
|
row.append(name, path, meta);
|
|
elements.results.append(row);
|
|
}
|
|
}
|
|
|
|
function activateSearchResult(item) {
|
|
const pane = state.activePane;
|
|
closeSearch();
|
|
if (item.type === "directory") {
|
|
navigateTo(pane, item.path);
|
|
return;
|
|
}
|
|
paneState(pane).pendingSelectionPath = item.path;
|
|
navigateTo(pane, item.parent_path);
|
|
}
|
|
|
|
function openSearch() {
|
|
const pane = state.activePane;
|
|
const elements = searchElements();
|
|
searchState.pane = pane;
|
|
searchState.path = paneState(pane).currentPath;
|
|
searchState.query = "";
|
|
elements.context.textContent = `Searching under: ${searchState.path}`;
|
|
elements.input.value = "";
|
|
elements.error.textContent = "";
|
|
elements.results.innerHTML = "";
|
|
elements.overlay.classList.remove("hidden");
|
|
elements.input.focus();
|
|
elements.input.select();
|
|
}
|
|
|
|
async function submitSearch() {
|
|
const elements = searchElements();
|
|
const query = elements.input.value.trim();
|
|
searchState.query = query;
|
|
elements.error.textContent = "";
|
|
elements.results.innerHTML = "";
|
|
try {
|
|
const data = await apiRequest("GET", `/api/search?${new URLSearchParams({
|
|
path: searchState.path,
|
|
query,
|
|
}).toString()}`);
|
|
renderSearchResults(data.items);
|
|
if (data.truncated) {
|
|
elements.error.textContent = "Result limit reached. Showing first matches.";
|
|
}
|
|
} catch (err) {
|
|
elements.error.textContent = err.message;
|
|
}
|
|
}
|
|
|
|
function setSettingsTab(tab) {
|
|
const elements = settingsElements();
|
|
settingsState.activeTab = tab === "logs" ? "logs" : "general";
|
|
const isGeneral = settingsState.activeTab === "general";
|
|
elements.generalTab.classList.toggle("is-active", isGeneral);
|
|
elements.generalTab.setAttribute("aria-selected", isGeneral ? "true" : "false");
|
|
elements.logsTab.classList.toggle("is-active", !isGeneral);
|
|
elements.logsTab.setAttribute("aria-selected", !isGeneral ? "true" : "false");
|
|
elements.generalPanel.classList.toggle("hidden", !isGeneral);
|
|
elements.logsPanel.classList.toggle("hidden", isGeneral);
|
|
}
|
|
|
|
function formatHistoryLine(item) {
|
|
const timestamp = item.finished_at || item.created_at || "";
|
|
const when = formatModified(timestamp);
|
|
const primaryPath = item.path || [item.source, item.destination].filter(Boolean).join(" -> ");
|
|
return {
|
|
title: `${item.operation} · ${item.status}`,
|
|
path: primaryPath || "-",
|
|
meta: when,
|
|
error: item.status === "failed" ? (item.error_message || item.error_code || "") : "",
|
|
};
|
|
}
|
|
|
|
function renderHistoryItems(items) {
|
|
const elements = settingsElements();
|
|
elements.logsList.innerHTML = "";
|
|
if (!Array.isArray(items) || items.length === 0) {
|
|
const empty = document.createElement("div");
|
|
empty.className = "popup-meta";
|
|
empty.textContent = "No history entries yet.";
|
|
elements.logsList.append(empty);
|
|
return;
|
|
}
|
|
for (const item of items) {
|
|
const line = formatHistoryLine(item);
|
|
const row = document.createElement("div");
|
|
row.className = `settings-log-item status-${item.status}`;
|
|
const title = document.createElement("div");
|
|
title.className = "settings-log-title";
|
|
title.textContent = line.title;
|
|
const path = document.createElement("div");
|
|
path.className = "settings-log-path";
|
|
path.textContent = line.path;
|
|
const meta = document.createElement("div");
|
|
meta.className = "settings-log-meta";
|
|
meta.textContent = line.meta;
|
|
row.append(title, path, meta);
|
|
if (line.error) {
|
|
const error = document.createElement("div");
|
|
error.className = "settings-log-error";
|
|
error.textContent = line.error;
|
|
row.append(error);
|
|
}
|
|
elements.logsList.append(row);
|
|
}
|
|
}
|
|
|
|
async function loadHistoryForSettings() {
|
|
const elements = settingsElements();
|
|
elements.logsError.textContent = "";
|
|
elements.logsList.innerHTML = '<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;
|
|
}
|
|
}
|
|
|
|
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 : elements.generalTab).focus();
|
|
}
|
|
|
|
function editorIsDirty() {
|
|
return editorElements().content.value !== editorState.originalContent;
|
|
}
|
|
|
|
function resetEditorState() {
|
|
editorState = {
|
|
path: null,
|
|
originalContent: "",
|
|
modified: null,
|
|
};
|
|
}
|
|
|
|
function attemptCloseEditor() {
|
|
if (editorIsDirty() && !window.confirm("Discard unsaved changes?")) {
|
|
return;
|
|
}
|
|
closeEditor();
|
|
}
|
|
|
|
function closeEditor() {
|
|
const editor = editorElements();
|
|
editor.overlay.classList.add("hidden");
|
|
editor.error.textContent = "";
|
|
editor.content.value = "";
|
|
resetEditorState();
|
|
}
|
|
|
|
async function 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;
|
|
}
|
|
|
|
function videoPlaybackMessage(item) {
|
|
const lower = (item.name || "").toLowerCase();
|
|
if (lower.endsWith(".mkv")) {
|
|
return "MKV playback is best-effort and depends on browser codec support.";
|
|
}
|
|
return "";
|
|
}
|
|
|
|
async function openVideoViewer() {
|
|
const selectedItems = activePaneState().selectedItems;
|
|
if (selectedItems.length !== 1 || !isVideoSelection(selectedItems[0])) {
|
|
return;
|
|
}
|
|
const selected = selectedItems[0];
|
|
const video = videoElements();
|
|
const streamUrl = `/api/files/video?${new URLSearchParams({ path: selected.path }).toString()}`;
|
|
|
|
video.overlay.classList.remove("hidden");
|
|
video.title.textContent = "Video";
|
|
video.fileName.textContent = selected.name;
|
|
video.filePath.textContent = selected.path;
|
|
video.error.textContent = videoPlaybackMessage(selected);
|
|
video.player.pause();
|
|
video.player.src = streamUrl;
|
|
video.player.load();
|
|
}
|
|
|
|
async function openEditor() {
|
|
const selectedItems = activePaneState().selectedItems;
|
|
if (selectedItems.length !== 1 || !isEditableSelection(selectedItems[0])) {
|
|
return;
|
|
}
|
|
const selected = selectedItems[0];
|
|
const editor = editorElements();
|
|
editor.overlay.classList.remove("hidden");
|
|
editor.title.textContent = "Edit";
|
|
editor.fileName.textContent = selected.name;
|
|
editor.filePath.textContent = selected.path;
|
|
editor.error.textContent = "";
|
|
editor.content.value = "";
|
|
editor.content.disabled = true;
|
|
editor.saveButton.disabled = true;
|
|
try {
|
|
const data = await apiRequest("GET", `/api/files/view?${new URLSearchParams({ path: selected.path, for_edit: "true" }).toString()}`);
|
|
editor.fileName.textContent = data.name;
|
|
editor.filePath.textContent = data.path;
|
|
editor.content.value = data.content;
|
|
editor.content.disabled = false;
|
|
editor.saveButton.disabled = false;
|
|
editorState.path = data.path;
|
|
editorState.originalContent = data.content;
|
|
editorState.modified = data.modified;
|
|
editor.content.focus();
|
|
} catch (err) {
|
|
editor.error.textContent = err.message;
|
|
}
|
|
}
|
|
|
|
function openViewer() {
|
|
const selectedItems = activePaneState().selectedItems;
|
|
if (selectedItems.length !== 1 || selectedItems[0].kind !== "file") {
|
|
return;
|
|
}
|
|
const selected = selectedItems[0];
|
|
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: editor.content.value,
|
|
expected_modified: editorState.modified,
|
|
});
|
|
editorState.originalContent = editor.content.value;
|
|
editorState.modified = response.modified;
|
|
setStatus(`Saved ${response.path}`);
|
|
closeEditor();
|
|
await loadBrowsePane(state.activePane);
|
|
} catch (err) {
|
|
editor.error.textContent = err.message;
|
|
}
|
|
}
|
|
|
|
function moveCurrentRow(delta) {
|
|
const pane = state.activePane;
|
|
const model = paneState(pane);
|
|
if (!Array.isArray(model.visibleItems) || model.visibleItems.length === 0) {
|
|
model.currentRowIndex = -1;
|
|
return;
|
|
}
|
|
if (model.currentRowIndex < 0) {
|
|
model.currentRowIndex = 0;
|
|
} else {
|
|
const maxIndex = model.visibleItems.length - 1;
|
|
model.currentRowIndex = Math.max(0, Math.min(maxIndex, model.currentRowIndex + delta));
|
|
}
|
|
renderPaneItems(pane);
|
|
scrollCurrentRowIntoView(pane);
|
|
}
|
|
|
|
function extendSelectionByRow(delta) {
|
|
const pane = state.activePane;
|
|
const model = paneState(pane);
|
|
if (!Array.isArray(model.visibleItems) || model.visibleItems.length === 0) {
|
|
model.currentRowIndex = -1;
|
|
return;
|
|
}
|
|
const originalIndex = model.currentRowIndex < 0 ? 0 : model.currentRowIndex;
|
|
if (model.currentRowIndex < 0) {
|
|
model.currentRowIndex = 0;
|
|
}
|
|
if (!Number.isInteger(model.selectionAnchorIndex)) {
|
|
model.selectionAnchorIndex = originalIndex;
|
|
}
|
|
const maxIndex = model.visibleItems.length - 1;
|
|
model.currentRowIndex = Math.max(0, Math.min(maxIndex, model.currentRowIndex + delta));
|
|
setRangeSelection(pane, model.selectionAnchorIndex, model.currentRowIndex);
|
|
renderPaneItems(pane);
|
|
scrollCurrentRowIntoView(pane);
|
|
}
|
|
|
|
function jumpCurrentRow(edge) {
|
|
const pane = state.activePane;
|
|
const model = paneState(pane);
|
|
if (!Array.isArray(model.visibleItems) || model.visibleItems.length === 0) {
|
|
model.currentRowIndex = -1;
|
|
return;
|
|
}
|
|
model.currentRowIndex = edge === "start" ? 0 : model.visibleItems.length - 1;
|
|
renderPaneItems(pane);
|
|
scrollCurrentRowIntoView(pane);
|
|
}
|
|
|
|
function openCurrentDirectory() {
|
|
const pane = state.activePane;
|
|
const item = currentRowItem(pane);
|
|
if (!item) {
|
|
return;
|
|
}
|
|
if (item.kind === "directory") {
|
|
navigateTo(pane, item.path);
|
|
return;
|
|
}
|
|
if (isVideoSelection(item)) {
|
|
openVideoViewer();
|
|
}
|
|
}
|
|
|
|
function toggleCurrentSelection() {
|
|
const pane = state.activePane;
|
|
const item = currentRowItem(pane);
|
|
if (!item || item.isParent) {
|
|
return;
|
|
}
|
|
setSelectionAnchor(pane, paneState(pane).currentRowIndex);
|
|
toggleSelection(pane, { path: item.path, name: item.name, kind: item.kind });
|
|
renderPaneItems(pane);
|
|
}
|
|
|
|
function clearSelectionForActivePane() {
|
|
const pane = state.activePane;
|
|
clearSelectionAnchor(pane);
|
|
setSelectedItem(pane, null);
|
|
renderPaneItems(pane);
|
|
}
|
|
|
|
function handleKeyboardShortcuts(event) {
|
|
if (isInfoOpen()) {
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
closeInfo();
|
|
}
|
|
return;
|
|
}
|
|
if (isSearchOpen()) {
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
closeSearch();
|
|
return;
|
|
}
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
submitSearch();
|
|
return;
|
|
}
|
|
return;
|
|
}
|
|
if (isRenamePopupOpen()) {
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
closeRenamePopup();
|
|
return;
|
|
}
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
submitRenamePopup();
|
|
return;
|
|
}
|
|
return;
|
|
}
|
|
if (isSettingsOpen()) {
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
closeSettings();
|
|
return;
|
|
}
|
|
return;
|
|
}
|
|
if (isBatchMovePopupOpen()) {
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
closeBatchMovePopup();
|
|
return;
|
|
}
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
submitBatchMovePopup();
|
|
return;
|
|
}
|
|
return;
|
|
}
|
|
if (isMovePopupOpen()) {
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
closeMovePopup();
|
|
return;
|
|
}
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
submitMovePopup();
|
|
}
|
|
return;
|
|
}
|
|
if (isEditorOpen()) {
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
attemptCloseEditor();
|
|
}
|
|
return;
|
|
}
|
|
if (isVideoOpen()) {
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
closeVideoViewer();
|
|
}
|
|
return;
|
|
}
|
|
if (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 === "ArrowUp") {
|
|
if (event.shiftKey) {
|
|
event.preventDefault();
|
|
extendSelectionByRow(-1);
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
moveCurrentRow(-1);
|
|
return;
|
|
}
|
|
if (event.key === "ArrowDown") {
|
|
if (event.shiftKey) {
|
|
event.preventDefault();
|
|
extendSelectionByRow(1);
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
moveCurrentRow(1);
|
|
return;
|
|
}
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
openCurrentDirectory();
|
|
return;
|
|
}
|
|
if (event.key === " " || event.code === "Space") {
|
|
event.preventDefault();
|
|
toggleCurrentSelection();
|
|
return;
|
|
}
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
clearSelectionForActivePane();
|
|
}
|
|
}
|
|
|
|
function setupPaneEvents(pane) {
|
|
document.getElementById(`${pane}-pane`).onclick = () => setActivePane(pane);
|
|
document.getElementById(`${pane}-hidden-toggle`).onchange = (ev) => {
|
|
setActivePane(pane);
|
|
paneState(pane).showHidden = ev.target.checked;
|
|
loadBrowsePane(pane);
|
|
};
|
|
}
|
|
|
|
function setupEvents() {
|
|
setupPaneEvents("left");
|
|
setupPaneEvents("right");
|
|
document.addEventListener("keydown", handleKeyboardShortcuts);
|
|
document.getElementById("theme-toggle").onclick = toggleTheme;
|
|
document.getElementById("settings-btn").onclick = () => openSettings("general");
|
|
document.getElementById("view-btn").onclick = openViewer;
|
|
document.getElementById("edit-btn").onclick = openEditor;
|
|
document.getElementById("rename-btn").onclick = openRenamePopup;
|
|
document.getElementById("delete-btn").onclick = deleteSelected;
|
|
document.getElementById("copy-btn").onclick = startCopySelected;
|
|
document.getElementById("move-btn").onclick = openF6Flow;
|
|
document.getElementById("mkdir-btn").onclick = createFolderForActivePane;
|
|
|
|
const rename = renameElements();
|
|
rename.closeButton.onclick = closeRenamePopup;
|
|
rename.cancelButton.onclick = closeRenamePopup;
|
|
rename.applyButton.onclick = submitRenamePopup;
|
|
rename.input.onkeydown = (event) => {
|
|
if (event.key === "Enter") {
|
|
event.preventDefault();
|
|
submitRenamePopup();
|
|
return;
|
|
}
|
|
if (event.key === "Escape") {
|
|
event.preventDefault();
|
|
closeRenamePopup();
|
|
}
|
|
};
|
|
rename.overlay.onclick = (event) => {
|
|
if (event.target === rename.overlay) {
|
|
closeRenamePopup();
|
|
}
|
|
};
|
|
|
|
const settings = settingsElements();
|
|
settings.closeButton.onclick = closeSettings;
|
|
settings.generalTab.onclick = () => setSettingsTab("general");
|
|
settings.logsTab.onclick = async () => {
|
|
setSettingsTab("logs");
|
|
await loadHistoryForSettings();
|
|
};
|
|
settings.showThumbnailsInput.onchange = handleShowThumbnailsChange;
|
|
settings.generalSaveButton.onclick = handlePreferredStartupPathSave;
|
|
settings.overlay.onclick = (event) => {
|
|
if (event.target === settings.overlay) {
|
|
closeSettings();
|
|
}
|
|
};
|
|
|
|
const search = searchElements();
|
|
search.closeButton.onclick = closeSearch;
|
|
search.overlay.onclick = (event) => {
|
|
if (event.target === search.overlay) {
|
|
closeSearch();
|
|
}
|
|
};
|
|
|
|
const info = infoElements();
|
|
info.closeButton.onclick = closeInfo;
|
|
info.overlay.onclick = (event) => {
|
|
if (event.target === info.overlay) {
|
|
closeInfo();
|
|
}
|
|
};
|
|
|
|
const 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 viewer = viewerElements();
|
|
viewer.closeButton.onclick = closeViewer;
|
|
viewer.overlay.onclick = (event) => {
|
|
if (event.target === viewer.overlay) {
|
|
closeViewer();
|
|
}
|
|
};
|
|
|
|
const video = videoElements();
|
|
video.closeButton.onclick = closeVideoViewer;
|
|
video.player.onerror = () => {
|
|
video.error.textContent = "Playback failed in this browser for this file.";
|
|
};
|
|
video.overlay.onclick = (event) => {
|
|
if (event.target === video.overlay) {
|
|
closeVideoViewer();
|
|
}
|
|
};
|
|
|
|
const editor = editorElements();
|
|
editor.closeButton.onclick = attemptCloseEditor;
|
|
editor.cancelButton.onclick = attemptCloseEditor;
|
|
editor.saveButton.onclick = saveEditor;
|
|
editor.overlay.onclick = (event) => {
|
|
if (event.target === editor.overlay) {
|
|
attemptCloseEditor();
|
|
}
|
|
};
|
|
}
|
|
|
|
async function init() {
|
|
setError("actions-error", "");
|
|
applyTheme(preferredTheme());
|
|
setActivePane("left");
|
|
setupEvents();
|
|
await loadSettings();
|
|
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();
|