Compare commits

...
4 Commits
Author SHA1 Message Date
owendswang 5a91067c2e Release v0.6 2026-06-24 01:19:18 +08:00
owendswang ba0704d48c Release v0.5 2026-06-22 02:33:23 +08:00
owendswang 4e10dee5fb Update README.md 2026-06-20 23:45:21 +08:00
owendswang 259d0a6434 Release v0.4 2026-06-20 23:23:25 +08:00
13 changed files with 1525 additions and 383 deletions

No files matched your search

+1
View File
@@ -3,3 +3,4 @@ gen/
web-file-mgr-linux
*.o
*.d
tools/
+1 -1
View File
@@ -13,7 +13,7 @@ ifeq ($(MAKECMDGOALS),)
endif
endif
VERSION_TAG := v0.3
VERSION_TAG := v0.6
TITLE_ID := FMGR88888
PYTHON ?= python3
STRIP ?= $(PS5_PAYLOAD_SDK)/bin/prospero-strip
+7
View File
@@ -15,6 +15,7 @@ PS5 web file manager payload. It runs an HTTP UI starting at port `8888`, instal
<a href="docs/screenshots/20260617_232348.855.jpg" target="_blank"><img src="docs/screenshots/20260617_232348.855.jpg" width="31%" alt="PS5 Web File Manager screenshot 3"></a>
<a href="docs/screenshots/20260619_131811.644.jpg" target="_blank"><img src="docs/screenshots/20260619_131811.644.jpg" width="31%" alt="PS5 Web File Manager screenshot 4"></a>
<a href="docs/screenshots/20260619_131535.239.jpg" target="_blank"><img src="docs/screenshots/20260619_131535.239.jpg" width="31%" alt="PS5 Web File Manager screenshot 5"></a>
<a href="docs/screenshots/20260620_232728.533.jpg" target="_blank"><img src="docs/screenshots/20260620_232728.533.jpg" width="31%" alt="PS5 Web File Manager screenshot 6"></a>
</p>
## Features
@@ -30,6 +31,7 @@ PS5 web file manager payload. It runs an HTTP UI starting at port `8888`, instal
- Chinese and English UI. The browser language is read from `navigator.languages` / `navigator.language`; Chinese uses `zh`, everything else uses English.
- Startup notification showing the app name, version, and listen port.
- Create/edit text files ending with `.txt`, `.json`, `.xml`, `.ini`, `.cfg`, `.conf`, `.md`, `.log`, `.lua`, `.js`, `.css`, `.html`, `.htm`, `.c`, `.h`, `.cpp`, `.hpp`, `.sh`, `.csv`, `.yaml`, `.yml`.
- Preview image files ending with `.png,`, `.jpg`, `.jpeg`, `.gif`, `.bmp`, `.webp`.
## Build
@@ -91,6 +93,11 @@ On first startup the payload installs a `PS5 Web File Manager` web shortcut in t
- The UI can recover the active task display if the browser is closed and reopened while the payload process is still running.
- Text editing is limited to common text-file extensions. Non-UTF-8 and oversized files are rejected.
## FAQ
- This is a homebrew app which does not affect system process or memory. It should crash itself instead of kernel panic theoretically. If kernel panic happens, make sure you'r using the latest jailbreak methods which includes latest elfldr. Or switch back to the stable method you have been using.
- If you're using P2JB, I suggest not to use this if kernel panic happened. It is suffering to wait another 50 min because of kernel panic. Stability first for P2JB.
- The preparing stage is calculating the folder size. It would be pretty slow if there are too many files in this folder. It make sure if there is enough available space for this folder you were trying to copy/move.
## Credits
This project was built with reference to these projects:
Binary file not shown.

After

Width:  |  Height:  |  Size: 139 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 B

