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

890 lines
26 KiB
JavaScript

let state = {
panes: {
left: {
currentPath: "storage1",
showHidden: false,
selectedItem: null,
selectedItems: [],
visibleItems: [],
currentRowIndex: -1,
},
right: {
currentPath: "storage1",
showHidden: false,
selectedItem: null,
selectedItems: [],
visibleItems: [],
currentRowIndex: -1,
},
},
activePane: "left",
selectedTaskId: null,
lastTaskCount: 0,
};
const ROW_JUMP_STEP = 10;
let wildcardDialogMode = "select";
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);
}
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 selectedPaths(pane) {
return paneState(pane).selectedItems.map((item) => item.path);
}
function setSingleSelection(pane, item) {
setSelectedItem(pane, item);
}
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 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 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("rename-btn").disabled = !exactlyOne;
document.getElementById("delete-btn").disabled = !hasSelection;
document.getElementById("copy-btn").disabled = !allFiles;
document.getElementById("move-btn").disabled = !allFiles;
}
function currentParentPath(path) {
if (!path.includes("/")) {
return null;
}
const segments = path.split("/");
if (segments.length === 2) {
return segments[0];
}
return segments.slice(0, -1).join("/");
}
function renderBreadcrumbs(pane, path) {
const nav = document.getElementById(`${pane}-breadcrumbs`);
nav.innerHTML = "";
const parts = path.split("/");
let aggregate = "";
for (let i = 0; i < parts.length; i += 1) {
aggregate = 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 marker = document.createElement("input");
marker.type = "checkbox";
marker.className = "select-marker";
marker.checked = paths.includes(entry.path);
marker.onclick = (ev) => {
ev.stopPropagation();
setActivePane(pane);
toggleSelection(pane, { path: entry.path, name: entry.name, kind });
loadBrowsePane(pane);
};
li.append(marker);
const name = document.createElement("span");
name.className = `entry-name ${kind === "directory" ? "entry-dir" : "entry-file"}`;
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);
};
name.append(open);
} else {
const fileName = document.createElement("span");
fileName.textContent = entry.name;
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;
renderPaneItems(pane);
};
up.append(document.createElement("span"));
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);
};
const upNameCell = document.createElement("span");
upNameCell.className = "entry-name entry-dir";
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;
setSingleSelection(pane, { path: entry.path, name: entry.name, kind: entry.kind });
renderPaneItems(pane);
};
const checkbox = row.querySelector(".select-marker");
if (checkbox) {
checkbox.onclick = (ev) => {
ev.stopPropagation();
setActivePane(pane);
model.currentRowIndex = index;
toggleSelection(pane, { path: entry.path, name: entry.name, kind: entry.kind });
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;
setSingleSelection(pane, { path: entry.path, name: entry.name, kind: entry.kind });
renderPaneItems(pane);
};
}
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;
}
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;
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 = sourcePath.slice(sourcePath.lastIndexOf("/") + 1);
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() {
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 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 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 isWildcardPopupOpen() {
return !wildcardPopupElements().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 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 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 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 || item.kind !== "directory") {
return;
}
navigateTo(pane, item.path);
}
function toggleCurrentSelection() {
const pane = state.activePane;
const item = currentRowItem(pane);
if (!item || item.isParent) {
return;
}
toggleSelection(pane, { path: item.path, name: item.name, kind: item.kind });
renderPaneItems(pane);
}
function clearSelectionForActivePane() {
const pane = state.activePane;
setSelectedItem(pane, null);
renderPaneItems(pane);
}
function handleKeyboardShortcuts(event) {
if (isWildcardPopupOpen()) {
return;
}
if (!shouldHandleShortcut(event.target)) {
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") {
event.preventDefault();
moveCurrentRow(-1);
return;
}
if (event.key === "ArrowDown") {
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("rename-btn").onclick = renameSelected;
document.getElementById("delete-btn").onclick = deleteSelected;
document.getElementById("copy-btn").onclick = startCopySelected;
document.getElementById("move-btn").onclick = startMoveSelected;
document.getElementById("mkdir-btn").onclick = createFolderForActivePane;
document.getElementById("add-bookmark-btn").onclick = addBookmark;
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();
}
};
}
async function init() {
setError("actions-error", "");
setActivePane("left");
setupEvents();
await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]);
}
init();