mirror of
https://github.com/LisherSong/ps5-web-file-manager.git
synced 2026-10-06 07:00:28 +02:00
First release under the fork-marker convention: VERSION_TAG carries a trailing
`M`, so /api/version, the PS5 start-up notification, the stdout banner, the UI
footer and the ELF file name all read "v1.9.3M" in one move -- and a fork build
can no longer collide with an upstream artifact of the same version, a mix-up
that already happened twice. The footer carries a tooltip spelling the marker
out.
Two bodies of work.
1. Encrypted archives (ZIP / RAR / 7z)
- ZIP: ZipCrypto (traditional PKWARE) and WinZip AES-256, through
minizip-ng plus a vendored crypto layer (mz_crypt_wfm.c,
mz_strm_pkcrypt.c, mz_strm_wzaes.c).
- RAR: RARSetPassword, wired after RAROpenArchiveEx and before the first
RARReadHeaderEx. The ordering is load-bearing, not stylistic.
- 7z: 7zAES including -mhe=on encrypted headers, via a virtual
ISeekInStream that splices a pseudo-header + the real archive + the
decrypted header, so no offset stored inside the archive has to move.
A wrong password is reported as ZIPX_ERR_PASSWORD, and a failed attempt
leaves no staging directory behind.
2. Reporting, and the UI round that on-device testing produced
- A RAR whose dictionary exceeds what the build supports now gets its own
extract_dict_too_large code instead of being mis-reported as "entry too
large"; the message names both the required and the supported size. The
behaviour is deliberately unchanged -- such archives are still refused,
because admitting one means allocating the whole window up front, which
is why rarlab's own CLI refuses them by default.
- The upload entry is a menu again: one "Upload" button opening "Upload
files / Upload folder". The previous main-button-plus-small-arrow made
"upload folder" effectively undiscoverable.
- A drag-and-drop hint sits in the footer (hidden on the console browser,
where drag is not how anyone uploads).
- The extract button is now always present and merely disabled until
exactly one archive is selected, instead of appearing out of nowhere.
- Upload-and-extract on an encrypted archive now prompts for the password
directly. The retry table used to be keyed by PATH, and for a non-ASCII
directory the string the page holds and the string the server reports
are not the same bytes -- the lookup missed, so the user got a bare
error box and had to press Extract by hand before the prompt appeared.
It is now keyed by task id, which the server assigns and echoes back
verbatim.
- Error text passes through decodeFsText(), so a GBK entry name no longer
surfaces as `â®…ç§.psd`.
- Local names are encoded with encodeFsText() before being joined onto a
server-side path. fs_path_value() declines to rewrite a path if ANY code
point exceeds 0xFF, so concatenating a local name onto a server directory
produced a mixed representation and a silently dead path.
- The menu row highlight was losing the cascade to the generic button rule
(identical specificity, later in the file) while inheriting the toolbar's
3px focus ring, which overflowed a 46px row. Both rules are now scoped to
the panel and the keyboard cue is an inset ring, so it cannot escape the
row at any line height.
- The footer status line is clamped to a single line; a long
"uploading 3/12: some-name.zip" used to wrap out of the 46px footer.
- A first failed password attempt now says the archive is encrypted,
instead of blaming a password the user was never asked for.
Artifact
web-file-mgr-v1.9.3M.elf
903,448 B
sha256 8ca47d5aaca75085b32641300cce30fadb7df7749cb6b53d04f129bcecc286b7
e_machine 0x003e (x86-64 / PS5)
The file size is identical to the four builds before it, and every one of
them carries a different sha256: only .rodata moved, and by less than the
16 KiB section alignment absorbs. Compare sections with `readelf -SW` --
never infer "nothing changed" from the byte count.
Verification
- host suites: 140 ZIP + 37 RAR = 177 checks, 0 failures
- 7z suite: 27 cases, 0 failures. The `aeshe` entry that used to sit in
KNOWN_GAPS is gone -- the -mhe=on fixture now passes both the folder
decoder and the extraction facade
- frontend: .build/ui_retry_test.mjs (40 checks), .build/ui_upload_menu_test.mjs
(40 checks), .build/preview_check.mjs (12 assertions in headless Chromium
against the real page and a fixture API). Two layout regressions and the
highlight cascade bug were caught by the last one and by nothing else --
reading the source, both CSS rules "look correct"
- built twice from this tree: byte-identical (cmp clean). rsync refreshes
every asset mtime, so this is a genuine recompile, not make short-circuiting
on unchanged sources
- embedded assets verified in place with .build/check-elf-gzip.py, because
gen-asset-module.py gzips them and plain `strings` finds none of their text
- exercised end to end on a real PS5; the checklist is
docs/DEVICE-TEST-v1.9.3M.md
Docs
- docs/USER-GUIDE-zh-CN.md (new, simplified Chinese user guide)
- docs/DEVICE-TEST-v1.9.3M.md (new, on-device acceptance checklist)
- docs/REAL-CONSOLE-PROFILE.md (new, measured console behaviour)
- docs/archive/HANDOVER-v1.8-planning.md (superseded v1.8 design notes)
- CHANGELOG / README (both languages) / HANDOVER updated with the artifact
fingerprint, the section deltas and the new test counts
2960 lines
99 KiB
JavaScript
2960 lines
99 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 = {};
|
||
|
||
// Last-resort fallback for the footer. The real value comes from
|
||
// /api/version, which reports the build's VERSION_TAG -- see loadVersion().
|
||
// Keeping a literal here used to be the only source, and it inevitably
|
||
// drifted (the footer said "v1.9" throughout the v1.9.1 release).
|
||
// The trailing "M" is the fork marker; see VERSION_TAG in the Makefile.
|
||
const APP_VERSION_FALLBACK = "v1.9.3M";
|
||
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 uploadBtn = document.getElementById("uploadBtn");
|
||
const uploadMenuEl = document.getElementById("uploadMenu");
|
||
const uploadFilesItemEl = document.getElementById("uploadFilesItem");
|
||
const uploadFolderItemEl = document.getElementById("uploadFolderItem");
|
||
const dropHintEl = document.getElementById("dropHint");
|
||
const uploadFilesEl = document.getElementById("uploadFiles");
|
||
const uploadFolderEl = document.getElementById("uploadFolder");
|
||
const dropUploadOverlayEl = document.getElementById("dropUploadOverlay");
|
||
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 decodeFsText(fallback) || t("backendError");
|
||
// Names arrive byte-mapped (see decodeFsText below): the server escapes every
|
||
// byte >= 0x80 as \u00XX so that names which are not valid UTF-8 -- a GBK entry
|
||
// name inside a ZIP, say -- survive the JSON round trip unchanged. The listing
|
||
// has always translated them back for display; an error message must do the
|
||
// same, or the entry name is unreadable at the exact moment the user needs to
|
||
// read it (which is how "解压失败: ... â®â¡.psd" happened).
|
||
arg = decodeFsText(arg);
|
||
fallback = decodeFsText(fallback);
|
||
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;
|
||
const label = L[key] ? t(key, params) : "";
|
||
if (label) {
|
||
// Append the backend's own message (e.g. "cannot create file: No space
|
||
// left on device") when present — it carries the strerror detail that
|
||
// the i18n label otherwise hides.
|
||
const hint = fallback && fallback !== t("backendError")
|
||
? " (" + String(fallback) + ")" : "";
|
||
return label + hint;
|
||
}
|
||
return 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;
|
||
}
|
||
// The hint starts hidden so the console browser never flashes it; the browser
|
||
// that can actually drag (and therefore has the upload button) shows it.
|
||
dropHintEl.hidden = isPlayStationBrowser();
|
||
exitBtn.title = t("exit");
|
||
exitBtn.setAttribute("aria-label", t("exit"));
|
||
parentBtn.title = t("parent");
|
||
parentBtn.setAttribute("aria-label", t("parent"));
|
||
versionEl.textContent = APP_VERSION_FALLBACK;
|
||
// "v1.9.3M" is opaque to anyone who has not read the release notes, so spell
|
||
// out what the trailing M means on hover. loadVersion() only rewrites the
|
||
// text, never the tooltip, so this survives the /api/version round trip.
|
||
versionEl.title = t("versionTooltip");
|
||
if (initLoadingEl) initLoadingEl.hidden = true;
|
||
}
|
||
|
||
// Ask the backend which version it was built as, so the footer can never
|
||
// disagree with the Makefile. A failure here is cosmetic: we simply keep the
|
||
// fallback text rather than nagging the user with an error toast.
|
||
async function loadVersion() {
|
||
try {
|
||
const data = await api("/api/version");
|
||
if (data && data.version) versionEl.textContent = data.version;
|
||
} catch (err) {
|
||
/* offline or very old payload -- keep APP_VERSION_FALLBACK */
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
}
|
||
}
|
||
|
||
// The inverse of decodeFsText(): turn a real Unicode name -- one that came from
|
||
// a File object or a prompt, not from the server -- into the byte-mapped form
|
||
// the server expects. Anything that crosses over has to be in one convention,
|
||
// because the server byte-repairs a path only when every non-ASCII code point in
|
||
// it is <= 0xFF: a single real CJK character in the same string makes
|
||
// fs_path_value() leave the whole thing alone, and a half-repaired path finds
|
||
// nothing on disk.
|
||
function encodeFsText(text) {
|
||
text = String(text || "");
|
||
const encoder = typeof TextEncoder !== "undefined" ? new TextEncoder() : null;
|
||
if (!encoder) return text;
|
||
let bytes;
|
||
try {
|
||
bytes = encoder.encode(text);
|
||
} catch (err) {
|
||
return text;
|
||
}
|
||
let ascii = true;
|
||
let out = "";
|
||
for (let i = 0; i < bytes.length; i++) {
|
||
if (bytes[i] >= 0x80) ascii = false;
|
||
out += String.fromCharCode(bytes[i]);
|
||
}
|
||
return ascii ? text : out;
|
||
}
|
||
|
||
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();
|
||
if (task.op === "extract" && task.error_code === "extract_password" &&
|
||
retryExtractWithPassword(task)) {
|
||
return;
|
||
}
|
||
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 isRarMainVolume(item) {
|
||
if (item.type !== "-") return false;
|
||
const name = item.name;
|
||
// RAR5 分卷首卷(数字最小的那一卷):name.part01.rar / name.part1.rar / name.part001.rar
|
||
if (/\.part0*1\.rar$/i.test(name)) return true;
|
||
// 真正的单卷 .rar —— 排除任何包含 .partNN. 的文件名
|
||
if (/\.rar$/i.test(name) && !/\.part0*\d+\./i.test(name)) return true;
|
||
return false;
|
||
}
|
||
|
||
function isRarSubVolume(item) {
|
||
if (item.type !== "-") return false;
|
||
// RAR5 非首卷:.part02.rar, .part2.rar, .part002.rar ...
|
||
if (/\.part0*\d+\.rar$/i.test(item.name) &&
|
||
!/\.part0*1\.rar$/i.test(item.name)) return true;
|
||
return false;
|
||
}
|
||
|
||
function isZipSplitVolume(item) {
|
||
if (item.type !== "-") return false;
|
||
const name = item.name;
|
||
// 分卷 ZIP 的任意一卷都能解压(引擎会自己找齐同目录的其余分卷):
|
||
// name.zip.001…(7-Zip)/ name.part1.zip…(WinRAR)/ name.z01…+name.zip(Info-ZIP)
|
||
if (/\.zip\.0*\d+$/i.test(name)) return true;
|
||
if (/\.part0*\d+\.zip$/i.test(name)) return true;
|
||
if (/\.z0*\d+$/i.test(name)) return true;
|
||
return false;
|
||
}
|
||
|
||
function isSevenZipArchive(item) {
|
||
return item.type === "-" && /\.7z$/i.test(item.name);
|
||
}
|
||
|
||
function isSevenZipSplitVolume(item) {
|
||
if (item.type !== "-") return false;
|
||
// Byte-split 7z sets: name.7z.001 / .002 / ... — any volume is enough,
|
||
// the engine walks the directory to assemble the rest.
|
||
if (/\.7z\.0*\d+$/i.test(item.name)) return true;
|
||
return false;
|
||
}
|
||
|
||
function isExtractableArchive(item) {
|
||
if (item.type !== "-") return false;
|
||
if (/\.zip$/i.test(item.name)) return true;
|
||
if (isZipSplitVolume(item)) return true;
|
||
if (isRarMainVolume(item)) return true;
|
||
if (isSevenZipArchive(item)) return true;
|
||
if (isSevenZipSplitVolume(item)) return true;
|
||
return false;
|
||
}
|
||
|
||
// The upload path knows a filename but has no directory entry to inspect, so
|
||
// reuse the toolbar's archive test on a synthetic item. Keeps a dropped
|
||
// .7z.001 or .part1.rar recognised as "offer to extract" without duplicating
|
||
// the rules.
|
||
function isExtractableName(name) {
|
||
return isExtractableArchive({ type: "-", name: String(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));
|
||
}
|
||
|
||
// The engine reports a missing or wrong password as `extract_password`. ZIP and
|
||
// RAR only find out once they have looked inside, so they are not asked up
|
||
// front; the failed task is re-sent with whatever the user types here instead.
|
||
// Remembering the original request is what keeps the conflict policy and the
|
||
// large-file opt-in intact across the retry.
|
||
//
|
||
// The request is remembered by TASK ID, never by path. Paths travel through the
|
||
// byte-mapped JSON described at decodeFsText(), and the server maps them back to
|
||
// raw bytes on the way in (fs_path_value), so the string this page holds for a
|
||
// directory and the one the task reports back differ for every name that is not
|
||
// pure ASCII. Keying the retry by path therefore made the password prompt
|
||
// silently never appear for archives in a non-ASCII directory -- the exact case
|
||
// the retry exists for. The id is assigned by the server and survives the round
|
||
// trip untouched.
|
||
const MAX_EXTRACT_PASSWORD_RETRIES = 3;
|
||
const extractRequestRetries = new Map();
|
||
|
||
function extractRetryKey(taskId) {
|
||
return "task:" + String(taskId);
|
||
}
|
||
|
||
// Entries are consumed by a retry or by a give-up, but a successful extraction
|
||
// never asks for one again, so its entry would sit here for the life of the
|
||
// page. Only one task can be live at a time (the server rejects a second with
|
||
// 409), so anything older than the last few is dead weight.
|
||
const MAX_REMEMBERED_EXTRACTS = 8;
|
||
|
||
function rememberExtractRequest(taskId, entry) {
|
||
extractRequestRetries.set(extractRetryKey(taskId), entry);
|
||
while (extractRequestRetries.size > MAX_REMEMBERED_EXTRACTS) {
|
||
extractRequestRetries.delete(extractRequestRetries.keys().next().value);
|
||
}
|
||
}
|
||
|
||
function retryExtractWithPassword(task) {
|
||
const key = extractRetryKey(task.id);
|
||
const remembered = extractRequestRetries.get(key);
|
||
if (!remembered || remembered.attempts >= MAX_EXTRACT_PASSWORD_RETRIES) {
|
||
extractRequestRetries.delete(key);
|
||
return false;
|
||
}
|
||
// The first failure is usually "no password was given at all"; only a later
|
||
// one is a password that did not work. Saying "the password is wrong" to
|
||
// someone who was never asked for one is what made this flow look broken.
|
||
const asked = prompt(t(remembered.attempts
|
||
? "extractPasswordRetryAsk" : "extractPasswordFirstAsk"), "");
|
||
if (!asked) {
|
||
// Cancel or an empty box means "give up": fall through so the normal failure
|
||
// report still explains what happened.
|
||
extractRequestRetries.delete(key);
|
||
return false;
|
||
}
|
||
// Consume the entry: the retry below registers itself under the new task id.
|
||
extractRequestRetries.delete(key);
|
||
clearTrackedTask();
|
||
startExtractTask(task.src, task.dst, remembered.conflict,
|
||
remembered.removeSource, remembered.name, remembered.large,
|
||
asked, remembered.attempts + 1);
|
||
return true;
|
||
}
|
||
|
||
async function startExtractTask(path, dstDir, conflict, removeSource, name, large,
|
||
password, attempts) {
|
||
try {
|
||
taskRefreshPath = cwd;
|
||
setBusy(true);
|
||
setStatus(t("extractStarted", { name }));
|
||
const form = {
|
||
path,
|
||
dst_dir: dstDir,
|
||
conflict,
|
||
remove_source: removeSource ? "1" : "0",
|
||
large: large ? "1" : "0"
|
||
};
|
||
if (password) form.password = password;
|
||
const data = await apiForm("/api/extract", form);
|
||
rememberExtractRequest(data.task_id, {
|
||
conflict, removeSource, name, large: Boolean(large),
|
||
attempts: Number(attempts || 0)
|
||
});
|
||
trackTask(data.task_id, "extract", false);
|
||
clearSelection(false);
|
||
await pollTasks();
|
||
} catch (err) {
|
||
setBusy(false);
|
||
showActionFailed(t("extract"), err.message);
|
||
}
|
||
}
|
||
|
||
// Threshold above which the web UI prompts the user before extracting.
|
||
// Tuned so a typical 3A-game archive (~300 GiB single file) and a PS5 system
|
||
// backup (~300 GiB total) extract under the default profile without prompting.
|
||
// Files between this threshold and the default max_file_bytes cap (512 GiB)
|
||
// still extract silently; larger files require explicit user opt-in via the
|
||
// large=1 API flag.
|
||
const LARGE_FILE_THRESHOLD_BYTES = 480 * 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 archives = selectedEntries().filter(isExtractableArchive);
|
||
if (archives.length !== 1) return;
|
||
const item = archives[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;
|
||
// 7z archives can be encrypted (7zAES); ask up front so an unprotected
|
||
// archive doesn't pay a wasted scan + folder parse. An empty submission is
|
||
// fine — the engine returns ZIPX_ERR_PASSWORD and the user retries.
|
||
// ZIP and RAR are not asked here: their headers are readable either way, so
|
||
// an empty password costs nothing and a failed attempt is retried through
|
||
// retryExtractWithPassword() instead of interrupting every extraction.
|
||
let password = "";
|
||
if (isSevenZipArchive(item) || isSevenZipSplitVolume(item)) {
|
||
const asked = prompt(t("extractPasswordAsk"), "");
|
||
if (asked === null) return;
|
||
password = asked;
|
||
}
|
||
startExtractTask(item.path, cwd, conflict, false, displayName(item), large, password);
|
||
}
|
||
|
||
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;
|
||
// dir is the byte-mapped echo of the listing; name typed here is real Unicode.
|
||
// Joining them raw would hand the server a path it cannot repair.
|
||
const item = { name, path: pathJoin(dir, encodeFsText(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 archives = items.filter(isExtractableArchive);
|
||
const subs = items.filter(isRarSubVolume);
|
||
|
||
/* The button is always on screen and merely greys out when the selection
|
||
cannot be extracted. It used to be hidden until an archive was selected,
|
||
which left the resting toolbar with no extract entry at all -- the same
|
||
discoverability problem the upload button was changed for. Being disabled
|
||
is not self-explanatory though, so the tooltip carries the reason. */
|
||
if (archives.length === 1) {
|
||
extractBtn.title = t("extractToCurrent") + ": " + itemTitle(archives);
|
||
extractBtn.disabled = locked;
|
||
return;
|
||
}
|
||
|
||
extractBtn.disabled = true;
|
||
if (archives.length > 1) {
|
||
extractBtn.title = t("extractOneAtATime");
|
||
} else if (subs.length > 0) {
|
||
// 只选中子卷(比如 .part02.rar),没有对应主卷 → 置灰 + 提示改选主卷
|
||
extractBtn.title = t("extractSelectMainVolume");
|
||
} else {
|
||
extractBtn.title = t("extractSelectArchive");
|
||
}
|
||
}
|
||
|
||
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;
|
||
uploadFilesItemEl.disabled = locked;
|
||
uploadFolderItemEl.disabled = locked;
|
||
if (locked && uploadMenuOpen) setUploadMenuOpen(false);
|
||
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 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");
|
||
// Bytes only, for every task type including extract. An entry counter was
|
||
// tried here and read as a hang: the archives that matter are a handful of
|
||
// huge entries (a game blob split across volumes), so entries_done sits at 0
|
||
// for the whole of the first one while bytes are plainly moving. The current
|
||
// file name is already shown on its own line, so the entry index added
|
||
// nothing a user could act on. Benchmarked: reporting is not a cost -- see
|
||
// tests/bench_progress.c --mode -- so this is about clarity, not speed.
|
||
progressItem.textContent = t("progressLabel") + ": " + (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, relativeNames) {
|
||
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];
|
||
// Dropped items carry no webkitRelativePath, so the drop collector
|
||
// rebuilds the path itself and passes it in.
|
||
rels.push(relativeNames && relativeNames[i] ? relativeNames[i] : uploadRelativeName(file));
|
||
sizes.push(String(file.size || 0));
|
||
total += Number(file.size || 0);
|
||
}
|
||
conflicts = uploadConflicts(rels);
|
||
} finally {
|
||
if (useLoading) hideContentLoading();
|
||
}
|
||
|
||
// A lone archive used to need the "upload and extract" menu entry. Ask once
|
||
// here instead, so the plain Upload button covers it too; declining is an
|
||
// ordinary upload, and the toolbar Extract button still works afterwards.
|
||
if (list.length === 1 && isExtractableName(rels[0])) {
|
||
uploadFilesEl.value = "";
|
||
uploadFolderEl.value = "";
|
||
if (confirm(t("extractUploadAsk", { name: rels[0] }))) {
|
||
return uploadAndExtractFile(list[0], rels[0], true);
|
||
}
|
||
}
|
||
|
||
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);
|
||
}
|
||
}
|
||
|
||
// A native file dialog is either file-only or folder-only (webkitdirectory), so
|
||
// a single dialog cannot offer both. The button therefore opens a two-entry
|
||
// list: a main button plus a small caret next to it was the old shape, and users
|
||
// read the caret as decoration and never found "upload a folder" at all. The
|
||
// drop target below is still the one gesture that accepts either.
|
||
let uploadMenuOpen = false;
|
||
|
||
function uploadMenuItems() {
|
||
return [uploadFilesItemEl, uploadFolderItemEl];
|
||
}
|
||
|
||
function setUploadMenuOpen(open) {
|
||
uploadMenuOpen = Boolean(open) && !busy && !loadingPath;
|
||
uploadMenuEl.hidden = !uploadMenuOpen;
|
||
uploadBtn.setAttribute("aria-expanded", uploadMenuOpen ? "true" : "false");
|
||
}
|
||
|
||
function toggleUploadMenu() {
|
||
if (busy || loadingPath) return;
|
||
setUploadMenuOpen(!uploadMenuOpen);
|
||
if (uploadMenuOpen) uploadMenuItems()[0].focus();
|
||
}
|
||
|
||
function actionUploadFiles() {
|
||
setUploadMenuOpen(false);
|
||
if (busy || loadingPath) return;
|
||
uploadFilesEl.click();
|
||
}
|
||
|
||
function actionUploadFolder() {
|
||
setUploadMenuOpen(false);
|
||
if (busy || loadingPath) return;
|
||
uploadFolderEl.click();
|
||
}
|
||
|
||
function moveUploadMenuFocus(step) {
|
||
const items = uploadMenuItems();
|
||
const current = items.indexOf(document.activeElement);
|
||
const next = ((current < 0 ? 0 : current + step) + items.length) % items.length;
|
||
items[next].focus();
|
||
}
|
||
|
||
function setupUploadMenu() {
|
||
// Closing on any click outside is what keeps a menu honest; it is bound to the
|
||
// document so that a reflowed toolbar cannot leave a stale open list behind.
|
||
document.addEventListener("click", event => {
|
||
if (!uploadMenuOpen) return;
|
||
const target = event.target;
|
||
if (target === uploadBtn || (target && uploadMenuEl.contains(target))) return;
|
||
setUploadMenuOpen(false);
|
||
});
|
||
document.addEventListener("keydown", event => {
|
||
if (!uploadMenuOpen) return;
|
||
if (event.key === "Escape") {
|
||
setUploadMenuOpen(false);
|
||
uploadBtn.focus();
|
||
return;
|
||
}
|
||
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
||
event.preventDefault();
|
||
moveUploadMenuFocus(event.key === "ArrowDown" ? 1 : -1);
|
||
}
|
||
});
|
||
uploadBtn.addEventListener("keydown", event => {
|
||
if (uploadMenuOpen || event.key !== "ArrowDown") return;
|
||
event.preventDefault();
|
||
toggleUploadMenu();
|
||
});
|
||
}
|
||
|
||
// A dropped directory arrives as a FileSystemEntry, which has no recursive
|
||
// listing of its own: readEntries() hands back one batch at a time and an
|
||
// empty batch marks the end, so it has to be driven until it drains.
|
||
async function readDroppedDirectory(directory) {
|
||
const reader = directory.createReader();
|
||
const out = [];
|
||
for (;;) {
|
||
const batch = await new Promise((resolve, reject) => reader.readEntries(resolve, reject));
|
||
if (!batch.length) return out;
|
||
for (const entry of batch) out.push(entry);
|
||
}
|
||
}
|
||
|
||
async function collectDroppedEntry(entry, prefix, files, relativeNames) {
|
||
if (entry.isFile) {
|
||
const file = await new Promise((resolve, reject) => entry.file(resolve, reject));
|
||
files.push(file);
|
||
relativeNames.push(prefix + file.name);
|
||
return;
|
||
}
|
||
if (!entry.isDirectory) return;
|
||
const children = await readDroppedDirectory(entry);
|
||
const childPrefix = prefix + entry.name + "/";
|
||
for (const child of children) {
|
||
await collectDroppedEntry(child, childPrefix, files, relativeNames);
|
||
}
|
||
}
|
||
|
||
async function uploadDroppedItems(dataTransfer) {
|
||
const files = [];
|
||
const relativeNames = [];
|
||
const dirEntries = [];
|
||
const items = dataTransfer.items;
|
||
if (items && items.length) {
|
||
for (let i = 0; i < items.length; i++) {
|
||
if (items[i].kind !== "file") continue;
|
||
const entry = items[i].webkitGetAsEntry ? items[i].webkitGetAsEntry() : null;
|
||
if (entry) {
|
||
dirEntries.push(entry);
|
||
continue;
|
||
}
|
||
const file = items[i].getAsFile();
|
||
if (file) {
|
||
files.push(file);
|
||
relativeNames.push(file.name);
|
||
}
|
||
}
|
||
} else {
|
||
for (let i = 0; i < dataTransfer.files.length; i++) {
|
||
const file = dataTransfer.files[i];
|
||
files.push(file);
|
||
relativeNames.push(uploadRelativeName(file));
|
||
}
|
||
}
|
||
for (const entry of dirEntries) {
|
||
await collectDroppedEntry(entry, "", files, relativeNames);
|
||
}
|
||
await uploadFiles(files, relativeNames);
|
||
}
|
||
|
||
function dataTransferHasFiles(dataTransfer) {
|
||
const types = dataTransfer && dataTransfer.types;
|
||
if (!types) return false;
|
||
for (let i = 0; i < types.length; i++) {
|
||
if (types[i] === "Files") return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function setupDropUpload() {
|
||
// The console browser has no drag source of its own, so the listeners would
|
||
// only ever raise a hint that nothing can dismiss.
|
||
if (isPlayStationBrowser()) return;
|
||
let dragDepth = 0;
|
||
window.addEventListener("dragenter", event => {
|
||
if (!dataTransferHasFiles(event.dataTransfer)) return;
|
||
event.preventDefault();
|
||
dragDepth++;
|
||
if (!busy && !loadingPath) dropUploadOverlayEl.hidden = false;
|
||
});
|
||
window.addEventListener("dragover", event => {
|
||
if (!dataTransferHasFiles(event.dataTransfer)) return;
|
||
event.preventDefault();
|
||
event.dataTransfer.dropEffect = "copy";
|
||
});
|
||
window.addEventListener("dragleave", () => {
|
||
dragDepth = Math.max(0, dragDepth - 1);
|
||
if (!dragDepth) dropUploadOverlayEl.hidden = true;
|
||
});
|
||
window.addEventListener("drop", event => {
|
||
if (!dataTransferHasFiles(event.dataTransfer)) return;
|
||
event.preventDefault();
|
||
dragDepth = 0;
|
||
dropUploadOverlayEl.hidden = true;
|
||
if (busy || loadingPath) return;
|
||
uploadDroppedItems(event.dataTransfer).catch(err => {
|
||
const message = t("uploadFailed", { error: err.message || t("backendError") });
|
||
setStatus(message);
|
||
alert(message);
|
||
});
|
||
});
|
||
}
|
||
|
||
async function uploadAndExtractFile(file, relativeName, alreadyAsked) {
|
||
if (busy || loadingPath) return;
|
||
if (!isExtractableName(relativeName || file.name || "")) {
|
||
alert(t("err_extract_unsupported", { arg: file.name }));
|
||
return;
|
||
}
|
||
const rel = relativeName || uploadRelativeName(file);
|
||
// cwd is the byte-mapped echo from the listing while rel comes straight from
|
||
// the File object; byte-map the name so the joined path stays in one
|
||
// convention (see encodeFsText).
|
||
const zipPath = pathJoin(cwd, encodeFsText(rel));
|
||
if (!alreadyAsked &&
|
||
!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;
|
||
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", toggleUploadMenu);
|
||
uploadFilesItemEl.addEventListener("click", actionUploadFiles);
|
||
uploadFolderItemEl.addEventListener("click", actionUploadFolder);
|
||
uploadFilesEl.addEventListener("change", () => uploadFiles(uploadFilesEl.files));
|
||
uploadFolderEl.addEventListener("change", () => uploadFiles(uploadFolderEl.files));
|
||
setupUploadMenu();
|
||
setupDropUpload();
|
||
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.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();
|
||
loadVersion();
|
||
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);
|