mirror of
https://github.com/LisherSong/ps5-web-file-manager.git
synced 2026-10-06 10:00:24 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3696468611 | ||
|
|
777bb4abd4 | ||
|
|
76f8fe21d2 | ||
|
|
5a91067c2e | ||
|
|
ba0704d48c | ||
|
|
4e10dee5fb | ||
|
|
259d0a6434 |
No files matched your search
@@ -3,3 +3,5 @@ gen/
|
||||
web-file-mgr-linux
|
||||
*.o
|
||||
*.d
|
||||
tools/
|
||||
test/
|
||||
@@ -13,7 +13,7 @@ ifeq ($(MAKECMDGOALS),)
|
||||
endif
|
||||
endif
|
||||
|
||||
VERSION_TAG := v0.3
|
||||
VERSION_TAG := v0.8b
|
||||
TITLE_ID := FMGR88888
|
||||
PYTHON ?= python3
|
||||
STRIP ?= $(PS5_PAYLOAD_SDK)/bin/prospero-strip
|
||||
|
||||
@@ -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 |
+20
-9
@@ -9,8 +9,12 @@
|
||||
<body>
|
||||
<main class="shell">
|
||||
<header class="topbar">
|
||||
<div id="path" class="path">/</div>
|
||||
<div id="spaceInfo" class="space-info"></div>
|
||||
<div class="top-left">
|
||||
<div id="path" class="path">/</div>
|
||||
</div>
|
||||
<div class="top-right">
|
||||
<div id="spaceInfo" class="space-info"></div>
|
||||
</div>
|
||||
<button id="exitBtn" class="icon-button power-button" type="button" aria-label="Exit"></button>
|
||||
</header>
|
||||
|
||||
@@ -49,6 +53,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 +77,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>
|
||||
|
||||
+13
-1
@@ -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",
|
||||
@@ -82,11 +91,14 @@ window.WFM_LANG = {
|
||||
storageUsb: "USB",
|
||||
storageM2: "M.2",
|
||||
storageExtended: "Extended",
|
||||
storageRoot: "Root",
|
||||
storageCurrent: "Current",
|
||||
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",
|
||||
|
||||
+14
-2
@@ -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: "文本文件名",
|
||||
@@ -82,11 +91,14 @@ window.WFM_LANG = {
|
||||
storageUsb: "USB存储",
|
||||
storageM2: "M2扩充存储",
|
||||
storageExtended: "扩展存储",
|
||||
storageRoot: "根分区",
|
||||
storageCurrent: "当前目录",
|
||||
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: "有任务正在执行",
|
||||
|
||||
+358
-72
@@ -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 {
|
||||
@@ -76,18 +43,48 @@ body {
|
||||
}
|
||||
|
||||
.topbar {
|
||||
position: relative;
|
||||
flex: 0 0 auto;
|
||||
height: auto;
|
||||
min-height: 54px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 12px 30px;
|
||||
align-items: start;
|
||||
flex-wrap: wrap;
|
||||
justify-content: space-between;
|
||||
padding: 0 18px;
|
||||
background: #1b2026;
|
||||
border-bottom: 1px solid #2d343d;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.top-left,
|
||||
.top-right {
|
||||
height: auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.top-left {
|
||||
flex: 1 1 420px;
|
||||
min-width: 0;
|
||||
margin-top: 15px;
|
||||
}
|
||||
|
||||
.top-right {
|
||||
flex: 1 1 360px;
|
||||
min-width: 0;
|
||||
margin-left: auto;
|
||||
justify-content: flex-end;
|
||||
flex-wrap: wrap;
|
||||
padding: 12px 40px 12px 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.path {
|
||||
color: #aab4be;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 22px;
|
||||
line-height: 1.35;
|
||||
line-height: 24px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -96,19 +93,25 @@ body {
|
||||
}
|
||||
|
||||
.space-info {
|
||||
height: auto;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
flex: 0 1 auto;
|
||||
max-width: 58vw;
|
||||
flex: 1 1 auto;
|
||||
max-width: none;
|
||||
color: #c9d3dc;
|
||||
font-size: 18px;
|
||||
line-height: 1.35;
|
||||
line-height: 30px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.space-item {
|
||||
margin-left: 14px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -129,8 +132,6 @@ body {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
flex: 0 0 24px;
|
||||
margin-left: 16px;
|
||||
}
|
||||
|
||||
.power-button {
|
||||
@@ -160,6 +161,13 @@ body {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
#exitBtn {
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
right: 18px;
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.toolbar,
|
||||
.tool-left,
|
||||
.tool-right {
|
||||
@@ -169,10 +177,14 @@ body {
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
flex: 0 0 auto;
|
||||
height: auto;
|
||||
min-height: 84px;
|
||||
justify-content: space-between;
|
||||
padding: 14px 30px;
|
||||
padding: 15px 18px 0;
|
||||
background: #15191e;
|
||||
border-bottom: 1px solid #2d343d;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.tool-left {
|
||||
@@ -180,9 +192,15 @@ body {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.toolbar .tool-left,
|
||||
.toolbar .tool-right {
|
||||
min-height: 54px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tool-left > *,
|
||||
.tool-right > * {
|
||||
margin: 0 12px 0 0;
|
||||
margin: 0 12px 15px 0;
|
||||
}
|
||||
|
||||
.tool-right > * {
|
||||
@@ -192,11 +210,12 @@ body {
|
||||
|
||||
.tool-right {
|
||||
justify-content: flex-end;
|
||||
flex: 0 0 auto;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
button,
|
||||
.button {
|
||||
height: 54px;
|
||||
min-height: 54px;
|
||||
min-width: 96px;
|
||||
padding: 0 20px;
|
||||
@@ -268,14 +287,38 @@ 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;
|
||||
min-width: 1034px;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
@@ -288,6 +331,7 @@ td {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
font-size: 20px;
|
||||
line-height: 24px;
|
||||
}
|
||||
|
||||
th {
|
||||
@@ -305,7 +349,7 @@ th {
|
||||
}
|
||||
|
||||
.name-col {
|
||||
min-width: 360px;
|
||||
min-width: 600px;
|
||||
}
|
||||
|
||||
td.name-col {
|
||||
@@ -342,6 +386,7 @@ tr {
|
||||
}
|
||||
|
||||
thead tr {
|
||||
height: 78px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -354,7 +399,12 @@ tr.selected {
|
||||
background: #1d2d35;
|
||||
}
|
||||
|
||||
tr[data-type="parent"] {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.select-hit {
|
||||
height: 78px;
|
||||
min-height: 78px;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
@@ -373,19 +423,12 @@ 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;
|
||||
}
|
||||
|
||||
.row-action {
|
||||
width: 100%;
|
||||
height: 78px;
|
||||
min-height: 78px;
|
||||
padding: 16px 18px;
|
||||
border: 0;
|
||||
@@ -416,10 +459,11 @@ input[type="checkbox"] {
|
||||
|
||||
.status {
|
||||
color: #aab4be;
|
||||
padding: 10px 25px;
|
||||
}
|
||||
|
||||
.status {
|
||||
flex: 0 0 auto;
|
||||
height: 46px;
|
||||
min-height: 46px;
|
||||
padding: 0 25px;
|
||||
line-height: 46px;
|
||||
border-top: 1px solid #2d343d;
|
||||
background: #15191e;
|
||||
font-size: 18px;
|
||||
@@ -450,6 +494,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 +506,7 @@ input[type="checkbox"] {
|
||||
}
|
||||
|
||||
.text-editor-path {
|
||||
flex: 0 0 auto;
|
||||
margin-bottom: 14px;
|
||||
overflow: hidden;
|
||||
color: #c9d3dc;
|
||||
@@ -509,6 +555,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 +631,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 +649,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 +680,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;
|
||||
@@ -667,18 +756,215 @@ input[type="checkbox"] {
|
||||
background: #8a929b;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.topbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
@media (max-width: 815px) {
|
||||
.top-left {
|
||||
padding: 0 40px 0 0;
|
||||
}
|
||||
|
||||
th:nth-child(4),
|
||||
td:nth-child(4),
|
||||
th:nth-child(5),
|
||||
td:nth-child(5),
|
||||
th:nth-child(6),
|
||||
td:nth-child(6) {
|
||||
.top-right {
|
||||
padding: 10px 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
body {
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.topbar {
|
||||
min-height: 42px;
|
||||
padding: 0 10px;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.top-left {
|
||||
flex: 1 1 auto;
|
||||
margin-top: 0;
|
||||
padding-right: 34px;
|
||||
}
|
||||
|
||||
.top-right {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.path {
|
||||
font-size: 16px;
|
||||
line-height: 42px;
|
||||
}
|
||||
|
||||
#exitBtn {
|
||||
top: 9px;
|
||||
right: 10px;
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
min-height: 0;
|
||||
padding: 8px 8px 0;
|
||||
}
|
||||
|
||||
.toolbar .tool-left,
|
||||
.toolbar .tool-right {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.tool-left,
|
||||
.tool-right {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
|
||||
.tool-right {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.tool-left > *,
|
||||
.tool-right > * {
|
||||
margin: 0 8px 8px 0;
|
||||
}
|
||||
|
||||
.tool-right > * {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
button,
|
||||
.button {
|
||||
height: 40px;
|
||||
min-height: 40px;
|
||||
min-width: 76px;
|
||||
padding: 0 12px;
|
||||
border-radius: 6px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.paste-action {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.paste-name {
|
||||
max-width: 120px;
|
||||
}
|
||||
|
||||
table {
|
||||
min-width: 960px;
|
||||
}
|
||||
|
||||
.name-col {
|
||||
width: 600px;
|
||||
}
|
||||
|
||||
th,
|
||||
td {
|
||||
padding: 10px 12px;
|
||||
font-size: 16px;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
tr,
|
||||
thead tr,
|
||||
.select-hit,
|
||||
.row-action {
|
||||
height: 56px;
|
||||
min-height: 56px;
|
||||
}
|
||||
|
||||
.row-action {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.icon {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
flex-basis: 26px;
|
||||
}
|
||||
|
||||
.status {
|
||||
height: 34px;
|
||||
min-height: 34px;
|
||||
padding: 0 12px;
|
||||
line-height: 34px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.task-overlay {
|
||||
padding: 18px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.task-panel {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
padding: 18px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.task > * + * {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.task-head {
|
||||
display: block;
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.task-name {
|
||||
display: flex;
|
||||
max-width: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-name > * {
|
||||
margin: 0 8px 0 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.task-name-subject {
|
||||
flex: 1 1 auto;
|
||||
max-width: none;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-elapsed {
|
||||
margin: 8px 0 0;
|
||||
text-align: left;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.task-current {
|
||||
font-size: 18px;
|
||||
white-space: normal;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.task-meta div,
|
||||
.task-meta div:nth-child(2),
|
||||
.task-meta div:nth-child(3) {
|
||||
display: block;
|
||||
margin: 0 0 8px;
|
||||
text-align: left;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.task-meta div:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.task .danger {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
+319
-90
@@ -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.8b";
|
||||
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 = () => {
|
||||
@@ -116,6 +129,81 @@ async function api(path, params, options) {
|
||||
return data;
|
||||
}
|
||||
|
||||
function decodeFsText(text) {
|
||||
text = String(text || "");
|
||||
let needsDecode = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
const code = text.charCodeAt(i);
|
||||
if (code > 0xff) return text;
|
||||
if (code >= 0x80) needsDecode = true;
|
||||
}
|
||||
if (!needsDecode || typeof TextDecoder === "undefined" || typeof Uint8Array === "undefined") return text;
|
||||
|
||||
const bytes = new Uint8Array(text.length);
|
||||
for (let i = 0; i < text.length; i++) bytes[i] = text.charCodeAt(i) & 0xff;
|
||||
if (isUtf8Bytes(bytes)) {
|
||||
try {
|
||||
return new TextDecoder("utf-8").decode(bytes);
|
||||
} catch (err) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
try {
|
||||
return new TextDecoder("gbk").decode(bytes);
|
||||
} catch (err) {
|
||||
try {
|
||||
return new TextDecoder("gb18030").decode(bytes);
|
||||
} catch (err2) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
async function fetchText(path, params) {
|
||||
const response = await request(path, params, { method: "GET" });
|
||||
return {
|
||||
@@ -164,22 +252,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 +342,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 +425,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 = 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");
|
||||
}
|
||||
|
||||
function setTextEditorBusy(value, status) {
|
||||
textEditorBusy = value;
|
||||
textEditorEl.disabled = value;
|
||||
@@ -343,8 +474,8 @@ function requestCloseTextEditor() {
|
||||
function showTextEditor(item, text, version) {
|
||||
textEditorPath = item.path;
|
||||
textEditorVersion = version;
|
||||
textEditorPathEl.textContent = item.path;
|
||||
textEditorPathEl.title = item.path;
|
||||
textEditorPathEl.textContent = displayPath(item.path);
|
||||
textEditorPathEl.title = displayPath(item.path);
|
||||
textEditorEl.value = text;
|
||||
textEditorOriginal = textEditorEl.value;
|
||||
textEditorOverlayEl.hidden = false;
|
||||
@@ -357,8 +488,8 @@ async function openTextEditor(item) {
|
||||
textEditorPath = item.path;
|
||||
textEditorVersion = null;
|
||||
textEditorOriginal = "";
|
||||
textEditorPathEl.textContent = item.path;
|
||||
textEditorPathEl.title = item.path;
|
||||
textEditorPathEl.textContent = displayPath(item.path);
|
||||
textEditorPathEl.title = displayPath(item.path);
|
||||
textEditorEl.value = "";
|
||||
textEditorOverlayEl.hidden = false;
|
||||
setTextEditorBusy(true, t("loadingText"));
|
||||
@@ -400,9 +531,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: "-" };
|
||||
|
||||
@@ -440,33 +573,35 @@ function clipboardTitle() {
|
||||
|
||||
function itemTitle(items) {
|
||||
if (!items.length) return "";
|
||||
return items.length === 1 ? items[0].name : t("selectedItems", { name: items[0].name, count: items.length });
|
||||
return items.length === 1 ? displayName(items[0]) : t("selectedItems", { name: displayName(items[0]), count: items.length });
|
||||
}
|
||||
|
||||
function pathName(path) {
|
||||
const clean = String(path || "").replace(/\/+$/, "");
|
||||
const pos = clean.lastIndexOf("/");
|
||||
return pos >= 0 ? clean.slice(pos + 1) || "/" : clean;
|
||||
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 count = Number(task.src_count || 0);
|
||||
const target = count > 1 ? t("countItems", { count }) : pathName(task.src);
|
||||
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 +617,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 +628,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 +651,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 +675,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 +708,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 +726,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 +739,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 +748,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);
|
||||
nameText.textContent = displayName(item);
|
||||
nameBtn.title = displayPath(item.path);
|
||||
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 +771,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,18 +799,25 @@ 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 });
|
||||
cwd = data.path;
|
||||
pathEl.textContent = cwd;
|
||||
pathEl.textContent = displayPath(cwd);
|
||||
savePath(cwd);
|
||||
refreshSpaces();
|
||||
entries = data.entries.sort((a, b) => {
|
||||
if (a.type === "d" && b.type !== "d") return -1;
|
||||
if (a.type !== "d" && b.type === "d") return 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
return displayName(a).localeCompare(displayName(b));
|
||||
});
|
||||
selected.clear();
|
||||
focusedPath = null;
|
||||
@@ -690,6 +826,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 +870,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 +880,7 @@ function setClipboard(op) {
|
||||
}
|
||||
|
||||
function clearClipboard() {
|
||||
if (busy || loadingPath) return;
|
||||
clipboard = null;
|
||||
renderClipboard();
|
||||
setStatus(t("clipboardCleared"));
|
||||
@@ -785,7 +928,7 @@ function validatePasteTarget() {
|
||||
|
||||
function conflictText(names) {
|
||||
if (!names.length) return "";
|
||||
const shown = names.slice(0, 4).join(", ");
|
||||
const shown = names.slice(0, 4).map(decodeFsText).join(", ");
|
||||
return names.length > 4 ? t("selectedItems", { name: shown, count: names.length }) : shown;
|
||||
}
|
||||
|
||||
@@ -822,7 +965,7 @@ function renderPendingOverlay(text) {
|
||||
if (pendingAbortController) pendingAbortController.abort();
|
||||
pendingAbortController = null;
|
||||
pendingOverlayText = "";
|
||||
overlayEl.hidden = true;
|
||||
hideTaskOverlay();
|
||||
setStatus(t("cancelPrepare"));
|
||||
setBusy(false);
|
||||
});
|
||||
@@ -833,13 +976,36 @@ function renderPendingOverlay(text) {
|
||||
|
||||
function showPendingOverlay(text) {
|
||||
pendingOverlayText = text;
|
||||
overlayEl.hidden = false;
|
||||
renderPendingOverlay(text);
|
||||
setBusy(true);
|
||||
showTaskOverlay();
|
||||
}
|
||||
|
||||
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 || !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 +1046,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 +1057,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 +1080,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 +1090,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 = displayPath(task.current || task.src);
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "task-meta";
|
||||
@@ -965,12 +1141,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 +1188,8 @@ async function pollTasks() {
|
||||
await refreshSpaces();
|
||||
}
|
||||
} catch (err) {
|
||||
setStatus(t("tasksPollFailed", { error: err.message }));
|
||||
const message = t("tasksPollFailed", { error: err.message });
|
||||
setStatus(message);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1010,16 +1202,16 @@ 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();
|
||||
if (!name || name === item.name) 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 || 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 +1232,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 +1248,53 @@ 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,
|
||||
displayName: decodeFsText(row.dataset.name)
|
||||
};
|
||||
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();
|
||||
@@ -1080,7 +1309,7 @@ async function init() {
|
||||
applyStaticText();
|
||||
const savedPath = readSavedPath();
|
||||
cwd = savedPath;
|
||||
pathEl.textContent = cwd;
|
||||
pathEl.textContent = displayPath(cwd);
|
||||
taskRefreshPath = savedPath;
|
||||
refreshSpaces();
|
||||
await pollTasks();
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 290 KiB |
+12
-27
@@ -4,8 +4,6 @@
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
#include <ps5/kernel.h>
|
||||
@@ -33,9 +31,16 @@ int sceAppInstUtilAppInstallAll(void *);
|
||||
|
||||
static int
|
||||
install_file(const char *path, const uint8_t *data, size_t size) {
|
||||
struct stat st;
|
||||
FILE *f;
|
||||
|
||||
if(!(f = fopen(path, "w"))) {
|
||||
if(!stat(path, &st)) {
|
||||
return 0;
|
||||
}
|
||||
if(errno != ENOENT) {
|
||||
return -1;
|
||||
}
|
||||
if(!(f = fopen(path, "wb"))) {
|
||||
return -1;
|
||||
}
|
||||
if(fwrite(data, size, 1, f) != 1) {
|
||||
@@ -65,32 +70,13 @@ install_app(const char *title_id, const char *dir) {
|
||||
}
|
||||
|
||||
static int
|
||||
needs_update(const char *path, const uint8_t *expected, size_t expected_size) {
|
||||
needs_install_file(const char *path) {
|
||||
struct stat st;
|
||||
uint8_t *buf;
|
||||
FILE *f;
|
||||
int mismatch;
|
||||
|
||||
if(stat(path, &st) || st.st_size != (off_t)expected_size) {
|
||||
if(stat(path, &st)) {
|
||||
return 1;
|
||||
}
|
||||
if(!(f = fopen(path, "r"))) {
|
||||
return 1;
|
||||
}
|
||||
if(!(buf = malloc(expected_size))) {
|
||||
fclose(f);
|
||||
return 1;
|
||||
}
|
||||
if(fread(buf, 1, expected_size, f) != expected_size) {
|
||||
free(buf);
|
||||
fclose(f);
|
||||
return 1;
|
||||
}
|
||||
fclose(f);
|
||||
|
||||
mismatch = memcmp(buf, expected, expected_size);
|
||||
free(buf);
|
||||
return mismatch != 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int
|
||||
@@ -111,8 +97,7 @@ app_install_if_needed(void) {
|
||||
|
||||
if(stat(base_dir, &st)) {
|
||||
update_needed = 1;
|
||||
} else if(needs_update(param_path, param_json, param_json_size) ||
|
||||
needs_update(icon_path, icon0_png, icon0_png_size)) {
|
||||
} else if(needs_install_file(param_path) || needs_install_file(icon_path)) {
|
||||
update_needed = 1;
|
||||
}
|
||||
|
||||
|
||||
+1327
-274
File diff suppressed because it is too large.
Load diff
@@ -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"},
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user