+14 -7
View File
@@ -49,6 +49,9 @@
<tbody id="files"></tbody>
</table>
<div id="empty" class="empty" data-i18n="empty" hidden></div>
<div id="contentLoading" class="content-loading" hidden>
<div class="content-loading-text" data-i18n="readDir"></div>
</div>
</section>
<footer class="status">
@@ -70,19 +73,23 @@
</section>
</div>
<div id="imagePreviewOverlay" class="text-editor-overlay" hidden>
<section class="text-editor-panel image-preview-panel">
<div id="imagePreviewName" class="text-editor-path"></div>
<div class="image-preview-stage">
<img id="imagePreview" class="image-preview" alt="">
</div>
<button id="imagePreviewCloseBtn" class="secondary" data-i18n="close"></button>
</section>
</div>
<div id="taskOverlay" class="task-overlay" hidden>
<div class="task-panel">
<div id="tasks" class="tasks"></div>
</div>
</div>
<div id="initLoading" class="init-loading" aria-hidden="true">
<div class="loading-dots">
<span></span>
<span></span>
<span></span>
</div>
</div>
<div id="initLoading" class="init-loading" aria-hidden="true"></div>
<script src="/main.js"></script>
</body>
+11 -1
View File
@@ -3,6 +3,9 @@ window.WFM_LANG = {
copy: "Copy",
move: "Move",
delete: "Delete",
copying: "Copying",
moving: "Moving",
deleting: "Deleting",
rename: "Rename",
paste: "Paste",
cancel: "Cancel",
@@ -36,6 +39,10 @@ window.WFM_LANG = {
speedLabel: "Speed",
progressLabel: "Progress",
etaLabel: "ETA",
elapsedLabel: "Elapsed",
durationHours: "{hours}h {minutes}m",
durationMinutes: "{minutes}m {seconds}s",
durationSeconds: "{seconds}s",
preparingTask: "Preparing task",
canceling: "Canceling...",
selectedItems: "{name} and {count} items",
@@ -61,6 +68,8 @@ window.WFM_LANG = {
cancelTaskConfirm: "Cancel current {label} task?",
cancelFailed: "Cancel failed: {error}",
tasksPollFailed: "Failed to read task status: {error}",
transferComplete: "{label} completed: {name}\n\nTotal time: {duration}\nTransferred: {size}",
transferCompleteFiles: "{label} completed: {name}\n\nTotal time: {duration}\nTransferred: {size}\nFiles: {count}",
renamePrompt: "New name",
mkdirPrompt: "Folder name",
newTextPrompt: "Text file name",
@@ -84,9 +93,10 @@ window.WFM_LANG = {
storageExtended: "Extended",
err_target_dir_not_writable: "Target folder is not writable: {path}",
err_target_file_not_writable: "Target file is not writable: {path}",
err_target_parent_not_writable: "Target parent folder is not writable: {path}",
err_target_parent_not_writable: "Current folder is not writable: {path}",
err_target_check_failed: "Cannot check target path: {path}",
err_target_path_too_long: "Target path is too long",
err_path_too_long: "Path is too long",
err_space_check_failed: "Cannot read target free space: {path}",
err_no_space: "Not enough target space. Required {required}, available {available}",
err_active_task: "A task is running",
+12 -2
View File
@@ -3,6 +3,9 @@ window.WFM_LANG = {
copy: "复制",
move: "移动",
delete: "删除",
copying: "复制",
moving: "移动",
deleting: "删除",
rename: "重命名",
paste: "粘贴",
cancel: "取消",
@@ -36,12 +39,16 @@ window.WFM_LANG = {
speedLabel: "速度",
progressLabel: "进度",
etaLabel: "剩余",
elapsedLabel: "耗时",
durationHours: "{hours}小时 {minutes}分",
durationMinutes: "{minutes}分 {seconds}秒",
durationSeconds: "{seconds}秒",
preparingTask: "正在准备任务",
canceling: "正在取消...",
selectedItems: "{name} 等 {count} 项",
countItems: "{count} 项",
totalItems: "共 {count} 项",
readDir: "读取目录...",
readDir: "读取目录中...",
actionBusy: "{label}...",
taskCreated: "{label}任务已创建",
actionDone: "{label}完成",
@@ -61,6 +68,8 @@ window.WFM_LANG = {
cancelTaskConfirm: "取消当前{label}任务?",
cancelFailed: "取消失败: {error}",
tasksPollFailed: "任务状态读取失败: {error}",
transferComplete: "{label}完成:{name}\n\n总耗时:{duration}\n传输大小:{size}",
transferCompleteFiles: "{label}完成:{name}\n\n总耗时:{duration}\n传输大小:{size}\n文件数量:{count}",
renamePrompt: "新名称",
mkdirPrompt: "目录名",
newTextPrompt: "文本文件名",
@@ -84,9 +93,10 @@ window.WFM_LANG = {
storageExtended: "扩展存储",
err_target_dir_not_writable: "目标目录不可写: {path}",
err_target_file_not_writable: "目标文件不可写: {path}",
err_target_parent_not_writable: "目标父目录不可写: {path}",
err_target_parent_not_writable: "当前目录不可写: {path}",
err_target_check_failed: "无法检查目标路径: {path}",
err_target_path_too_long: "目标路径过长",
err_path_too_long: "路径过长",
err_space_check_failed: "无法读取目标剩余空间: {path}",
err_no_space: "目标空间不足,需要 {required},可用 {available}",
err_active_task: "有任务正在执行",
+76 -45
View File
@@ -23,41 +23,8 @@
align-items: center;
justify-content: center;
background: #111316;
}
.loading-dots {
display: flex;
}
.loading-dots span {
width: 14px;
height: 14px;
margin: 0 6px;
border-radius: 50%;
background: #8fa1b3;
animation: loading-pulse 1s ease-in-out infinite;
}
.loading-dots span:nth-child(2) {
animation-delay: .12s;
}
.loading-dots span:nth-child(3) {
animation-delay: .24s;
}
@keyframes loading-pulse {
0%,
80%,
100% {
opacity: .35;
transform: scale(.75);
}
40% {
opacity: 1;
transform: scale(1);
}
color: #aab4be;
font-size: 18px;
}
body {
@@ -268,14 +235,37 @@ button.primary:hover {
}
.content {
position: relative;
flex: 1 1 auto;
min-height: 0;
overflow: auto;
}
.content.loading {
pointer-events: none;
}
.content-loading {
position: absolute;
right: 0;
left: 0;
height: 100%;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
background: rgba(4, 6, 8, .72);
}
.content-loading-text {
color: #aab4be;
font-size: 18px;
}
table {
width: 100%;
border-collapse: collapse;
border-collapse: separate;
border-spacing: 0;
table-layout: fixed;
}
@@ -354,6 +344,10 @@ tr.selected {
background: #1d2d35;
}
tr[data-type="parent"] {
cursor: pointer;
}
.select-hit {
min-height: 78px;
width: 100%;
@@ -373,14 +367,6 @@ input[type="checkbox"] {
width: 32px;
height: 32px;
flex: 0 0 32px;
display: inline-flex;
align-items: center;
justify-content: center;
}
.icon img {
width: 32px;
height: 32px;
display: block;
}
@@ -450,6 +436,7 @@ input[type="checkbox"] {
.text-editor-panel {
width: 1200px;
height: calc(100vh - 96px);
min-width: 0;
max-width: calc(100vw - 96px);
display: flex;
flex-direction: column;
@@ -461,6 +448,7 @@ input[type="checkbox"] {
}
.text-editor-path {
flex: 0 0 auto;
margin-bottom: 14px;
overflow: hidden;
color: #c9d3dc;
@@ -509,6 +497,34 @@ input[type="checkbox"] {
margin-left: 14px;
}
.image-preview-stage {
width: 100%;
min-width: 0;
min-height: 0;
flex: 1 1 auto;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
}
.image-preview {
display: block;
width: auto;
height: auto;
max-width: 100%;
max-height: 100%;
object-fit: contain;
pointer-events: none;
-webkit-user-drag: none;
user-select: none;
}
.image-preview-panel > button {
width: 100%;
margin-top: 14px;
}
.task-overlay {
position: fixed;
top: 0;
@@ -557,6 +573,8 @@ input[type="checkbox"] {
display: flex;
align-items: flex-start;
font-size: 24px;
justify-content: space-between;
position: relative;
}
.task-head.has-amount {
@@ -573,13 +591,18 @@ input[type="checkbox"] {
.task-current {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-current-plain {
text-overflow: ellipsis;
}
.task-name {
display: flex;
min-width: 0;
text-overflow: ellipsis;
flex: 1 1 auto;
}
.task-name > * {
@@ -599,6 +622,14 @@ input[type="checkbox"] {
white-space: nowrap;
}
.task-elapsed {
flex: 0 0 auto;
white-space: nowrap;
text-align: right;
margin-left: 16px;
color: #aab4be;
}
.task-meta {
display: flex;
color: #c9d3dc;
+224 -76
View File
@@ -10,17 +10,25 @@ let pendingAbortController = null;
let taskRefreshPath = "/";
let hoverPaused = false;
let hoverResumeTimer = 0;
let hoveredRow = null;
let loadingPath = null;
let directoryLoadingTimer = 0;
let taskOverlayTimer = 0;
let lastCompletionId = null;
let taskPollFailedAlertShown = false;
let textEditorPath = null;
let textEditorVersion = null;
let textEditorOriginal = "";
let textEditorBusy = false;
let L = {};
const APP_VERSION = "v0.3";
const APP_VERSION = "v0.6";
const LAST_PATH_KEY = "ps5-web-file-mgr:last-path";
const LOADING_DISPLAY_DELAY = 250;
const filesEl = document.getElementById("files");
const contentEl = document.getElementById("content");
const contentLoadingEl = document.getElementById("contentLoading");
const emptyEl = document.getElementById("empty");
const pathEl = document.getElementById("path");
const spaceInfoEl = document.getElementById("spaceInfo");
@@ -43,6 +51,10 @@ 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");
function chooseLanguage() {
const langs = navigator.languages && navigator.languages.length ? navigator.languages : [navigator.language || ""];
@@ -53,6 +65,7 @@ function chooseLanguage() {
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 = () => {
@@ -164,22 +177,30 @@ function formatDuration(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = seconds % 60;
if (hours) return hours + "h " + minutes + "m";
if (minutes) return minutes + "m " + secs + "s";
return secs + "s";
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 elapsed = Math.max(1, Number(task.elapsed || 0));
const averageSpeed = done / elapsed;
return averageSpeed > 0 ? formatDuration((total - done) / averageSpeed) : "--";
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") }[op] || op;
}
function taskOpLabel(op) {
return { copy: t("copying"), move: t("moving"), delete: t("deleting") }[op] || op;
}
function stateLabel(state) {
return { queued: t("queued"), running: t("running"), done: t("done"), failed: t("failed"), canceled: t("canceled") }[state] || state;
}
@@ -246,6 +267,21 @@ function setBusy(value) {
updateButtons();
}
function showTaskOverlay() {
setBusy(true);
if (!overlayEl.hidden || taskOverlayTimer) return;
taskOverlayTimer = setTimeout(() => {
taskOverlayTimer = 0;
overlayEl.hidden = false;
}, LOADING_DISPLAY_DELAY);
}
function hideTaskOverlay() {
clearTimeout(taskOverlayTimer);
taskOverlayTimer = 0;
overlayEl.hidden = true;
}
function showActionFailed(label, error) {
const message = t("actionFailed", { label, error });
setStatus(message);
@@ -314,6 +350,26 @@ function isEditableText(item) {
/\.(txt|json|xml|ini|cfg|conf|md|log|lua|js|css|html?|c|h|cpp|hpp|sh|csv|ya?ml)$/i.test(item.name);
}
function isPreviewableImage(item) {
return item.type === "-" && /\.(png|jpe?g|gif|bmp|webp)$/i.test(item.name);
}
function openImagePreview(item) {
if (busy) return;
imagePreviewNameEl.textContent = item.name;
imagePreviewNameEl.title = item.name;
imagePreviewEl.src = "/fs?path=" + encodeURIComponent(item.path);
imagePreviewOverlayEl.hidden = false;
imagePreviewCloseBtn.focus();
}
function closeImagePreview() {
imagePreviewOverlayEl.hidden = true;
imagePreviewNameEl.textContent = "";
imagePreviewNameEl.title = "";
imagePreviewEl.removeAttribute("src");
}
function setTextEditorBusy(value, status) {
textEditorBusy = value;
textEditorEl.disabled = value;
@@ -400,9 +456,11 @@ async function saveTextEditor() {
}
async function actionNewText() {
if (busy) return;
if (busy || loadingPath) return;
const dir = cwd;
const name = (prompt(t("newTextPrompt"), "") || "").trim();
const input = prompt(t("newTextPrompt"));
if (input === null) return;
const name = input.trim();
if (!name) return;
const item = { name, path: pathJoin(dir, name), type: "-" };
@@ -449,24 +507,26 @@ function pathName(path) {
return pos >= 0 ? clean.slice(pos + 1) || "/" : clean;
}
function renderTaskName(el, task) {
function taskSubject(task) {
const count = Number(task.src_count || 0);
const target = count > 1 ? t("countItems", { count }) : pathName(task.src);
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 = opLabel(task.op);
op.textContent = taskOpLabel(task.op);
const subject = document.createElement("span");
subject.className = "task-name-subject";
subject.textContent = target;
subject.title = target;
const state = document.createElement("span");
state.className = "task-name-state";
state.textContent = stateLabel(task.state);
appendChildren(el, op, subject, state);
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) {
@@ -482,8 +542,8 @@ function renderClipboard() {
pasteVerbEl.textContent = clipboard.op === "move" ? t("move") : t("paste");
pasteNameEl.textContent = title;
pasteBtn.title = t("pasteTitle", { label: clipboard.op === "move" ? t("move") : t("paste"), name: title, path: cwd });
pasteBtn.disabled = busy;
clearClipboardBtn.disabled = busy;
pasteBtn.disabled = locked;
clearClipboardBtn.disabled = locked;
}
function singleSelected() {
@@ -493,12 +553,15 @@ function singleSelected() {
function updateButtons() {
const items = selectedEntries();
document.getElementById("copyBtn").disabled = busy || items.length === 0;
document.getElementById("moveBtn").disabled = busy || items.length === 0;
document.getElementById("renameBtn").disabled = busy || items.length !== 1;
document.getElementById("deleteBtn").disabled = busy || items.length === 0;
document.getElementById("mkdirBtn").disabled = busy;
newTextBtn.disabled = busy;
const locked = busy || contentEl.classList.contains("loading");
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;
document.getElementById("refreshBtn").disabled = locked;
document.getElementById("mkdirBtn").disabled = locked;
newTextBtn.disabled = locked;
selectAllEl.disabled = locked;
selectAllEl.checked = entries.length > 0 && items.length === entries.length;
selectAllEl.indeterminate = items.length > 0 && items.length < entries.length;
renderClipboard();
@@ -513,18 +576,17 @@ function focusPath(path) {
if (next) next.classList.add("focused");
}
function hoverPath(path) {
function hoverRow(row) {
if (hoverPaused) return;
const old = filesEl.querySelector("tr.hovered");
if (old && old.dataset.path === path) return;
const next = path ? filesEl.querySelector("tr[data-path=\"" + cssEscape(path) + "\"]") : null;
if (old) old.classList.remove("hovered");
if (next) next.classList.add("hovered");
if (hoveredRow === row) return;
clearHoverPath();
hoveredRow = row;
if (hoveredRow) hoveredRow.classList.add("hovered");
}
function clearHoverPath() {
const old = filesEl.querySelector("tr.hovered");
if (old) old.classList.remove("hovered");
if (hoveredRow) hoveredRow.classList.remove("hovered");
hoveredRow = null;
}
function cssEscape(value) {
@@ -538,11 +600,6 @@ function appendChildren(parent) {
}
}
function setClass(el, name, enabled) {
if (enabled) el.classList.add(name);
else el.classList.remove(name);
}
function bindPress(el, fn) {
let handled = false;
const run = event => {
@@ -576,8 +633,10 @@ function togglePath(path, checked) {
}
function render() {
hoveredRow = null;
filesEl.innerHTML = "";
emptyEl.hidden = entries.length !== 0;
const fragment = document.createDocumentFragment();
const rows = cwd === "/" ? entries : [{
name: "..",
@@ -592,8 +651,10 @@ function render() {
const isParent = item.type === "parent";
const tr = document.createElement("tr");
tr.dataset.path = item.path;
setClass(tr, "selected", !isParent && selected.has(item.path));
setClass(tr, "focused", focusedPath === 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";
@@ -603,8 +664,6 @@ function render() {
const checkbox = document.createElement("input");
checkbox.type = "checkbox";
checkbox.checked = selected.has(item.path);
checkbox.addEventListener("focus", () => focusPath(item.path));
checkbox.addEventListener("change", () => togglePath(item.path, checkbox.checked));
label.appendChild(checkbox);
selectTd.appendChild(label);
}
@@ -614,24 +673,18 @@ function render() {
const nameBtn = document.createElement("button");
nameBtn.className = "row-action";
nameBtn.type = "button";
const icon = document.createElement("span");
icon.className = "icon";
const iconImg = document.createElement("img");
iconImg.src = isParent ? "/icon-up.png" : item.type === "d" ? "/icon-folder.png" : "/icon-file.png";
iconImg.alt = "";
icon.appendChild(iconImg);
const nameText = document.createElement("span");
nameText.className = "name-text";
nameText.textContent = item.name;
nameBtn.title = item.path;
nameBtn.addEventListener("focus", () => focusPath(item.path));
nameBtn.addEventListener("click", () => {
focusPath(item.path);
if (isParent || item.type === "d") load(item.path);
else if (isEditableText(item)) openTextEditor(item);
else togglePath(item.path, !selected.has(item.path));
});
appendChildren(nameBtn, icon, nameText);
const iconImg = document.createElement("img");
iconImg.className = "icon";
iconImg.src = isParent ? "/icon-up.png" : item.type === "d" ? "/icon-folder.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);
appendChildren(
@@ -643,8 +696,9 @@ function render() {
classCell("time-col", formatTime(item.mtime)),
classCell("mode-col", isParent ? "" : "0" + Number(item.mode).toString(8))
);
filesEl.appendChild(tr);
fragment.appendChild(tr);
}
filesEl.appendChild(fragment);
updateButtons();
}
@@ -670,7 +724,14 @@ function classCell(className, text) {
async function load(path, scrollTop, force) {
const shouldScrollTop = scrollTop !== false;
if (busy && !force) return;
if ((busy && !force) || loadingPath) return;
loadingPath = path;
directoryLoadingTimer = setTimeout(() => {
contentLoadingEl.style.top = contentEl.scrollTop + "px";
contentLoadingEl.hidden = false;
contentEl.classList.add("loading");
updateButtons();
}, LOADING_DISPLAY_DELAY);
try {
setStatus(t("readDir"));
const data = await api("/api/list", { path });
@@ -690,6 +751,12 @@ async function load(path, scrollTop, force) {
setStatus(t("totalItems", { count: entries.length }));
} catch (err) {
setStatus(err.message);
} finally {
clearTimeout(directoryLoadingTimer);
contentEl.classList.remove("loading");
contentLoadingEl.hidden = true;
loadingPath = null;
updateButtons();
}
}
@@ -728,7 +795,7 @@ async function runImmediateAction(label, fn) {
}
function setClipboard(op) {
if (busy || selected.size === 0) return;
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 };
@@ -738,6 +805,7 @@ function setClipboard(op) {
}
function clearClipboard() {
if (busy || loadingPath) return;
clipboard = null;
renderClipboard();
setStatus(t("clipboardCleared"));
@@ -822,7 +890,7 @@ function renderPendingOverlay(text) {
if (pendingAbortController) pendingAbortController.abort();
pendingAbortController = null;
pendingOverlayText = "";
overlayEl.hidden = true;
hideTaskOverlay();
setStatus(t("cancelPrepare"));
setBusy(false);
});
@@ -833,13 +901,35 @@ function renderPendingOverlay(text) {
function showPendingOverlay(text) {
pendingOverlayText = text;
overlayEl.hidden = false;
renderPendingOverlay(text);
setBusy(true);
showTaskOverlay();
}
function renderTaskPath(element, 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 || !clipboard || clipboard.items.length === 0) return;
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();
@@ -880,7 +970,7 @@ async function actionPaste() {
const aborted = err && err.name === "AbortError";
pendingAbortController = null;
pendingOverlayText = "";
overlayEl.hidden = true;
hideTaskOverlay();
if (aborted) setStatus(t("cancelPrepare"));
else showActionFailed(label, err.message);
renderClipboard();
@@ -891,16 +981,18 @@ async function actionPaste() {
function renderTasks(tasks) {
const active = tasks.find(task => task.state === "queued" || task.state === "running");
if (!active && pendingOverlayText) {
overlayEl.hidden = false;
renderPendingOverlay(pendingOverlayText);
setBusy(true);
showTaskOverlay();
return;
}
tasksEl.innerHTML = "";
const hasActive = Boolean(active);
overlayEl.hidden = !hasActive;
setBusy(hasActive);
if (hasActive) showTaskOverlay();
else {
hideTaskOverlay();
setBusy(false);
}
if (!hasActive) return;
@@ -912,7 +1004,7 @@ function renderTasks(tasks) {
const isPreparing = (task.op === "copy" || task.op === "move") &&
task.state === "running" && done === 0;
const isFinishing = !isDelete && task.state === "running" && total > 0 && done >= total;
const pct = total > 0 ? Math.min(100, Math.round(done * 100 / total)) : 0;
const pct = total > 0 ? Math.min(100, Math.floor(done * 100 / total)) : 0;
const div = document.createElement("div");
div.className = "task " + task.state;
@@ -922,10 +1014,18 @@ function renderTasks(tasks) {
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";
current.textContent = task.error || task.current || task.src;
if (task.error) {
current.className += " task-current-plain";
current.textContent = task.error;
}
else current.textContent = task.current || task.src;
const meta = document.createElement("div");
meta.className = "task-meta";
@@ -965,12 +1065,27 @@ function renderTasks(tasks) {
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 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;
renderTasks(tasks);
let shouldRefresh = false;
@@ -997,7 +1112,8 @@ async function pollTasks() {
await refreshSpaces();
}
} catch (err) {
setStatus(t("tasksPollFailed", { error: err.message }));
const message = t("tasksPollFailed", { error: err.message });
setStatus(message);
}
}
@@ -1010,7 +1126,7 @@ function actionMove() {
}
function actionRename() {
if (busy) return;
if (busy || loadingPath) return;
const item = singleSelected();
if (!item) return;
const name = (prompt(t("renamePrompt"), item.name) || "").trim();
@@ -1019,7 +1135,7 @@ function actionRename() {
}
function actionDelete() {
if (busy || selected.size === 0) return;
if (busy || loadingPath || selected.size === 0) return;
const items = selectedEntries();
const paths = items.map(item => item.path);
const target = itemTitle(items);
@@ -1040,7 +1156,7 @@ function actionExit() {
document.getElementById("refreshBtn").addEventListener("click", () => load(cwd, false));
document.getElementById("mkdirBtn").addEventListener("click", () => {
if (busy) return;
if (busy || loadingPath) return;
const path = cwd;
const name = (prompt(t("mkdirPrompt")) || "").trim();
if (!name) return;
@@ -1056,16 +1172,48 @@ exitBtn.addEventListener("click", actionExit);
textEditorCloseBtn.addEventListener("click", requestCloseTextEditor);
textEditorSaveBtn.addEventListener("click", saveTextEditor);
newTextBtn.addEventListener("click", actionNewText);
imagePreviewCloseBtn.addEventListener("click", closeImagePreview);
textEditorOverlayEl.addEventListener("click", event => {
if (event.target === textEditorOverlayEl) requestCloseTextEditor();
});
imagePreviewOverlayEl.addEventListener("click", event => {
if (event.target === imagePreviewOverlayEl) closeImagePreview();
});
contentEl.addEventListener("click", event => {
if (!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 => {
const row = findRowFromTarget(event.target);
if (row && event.target.type === "checkbox") togglePath(row.dataset.path, event.target.checked);
});
filesEl.addEventListener("click", event => {
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 };
focusPath(item.path);
if (item.type === "parent" || item.type === "d") load(item.path);
else if (isPreviewableImage(item)) openImagePreview(item);
else if (isEditableText(item)) openTextEditor(item);
else togglePath(item.path, !selected.has(item.path));
});
selectAllEl.addEventListener("change", () => {
selected.clear();
if (selectAllEl.checked) entries.forEach(item => selected.add(item.path));
render();
});
contentEl.addEventListener("mousemove", event => {
const row = findRowFromTarget(event.target);
hoverPath(row ? row.getAttribute("data-path") : null);
hoverRow(findRowFromTarget(event.target));
});
contentEl.addEventListener("mouseleave", () => hoverPath(null));
contentEl.addEventListener("mouseleave", () => hoverRow(null));
contentEl.addEventListener("scroll", () => {
hoverPaused = true;
clearHoverPath();
Binary file not shown.

After

Width:  |  Height:  |  Size: 290 KiB

+1176 -251
View File
File diff suppressed because it is too large. Load diff
+3
View File
@@ -8,8 +8,10 @@ typedef struct mime_entry {
} mime_entry_t;
static const mime_entry_t g_mimes[] = {
{"bmp", "image/bmp"},
{"css", "text/css"},
{"elf", "application/octet-stream"},
{"gif", "image/gif"},
{"html", "text/html"},
{"jpeg", "image/jpeg"},
{"jpg", "image/jpeg"},
@@ -20,6 +22,7 @@ static const mime_entry_t g_mimes[] = {
{"png", "image/png"},
{"txt", "text/plain"},
{"xml", "text/xml"},
{"webp", "image/webp"},
{"zip", "application/zip"},
};