519 lines
15 KiB
JavaScript
519 lines
15 KiB
JavaScript
let state = {
|
|
panes: {
|
|
left: {
|
|
currentPath: "storage1",
|
|
showHidden: false,
|
|
selectedItem: null,
|
|
selectedItems: [],
|
|
},
|
|
right: {
|
|
currentPath: "storage1",
|
|
showHidden: false,
|
|
selectedItem: null,
|
|
selectedItems: [],
|
|
},
|
|
},
|
|
activePane: "left",
|
|
selectedTaskId: null,
|
|
};
|
|
|
|
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;
|
|
}
|
|
|
|
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 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";
|
|
const paths = selectedPaths(pane);
|
|
if (paths.includes(entry.path)) {
|
|
li.classList.add("is-selected");
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
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 items = document.getElementById(`${pane}-items`);
|
|
items.innerHTML = "";
|
|
|
|
const parent = currentParentPath(data.path);
|
|
if (parent) {
|
|
const up = document.createElement("li");
|
|
up.className = "selectable";
|
|
up.append(document.createElement("span"));
|
|
const upName = document.createElement("button");
|
|
upName.type = "button";
|
|
upName.className = "dir-link";
|
|
upName.textContent = "../";
|
|
upName.onclick = () => navigateTo(pane, parent);
|
|
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);
|
|
}
|
|
|
|
const visiblePaths = new Set();
|
|
for (const entry of data.directories) {
|
|
visiblePaths.add(entry.path);
|
|
items.append(createBrowseItem(pane, entry, "directory"));
|
|
}
|
|
for (const entry of data.files) {
|
|
visiblePaths.add(entry.path);
|
|
items.append(createBrowseItem(pane, entry, "file"));
|
|
}
|
|
|
|
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;
|
|
}
|
|
updateActionButtons();
|
|
setStatus(`Loaded ${pane}: ${data.path}`);
|
|
} catch (err) {
|
|
setError(`${pane}-browse-error`, `Browse: ${err.message}`);
|
|
}
|
|
}
|
|
|
|
function navigateTo(pane, path) {
|
|
paneState(pane).currentPath = path;
|
|
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 = window.prompt(
|
|
"Copy destination base path (full path)",
|
|
paneState(destinationPane).currentPath,
|
|
);
|
|
if (!baseDestination) {
|
|
return;
|
|
}
|
|
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;
|
|
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 = window.prompt(
|
|
"Move destination base path (full path)",
|
|
paneState(destinationPane).currentPath,
|
|
);
|
|
if (!baseDestination) {
|
|
return;
|
|
}
|
|
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;
|
|
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 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.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;
|
|
}
|
|
|
|
async function init() {
|
|
setError("actions-error", "");
|
|
setActivePane("left");
|
|
setupEvents();
|
|
await Promise.all([loadBrowsePane("left"), loadBrowsePane("right")]);
|
|
}
|
|
|
|
init();
|