mirror of
https://github.com/LisherSong/ps5-web-file-manager.git
synced 2026-10-06 10:00:24 +02:00
Homebrew HTTP file manager payload for jailbroken PS5 consoles (x86_64-sie-ps5,
title id FMGR88888). Browse, edit, upload, download and extract ZIPs through
any browser on the LAN; single self-contained ELF, no external services.
Highlights (v1.7):
- ZIP large-file profile (large=1): 2 TiB total / 1 TiB per entry / 1000:1
ratio. Frontend prompts for confirmation whenever archive > 60 GiB on disk.
- Default ZIP profile stays safe: 512 GiB / 64 GiB / 200:1.
- 69 host-side C tests (tests/run-tests.sh) covering traversal, ZIP64,
encryption rejection, conflict policies and the large-file profile.
- Three-phase extraction engine (scan -> extract -> publish -> cleanup) with
atomic staging + rename, located in src/zip_extract.{c,h}.
Project layout:
src/ C payload sources
assets/ HTML / CSS / JS / icons / param.json
tests/ POSIX/host test suite + fixtures
third_party/ vendored zlib + minizip-ng (zlib license)
docs/screenshots/ README screenshot images
LICENSE GPLv3+
Build:
make # cross-compile with PS5_PAYLOAD_SDK
make linux # Linux host binary for UI dev
2563 lines
82 KiB
JavaScript
2563 lines
82 KiB
JavaScript
let cwd = "/";
|
|
let entries = [];
|
|
let selected = new Set();
|
|
let focusedPath = null;
|
|
let trackedTask = null;
|
|
let busy = false;
|
|
let clipboard = null;
|
|
let pendingOverlayText = "";
|
|
let pendingOverlayLabel = "";
|
|
let pendingAbortController = null;
|
|
let taskRefreshPath = "/";
|
|
let hoverPaused = false;
|
|
let hoverResumeTimer = 0;
|
|
let hoveredRow = null;
|
|
let loadingPath = null;
|
|
let directoryLoadingTimer = 0;
|
|
let directoryLoadingStartedAt = 0;
|
|
let taskOverlayTimer = 0;
|
|
let lastCompletionId = null;
|
|
let taskPollFailedAlertShown = false;
|
|
let localActionBusy = false;
|
|
let specialStoragePaths = {};
|
|
let hasSpecialMntStorage = false;
|
|
let textEditorPath = null;
|
|
let textEditorVersion = null;
|
|
let textEditorOriginal = "";
|
|
let textEditorBusy = false;
|
|
let permissionItems = [];
|
|
let permissionOriginal = "";
|
|
let permissionBusy = false;
|
|
let pkgInfoItem = null;
|
|
let pkgInfoRequestId = 0;
|
|
let downloadFrame = null;
|
|
let uploadXhr = null;
|
|
let uploadTerminalAbort = false;
|
|
let L = {};
|
|
|
|
const APP_VERSION = "v1.7";
|
|
const LAST_PATH_KEY = "ps5-web-file-mgr:last-path";
|
|
const SORT_KEY = "ps5-web-file-mgr:list-sort";
|
|
const LOADING_DISPLAY_DELAY = 250;
|
|
const DOWNLOAD_OVERLAY_DISPLAY_DELAY = 1200;
|
|
const SELECT_ALL_LOADING_THRESHOLD = 1000;
|
|
const SORT_KEYS = { name: true, type: true, size: true, mtime: true, mode: true };
|
|
|
|
let sortKey = "";
|
|
let sortDir = "none";
|
|
|
|
const filesEl = document.getElementById("files");
|
|
const contentEl = document.getElementById("content");
|
|
const contentLoadingEl = document.getElementById("contentLoading");
|
|
const contentLoadingTextEl = contentLoadingEl.querySelector(".content-loading-text");
|
|
const emptyEl = document.getElementById("empty");
|
|
const pathEl = document.getElementById("path");
|
|
const spaceInfoEl = document.getElementById("spaceInfo");
|
|
const statusEl = document.getElementById("statusText");
|
|
const versionEl = document.getElementById("versionText");
|
|
const tasksEl = document.getElementById("tasks");
|
|
const overlayEl = document.getElementById("taskOverlay");
|
|
const selectAllEl = document.getElementById("selectAll");
|
|
const parentBtn = document.getElementById("parentBtn");
|
|
const pasteBtn = document.getElementById("pasteBtn");
|
|
const pasteVerbEl = document.getElementById("pasteVerb");
|
|
const pasteNameEl = document.getElementById("pasteName");
|
|
const pasteCountEl = document.getElementById("pasteCount");
|
|
const pasteTargetTextEl = document.getElementById("pasteTargetText");
|
|
const installPkgBtn = document.getElementById("installPkgBtn");
|
|
const extractBtn = document.getElementById("extractBtn");
|
|
const clearClipboardBtn = document.getElementById("clearClipboardBtn");
|
|
const downloadBtn = document.getElementById("downloadBtn");
|
|
const uploadMenuEl = document.getElementById("uploadMenu");
|
|
const uploadBtn = document.getElementById("uploadBtn");
|
|
const uploadMenuBtn = document.getElementById("uploadMenuBtn");
|
|
const uploadFolderBtn = document.getElementById("uploadFolderBtn");
|
|
const uploadAndExtractBtn = document.getElementById("uploadAndExtractBtn");
|
|
const uploadFilesEl = document.getElementById("uploadFiles");
|
|
const uploadFolderEl = document.getElementById("uploadFolder");
|
|
const uploadZipEl = document.getElementById("uploadZip");
|
|
const initLoadingEl = document.getElementById("initLoading");
|
|
const exitBtn = document.getElementById("exitBtn");
|
|
const textEditorOverlayEl = document.getElementById("textEditorOverlay");
|
|
const textEditorPathEl = document.getElementById("textEditorPath");
|
|
const textEditorEl = document.getElementById("textEditor");
|
|
const textEditorStatusEl = document.getElementById("textEditorStatus");
|
|
const textEditorCloseBtn = document.getElementById("textEditorCloseBtn");
|
|
const textEditorSaveBtn = document.getElementById("textEditorSaveBtn");
|
|
const newTextBtn = document.getElementById("newTextBtn");
|
|
const imagePreviewOverlayEl = document.getElementById("imagePreviewOverlay");
|
|
const imagePreviewNameEl = document.getElementById("imagePreviewName");
|
|
const imagePreviewEl = document.getElementById("imagePreview");
|
|
const imagePreviewCloseBtn = document.getElementById("imagePreviewCloseBtn");
|
|
const pkgInfoOverlayEl = document.getElementById("pkgInfoOverlay");
|
|
const pkgInfoImageEl = document.getElementById("pkgInfoImage");
|
|
const pkgInfoTitleEl = document.getElementById("pkgInfoTitle");
|
|
const pkgInfoFieldsEl = document.getElementById("pkgInfoFields");
|
|
const pkgInfoCloseBtn = document.getElementById("pkgInfoCloseBtn");
|
|
const pkgInfoInstallBtn = document.getElementById("pkgInfoInstallBtn");
|
|
const permissionOverlayEl = document.getElementById("permissionOverlay");
|
|
const permissionPathEl = document.getElementById("permissionPath");
|
|
const permissionModeEl = document.getElementById("permissionMode");
|
|
const permissionRecursiveOptionEl = document.getElementById("permissionRecursiveOption");
|
|
const permissionRecursiveEl = document.getElementById("permissionRecursive");
|
|
const permissionCancelBtn = document.getElementById("permissionCancelBtn");
|
|
const permissionApplyBtn = document.getElementById("permissionApplyBtn");
|
|
const permissionChecks = [
|
|
document.getElementById("permissionOwnerRead"),
|
|
document.getElementById("permissionOwnerWrite"),
|
|
document.getElementById("permissionOwnerExecute"),
|
|
document.getElementById("permissionGroupRead"),
|
|
document.getElementById("permissionGroupWrite"),
|
|
document.getElementById("permissionGroupExecute"),
|
|
document.getElementById("permissionOtherRead"),
|
|
document.getElementById("permissionOtherWrite"),
|
|
document.getElementById("permissionOtherExecute")
|
|
];
|
|
|
|
function chooseLanguage() {
|
|
const langs = navigator.languages && navigator.languages.length ? navigator.languages : [navigator.language || ""];
|
|
const lang = String(langs[0] || "").toLowerCase();
|
|
return lang.indexOf("zh") === 0 ? "zh" : "en";
|
|
}
|
|
|
|
function loadLanguage() {
|
|
return new Promise(resolve => {
|
|
const lang = chooseLanguage();
|
|
initLoadingEl.textContent = lang === "zh" ? "加载中..." : "Loading...";
|
|
const script = document.createElement("script");
|
|
script.src = "/lang-" + lang + ".js";
|
|
script.onload = () => {
|
|
L = window.WFM_LANG || {};
|
|
document.documentElement.lang = lang === "zh" ? "zh-CN" : "en";
|
|
resolve();
|
|
};
|
|
script.onerror = () => {
|
|
L = {};
|
|
resolve();
|
|
};
|
|
document.head.appendChild(script);
|
|
});
|
|
}
|
|
|
|
function t(key, params) {
|
|
let text = L[key] || key;
|
|
for (const name in params || {}) {
|
|
text = text.replace(new RegExp("\\{" + name + "\\}", "g"), params[name]);
|
|
}
|
|
return text;
|
|
}
|
|
|
|
function backendErrorText(code, arg, fallback) {
|
|
if (!code) return fallback || t("backendError");
|
|
const params = { path: arg || "", arg: arg || "" };
|
|
if (code === "no_space") {
|
|
const parts = String(arg || "").split(",");
|
|
params.required = formatBytes(parts[0] || 0, false);
|
|
params.available = formatBytes(parts[1] || 0, false);
|
|
}
|
|
const key = "err_" + code;
|
|
return L[key] ? t(key, params) : fallback || t("backendError");
|
|
}
|
|
|
|
function applyStaticText() {
|
|
document.title = t("appTitle");
|
|
for (const el of document.querySelectorAll("[data-i18n]")) {
|
|
el.textContent = t(el.dataset.i18n);
|
|
}
|
|
if (isPlayStationBrowser()) {
|
|
for (const el of document.querySelectorAll(".remote-only")) el.hidden = true;
|
|
}
|
|
exitBtn.title = t("exit");
|
|
exitBtn.setAttribute("aria-label", t("exit"));
|
|
parentBtn.title = t("parent");
|
|
parentBtn.setAttribute("aria-label", t("parent"));
|
|
versionEl.textContent = APP_VERSION;
|
|
if (initLoadingEl) initLoadingEl.hidden = true;
|
|
}
|
|
|
|
function nextPaint() {
|
|
return new Promise(resolve => {
|
|
if (window.requestAnimationFrame) {
|
|
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
|
|
}
|
|
else setTimeout(resolve, 0);
|
|
});
|
|
}
|
|
|
|
function delay(ms) {
|
|
return new Promise(resolve => setTimeout(resolve, ms));
|
|
}
|
|
|
|
function showContentLoading(text) {
|
|
contentLoadingTextEl.textContent = text || t("readDir");
|
|
contentLoadingEl.style.top = contentEl.scrollTop + "px";
|
|
contentLoadingEl.hidden = false;
|
|
contentEl.classList.add("loading");
|
|
updateButtons();
|
|
}
|
|
|
|
function startContentLoadingTimer() {
|
|
if (!contentLoadingEl.hidden || directoryLoadingTimer) return;
|
|
directoryLoadingStartedAt = Date.now();
|
|
directoryLoadingTimer = setTimeout(() => {
|
|
directoryLoadingTimer = 0;
|
|
showContentLoading();
|
|
}, LOADING_DISPLAY_DELAY);
|
|
}
|
|
|
|
async function showContentLoadingAfterDelay() {
|
|
if (!contentLoadingEl.hidden) return;
|
|
const elapsed = directoryLoadingStartedAt ? Date.now() - directoryLoadingStartedAt : 0;
|
|
const remaining = LOADING_DISPLAY_DELAY - elapsed;
|
|
if (remaining > 0) await delay(remaining);
|
|
clearTimeout(directoryLoadingTimer);
|
|
directoryLoadingTimer = 0;
|
|
showContentLoading();
|
|
}
|
|
|
|
function hideContentLoading() {
|
|
clearTimeout(directoryLoadingTimer);
|
|
directoryLoadingTimer = 0;
|
|
directoryLoadingStartedAt = 0;
|
|
contentEl.classList.remove("loading");
|
|
contentLoadingEl.hidden = true;
|
|
updateButtons();
|
|
}
|
|
|
|
async function request(path, params, options) {
|
|
const qs = new URLSearchParams(params || {});
|
|
const fetchOptions = Object.assign({ method: "POST" }, options || {});
|
|
const response = await fetch(path + (qs.toString() ? "?" + qs.toString() : ""), fetchOptions);
|
|
if (!response.ok) {
|
|
const data = await response.json();
|
|
throw new Error(backendErrorText(data.error_code, data.error_arg, data.error));
|
|
}
|
|
return response;
|
|
}
|
|
|
|
async function api(path, params, options) {
|
|
const data = await (await request(path, params, options)).json();
|
|
if (!data.ok) throw new Error(backendErrorText(data.error_code, data.error_arg, data.error));
|
|
return data;
|
|
}
|
|
|
|
async function apiForm(path, params, options) {
|
|
const fetchOptions = Object.assign({
|
|
method: "POST",
|
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
body: new URLSearchParams(params || {}).toString()
|
|
}, options || {});
|
|
const data = await (await request(path, null, fetchOptions)).json();
|
|
if (!data.ok) throw new Error(backendErrorText(data.error_code, data.error_arg, data.error));
|
|
return data;
|
|
}
|
|
|
|
function decodeFsText(text) {
|
|
text = String(text || "");
|
|
if (typeof TextDecoder === "undefined" || typeof Uint8Array === "undefined") return text;
|
|
|
|
let out = "";
|
|
let bytes = [];
|
|
let raw = "";
|
|
let changed = false;
|
|
|
|
function flushBytes() {
|
|
if (!bytes.length) return;
|
|
const decoded = decodeFsBytes(bytes, raw);
|
|
if (decoded !== raw) changed = true;
|
|
out += decoded;
|
|
bytes = [];
|
|
raw = "";
|
|
}
|
|
|
|
for (let i = 0; i < text.length; i++) {
|
|
const code = text.charCodeAt(i);
|
|
if (code >= 0x80 && code <= 0xff) {
|
|
bytes.push(code & 0xff);
|
|
raw += text.charAt(i);
|
|
continue;
|
|
}
|
|
flushBytes();
|
|
out += text.charAt(i);
|
|
}
|
|
flushBytes();
|
|
return changed ? out : text;
|
|
}
|
|
|
|
function decodeFsBytes(bytes, fallback) {
|
|
const data = new Uint8Array(bytes);
|
|
if (isUtf8Bytes(data)) {
|
|
try {
|
|
return new TextDecoder("utf-8").decode(data);
|
|
} catch (err) {
|
|
return fallback;
|
|
}
|
|
}
|
|
try {
|
|
return new TextDecoder("gbk").decode(data);
|
|
} catch (err) {
|
|
try {
|
|
return new TextDecoder("gb18030").decode(data);
|
|
} catch (err2) {
|
|
return fallback;
|
|
}
|
|
}
|
|
}
|
|
|
|
function isUtf8Bytes(bytes) {
|
|
for (let i = 0; i < bytes.length;) {
|
|
const c = bytes[i];
|
|
let n = 0;
|
|
let cp = 0;
|
|
|
|
if (c < 0x80) {
|
|
i++;
|
|
continue;
|
|
}
|
|
if ((c & 0xe0) === 0xc0) {
|
|
n = 2;
|
|
cp = c & 0x1f;
|
|
} else if ((c & 0xf0) === 0xe0) {
|
|
n = 3;
|
|
cp = c & 0x0f;
|
|
} else if ((c & 0xf8) === 0xf0) {
|
|
n = 4;
|
|
cp = c & 0x07;
|
|
} else {
|
|
return false;
|
|
}
|
|
if (i + n > bytes.length) return false;
|
|
for (let j = 1; j < n; j++) {
|
|
const t = bytes[i + j];
|
|
if ((t & 0xc0) !== 0x80) return false;
|
|
cp = (cp << 6) | (t & 0x3f);
|
|
}
|
|
if ((n === 2 && cp < 0x80) ||
|
|
(n === 3 && cp < 0x800) ||
|
|
(n === 4 && (cp < 0x10000 || cp > 0x10ffff)) ||
|
|
(cp >= 0xd800 && cp <= 0xdfff)) return false;
|
|
i += n;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function displayName(item) {
|
|
return item.displayName || decodeFsText(item.name);
|
|
}
|
|
|
|
function displayPath(path) {
|
|
return decodeFsText(path);
|
|
}
|
|
|
|
function renderEndOfPath(element, path) {
|
|
element.textContent = path;
|
|
if (element.scrollWidth <= element.clientWidth) return;
|
|
|
|
let low = 0;
|
|
let high = path.length;
|
|
while (low < high) {
|
|
const count = Math.ceil((low + high) / 2);
|
|
element.textContent = "..." + path.slice(path.length - count);
|
|
if (element.scrollWidth <= element.clientWidth) low = count;
|
|
else high = count - 1;
|
|
}
|
|
element.textContent = "..." + path.slice(path.length - low);
|
|
}
|
|
|
|
function renderAddressPath() {
|
|
const path = displayPath(cwd);
|
|
pathEl.title = path;
|
|
renderEndOfPath(pathEl, path);
|
|
}
|
|
|
|
async function fetchText(path, params) {
|
|
const response = await request(path, params, { method: "GET" });
|
|
return {
|
|
text: await response.text(),
|
|
version: response.headers.get("X-Text-Version") || ""
|
|
};
|
|
}
|
|
|
|
function formatSize(size, type) {
|
|
if (type === "d" || type === "parent") return "";
|
|
return formatBytes(size, true);
|
|
}
|
|
|
|
function formatBytes(size, decimals) {
|
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
|
let value = Number(size || 0);
|
|
let unit = 0;
|
|
while (value >= 1024 && unit < units.length - 1) {
|
|
value /= 1024;
|
|
unit++;
|
|
}
|
|
if (!unit) return value + " B";
|
|
return (decimals ? value.toFixed(2) : Math.round(value)) + " " + units[unit];
|
|
}
|
|
|
|
function formatTime(seconds) {
|
|
if (!seconds) return "";
|
|
const date = new Date(seconds * 1000);
|
|
const year = date.getFullYear();
|
|
const month = date.getMonth() + 1;
|
|
const day = date.getDate();
|
|
const time = [date.getHours(), date.getMinutes(), date.getSeconds()]
|
|
.map(value => value < 10 ? "0" + value : value).join(":");
|
|
return document.documentElement.lang === "en" ?
|
|
month + "/" + day + "/" + year + ", " + time :
|
|
year + "/" + month + "/" + day + " " + time;
|
|
}
|
|
|
|
function formatSpeed(bytes) {
|
|
return formatSize(bytes) + "/s";
|
|
}
|
|
|
|
function formatDuration(seconds) {
|
|
if (!isFinite(seconds) || seconds < 0) return "--";
|
|
seconds = Math.ceil(seconds);
|
|
const hours = Math.floor(seconds / 3600);
|
|
const minutes = Math.floor((seconds % 3600) / 60);
|
|
const secs = seconds % 60;
|
|
if (hours) return t("durationHours", { hours, minutes });
|
|
if (minutes) return t("durationMinutes", { minutes, seconds: secs });
|
|
return t("durationSeconds", { seconds: secs });
|
|
}
|
|
|
|
function averageEta(task, done, total) {
|
|
if (!total || !done || done >= total) return "--";
|
|
const eta = Number(task.eta || 0);
|
|
return eta > 0 ? formatDuration(eta) : "--";
|
|
}
|
|
|
|
function taskElapsed(task) {
|
|
if (!task || !Object.prototype.hasOwnProperty.call(task, "total_elapsed")) return "--";
|
|
return formatDuration(Number(task.total_elapsed || 0));
|
|
}
|
|
|
|
function opLabel(op) {
|
|
return { copy: t("copy"), move: t("move"), delete: t("delete"), chmod: t("permissionsTitle"), download: t("download"), upload: t("upload"), pkg_install: t("installPackage"), extract: t("extract") }[op] || op;
|
|
}
|
|
|
|
function taskOpLabel(op) {
|
|
return { copy: t("copying"), move: t("moving"), delete: t("deleting"), chmod: t("changingPermissions"), download: t("downloading"), upload: t("uploading"), pkg_install: t("installPackage"), extract: t("extracting") }[op] || op;
|
|
}
|
|
|
|
function isPlayStationBrowser() {
|
|
return /PlayStation/i.test(navigator.userAgent || "");
|
|
}
|
|
|
|
function stateLabel(state) {
|
|
return { queued: t("queued"), running: t("running"), done: t("done"), failed: t("failed"), canceled: t("canceled") }[state] || state;
|
|
}
|
|
|
|
function isTerminalTask(task) {
|
|
return ["done", "failed", "canceled"].includes(task.state);
|
|
}
|
|
|
|
function trackTask(id, op, fromClipboard, clearClipboardOnDone) {
|
|
trackedTask = {
|
|
id,
|
|
op,
|
|
fromClipboard: Boolean(fromClipboard),
|
|
clearClipboardOnDone: Boolean(clearClipboardOnDone),
|
|
cancelRequested: false,
|
|
state: "queued"
|
|
};
|
|
}
|
|
|
|
function requestTaskCancel(id) {
|
|
if (trackedTask && trackedTask.id === id) trackedTask.cancelRequested = true;
|
|
}
|
|
|
|
function clearTrackedTask() {
|
|
if (trackedTask && trackedTask.op === "download" && downloadFrame) {
|
|
downloadFrame.parentNode.removeChild(downloadFrame);
|
|
downloadFrame = null;
|
|
}
|
|
if (trackedTask && trackedTask.op === "upload" && uploadXhr) {
|
|
uploadTerminalAbort = true;
|
|
uploadXhr.abort();
|
|
}
|
|
trackedTask = null;
|
|
}
|
|
|
|
function clearClipboardAfterPaste() {
|
|
clipboard = null;
|
|
renderClipboard();
|
|
}
|
|
|
|
function taskFailureMessage(task) {
|
|
return t("taskFailed", {
|
|
label: opLabel(task.op),
|
|
error: backendErrorText(task.error_code, task.error_arg, task.error || task.current)
|
|
});
|
|
}
|
|
|
|
function handleTerminalTask(task) {
|
|
if (task.op === "pkg_install") {
|
|
if (task.state === "failed") {
|
|
const message = taskFailureMessage(task);
|
|
setStatus(message);
|
|
alert(message);
|
|
}
|
|
return;
|
|
}
|
|
if (task.state === "failed") {
|
|
clearTrackedTask();
|
|
const message = taskFailureMessage(task);
|
|
setStatus(message);
|
|
alert(message);
|
|
return;
|
|
}
|
|
if (task.state === "canceled") {
|
|
clearTrackedTask();
|
|
setStatus(t("taskCanceled", { label: opLabel(task.op) }));
|
|
return;
|
|
}
|
|
if (task.state === "done") {
|
|
if (task.op === "extract") {
|
|
clearTrackedTask();
|
|
setStatus(t("extractDone", { name: taskSubject(task) }));
|
|
return;
|
|
}
|
|
if (trackedTask && trackedTask.id === task.id &&
|
|
(trackedTask.fromClipboard || trackedTask.clearClipboardOnDone)) clearClipboardAfterPaste();
|
|
clearTrackedTask();
|
|
setStatus(t("actionDone", { label: opLabel(task.op) }));
|
|
}
|
|
}
|
|
|
|
function setStatus(text) {
|
|
statusEl.textContent = text;
|
|
}
|
|
|
|
function setBusy(value) {
|
|
busy = value;
|
|
updateButtons();
|
|
}
|
|
|
|
function setModalBackgroundLocked(value) {
|
|
contentEl.classList.toggle("loading", value);
|
|
if (value) clearHoverPath();
|
|
updateButtons();
|
|
}
|
|
|
|
function showTaskOverlay(immediate, delay) {
|
|
setBusy(true);
|
|
if (!overlayEl.hidden || taskOverlayTimer) return;
|
|
if (immediate) {
|
|
overlayEl.hidden = false;
|
|
return;
|
|
}
|
|
taskOverlayTimer = setTimeout(() => {
|
|
taskOverlayTimer = 0;
|
|
overlayEl.hidden = false;
|
|
}, delay || LOADING_DISPLAY_DELAY);
|
|
}
|
|
|
|
function hideTaskOverlay() {
|
|
clearTimeout(taskOverlayTimer);
|
|
taskOverlayTimer = 0;
|
|
overlayEl.hidden = true;
|
|
}
|
|
|
|
function showActionFailed(label, error) {
|
|
const message = t("actionFailed", { label, error });
|
|
setStatus(message);
|
|
alert(message);
|
|
}
|
|
|
|
function readSavedPath() {
|
|
try {
|
|
return localStorage.getItem(LAST_PATH_KEY) || "/";
|
|
} catch (err) {
|
|
return "/";
|
|
}
|
|
}
|
|
|
|
function historyPath() {
|
|
const hash = window.location.hash || "";
|
|
if (hash.length <= 1) return "";
|
|
try {
|
|
const path = decodeURIComponent(hash.slice(1));
|
|
return path.charAt(0) === "/" ? path : "";
|
|
} catch (err) {
|
|
return "";
|
|
}
|
|
}
|
|
|
|
function writeHistoryPath(path, replace) {
|
|
if (!window.history || !history.pushState) return;
|
|
const hash = "#" + encodeURIComponent(path || "/");
|
|
if (window.location.hash === hash) return;
|
|
const state = { path: path || "/" };
|
|
if (replace && history.replaceState) history.replaceState(state, "", hash);
|
|
else history.pushState(state, "", hash);
|
|
}
|
|
|
|
function pathHistoryChain(path) {
|
|
const clean = String(path || "/").replace(/\/+$/, "") || "/";
|
|
const parts = clean.split("/");
|
|
const chain = ["/"];
|
|
let current = "";
|
|
for (let i = 1; i < parts.length; i++) {
|
|
if (!parts[i]) continue;
|
|
current += "/" + parts[i];
|
|
chain.push(current);
|
|
}
|
|
return chain;
|
|
}
|
|
|
|
function seedHistoryPath(path) {
|
|
if (!window.history || !history.pushState || !history.replaceState) return;
|
|
const chain = pathHistoryChain(path);
|
|
for (let i = 0; i < chain.length; i++) {
|
|
writeHistoryPath(chain[i], i === 0);
|
|
}
|
|
}
|
|
|
|
function historyBlocked() {
|
|
return Boolean(busy || loadingPath || pendingAbortController ||
|
|
pendingOverlayText || taskOverlayTimer || !overlayEl.hidden ||
|
|
!contentLoadingEl.hidden || contentEl.classList.contains("loading") ||
|
|
!textEditorOverlayEl.hidden || !imagePreviewOverlayEl.hidden ||
|
|
!pkgInfoOverlayEl.hidden || !permissionOverlayEl.hidden);
|
|
}
|
|
|
|
function savePath(path) {
|
|
try {
|
|
localStorage.setItem(LAST_PATH_KEY, path || "/");
|
|
} catch (err) {
|
|
}
|
|
}
|
|
|
|
function readSavedSort() {
|
|
try {
|
|
const value = localStorage.getItem(SORT_KEY) || "";
|
|
const parts = value.split(":");
|
|
if (value === "none") {
|
|
sortKey = "";
|
|
sortDir = "none";
|
|
} else if (SORT_KEYS[parts[0]] && (parts[1] === "asc" || parts[1] === "desc")) {
|
|
sortKey = parts[0];
|
|
sortDir = parts[1];
|
|
}
|
|
} catch (err) {
|
|
}
|
|
}
|
|
|
|
function saveSort() {
|
|
try {
|
|
localStorage.setItem(SORT_KEY, sortDir === "none" ? "none" : sortKey + ":" + sortDir);
|
|
} catch (err) {
|
|
}
|
|
}
|
|
|
|
async function refreshSpaces() {
|
|
try {
|
|
const data = await api("/api/space", { path: cwd });
|
|
updateSpecialStoragePaths(data.spaces || []);
|
|
spaceInfoEl.innerHTML = "";
|
|
if ((data.spaces || []).length) {
|
|
const prefix = document.createElement("span");
|
|
prefix.className = "space-prefix";
|
|
prefix.textContent = t("freeSpace");
|
|
spaceInfoEl.appendChild(prefix);
|
|
}
|
|
for (const item of data.spaces || []) {
|
|
const button = document.createElement("button");
|
|
button.type = "button";
|
|
button.className = "space-item" + (item.current ? " current" : "");
|
|
const label = item.label_key ? t(item.label_key) : item.label;
|
|
button.textContent = label + ": " +
|
|
formatBytes(item.free, false) + "/" + formatBytes(item.total, false);
|
|
button.title = item.path + " " + button.textContent;
|
|
if (item.current) button.setAttribute("aria-current", "location");
|
|
button.disabled = spaceNavigationLocked();
|
|
bindPress(button, () => {
|
|
if (!item.path || spaceNavigationLocked()) return;
|
|
load(item.path, undefined, false, true, "push");
|
|
});
|
|
spaceInfoEl.appendChild(button);
|
|
}
|
|
renderAddressPath();
|
|
} catch (err) {
|
|
spaceInfoEl.textContent = "";
|
|
renderAddressPath();
|
|
}
|
|
}
|
|
|
|
function spaceNavigationLocked() {
|
|
return Boolean(busy || loadingPath || !contentLoadingEl.hidden ||
|
|
contentEl.classList.contains("loading"));
|
|
}
|
|
|
|
function updateSpecialStoragePaths(spaces) {
|
|
const next = {};
|
|
let hasMnt = false;
|
|
for (let i = 0; i < spaces.length; i++) {
|
|
const path = spaces[i].path || "";
|
|
if (/^\/mnt\/(?:usb|ext)[0-9]+$/.test(path)) {
|
|
next[path] = true;
|
|
hasMnt = true;
|
|
}
|
|
}
|
|
|
|
const changed = hasMnt !== hasSpecialMntStorage ||
|
|
Object.keys(next).join("\n") !== Object.keys(specialStoragePaths).join("\n");
|
|
specialStoragePaths = next;
|
|
hasSpecialMntStorage = hasMnt;
|
|
if (changed && entries.length) render();
|
|
}
|
|
|
|
function selectedEntries() {
|
|
return entries.filter(item => selected.has(item.path));
|
|
}
|
|
|
|
function pathJoin(dir, name) {
|
|
return dir === "/" ? "/" + name : dir.replace(/\/+$/, "") + "/" + name;
|
|
}
|
|
|
|
function pathIsSameOrChild(parent, child) {
|
|
parent = String(parent || "").replace(/\/+$/, "") || "/";
|
|
child = String(child || "").replace(/\/+$/, "") || "/";
|
|
return child === parent || (parent !== "/" && child.indexOf(parent + "/") === 0);
|
|
}
|
|
|
|
function typeLabel(type) {
|
|
if (type === "parent") return t("parent");
|
|
return type === "d" ? t("dir") : t("file");
|
|
}
|
|
|
|
function isEditableText(item) {
|
|
return item.type === "-" &&
|
|
/\.(txt|json|xml|ini|cfg|conf|md|log|lua|js|css|html?|c|h|cpp|hpp|sh|csv|ya?ml|shn)$/i.test(item.name);
|
|
}
|
|
|
|
function isPreviewableImage(item) {
|
|
return item.type === "-" && /\.(png|jpe?g|gif|bmp|webp)$/i.test(item.name);
|
|
}
|
|
|
|
function isPkgPackage(item) {
|
|
return item.type === "-" && /\.pkg$/i.test(item.name);
|
|
}
|
|
|
|
function isZipArchive(item) {
|
|
return item.type === "-" && /\.zip$/i.test(item.name);
|
|
}
|
|
|
|
function isSpecialDirectory(item) {
|
|
if (item.type !== "d") return false;
|
|
if (item.path === "/data") return true;
|
|
if (item.path === "/mnt") return hasSpecialMntStorage;
|
|
return Boolean(specialStoragePaths[item.path]);
|
|
}
|
|
|
|
function itemTypeLabel(item) {
|
|
if (item.type === "parent" || item.type === "d") return typeLabel(item.type);
|
|
if (isPkgPackage(item)) return "PKG";
|
|
if (isPreviewableImage(item)) return t("image");
|
|
if (isEditableText(item)) return t("textFile");
|
|
return t("file");
|
|
}
|
|
|
|
async function queuePkgInstall(items) {
|
|
if (busy || loadingPath || !items.length) return;
|
|
try {
|
|
localActionBusy = true;
|
|
setBusy(true);
|
|
setStatus(t("pkgInstalling", { name: itemTitle(items) }));
|
|
await apiForm("/api/install-pkg", {
|
|
paths: items.map(item => item.path).join("\n")
|
|
});
|
|
clearSelection();
|
|
setStatus(t("pkgInstallStarted", { name: itemTitle(items) }));
|
|
await pollTasks();
|
|
} catch (err) {
|
|
showActionFailed(t("installPackage"), err.message);
|
|
} finally {
|
|
localActionBusy = false;
|
|
if (overlayEl.hidden && !taskOverlayTimer) setBusy(false);
|
|
}
|
|
}
|
|
|
|
function installPkg(item) {
|
|
return queuePkgInstall([item]);
|
|
}
|
|
|
|
function actionInstallSelectedPkgs() {
|
|
return queuePkgInstall(selectedEntries().filter(isPkgPackage));
|
|
}
|
|
|
|
async function startExtractTask(path, dstDir, conflict, removeSource, name, large) {
|
|
try {
|
|
taskRefreshPath = cwd;
|
|
setBusy(true);
|
|
setStatus(t("extractStarted", { name }));
|
|
const data = await apiForm("/api/extract", {
|
|
path,
|
|
dst_dir: dstDir,
|
|
conflict,
|
|
remove_source: removeSource ? "1" : "0",
|
|
large: large ? "1" : "0"
|
|
});
|
|
trackTask(data.task_id, "extract", false);
|
|
clearSelection(false);
|
|
await pollTasks();
|
|
} catch (err) {
|
|
setBusy(false);
|
|
showActionFailed(t("extract"), err.message);
|
|
}
|
|
}
|
|
|
|
const LARGE_FILE_THRESHOLD_BYTES = 60 * 1024 * 1024 * 1024;
|
|
|
|
function shouldPromptLargeMode(itemSize) {
|
|
return Number(itemSize || 0) > LARGE_FILE_THRESHOLD_BYTES;
|
|
}
|
|
|
|
function promptLargeMode(sizeBytes) {
|
|
return confirm(t("extractLargeAsk", { size: formatSize(sizeBytes, '-') }));
|
|
}
|
|
|
|
function actionExtract() {
|
|
if (busy || loadingPath) return;
|
|
const zips = selectedEntries().filter(isZipArchive);
|
|
if (zips.length !== 1) return;
|
|
const item = zips[0];
|
|
if (!confirm(t("extractConfirm", { name: displayName(item), path: displayPath(cwd) }))) return;
|
|
const conflict = confirm(t("extractOverwriteAsk")) ? "overwrite" : "fail";
|
|
const large = shouldPromptLargeMode(item.size) ? promptLargeMode(item.size) : false;
|
|
startExtractTask(item.path, cwd, conflict, false, displayName(item), large);
|
|
}
|
|
|
|
function openImagePreview(item) {
|
|
if (busy) return;
|
|
setModalBackgroundLocked(true);
|
|
imagePreviewNameEl.textContent = displayName(item);
|
|
imagePreviewNameEl.title = displayName(item);
|
|
imagePreviewEl.src = "/fs?path=" + encodeURIComponent(item.path);
|
|
imagePreviewOverlayEl.hidden = false;
|
|
imagePreviewCloseBtn.focus();
|
|
}
|
|
|
|
function closeImagePreview() {
|
|
imagePreviewOverlayEl.hidden = true;
|
|
imagePreviewNameEl.textContent = "";
|
|
imagePreviewNameEl.title = "";
|
|
imagePreviewEl.removeAttribute("src");
|
|
setModalBackgroundLocked(false);
|
|
}
|
|
|
|
function addPkgInfoRow(name, value) {
|
|
if (value === undefined || value === null || String(value) === "") return;
|
|
const row = document.createElement("div");
|
|
row.className = "pkg-info-row";
|
|
const key = document.createElement("div");
|
|
key.className = "pkg-info-key";
|
|
key.textContent = name;
|
|
const text = document.createElement("div");
|
|
text.className = "pkg-info-value";
|
|
text.textContent = String(value);
|
|
appendChildren(row, key, text);
|
|
pkgInfoFieldsEl.appendChild(row);
|
|
}
|
|
|
|
function pkgHex(value) {
|
|
return "0x" + ("00000000" + (Number(value || 0) >>> 0).toString(16).toUpperCase()).slice(-8);
|
|
}
|
|
|
|
async function openPkgInfo(item) {
|
|
if (busy || loadingPath) return;
|
|
const requestId = ++pkgInfoRequestId;
|
|
pkgInfoItem = item;
|
|
setModalBackgroundLocked(true);
|
|
pkgInfoFieldsEl.innerHTML = "";
|
|
pkgInfoImageEl.src = "/icon-pkg.png";
|
|
pkgInfoInstallBtn.disabled = true;
|
|
setStatus(t("pkgInfoLoading"));
|
|
try {
|
|
const data = await api("/api/pkg-info", { path: item.path }, { method: "GET" });
|
|
if (requestId !== pkgInfoRequestId || pkgInfoItem !== item) return;
|
|
pkgInfoFieldsEl.innerHTML = "";
|
|
const platform = data.platform === "PS5" ? "PS5" : "PS4";
|
|
pkgInfoTitleEl.textContent = platform + " " + t("pkgInfoTitle");
|
|
const fields = data.fields || [];
|
|
const priority = [
|
|
"TITLE", "titleName", "TITLE_ID", "titleId",
|
|
"VERSION", "masterVersion", "APP_VER", "contentVersion"
|
|
];
|
|
const shown = {};
|
|
for (const name of priority) {
|
|
const field = fields.find(item => item.name === name);
|
|
if (field) {
|
|
addPkgInfoRow(field.name, field.value);
|
|
shown[name] = true;
|
|
}
|
|
}
|
|
addPkgInfoRow("PKG_SIZE", formatBytes(data.size, true));
|
|
for (const field of fields) {
|
|
if (!shown[field.name]) addPkgInfoRow(field.name, field.value);
|
|
}
|
|
if (!fields.some(field => field.name === "CONTENT_ID" || field.name === "contentId")) {
|
|
addPkgInfoRow("PKG_CONTENT_ID", data.content_id);
|
|
}
|
|
addPkgInfoRow("PKG_CONTENT_TYPE", pkgHex(data.content_type));
|
|
addPkgInfoRow("PKG_CONTENT_FLAGS", pkgHex(data.content_flags));
|
|
if (data.has_icon) {
|
|
pkgInfoImageEl.src = "/api/pkg-icon?path=" + encodeURIComponent(item.path) +
|
|
"&mtime=" + encodeURIComponent(item.mtime || 0);
|
|
}
|
|
pkgInfoInstallBtn.disabled = false;
|
|
pkgInfoOverlayEl.hidden = false;
|
|
pkgInfoFieldsEl.scrollTop = 0;
|
|
pkgInfoInstallBtn.focus();
|
|
await nextPaint();
|
|
if (requestId === pkgInfoRequestId) pkgInfoFieldsEl.scrollTop = 0;
|
|
} catch (err) {
|
|
if (requestId !== pkgInfoRequestId) return;
|
|
pkgInfoItem = null;
|
|
pkgInfoFieldsEl.innerHTML = "";
|
|
setModalBackgroundLocked(false);
|
|
showActionFailed(t("pkgInfoTitle"), err.message);
|
|
}
|
|
}
|
|
|
|
function closePkgInfo() {
|
|
pkgInfoRequestId++;
|
|
pkgInfoOverlayEl.hidden = true;
|
|
pkgInfoItem = null;
|
|
pkgInfoTitleEl.textContent = t("pkgInfoTitle");
|
|
pkgInfoFieldsEl.innerHTML = "";
|
|
pkgInfoImageEl.src = "/icon-pkg.png";
|
|
pkgInfoInstallBtn.disabled = true;
|
|
setModalBackgroundLocked(false);
|
|
}
|
|
|
|
function installPkgFromInfo() {
|
|
const item = pkgInfoItem;
|
|
if (!item || pkgInfoInstallBtn.disabled) return;
|
|
closePkgInfo();
|
|
installPkg(item);
|
|
}
|
|
|
|
function permissionModeText(mode) {
|
|
const octal = (Number(mode || 0) & 0x1ff).toString(8);
|
|
return "0" + ("000" + octal).slice(-3);
|
|
}
|
|
|
|
function validPermissionMode(value) {
|
|
return /^0[0-7]{3}$/.test(String(value || ""));
|
|
}
|
|
|
|
function syncPermissionChecks(value) {
|
|
if (!validPermissionMode(value)) return;
|
|
const mode = parseInt(value, 8);
|
|
const bits = [0x100, 0x080, 0x040, 0x020, 0x010, 0x008, 0x004, 0x002, 0x001];
|
|
for (let i = 0; i < permissionChecks.length; i++) {
|
|
permissionChecks[i].checked = Boolean(mode & bits[i]);
|
|
}
|
|
}
|
|
|
|
function syncPermissionMode() {
|
|
const bits = [0x100, 0x080, 0x040, 0x020, 0x010, 0x008, 0x004, 0x002, 0x001];
|
|
let mode = 0;
|
|
for (let i = 0; i < permissionChecks.length; i++) {
|
|
if (permissionChecks[i].checked) mode |= bits[i];
|
|
}
|
|
permissionModeEl.value = permissionModeText(mode);
|
|
}
|
|
|
|
function validatePermissionMode() {
|
|
if (validPermissionMode(permissionModeEl.value)) {
|
|
syncPermissionChecks(permissionModeEl.value);
|
|
return true;
|
|
}
|
|
alert(t("permissionInvalid"));
|
|
permissionModeEl.value = permissionModeText(permissionItems.length ? permissionItems[0].mode : 0);
|
|
syncPermissionChecks(permissionModeEl.value);
|
|
permissionModeEl.focus();
|
|
permissionModeEl.select();
|
|
return false;
|
|
}
|
|
|
|
function setPermissionBusy(value) {
|
|
permissionBusy = value;
|
|
permissionModeEl.disabled = value;
|
|
permissionRecursiveEl.disabled = value;
|
|
permissionCancelBtn.disabled = value;
|
|
permissionApplyBtn.disabled = value;
|
|
for (const checkbox of permissionChecks) checkbox.disabled = value;
|
|
}
|
|
|
|
function openPermissionDialog(item) {
|
|
if (busy || loadingPath || permissionBusy) return;
|
|
const items = selected.has(item.path) ? selectedEntries() : [item];
|
|
setModalBackgroundLocked(true);
|
|
permissionItems = items;
|
|
renderPermissionItems(items);
|
|
permissionModeEl.value = permissionModeText(item.mode);
|
|
permissionOriginal = permissionModeEl.value;
|
|
permissionRecursiveOptionEl.hidden = !items.some(entry => entry.type === "d");
|
|
permissionRecursiveEl.checked = true;
|
|
syncPermissionChecks(permissionModeEl.value);
|
|
setPermissionBusy(false);
|
|
permissionOverlayEl.hidden = false;
|
|
renderSinglePermissionPath();
|
|
permissionModeEl.focus();
|
|
permissionModeEl.select();
|
|
}
|
|
|
|
function closePermissionDialog() {
|
|
if (permissionBusy) return;
|
|
permissionOverlayEl.hidden = true;
|
|
permissionItems = [];
|
|
permissionOriginal = "";
|
|
permissionPathEl.textContent = "";
|
|
permissionPathEl.title = "";
|
|
permissionRecursiveOptionEl.hidden = true;
|
|
setModalBackgroundLocked(false);
|
|
}
|
|
|
|
function requestClosePermissionDialog() {
|
|
if (permissionBusy) return;
|
|
if (validPermissionMode(permissionModeEl.value) &&
|
|
(permissionModeEl.value !== permissionOriginal ||
|
|
(!permissionRecursiveOptionEl.hidden && !permissionRecursiveEl.checked)) &&
|
|
!confirm(t("unsavedPermissionConfirm"))) return;
|
|
closePermissionDialog();
|
|
}
|
|
|
|
async function applyPermissionMode() {
|
|
if (permissionBusy || !permissionItems.length || !validatePermissionMode()) return;
|
|
const items = permissionItems.slice();
|
|
const mode = permissionModeEl.value;
|
|
setPermissionBusy(true);
|
|
setStatus(t("permissionChanging"));
|
|
taskRefreshPath = cwd;
|
|
try {
|
|
const data = await apiForm("/api/chmod", {
|
|
paths: items.map(item => item.path).join("\n"),
|
|
mode,
|
|
recursive: items.some(item => item.type === "d") && permissionRecursiveEl.checked ? "1" : "0"
|
|
});
|
|
setPermissionBusy(false);
|
|
closePermissionDialog();
|
|
setBusy(true);
|
|
trackTask(data.task_id, "chmod", false);
|
|
clearSelection(false);
|
|
setStatus(t("taskCreated", { label: t("permissionsTitle") }));
|
|
await pollTasks();
|
|
} catch (err) {
|
|
setPermissionBusy(false);
|
|
const message = t("permissionChangeFailed", { error: err.message });
|
|
setStatus(message);
|
|
alert(message);
|
|
permissionModeEl.focus();
|
|
}
|
|
}
|
|
|
|
function setTextEditorBusy(value, status) {
|
|
textEditorBusy = value;
|
|
textEditorEl.disabled = value;
|
|
textEditorCloseBtn.disabled = value;
|
|
textEditorSaveBtn.disabled = value;
|
|
textEditorStatusEl.textContent = status || "";
|
|
}
|
|
|
|
function closeTextEditor() {
|
|
if (textEditorBusy) return;
|
|
textEditorOverlayEl.hidden = true;
|
|
setModalBackgroundLocked(false);
|
|
textEditorPath = null;
|
|
textEditorVersion = null;
|
|
textEditorOriginal = "";
|
|
textEditorEl.value = "";
|
|
textEditorStatusEl.textContent = "";
|
|
return load(cwd, false, true);
|
|
}
|
|
|
|
function requestCloseTextEditor() {
|
|
if (textEditorBusy) return;
|
|
if (textEditorEl.value !== textEditorOriginal &&
|
|
!confirm(t("unsavedSaveConfirm"))) return;
|
|
return closeTextEditor();
|
|
}
|
|
|
|
function showTextEditor(item, text, version) {
|
|
setModalBackgroundLocked(true);
|
|
textEditorPath = item.path;
|
|
textEditorVersion = version;
|
|
textEditorPathEl.textContent = displayPath(item.path);
|
|
textEditorPathEl.title = displayPath(item.path);
|
|
textEditorEl.value = text;
|
|
textEditorOriginal = textEditorEl.value;
|
|
textEditorOverlayEl.hidden = false;
|
|
setTextEditorBusy(false, "");
|
|
textEditorEl.focus();
|
|
}
|
|
|
|
async function openTextEditor(item) {
|
|
if (busy || textEditorBusy) return;
|
|
setModalBackgroundLocked(true);
|
|
textEditorPath = item.path;
|
|
textEditorVersion = null;
|
|
textEditorOriginal = "";
|
|
textEditorPathEl.textContent = displayPath(item.path);
|
|
textEditorPathEl.title = displayPath(item.path);
|
|
textEditorEl.value = "";
|
|
textEditorOverlayEl.hidden = false;
|
|
setTextEditorBusy(true, t("loadingText"));
|
|
try {
|
|
const data = await fetchText("/api/text", { path: item.path });
|
|
showTextEditor(item, data.text, data.version);
|
|
} catch (err) {
|
|
setTextEditorBusy(false, "");
|
|
textEditorOverlayEl.hidden = true;
|
|
setModalBackgroundLocked(false);
|
|
textEditorPath = null;
|
|
const message = t("openTextFailed", { error: err.message });
|
|
setStatus(message);
|
|
alert(message);
|
|
await load(cwd, false, true);
|
|
}
|
|
}
|
|
|
|
async function saveTextEditor() {
|
|
if (textEditorBusy || !textEditorPath || !textEditorVersion) return;
|
|
if (textEditorEl.value === textEditorOriginal) {
|
|
await closeTextEditor();
|
|
return;
|
|
}
|
|
const path = textEditorPath;
|
|
setTextEditorBusy(true, t("savingText"));
|
|
try {
|
|
await api("/api/text/save", { path, version: textEditorVersion }, {
|
|
body: textEditorEl.value,
|
|
headers: { "Content-Type": "text/plain; charset=utf-8" }
|
|
});
|
|
setTextEditorBusy(false, "");
|
|
await closeTextEditor();
|
|
setStatus(t("textSaved"));
|
|
} catch (err) {
|
|
setTextEditorBusy(false, "");
|
|
const message = t("saveTextFailed", { error: err.message });
|
|
setStatus(message);
|
|
alert(message);
|
|
}
|
|
}
|
|
|
|
async function actionNewText() {
|
|
if (busy || loadingPath) return;
|
|
const dir = cwd;
|
|
const input = prompt(t("newTextPrompt"));
|
|
if (input === null) return;
|
|
const name = input.trim();
|
|
if (!name) return;
|
|
const item = { name, path: pathJoin(dir, name), type: "-" };
|
|
|
|
try {
|
|
setBusy(true);
|
|
setStatus(t("creatingText"));
|
|
const data = await api("/api/text/create", { path: dir, name });
|
|
setBusy(false);
|
|
showTextEditor(item, "", data.version);
|
|
} catch (err) {
|
|
setBusy(false);
|
|
const message = t("createTextFailed", { error: err.message });
|
|
setStatus(message);
|
|
alert(message);
|
|
}
|
|
}
|
|
|
|
function resetScrollTop() {
|
|
const apply = () => {
|
|
contentEl.scrollTop = 0;
|
|
document.documentElement.scrollTop = 0;
|
|
document.body.scrollTop = 0;
|
|
window.scrollTo(0, 0);
|
|
};
|
|
apply();
|
|
setTimeout(apply, 0);
|
|
if (window.requestAnimationFrame) requestAnimationFrame(apply);
|
|
}
|
|
|
|
function revealPathInList(path) {
|
|
const row = filesEl.querySelector("tr[data-path=\"" + cssEscape(path) + "\"]");
|
|
if (!row) return;
|
|
|
|
const top = row.offsetTop;
|
|
const maxScroll = contentEl.scrollHeight - contentEl.clientHeight;
|
|
if (maxScroll <= 0) return;
|
|
const target = top - Math.floor((contentEl.clientHeight - row.offsetHeight) / 2);
|
|
contentEl.scrollTop = Math.max(0, Math.min(maxScroll, target));
|
|
}
|
|
|
|
function clipboardTitle() {
|
|
if (!clipboard || clipboard.items.length === 0) return "";
|
|
return itemTitle(clipboard.items);
|
|
}
|
|
|
|
function itemTitle(items) {
|
|
if (!items.length) return "";
|
|
return items.length === 1 ? displayName(items[0]) : t("selectedItems", { name: displayName(items[0]), count: items.length });
|
|
}
|
|
|
|
function itemListTitle(items, limit) {
|
|
if (!items.length) return "";
|
|
if (items.length === 1) return displayName(items[0]);
|
|
const shown = items.slice(0, limit).map(displayName).join(", ");
|
|
return items.length > limit ? t("selectedItems", { name: shown, count: items.length }) : shown;
|
|
}
|
|
|
|
function renderSinglePermissionPath() {
|
|
if (permissionOverlayEl.hidden || permissionItems.length !== 1) return;
|
|
const namesEl = permissionPathEl.querySelector(".permission-path-names");
|
|
if (namesEl) renderEndOfPath(namesEl, displayPath(permissionItems[0].path));
|
|
}
|
|
|
|
function renderPermissionItems(items) {
|
|
const names = items.length === 1 ? displayPath(items[0].path) :
|
|
items.map(displayName).join(", ");
|
|
const count = items.length > 1 ? t("permissionObjectCount", { count: items.length }) : "";
|
|
const namesEl = document.createElement("span");
|
|
namesEl.className = "permission-path-names";
|
|
namesEl.textContent = names;
|
|
permissionPathEl.innerHTML = "";
|
|
permissionPathEl.appendChild(namesEl);
|
|
if (count) {
|
|
const countEl = document.createElement("span");
|
|
countEl.className = "permission-path-count";
|
|
countEl.textContent = count;
|
|
permissionPathEl.appendChild(countEl);
|
|
}
|
|
permissionPathEl.title = count ? names + " " + count : names;
|
|
}
|
|
|
|
function compareText(a, b) {
|
|
return String(a || "").localeCompare(String(b || ""));
|
|
}
|
|
|
|
function compareEntries(a, b) {
|
|
if (sortDir === "none") {
|
|
if (a.type === "d" && b.type !== "d") return -1;
|
|
if (a.type !== "d" && b.type === "d") return 1;
|
|
let defaultResult = compareText(displayName(a), displayName(b));
|
|
if (!defaultResult) defaultResult = compareText(a.path, b.path);
|
|
return defaultResult;
|
|
}
|
|
|
|
let result = 0;
|
|
if (sortKey === "name") result = compareText(displayName(a), displayName(b));
|
|
else if (sortKey === "type") result = compareText(itemTypeLabel(a), itemTypeLabel(b));
|
|
else if (sortKey === "size") result = (a.type === "d" ? 0 : Number(a.size || 0)) -
|
|
(b.type === "d" ? 0 : Number(b.size || 0));
|
|
else if (sortKey === "mtime") result = Number(a.mtime || 0) - Number(b.mtime || 0);
|
|
else if (sortKey === "mode") result = Number(a.mode || 0) - Number(b.mode || 0);
|
|
|
|
if (!result) result = compareText(displayName(a), displayName(b));
|
|
if (!result) result = compareText(a.path, b.path);
|
|
return sortDir === "desc" ? -result : result;
|
|
}
|
|
|
|
function itemCountSuffix(items) {
|
|
if (items.length <= 1) return "";
|
|
const text = t("selectedItems", { name: "", count: items.length });
|
|
return text.replace(/^\s+/, "");
|
|
}
|
|
|
|
function pathName(path) {
|
|
const clean = String(path || "").replace(/\/+$/, "");
|
|
const pos = clean.lastIndexOf("/");
|
|
return displayPath(pos >= 0 ? clean.slice(pos + 1) || "/" : clean);
|
|
}
|
|
|
|
function taskSubject(task) {
|
|
const count = Number(task.src_count || 0);
|
|
return count > 1 ? t("countItems", { count }) : pathName(task.src);
|
|
}
|
|
|
|
function renderTaskName(el, task) {
|
|
const target = taskSubject(task);
|
|
const op = document.createElement("span");
|
|
op.className = "task-name-op";
|
|
op.textContent = taskOpLabel(task.op);
|
|
const subject = document.createElement("span");
|
|
subject.className = "task-name-subject";
|
|
subject.textContent = target;
|
|
subject.title = target;
|
|
appendChildren(el, op, subject);
|
|
}
|
|
|
|
function renderClipboard() {
|
|
const hasClipboard = Boolean(clipboard && clipboard.items.length);
|
|
const locked = busy || contentEl.classList.contains("loading");
|
|
pasteBtn.hidden = !hasClipboard;
|
|
clearClipboardBtn.hidden = !hasClipboard;
|
|
if (!hasClipboard) {
|
|
pasteVerbEl.textContent = t("paste");
|
|
pasteNameEl.textContent = "";
|
|
pasteCountEl.textContent = "";
|
|
pasteBtn.title = "";
|
|
pasteBtn.disabled = true;
|
|
clearClipboardBtn.disabled = true;
|
|
return;
|
|
}
|
|
|
|
const title = clipboardTitle();
|
|
pasteVerbEl.textContent = clipboard.op === "move" ? t("move") : t("paste");
|
|
pasteNameEl.textContent = clipboard.items.length === 1 ? title : displayName(clipboard.items[0]);
|
|
pasteCountEl.textContent = itemCountSuffix(clipboard.items);
|
|
pasteBtn.title = t("pasteTitle", { label: clipboard.op === "move" ? t("move") : t("paste"), name: title, path: cwd });
|
|
pasteBtn.disabled = locked;
|
|
clearClipboardBtn.disabled = locked;
|
|
}
|
|
|
|
function renderInstallPkgButton(items, locked) {
|
|
const pkgs = items.filter(isPkgPackage);
|
|
installPkgBtn.hidden = pkgs.length === 0;
|
|
if (!pkgs.length) {
|
|
installPkgBtn.title = "";
|
|
installPkgBtn.disabled = true;
|
|
return;
|
|
}
|
|
const title = itemTitle(pkgs);
|
|
installPkgBtn.title = t("installPackage") + ": " + title;
|
|
installPkgBtn.disabled = locked;
|
|
}
|
|
|
|
function renderExtractButton(items, locked) {
|
|
const zips = items.filter(isZipArchive);
|
|
extractBtn.hidden = zips.length !== 1;
|
|
if (zips.length !== 1) {
|
|
extractBtn.title = "";
|
|
extractBtn.disabled = true;
|
|
return;
|
|
}
|
|
extractBtn.title = t("extractToCurrent") + ": " + itemTitle(zips);
|
|
extractBtn.disabled = locked;
|
|
}
|
|
|
|
function singleSelected() {
|
|
const items = selectedEntries();
|
|
return items.length === 1 ? items[0] : null;
|
|
}
|
|
|
|
function updateButtons() {
|
|
const items = selectedEntries();
|
|
const locked = busy || contentEl.classList.contains("loading");
|
|
for (const button of spaceInfoEl.querySelectorAll(".space-item")) {
|
|
button.disabled = spaceNavigationLocked();
|
|
}
|
|
document.getElementById("copyBtn").disabled = locked || items.length === 0;
|
|
document.getElementById("moveBtn").disabled = locked || items.length === 0;
|
|
document.getElementById("renameBtn").disabled = locked || items.length !== 1;
|
|
document.getElementById("deleteBtn").disabled = locked || items.length === 0;
|
|
downloadBtn.disabled = locked || items.length === 0;
|
|
document.getElementById("refreshBtn").disabled = locked;
|
|
uploadBtn.disabled = locked;
|
|
uploadMenuBtn.disabled = locked;
|
|
uploadFolderBtn.disabled = locked;
|
|
uploadAndExtractBtn.disabled = locked;
|
|
document.getElementById("mkdirBtn").disabled = locked;
|
|
newTextBtn.disabled = locked;
|
|
for (const button of filesEl.querySelectorAll(".row-action, .mode-action")) button.disabled = locked;
|
|
for (const checkbox of filesEl.querySelectorAll(".select-cell input")) checkbox.disabled = locked;
|
|
selectAllEl.disabled = locked;
|
|
parentBtn.disabled = locked || cwd === "/";
|
|
exitBtn.disabled = locked;
|
|
selectAllEl.checked = entries.length > 0 && items.length === entries.length;
|
|
selectAllEl.indeterminate = items.length > 0 && items.length < entries.length;
|
|
renderInstallPkgButton(items, locked);
|
|
renderExtractButton(items, locked);
|
|
renderClipboard();
|
|
}
|
|
|
|
function updateSortHeaders() {
|
|
const headers = document.querySelectorAll("th[data-sort]");
|
|
for (const th of headers) {
|
|
const key = th.getAttribute("data-sort");
|
|
th.classList.remove("sorted-asc");
|
|
th.classList.remove("sorted-desc");
|
|
th.setAttribute("aria-sort", key === sortKey && sortDir !== "none" ?
|
|
(sortDir === "asc" ? "ascending" : "descending") : "none");
|
|
if (key === sortKey && sortDir !== "none") th.classList.add(sortDir === "asc" ? "sorted-asc" : "sorted-desc");
|
|
}
|
|
}
|
|
|
|
async function setSort(key) {
|
|
if (busy || loadingPath) return;
|
|
if (!SORT_KEYS[key]) return;
|
|
if (sortKey !== key || sortDir === "none") {
|
|
sortKey = key;
|
|
sortDir = "asc";
|
|
} else if (sortDir === "asc") {
|
|
sortDir = "desc";
|
|
} else {
|
|
sortKey = "";
|
|
sortDir = "none";
|
|
}
|
|
saveSort();
|
|
updateSortHeaders();
|
|
const useLoading = entries.length >= SELECT_ALL_LOADING_THRESHOLD;
|
|
if (useLoading) {
|
|
showContentLoading(t("processing"));
|
|
await nextPaint();
|
|
}
|
|
entries.sort(compareEntries);
|
|
render();
|
|
if (useLoading) hideContentLoading();
|
|
}
|
|
|
|
function focusPath(path) {
|
|
if (focusedPath === path) return;
|
|
focusedPath = path;
|
|
const old = filesEl.querySelector("tr.focused");
|
|
const next = filesEl.querySelector("tr[data-path=\"" + cssEscape(path) + "\"]");
|
|
if (old) old.classList.remove("focused");
|
|
if (next) next.classList.add("focused");
|
|
}
|
|
|
|
function hoverRow(row) {
|
|
if (hoverPaused) return;
|
|
if (hoveredRow === row) return;
|
|
clearHoverPath();
|
|
hoveredRow = row;
|
|
if (hoveredRow) hoveredRow.classList.add("hovered");
|
|
}
|
|
|
|
function clearHoverPath() {
|
|
if (hoveredRow) hoveredRow.classList.remove("hovered");
|
|
hoveredRow = null;
|
|
}
|
|
|
|
function cssEscape(value) {
|
|
if (window.CSS && CSS.escape) return CSS.escape(value);
|
|
return String(value).replace(/["\\]/g, "\\$&");
|
|
}
|
|
|
|
function appendChildren(parent) {
|
|
for (let i = 1; i < arguments.length; i++) {
|
|
parent.appendChild(arguments[i]);
|
|
}
|
|
}
|
|
|
|
function bindPress(el, fn) {
|
|
let handled = false;
|
|
const run = event => {
|
|
if (handled) {
|
|
if (event && event.preventDefault) event.preventDefault();
|
|
return;
|
|
}
|
|
handled = true;
|
|
if (event && event.preventDefault) event.preventDefault();
|
|
fn();
|
|
setTimeout(() => {
|
|
handled = false;
|
|
}, 350);
|
|
};
|
|
el.addEventListener("mousedown", run);
|
|
el.addEventListener("click", run);
|
|
}
|
|
|
|
function findRowFromTarget(target) {
|
|
while (target && target !== contentEl) {
|
|
if (target.tagName === "TR" && target.getAttribute("data-path") !== null) return target;
|
|
target = target.parentNode;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function togglePath(path, checked) {
|
|
if (checked) selected.add(path);
|
|
else selected.delete(path);
|
|
const row = filesEl.querySelector("tr[data-path=\"" + cssEscape(path) + "\"]");
|
|
if (row) {
|
|
if (checked) row.classList.add("selected");
|
|
else row.classList.remove("selected");
|
|
const checkbox = row.querySelector("input[type=\"checkbox\"]");
|
|
if (checkbox) checkbox.checked = checked;
|
|
}
|
|
updateButtons();
|
|
}
|
|
|
|
function clearSelection(updateVisibleRows) {
|
|
if (selected.size === 0) {
|
|
updateButtons();
|
|
return;
|
|
}
|
|
if (updateVisibleRows === false) {
|
|
selected.clear();
|
|
updateButtons();
|
|
return;
|
|
}
|
|
selected.forEach(path => {
|
|
const row = filesEl.querySelector("tr[data-path=\"" + cssEscape(path) + "\"]");
|
|
if (!row) return;
|
|
row.classList.remove("selected");
|
|
const checkbox = row.querySelector("input[type=\"checkbox\"]");
|
|
if (checkbox) checkbox.checked = false;
|
|
});
|
|
selected.clear();
|
|
updateButtons();
|
|
}
|
|
|
|
function render() {
|
|
hoveredRow = null;
|
|
filesEl.innerHTML = "";
|
|
emptyEl.hidden = entries.length !== 0;
|
|
const fragment = document.createDocumentFragment();
|
|
|
|
const rows = entries;
|
|
|
|
for (const item of rows) {
|
|
const isParent = item.type === "parent";
|
|
const tr = document.createElement("tr");
|
|
tr.dataset.path = item.path;
|
|
tr.dataset.name = item.name;
|
|
tr.dataset.type = item.type;
|
|
tr.className = (!isParent && selected.has(item.path) ? "selected " : "") +
|
|
(focusedPath === item.path ? "focused" : "");
|
|
|
|
const selectTd = document.createElement("td");
|
|
selectTd.className = "select-cell";
|
|
if (!isParent) {
|
|
const label = document.createElement("label");
|
|
label.className = "select-hit";
|
|
const checkbox = document.createElement("input");
|
|
checkbox.type = "checkbox";
|
|
checkbox.checked = selected.has(item.path);
|
|
label.appendChild(checkbox);
|
|
selectTd.appendChild(label);
|
|
}
|
|
|
|
const nameTd = document.createElement("td");
|
|
nameTd.className = "name-col";
|
|
const nameBtn = document.createElement("button");
|
|
nameBtn.className = "row-action";
|
|
nameBtn.type = "button";
|
|
const nameText = document.createElement("span");
|
|
nameText.className = "name-text";
|
|
nameText.textContent = displayName(item);
|
|
nameBtn.title = displayPath(item.path);
|
|
const iconImg = document.createElement("img");
|
|
iconImg.className = "icon" + (isSpecialDirectory(item) ? " special-folder-icon" : "");
|
|
iconImg.src = isParent ? "/icon-up.png" : item.type === "d" ? "/icon-folder.png" :
|
|
isPkgPackage(item) ? "/icon-pkg.png" :
|
|
isPreviewableImage(item) ? "/icon-image.png" :
|
|
isEditableText(item) ? "/icon-file.png" : "/icon-generic.png";
|
|
iconImg.alt = "";
|
|
nameBtn.appendChild(iconImg);
|
|
nameBtn.appendChild(nameText);
|
|
nameTd.appendChild(nameBtn);
|
|
|
|
const modeTd = document.createElement("td");
|
|
modeTd.className = "mode-col";
|
|
if (!isParent) {
|
|
const modeBtn = document.createElement("button");
|
|
modeBtn.className = "mode-action";
|
|
modeBtn.type = "button";
|
|
modeBtn.textContent = permissionModeText(item.mode);
|
|
modeBtn.title = t("permissionChangeTitle", { name: displayName(item) });
|
|
modeBtn.setAttribute("aria-label", modeBtn.title);
|
|
modeBtn.disabled = busy || Boolean(loadingPath);
|
|
modeBtn.addEventListener("click", event => {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
focusPath(item.path);
|
|
openPermissionDialog(item);
|
|
});
|
|
modeTd.appendChild(modeBtn);
|
|
}
|
|
|
|
appendChildren(
|
|
tr,
|
|
selectTd,
|
|
nameTd,
|
|
classCell("type-col", itemTypeLabel(item)),
|
|
classCell("size-col", formatSize(item.size, item.type)),
|
|
classCell("time-col", formatTime(item.mtime)),
|
|
modeTd
|
|
);
|
|
fragment.appendChild(tr);
|
|
}
|
|
filesEl.appendChild(fragment);
|
|
updateButtons();
|
|
}
|
|
|
|
function parentPath(path) {
|
|
if (path === "/") return "/";
|
|
const parts = path.replace(/\/+$/, "").split("/");
|
|
parts.pop();
|
|
return parts.join("/") || "/";
|
|
}
|
|
|
|
function actionParentDirectory(event) {
|
|
if (event) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}
|
|
if (busy || loadingPath || cwd === "/") return;
|
|
if (window.history && history.back && historyPath() === cwd) {
|
|
history.back();
|
|
} else {
|
|
load(parentPath(cwd), undefined, false, true, "push");
|
|
}
|
|
}
|
|
|
|
function cell(text) {
|
|
const td = document.createElement("td");
|
|
td.textContent = text;
|
|
td.title = text;
|
|
return td;
|
|
}
|
|
|
|
function classCell(className, text) {
|
|
const td = cell(text);
|
|
td.className = className;
|
|
return td;
|
|
}
|
|
|
|
async function load(path, scrollTop, force, alertOnError, historyMode) {
|
|
const shouldScrollTop = scrollTop !== false;
|
|
if ((busy && !force) || loadingPath) return;
|
|
loadingPath = path;
|
|
updateButtons();
|
|
startContentLoadingTimer();
|
|
try {
|
|
setStatus(t("readDir"));
|
|
const data = await api("/api/list", { path });
|
|
if (contentLoadingEl.hidden && data.entries && data.entries.length > 80) {
|
|
await showContentLoadingAfterDelay();
|
|
}
|
|
if (!contentLoadingEl.hidden) await nextPaint();
|
|
cwd = data.path;
|
|
renderAddressPath();
|
|
savePath(cwd);
|
|
if (historyMode) writeHistoryPath(cwd, historyMode === "replace");
|
|
refreshSpaces();
|
|
entries = data.entries.sort(compareEntries);
|
|
selected.clear();
|
|
focusedPath = null;
|
|
render();
|
|
if (shouldScrollTop) resetScrollTop();
|
|
setStatus(t("totalItems", { count: entries.length }));
|
|
return true;
|
|
} catch (err) {
|
|
setStatus(err.message);
|
|
if (alertOnError) alert(err.message);
|
|
return false;
|
|
} finally {
|
|
loadingPath = null;
|
|
hideContentLoading();
|
|
}
|
|
}
|
|
|
|
async function loadAndReveal(path, scrollTop, force, alertOnError, historyMode, revealPath) {
|
|
const ok = await load(path, scrollTop, force, alertOnError, historyMode);
|
|
if (ok && revealPath) revealPathInList(revealPath);
|
|
return ok;
|
|
}
|
|
|
|
async function runAction(label, fn, options) {
|
|
try {
|
|
taskRefreshPath = cwd;
|
|
setBusy(true);
|
|
setStatus(t("actionBusy", { label }));
|
|
const data = await fn();
|
|
setStatus(t("taskCreated", { label }));
|
|
trackTask(data.task_id, "delete", false, options && options.clearClipboardOnDone);
|
|
clearSelection(false);
|
|
await pollTasks();
|
|
} catch (err) {
|
|
setBusy(false);
|
|
showActionFailed(label, err.message);
|
|
}
|
|
}
|
|
|
|
async function runImmediateAction(label, fn) {
|
|
const refreshPath = cwd;
|
|
try {
|
|
setBusy(true);
|
|
setStatus(t("actionBusy", { label }));
|
|
await fn();
|
|
clearSelection();
|
|
await load(refreshPath, false, true);
|
|
setStatus(t("actionDone", { label }));
|
|
} catch (err) {
|
|
showActionFailed(label, err.message);
|
|
} finally {
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function setClipboard(op) {
|
|
if (busy || loadingPath || selected.size === 0) return;
|
|
const items = selectedEntries().map(item => ({ path: item.path, name: item.name, type: item.type }));
|
|
if (items.length === 0) return;
|
|
clipboard = { op, items };
|
|
renderClipboard();
|
|
setStatus(t("selectedForPaste", { name: itemTitle(items), verb: op === "move" ? t("move") : t("paste") }));
|
|
}
|
|
|
|
function clearClipboard() {
|
|
if (busy || loadingPath) return;
|
|
clipboard = null;
|
|
renderClipboard();
|
|
setStatus(t("clipboardCleared"));
|
|
}
|
|
|
|
function pathsTouchClipboard(paths) {
|
|
if (!clipboard || !clipboard.items.length) return false;
|
|
for (const path of paths) {
|
|
for (const item of clipboard.items) {
|
|
if (pathIsSameOrChild(path, item.path)) return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
function validatePasteTarget() {
|
|
const byName = new Map(entries.map(item => [item.name, item]));
|
|
const sameTargets = [];
|
|
const sameFiles = [];
|
|
const sameDirs = [];
|
|
|
|
for (const item of clipboard.items) {
|
|
const targetPath = pathJoin(cwd, item.name);
|
|
const existing = byName.get(item.name);
|
|
if (item.path === targetPath) {
|
|
sameTargets.push(item.name);
|
|
continue;
|
|
}
|
|
if (item.type === "d" && pathIsSameOrChild(item.path, targetPath)) {
|
|
return {
|
|
ok: false,
|
|
message: t("err_destination_inside_source")
|
|
};
|
|
}
|
|
if (!existing) continue;
|
|
|
|
if ((item.type === "d") !== (existing.type === "d")) {
|
|
return {
|
|
ok: false,
|
|
message: t("removeConflictFirst", {
|
|
existingType: itemTypeLabel(existing),
|
|
name: item.name,
|
|
label: clipboard.op === "move" ? t("move") : t("copy"),
|
|
sourceType: itemTypeLabel(item)
|
|
})
|
|
};
|
|
}
|
|
if (item.type === "d") sameDirs.push(item.name);
|
|
else sameFiles.push(item.name);
|
|
}
|
|
if (sameTargets.length) {
|
|
return {
|
|
ok: false,
|
|
message: t("sameSourceTarget", {
|
|
label: clipboard.op === "move" ? t("move") : t("copy"),
|
|
name: conflictText(sameTargets)
|
|
})
|
|
};
|
|
}
|
|
|
|
return { ok: true, sameFiles, sameDirs };
|
|
}
|
|
|
|
function conflictText(names) {
|
|
if (!names.length) return "";
|
|
const shown = names.slice(0, 4).map(decodeFsText).join(", ");
|
|
return names.length > 4 ? t("selectedItems", { name: shown, count: names.length }) : shown;
|
|
}
|
|
|
|
function renderPendingOverlay(text, label) {
|
|
tasksEl.innerHTML = "";
|
|
const div = document.createElement("div");
|
|
div.className = "task running";
|
|
|
|
const head = document.createElement("div");
|
|
head.className = "task-head has-amount";
|
|
const name = document.createElement("div");
|
|
name.className = "task-name";
|
|
name.textContent = label || t("preparingTask");
|
|
const amount = document.createElement("div");
|
|
amount.className = "task-amount";
|
|
amount.textContent = t("pleaseWait");
|
|
appendChildren(head, name, amount);
|
|
|
|
const current = document.createElement("div");
|
|
current.className = "task-current";
|
|
current.textContent = displayPath(text);
|
|
|
|
const progress = document.createElement("div");
|
|
progress.className = "progress";
|
|
const progressText = document.createElement("div");
|
|
progressText.className = "progress-text";
|
|
progressText.textContent = t("checking");
|
|
progress.appendChild(progressText);
|
|
|
|
const cancel = document.createElement("button");
|
|
cancel.className = "danger";
|
|
cancel.textContent = t("cancel");
|
|
bindPress(cancel, () => {
|
|
if (pendingAbortController) pendingAbortController.abort();
|
|
pendingAbortController = null;
|
|
pendingOverlayText = "";
|
|
pendingOverlayLabel = "";
|
|
hideTaskOverlay();
|
|
setStatus(t("cancelPrepare"));
|
|
setBusy(false);
|
|
});
|
|
|
|
appendChildren(div, head, current, progress, cancel);
|
|
tasksEl.appendChild(div);
|
|
}
|
|
|
|
function showPendingOverlay(text, label) {
|
|
pendingOverlayText = text;
|
|
pendingOverlayLabel = label || "";
|
|
renderPendingOverlay(text, pendingOverlayLabel);
|
|
showTaskOverlay(true);
|
|
}
|
|
|
|
function renderTaskPath(element, path) {
|
|
path = displayPath(path);
|
|
element.title = path;
|
|
element.textContent = path;
|
|
if (element.scrollWidth <= element.clientWidth) return;
|
|
|
|
let low = 0;
|
|
let high = path.length;
|
|
while (low < high) {
|
|
const count = Math.ceil((low + high) / 2);
|
|
const left = Math.ceil(count / 2);
|
|
const right = Math.floor(count / 2);
|
|
element.textContent = path.slice(0, left) + "..." +
|
|
(right ? path.slice(-right) : "");
|
|
if (element.scrollWidth <= element.clientWidth) low = count;
|
|
else high = count - 1;
|
|
}
|
|
|
|
const left = Math.ceil(low / 2);
|
|
const right = Math.floor(low / 2);
|
|
element.textContent = path.slice(0, left) + "..." +
|
|
(right ? path.slice(-right) : "");
|
|
}
|
|
|
|
async function actionPaste() {
|
|
if (busy || loadingPath || !clipboard || clipboard.items.length === 0) return;
|
|
const op = clipboard.op;
|
|
const label = clipboard.op === "move" ? t("move") : t("copy");
|
|
const check = validatePasteTarget();
|
|
if (!check.ok) {
|
|
alert(check.message);
|
|
setStatus(check.message);
|
|
return;
|
|
}
|
|
|
|
const hasFileConflicts = check.sameFiles.length > 0;
|
|
const hasDirConflicts = check.sameDirs.length > 0;
|
|
if (hasFileConflicts || hasDirConflicts) {
|
|
const lines = [];
|
|
if (hasFileConflicts) lines.push(t("overwriteFiles", { names: conflictText(check.sameFiles) }));
|
|
if (hasDirConflicts) lines.push(t("mergeDirs", { names: conflictText(check.sameDirs) }));
|
|
if (!confirm(lines.join("\n") + "\n\n" + t("continueConfirm"))) return;
|
|
}
|
|
|
|
try {
|
|
pendingAbortController = new AbortController();
|
|
taskRefreshPath = cwd;
|
|
showPendingOverlay(t("preparingPaste", { label }), label);
|
|
setBusy(true);
|
|
setStatus(t("preparingStatus", { label }));
|
|
await nextPaint();
|
|
const payload = {
|
|
paths: clipboard.items.map(item => item.path).join("\n"),
|
|
dst: cwd,
|
|
overwrite: hasFileConflicts || hasDirConflicts ? "1" : "0"
|
|
};
|
|
const data = await apiForm("/api/" + op, payload, { signal: pendingAbortController.signal });
|
|
pendingAbortController = null;
|
|
pendingOverlayText = "";
|
|
pendingOverlayLabel = "";
|
|
setStatus(t("taskCreated", { label }));
|
|
trackTask(data.task_id, op, true);
|
|
clearSelection(false);
|
|
await pollTasks();
|
|
} catch (err) {
|
|
const aborted = err && err.name === "AbortError";
|
|
pendingAbortController = null;
|
|
pendingOverlayText = "";
|
|
pendingOverlayLabel = "";
|
|
hideTaskOverlay();
|
|
if (aborted) setStatus(t("cancelPrepare"));
|
|
else showActionFailed(label, err.message);
|
|
renderClipboard();
|
|
setBusy(false);
|
|
}
|
|
}
|
|
|
|
function renderTasks(tasks) {
|
|
const fileTasks = tasks.filter(task => task.op !== "pkg_install");
|
|
const active = fileTasks.find(task => task.state === "queued" || task.state === "running");
|
|
if (!active && pendingOverlayText) {
|
|
renderPendingOverlay(pendingOverlayText, pendingOverlayLabel);
|
|
showTaskOverlay();
|
|
return;
|
|
}
|
|
|
|
tasksEl.innerHTML = "";
|
|
const hasActive = Boolean(active);
|
|
if (hasActive) showTaskOverlay(false, active.op === "download" ?
|
|
DOWNLOAD_OVERLAY_DISPLAY_DELAY : LOADING_DISPLAY_DELAY);
|
|
else {
|
|
hideTaskOverlay();
|
|
if (!localActionBusy) setBusy(false);
|
|
}
|
|
|
|
if (!hasActive) return;
|
|
|
|
const task = active;
|
|
const total = Number(task.total || 0);
|
|
const done = Number(task.done || 0);
|
|
const speed = Number(task.speed || 0);
|
|
const isDelete = task.op === "delete";
|
|
const isDownload = task.op === "download";
|
|
const isChmod = task.op === "chmod";
|
|
const isExtract = task.op === "extract";
|
|
const isPreparing = (task.op === "copy" || task.op === "move" || isChmod) &&
|
|
task.state === "running" && done === 0;
|
|
const isFinishing = !isDelete && !isDownload && task.state === "running" && total > 0 && done >= total;
|
|
const pct = total > 0 ? Math.min(100, Math.floor(done * 100 / total)) : 0;
|
|
const div = document.createElement("div");
|
|
div.className = "task " + task.state;
|
|
|
|
const head = document.createElement("div");
|
|
head.className = "task-head";
|
|
const name = document.createElement("div");
|
|
name.className = "task-name";
|
|
renderTaskName(name, task);
|
|
head.appendChild(name);
|
|
const elapsed = document.createElement("div");
|
|
elapsed.className = "task-elapsed";
|
|
elapsed.textContent = t("elapsedLabel") + ": " + taskElapsed(task);
|
|
head.appendChild(elapsed);
|
|
|
|
const current = document.createElement("div");
|
|
current.className = "task-current";
|
|
if (task.error) {
|
|
current.className += " task-current-plain";
|
|
current.textContent = task.error;
|
|
}
|
|
else current.textContent = displayPath(task.current || task.src);
|
|
|
|
const meta = document.createElement("div");
|
|
meta.className = "task-meta";
|
|
const speedItem = document.createElement("div");
|
|
speedItem.textContent = t("speedLabel") + ": " + (isChmod ?
|
|
t("itemsPerSecond", { count: Math.round(speed) }) : formatSpeed(speed));
|
|
const progressItem = document.createElement("div");
|
|
progressItem.textContent = t("progressLabel") + ": " + (isExtract && Number(task.entries_total) ?
|
|
t("extractProgress", { done: Number(task.entries_done || 0), total: Number(task.entries_total || 0) }) :
|
|
isChmod ? t("permissionProgress", { done, total }) : formatSize(done) + " / " + formatSize(total));
|
|
const etaItem = document.createElement("div");
|
|
etaItem.textContent = t("etaLabel") + ": " + averageEta(task, done, total);
|
|
appendChildren(meta, speedItem, progressItem, etaItem);
|
|
|
|
const progress = document.createElement("div");
|
|
progress.className = "progress";
|
|
const bar = document.createElement("div");
|
|
bar.className = "progress-bar";
|
|
bar.style.width = isDelete ? "0" : pct + "%";
|
|
const progressText = document.createElement("div");
|
|
progressText.className = "progress-text";
|
|
progressText.textContent = isDownload ? (total ? pct + "%" : t("preparing")) :
|
|
isPreparing ? t("preparing") : isFinishing ? t("finishing") :
|
|
total ? pct + "%" : stateLabel(task.state);
|
|
appendChildren(progress, bar, progressText);
|
|
|
|
const cancel = document.createElement("button");
|
|
cancel.className = "danger";
|
|
cancel.textContent = task.cancel_requested ? t("canceling") : t("cancel");
|
|
cancel.disabled = task.cancel_requested;
|
|
bindPress(cancel, () => {
|
|
if (confirm(t("cancelTaskConfirm", { label: opLabel(task.op) }))) {
|
|
requestTaskCancel(task.id);
|
|
api("/api/cancel", { id: task.id }).then(() => {
|
|
if (task.op !== "upload") return pollTasks();
|
|
if (uploadXhr) uploadXhr.abort();
|
|
return api("/api/upload/finish", { task_id: task.id })
|
|
.catch(() => {})
|
|
.then(pollTasks);
|
|
}).catch(err => {
|
|
if (trackedTask && trackedTask.id === task.id) trackedTask.cancelRequested = false;
|
|
setStatus(t("cancelFailed", { error: err.message }));
|
|
});
|
|
}
|
|
});
|
|
|
|
if (isDelete) appendChildren(div, head, current, cancel);
|
|
else if (total && !isFinishing) appendChildren(div, head, current, meta, progress, cancel);
|
|
else appendChildren(div, head, current, progress, cancel);
|
|
tasksEl.appendChild(div);
|
|
if (!task.error) renderTaskPath(current, task.current || task.src);
|
|
}
|
|
|
|
async function pollTasks() {
|
|
try {
|
|
const data = await api("/api/tasks");
|
|
taskPollFailedAlertShown = false;
|
|
const tasks = data.tasks || [];
|
|
const fileTasks = tasks.filter(task => task.op !== "pkg_install");
|
|
const completion = data.completion;
|
|
if (lastCompletionId === null) {
|
|
lastCompletionId = completion ? completion.id : 0;
|
|
} else if (completion && completion.id !== lastCompletionId) {
|
|
lastCompletionId = completion.id;
|
|
alert(t(completion.file_count ? "transferCompleteFiles" : "transferComplete", {
|
|
label: opLabel(completion.op),
|
|
name: taskSubject(completion),
|
|
duration: formatDuration(Number(completion.elapsed || 0)),
|
|
size: formatBytes(completion.total, true),
|
|
count: completion.file_count
|
|
}));
|
|
}
|
|
const wasBusy = busy;
|
|
const wasTaskBusy = Boolean(trackedTask || pendingOverlayText ||
|
|
!overlayEl.hidden || taskOverlayTimer);
|
|
renderTasks(tasks);
|
|
const becameIdle = wasBusy && !busy;
|
|
const taskBecameIdle = wasTaskBusy && !busy;
|
|
let shouldRefresh = false;
|
|
let sawTrackedTask = false;
|
|
for (const task of tasks) {
|
|
if (trackedTask && trackedTask.id === task.id) {
|
|
sawTrackedTask = true;
|
|
if (task.cancel_requested) trackedTask.cancelRequested = true;
|
|
if (trackedTask.state === task.state) continue;
|
|
trackedTask.state = task.state;
|
|
}
|
|
if (isTerminalTask(task)) {
|
|
if (task.op !== "pkg_install") shouldRefresh = true;
|
|
handleTerminalTask(task);
|
|
}
|
|
}
|
|
if (trackedTask && becameIdle && !sawTrackedTask) {
|
|
if ((trackedTask.fromClipboard || trackedTask.clearClipboardOnDone) &&
|
|
!trackedTask.cancelRequested) clearClipboardAfterPaste();
|
|
shouldRefresh = true;
|
|
clearTrackedTask();
|
|
}
|
|
if (taskBecameIdle && !fileTasks.length) shouldRefresh = true;
|
|
if (shouldRefresh) {
|
|
if (taskBecameIdle) startContentLoadingTimer();
|
|
clearSelection(false);
|
|
await load(taskRefreshPath || cwd, false);
|
|
await refreshSpaces();
|
|
}
|
|
} catch (err) {
|
|
const message = t("tasksPollFailed", { error: err.message });
|
|
setStatus(message);
|
|
}
|
|
}
|
|
|
|
function actionCopy() {
|
|
setClipboard("copy");
|
|
}
|
|
|
|
function actionMove() {
|
|
setClipboard("move");
|
|
}
|
|
|
|
function actionRename() {
|
|
if (busy || loadingPath) return;
|
|
const item = singleSelected();
|
|
if (!item) return;
|
|
const name = (prompt(t("renamePrompt"), displayName(item)) || "").trim();
|
|
if (!name || name === displayName(item)) return;
|
|
runImmediateAction(t("rename"), () => api("/api/rename", { path: item.path, name }));
|
|
}
|
|
|
|
function actionDelete() {
|
|
if (busy || loadingPath || selected.size === 0) return;
|
|
const items = selectedEntries();
|
|
const paths = items.map(item => item.path);
|
|
const target = itemListTitle(items, 4);
|
|
const confirmKey = items.some(item => item.type === "d") ?
|
|
"deleteConfirmRecursive" : "deleteConfirm";
|
|
if (!confirm(t(confirmKey, { name: target }))) return;
|
|
runAction(t("delete"), () => apiForm("/api/delete", { paths: paths.join("\n") }), {
|
|
clearClipboardOnDone: pathsTouchClipboard(paths)
|
|
});
|
|
}
|
|
|
|
async function actionDownload() {
|
|
if (busy || loadingPath || selected.size === 0) return;
|
|
const items = selectedEntries();
|
|
const label = t("download");
|
|
try {
|
|
setBusy(true);
|
|
taskRefreshPath = cwd;
|
|
setStatus(t("actionBusy", { label }));
|
|
const data = await apiForm("/api/download/prepare", {
|
|
paths: items.map(item => item.path).join("\n")
|
|
});
|
|
trackTask(data.task_id, "download", false);
|
|
if (downloadFrame) downloadFrame.parentNode.removeChild(downloadFrame);
|
|
downloadFrame = document.createElement("iframe");
|
|
downloadFrame.hidden = true;
|
|
downloadFrame.src = "/api/download?id=" + encodeURIComponent(data.task_id);
|
|
document.body.appendChild(downloadFrame);
|
|
setStatus(t("downloadStarted", { name: itemTitle(items) }));
|
|
await pollTasks();
|
|
} catch (err) {
|
|
setBusy(false);
|
|
showActionFailed(label, err.message);
|
|
}
|
|
}
|
|
|
|
function uploadRelativeName(file) {
|
|
return file.webkitRelativePath || file.name;
|
|
}
|
|
|
|
function uploadConflicts(rels) {
|
|
const names = {};
|
|
const conflicts = [];
|
|
for (const item of entries) names[item.name] = true;
|
|
for (const rel of rels) {
|
|
const top = rel.split("/")[0];
|
|
if (names[top] && conflicts.indexOf(top) < 0) conflicts.push(top);
|
|
}
|
|
return conflicts;
|
|
}
|
|
|
|
function uploadFileRequest(taskId, file, rel, overwrite, index) {
|
|
return new Promise((resolve, reject) => {
|
|
const xhr = new XMLHttpRequest();
|
|
uploadXhr = xhr;
|
|
xhr.open("POST", "/api/upload-file", true);
|
|
xhr.setRequestHeader("Content-Type", "application/octet-stream");
|
|
xhr.setRequestHeader("X-WFM-Task-ID", String(taskId));
|
|
xhr.setRequestHeader("X-WFM-Path", encodeURIComponent(cwd));
|
|
xhr.setRequestHeader("X-WFM-Rel", encodeURIComponent(rel));
|
|
xhr.setRequestHeader("X-WFM-Size", String(file.size));
|
|
xhr.setRequestHeader("X-WFM-Overwrite", overwrite ? "1" : "0");
|
|
xhr.onload = () => {
|
|
uploadXhr = null;
|
|
if (xhr.status >= 200 && xhr.status < 300) {
|
|
resolve();
|
|
return;
|
|
}
|
|
try {
|
|
const data = JSON.parse(xhr.responseText || "{}");
|
|
reject(new Error(backendErrorText(data.error_code, data.error_arg, data.error)));
|
|
} catch (err) {
|
|
reject(new Error(xhr.statusText || String(xhr.status)));
|
|
}
|
|
};
|
|
xhr.onerror = () => {
|
|
uploadXhr = null;
|
|
const error = new Error(xhr.statusText || t("backendError"));
|
|
api("/api/tasks").then(data => {
|
|
const task = (data.tasks || []).find(item => item.id === taskId);
|
|
if (task && Number(task.completed_count || 0) >= index) {
|
|
resolve();
|
|
} else if (task && task.state === "failed") {
|
|
reject(new Error(backendErrorText(task.error_code, task.error_arg,
|
|
task.error || task.current)));
|
|
} else {
|
|
reject(error);
|
|
}
|
|
}).catch(() => reject(error));
|
|
};
|
|
xhr.onabort = () => {
|
|
uploadXhr = null;
|
|
const terminal = uploadTerminalAbort;
|
|
uploadTerminalAbort = false;
|
|
const err = new Error("aborted");
|
|
err.name = terminal ? "TerminalTaskAbort" : "AbortError";
|
|
reject(err);
|
|
};
|
|
xhr.send(file);
|
|
});
|
|
}
|
|
|
|
async function uploadFiles(files) {
|
|
if (busy || loadingPath || !files.length) return;
|
|
const useLoading = files.length >= SELECT_ALL_LOADING_THRESHOLD;
|
|
let list;
|
|
let rels = [];
|
|
let sizes = [];
|
|
let total = 0;
|
|
let conflicts = [];
|
|
|
|
if (useLoading) {
|
|
showContentLoading(t("processing"));
|
|
await nextPaint();
|
|
}
|
|
try {
|
|
list = Array.prototype.slice.call(files);
|
|
for (let i = 0; i < list.length; i++) {
|
|
const file = list[i];
|
|
rels.push(uploadRelativeName(file));
|
|
sizes.push(String(file.size || 0));
|
|
total += Number(file.size || 0);
|
|
}
|
|
conflicts = uploadConflicts(rels);
|
|
} finally {
|
|
if (useLoading) hideContentLoading();
|
|
}
|
|
|
|
const overwrite = conflicts.length &&
|
|
confirm(t("uploadOverwriteConfirm", { names: conflictText(conflicts) }));
|
|
if (conflicts.length && !overwrite) return;
|
|
|
|
let taskId = 0;
|
|
|
|
try {
|
|
setBusy(true);
|
|
taskRefreshPath = cwd;
|
|
const task = await apiForm("/api/upload/prepare", {
|
|
path: cwd,
|
|
src: rels[0],
|
|
total,
|
|
count: list.length,
|
|
rels: rels.join("\n"),
|
|
sizes: sizes.join("\n"),
|
|
overwrite: overwrite ? "1" : "0"
|
|
});
|
|
taskId = task.task_id;
|
|
trackTask(taskId, "upload", false);
|
|
await pollTasks();
|
|
for (let i = 0; i < list.length; i++) {
|
|
const file = list[i];
|
|
const rel = rels[i];
|
|
setStatus(t("uploadingStatus", { index: i + 1, count: list.length, name: rel }));
|
|
await uploadFileRequest(taskId, file, rel, overwrite, i + 1);
|
|
}
|
|
await api("/api/upload/finish", { task_id: taskId });
|
|
await pollTasks();
|
|
setStatus(t("uploadDone", { count: list.length }));
|
|
} catch (err) {
|
|
const terminalAbort = err && err.name === "TerminalTaskAbort";
|
|
const canceled = err && err.name === "AbortError";
|
|
if (!terminalAbort) {
|
|
if (taskId) {
|
|
try {
|
|
await api("/api/cancel", { id: taskId });
|
|
await api("/api/upload/finish", { task_id: taskId });
|
|
} catch (cancelErr) {
|
|
/* The server may already have moved a failed upload to a terminal state. */
|
|
}
|
|
}
|
|
await pollTasks();
|
|
if (canceled) {
|
|
setStatus(t("taskCanceled", { label: opLabel("upload") }));
|
|
} else {
|
|
const message = t("uploadFailed", { error: err.message });
|
|
setStatus(message);
|
|
alert(message);
|
|
}
|
|
}
|
|
} finally {
|
|
uploadXhr = null;
|
|
uploadFilesEl.value = "";
|
|
uploadFolderEl.value = "";
|
|
if (!trackedTask) setBusy(false);
|
|
}
|
|
}
|
|
|
|
function actionUploadFiles() {
|
|
if (busy || loadingPath) return;
|
|
uploadMenuEl.classList.remove("open");
|
|
uploadFilesEl.click();
|
|
}
|
|
|
|
function actionUploadFolder() {
|
|
if (busy || loadingPath) return;
|
|
uploadMenuEl.classList.remove("open");
|
|
uploadFolderEl.click();
|
|
}
|
|
|
|
function toggleUploadMenu() {
|
|
if (busy || loadingPath) return;
|
|
uploadMenuEl.classList.toggle("open");
|
|
}
|
|
|
|
function actionUploadAndExtract() {
|
|
if (busy || loadingPath) return;
|
|
uploadMenuEl.classList.remove("open");
|
|
uploadZipEl.click();
|
|
}
|
|
|
|
async function uploadAndExtractFile(file) {
|
|
if (busy || loadingPath) return;
|
|
if (!/\.zip$/i.test(file.name || "")) {
|
|
alert(t("err_extract_unsupported", { arg: file.name }));
|
|
return;
|
|
}
|
|
const rel = uploadRelativeName(file);
|
|
const zipPath = pathJoin(cwd, rel);
|
|
if (!confirm(t("extractUploadConfirm", { name: rel, path: displayPath(cwd) }))) return;
|
|
const conflict = confirm(t("extractOverwriteAsk")) ? "overwrite" : "fail";
|
|
|
|
let taskId = 0;
|
|
try {
|
|
taskRefreshPath = cwd;
|
|
setBusy(true);
|
|
const task = await apiForm("/api/upload/prepare", {
|
|
path: cwd,
|
|
src: rel,
|
|
total: file.size,
|
|
count: 1,
|
|
rels: rel,
|
|
sizes: String(file.size || 0),
|
|
overwrite: "1"
|
|
});
|
|
taskId = task.task_id;
|
|
trackTask(taskId, "upload", false);
|
|
await pollTasks();
|
|
setStatus(t("uploadingStatus", { index: 1, count: 1, name: rel }));
|
|
await uploadFileRequest(taskId, file, rel, true, 1);
|
|
await api("/api/upload/finish", { task_id: taskId });
|
|
await pollTasks();
|
|
await startExtractTask(zipPath, cwd, conflict, true, rel);
|
|
} catch (err) {
|
|
const terminalAbort = err && err.name === "TerminalTaskAbort";
|
|
const canceled = err && err.name === "AbortError";
|
|
if (!terminalAbort) {
|
|
if (taskId) {
|
|
try {
|
|
await api("/api/cancel", { id: taskId });
|
|
await api("/api/upload/finish", { task_id: taskId });
|
|
} catch (cancelErr) {
|
|
/* The server may already have moved a failed upload to a terminal state. */
|
|
}
|
|
}
|
|
await pollTasks();
|
|
if (canceled) {
|
|
setStatus(t("taskCanceled", { label: opLabel("upload") }));
|
|
} else {
|
|
const message = t("uploadFailed", { error: err.message });
|
|
setStatus(message);
|
|
alert(message);
|
|
}
|
|
}
|
|
} finally {
|
|
uploadXhr = null;
|
|
uploadZipEl.value = "";
|
|
if (!trackedTask) setBusy(false);
|
|
}
|
|
}
|
|
|
|
function actionExit() {
|
|
if (!confirm(t("exitConfirm"))) return;
|
|
setStatus(t("exiting"));
|
|
exitBtn.disabled = true;
|
|
api("/api/exit").catch(err => {
|
|
exitBtn.disabled = false;
|
|
showActionFailed(t("exit"), err.message);
|
|
});
|
|
}
|
|
|
|
document.getElementById("refreshBtn").addEventListener("click", () => load(cwd, false));
|
|
document.getElementById("mkdirBtn").addEventListener("click", () => {
|
|
if (busy || loadingPath) return;
|
|
const path = cwd;
|
|
const name = (prompt(t("mkdirPrompt")) || "").trim();
|
|
if (!name) return;
|
|
runImmediateAction(t("mkdir"), () => api("/api/mkdir", { path, name }));
|
|
});
|
|
document.getElementById("copyBtn").addEventListener("click", actionCopy);
|
|
document.getElementById("moveBtn").addEventListener("click", actionMove);
|
|
pasteBtn.addEventListener("click", actionPaste);
|
|
installPkgBtn.addEventListener("click", actionInstallSelectedPkgs);
|
|
extractBtn.addEventListener("click", actionExtract);
|
|
clearClipboardBtn.addEventListener("click", clearClipboard);
|
|
document.getElementById("renameBtn").addEventListener("click", actionRename);
|
|
downloadBtn.addEventListener("click", actionDownload);
|
|
document.getElementById("deleteBtn").addEventListener("click", actionDelete);
|
|
uploadBtn.addEventListener("click", actionUploadFiles);
|
|
uploadMenuBtn.addEventListener("click", toggleUploadMenu);
|
|
uploadFolderBtn.addEventListener("click", actionUploadFolder);
|
|
uploadAndExtractBtn.addEventListener("click", actionUploadAndExtract);
|
|
uploadFilesEl.addEventListener("change", () => uploadFiles(uploadFilesEl.files));
|
|
uploadFolderEl.addEventListener("change", () => uploadFiles(uploadFolderEl.files));
|
|
uploadZipEl.addEventListener("change", () => {
|
|
if (uploadZipEl.files.length === 1) uploadAndExtractFile(uploadZipEl.files[0]);
|
|
else uploadZipEl.value = "";
|
|
});
|
|
exitBtn.addEventListener("click", actionExit);
|
|
parentBtn.addEventListener("click", actionParentDirectory);
|
|
textEditorCloseBtn.addEventListener("click", requestCloseTextEditor);
|
|
textEditorSaveBtn.addEventListener("click", saveTextEditor);
|
|
newTextBtn.addEventListener("click", actionNewText);
|
|
imagePreviewCloseBtn.addEventListener("click", closeImagePreview);
|
|
pkgInfoCloseBtn.addEventListener("click", closePkgInfo);
|
|
pkgInfoInstallBtn.addEventListener("click", installPkgFromInfo);
|
|
pkgInfoImageEl.addEventListener("error", () => {
|
|
if (pkgInfoImageEl.getAttribute("src") !== "/icon-pkg.png") {
|
|
pkgInfoImageEl.src = "/icon-pkg.png";
|
|
}
|
|
});
|
|
permissionCancelBtn.addEventListener("click", requestClosePermissionDialog);
|
|
permissionApplyBtn.addEventListener("click", applyPermissionMode);
|
|
permissionModeEl.addEventListener("input", () => {
|
|
if (validPermissionMode(permissionModeEl.value)) syncPermissionChecks(permissionModeEl.value);
|
|
});
|
|
permissionModeEl.addEventListener("change", validatePermissionMode);
|
|
for (const checkbox of permissionChecks) checkbox.addEventListener("change", syncPermissionMode);
|
|
window.addEventListener("resize", () => {
|
|
renderAddressPath();
|
|
renderSinglePermissionPath();
|
|
});
|
|
window.addEventListener("popstate", event => {
|
|
if (historyBlocked()) {
|
|
writeHistoryPath(cwd, false);
|
|
if (!textEditorOverlayEl.hidden) {
|
|
requestCloseTextEditor();
|
|
return;
|
|
}
|
|
if (!permissionOverlayEl.hidden) {
|
|
requestClosePermissionDialog();
|
|
return;
|
|
}
|
|
if (!pkgInfoOverlayEl.hidden) {
|
|
closePkgInfo();
|
|
return;
|
|
}
|
|
setStatus(t("activeTask"));
|
|
return;
|
|
}
|
|
const path = event.state && event.state.path ? event.state.path : historyPath();
|
|
if (path && path !== cwd) {
|
|
const revealPath = parentPath(cwd) === path ? cwd : "";
|
|
loadAndReveal(path, false, false, true, null, revealPath);
|
|
}
|
|
});
|
|
document.addEventListener("click", event => {
|
|
if (!uploadMenuEl || uploadMenuEl.contains(event.target)) return;
|
|
uploadMenuEl.classList.remove("open");
|
|
});
|
|
document.querySelector("thead").addEventListener("click", event => {
|
|
if (busy || loadingPath) return;
|
|
if (parentBtn.contains(event.target)) return;
|
|
let target = event.target;
|
|
while (target && target.tagName !== "TH") target = target.parentNode;
|
|
if (target && target.getAttribute("data-sort")) setSort(target.getAttribute("data-sort"));
|
|
});
|
|
textEditorOverlayEl.addEventListener("click", event => {
|
|
if (event.target === textEditorOverlayEl) requestCloseTextEditor();
|
|
});
|
|
imagePreviewOverlayEl.addEventListener("click", event => {
|
|
if (event.target === imagePreviewOverlayEl) closeImagePreview();
|
|
});
|
|
pkgInfoOverlayEl.addEventListener("click", event => {
|
|
if (event.target === pkgInfoOverlayEl) closePkgInfo();
|
|
});
|
|
permissionOverlayEl.addEventListener("click", event => {
|
|
if (event.target === permissionOverlayEl) requestClosePermissionDialog();
|
|
});
|
|
contentEl.addEventListener("click", event => {
|
|
if (!busy && !loadingPath) return;
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
}, true);
|
|
filesEl.addEventListener("focusin", event => {
|
|
const row = findRowFromTarget(event.target);
|
|
if (row) focusPath(row.dataset.path);
|
|
});
|
|
filesEl.addEventListener("change", event => {
|
|
if (busy || loadingPath) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
const row = findRowFromTarget(event.target);
|
|
if (row && event.target.type === "checkbox") togglePath(row.dataset.path, event.target.checked);
|
|
});
|
|
filesEl.addEventListener("click", event => {
|
|
if (busy || loadingPath) {
|
|
event.preventDefault();
|
|
return;
|
|
}
|
|
const row = findRowFromTarget(event.target);
|
|
if (!row) return;
|
|
let target = event.target;
|
|
while (target && target !== row && !target.classList.contains("row-action")) target = target.parentNode;
|
|
if (target === row && row.dataset.type !== "parent") return;
|
|
const item = {
|
|
path: row.dataset.path,
|
|
name: row.dataset.name,
|
|
type: row.dataset.type,
|
|
displayName: decodeFsText(row.dataset.name)
|
|
};
|
|
focusPath(item.path);
|
|
if (item.type === "parent" || item.type === "d") {
|
|
const revealPath = item.type === "parent" ? cwd : "";
|
|
setTimeout(async () => {
|
|
if (await load(item.path, item.type === "parent" ? false : undefined, false, true, "push")) {
|
|
if (revealPath) revealPathInList(revealPath);
|
|
} else await load(cwd, false, true);
|
|
}, 0);
|
|
}
|
|
else if (isPkgPackage(item)) openPkgInfo(item);
|
|
else if (isPreviewableImage(item)) openImagePreview(item);
|
|
else if (isEditableText(item)) openTextEditor(item);
|
|
else togglePath(item.path, !selected.has(item.path));
|
|
});
|
|
selectAllEl.addEventListener("change", async () => {
|
|
if (busy || loadingPath) {
|
|
selectAllEl.checked = selected.size > 0 && selected.size === entries.length;
|
|
return;
|
|
}
|
|
const checked = selectAllEl.checked;
|
|
const useLoading = entries.length >= SELECT_ALL_LOADING_THRESHOLD;
|
|
if (useLoading) {
|
|
showContentLoading(t("processing"));
|
|
await nextPaint();
|
|
}
|
|
selected.clear();
|
|
if (checked) entries.forEach(item => selected.add(item.path));
|
|
render();
|
|
if (useLoading) hideContentLoading();
|
|
});
|
|
contentEl.addEventListener("mousemove", event => {
|
|
hoverRow(findRowFromTarget(event.target));
|
|
});
|
|
contentEl.addEventListener("mouseleave", () => hoverRow(null));
|
|
contentEl.addEventListener("scroll", () => {
|
|
hoverPaused = true;
|
|
clearHoverPath();
|
|
clearTimeout(hoverResumeTimer);
|
|
hoverResumeTimer = setTimeout(() => {
|
|
hoverPaused = false;
|
|
}, 160);
|
|
});
|
|
|
|
async function init() {
|
|
await loadLanguage();
|
|
applyStaticText();
|
|
readSavedSort();
|
|
updateSortHeaders();
|
|
const savedPath = historyPath() || readSavedPath();
|
|
cwd = savedPath;
|
|
renderAddressPath();
|
|
taskRefreshPath = savedPath;
|
|
refreshSpaces();
|
|
await pollTasks();
|
|
if (busy) {
|
|
setStatus(t("activeTask"));
|
|
return;
|
|
}
|
|
if (await load(savedPath || "/", undefined, false, false)) {
|
|
seedHistoryPath(cwd);
|
|
} else if (savedPath !== "/" && await load("/", undefined, false, false)) {
|
|
seedHistoryPath(cwd);
|
|
}
|
|
}
|
|
|
|
init();
|
|
setInterval(pollTasks, 1000);
|
|
setInterval(refreshSpaces, 10000);
|