Files
LisherSong--ps5-web-file-ma…/assets/main.js
T
Songlx516 ba668ade50 release: v1.9.3M -- encrypted archives, plus the UI round that followed
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
2026-09-24 21:07:12 +08:00

2960 lines
99 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);