diff --git a/.github/release-notes/v1.7.4.md b/.github/release-notes/v1.7.4.md
new file mode 100644
index 0000000..7ce8cca
--- /dev/null
+++ b/.github/release-notes/v1.7.4.md
@@ -0,0 +1,61 @@
+
+
+[English](#v1-7-4-english) · [简体中文](#v1-7-4-zh)
+
+
+### English
+
+> This release improves transfer reliability and makes the task center easier to control. Upgrading is recommended for users who regularly transfer multiple or large files over SFTP.
+
+#### Important fixes
+
+- Restored terminal, SFTP, remote terminal, and VNC tabs now start as disconnected instead of showing a stale connected state.
+- Large SFTP downloads use lighter status updates and non-blocking desktop delivery, reducing window freezes during transfer and cross-volume saves.
+- The task center now stays above page notifications, repairs oversized saved dimensions, and provides an always-accessible reset-size button.
+
+#### Important additions
+
+- Multi-file uploads, separate downloads, and cross-host transfers now share one visible queue with pause-all, resume-all, and clear-queue controls.
+
+#### Other improvements
+
+- Dropping files into a terminal now explains that SFTP still uses the SSH connection's original login user after `su` or `sudo` switches the terminal user.
+
+#### Known issue
+
+- macOS packages are currently not code-signed or notarized, so Gatekeeper may report that Terma is damaged or cannot be opened. See [Issue #36](https://github.com/zmide/Terma/issues/36) for a temporary workaround.
+
+#### Changes in this release
+
+- Release v1.7.4 with transfer queue and task center improvements ([#38](https://github.com/zmide/Terma/pull/38), [@JunXiaoRuo](https://github.com/JunXiaoRuo))
+
+**Full Changelog**: [v1.7.3...v1.7.4](https://github.com/zmide/Terma/compare/v1.7.3...v1.7.4)
+
+
+### 简体中文
+
+> 本版提升传输可靠性,并让任务中心更容易控制。经常通过 SFTP 传输多个文件或大文件的用户建议升级。
+
+#### 重要修复
+
+- 恢复终端、SFTP、远程终端和 VNC 标签时统一显示为已断开,不再错误继承上次运行的绿色连接状态。
+- 大文件 SFTP 下载采用更轻量的状态更新和非阻塞桌面保存,减少传输及跨磁盘保存时的窗口卡死。
+- 任务中心打开时不再被页面通知遮挡;异常保存尺寸会自动修正,并提供始终可用的“恢复默认大小”按钮。
+
+#### 重要新增
+
+- 多文件上传、分别下载和跨主机传输现在统一显示在同一队列,并支持全部暂停、全部继续和清空队列。
+
+#### 其他优化
+
+- 拖入终端上传文件时会明确说明:即使终端已通过 `su` 或 `sudo` 切换用户,SFTP 仍使用 SSH 连接的原登录用户。
+
+#### 已知问题
+
+- macOS 发布包目前尚未完成代码签名和公证,Gatekeeper 可能提示 Terma 已损坏或无法打开。临时处理方法见 [Issue #36](https://github.com/zmide/Terma/issues/36)。
+
+#### 本次变更
+
+- 发布 v1.7.4,完善传输队列与任务中心([#38](https://github.com/zmide/Terma/pull/38),[@JunXiaoRuo](https://github.com/JunXiaoRuo))
+
+**完整变更**:[v1.7.3...v1.7.4](https://github.com/zmide/Terma/compare/v1.7.3...v1.7.4)
diff --git a/docs/update.md b/docs/update.md
index 5ef2816..8f7edc5 100644
--- a/docs/update.md
+++ b/docs/update.md
@@ -2,12 +2,12 @@
## Next release draft / 下一版草稿
-[English](#english) · [简体中文](#简体中文)
+[English](#v1-7-5-english) · [简体中文](#v1-7-5-zh)
-
+
### English
-> The next release will continue recoverable terminal, interoperability, and cross-platform remote desktop work.
+> The v1.7.5 draft will continue recoverable terminal, interoperability, and cross-platform remote desktop work.
#### Planned
@@ -15,10 +15,10 @@
- Expand terminal AI interoperability tests with additional OpenAI-compatible gateways while keeping command execution and MCP approval boundaries unchanged.
- Complete real TigerVNC/noVNC/system-client clipboard acceptance for text, Chinese and image transfer on all three desktop platforms.
-
+
### 简体中文
-> 下一版本继续推进可恢复终端、互操作场景和跨平台远程桌面验收。
+> v1.7.5 草稿继续推进可恢复终端、互操作场景和跨平台远程桌面验收。
#### 计划
@@ -26,6 +26,68 @@
- 使用更多 OpenAI 兼容网关扩展终端 AI 互操作测试,同时保持命令执行和 MCP 确认边界不变。
- 完成 TigerVNC、noVNC、系统客户端在三种桌面平台上的文本、中文和图片剪贴板验收。
+## v1.7.4
+
+[English](#v1-7-4-english) · [简体中文](#v1-7-4-zh)
+
+
+### English
+
+> This release improves transfer reliability and makes the task center easier to control. Upgrading is recommended for users who regularly transfer multiple or large files over SFTP.
+
+#### Important fixes
+
+- Restored terminal, SFTP, remote terminal, and VNC tabs now start as disconnected instead of showing a stale connected state.
+- Large SFTP downloads use lighter status updates and non-blocking desktop delivery, reducing window freezes during transfer and cross-volume saves.
+- The task center now stays above page notifications, repairs oversized saved dimensions, and provides an always-accessible reset-size button.
+
+#### Important additions
+
+- Multi-file uploads, separate downloads, and cross-host transfers now share one visible queue with pause-all, resume-all, and clear-queue controls.
+
+#### Other improvements
+
+- Dropping files into a terminal now explains that SFTP still uses the SSH connection's original login user after `su` or `sudo` switches the terminal user.
+
+#### Known issue
+
+- macOS packages are currently not code-signed or notarized, so Gatekeeper may report that Terma is damaged or cannot be opened. See [Issue #36](https://github.com/zmide/Terma/issues/36) for a temporary workaround.
+
+#### Changes in this release
+
+- Release v1.7.4 with transfer queue and task center improvements ([#38](https://github.com/zmide/Terma/pull/38), [@JunXiaoRuo](https://github.com/JunXiaoRuo))
+
+**Full Changelog**: [v1.7.3...v1.7.4](https://github.com/zmide/Terma/compare/v1.7.3...v1.7.4)
+
+
+### 简体中文
+
+> 本版提升传输可靠性,并让任务中心更容易控制。经常通过 SFTP 传输多个文件或大文件的用户建议升级。
+
+#### 重要修复
+
+- 恢复终端、SFTP、远程终端和 VNC 标签时统一显示为已断开,不再错误继承上次运行的绿色连接状态。
+- 大文件 SFTP 下载采用更轻量的状态更新和非阻塞桌面保存,减少传输及跨磁盘保存时的窗口卡死。
+- 任务中心打开时不再被页面通知遮挡;异常保存尺寸会自动修正,并提供始终可用的“恢复默认大小”按钮。
+
+#### 重要新增
+
+- 多文件上传、分别下载和跨主机传输现在统一显示在同一队列,并支持全部暂停、全部继续和清空队列。
+
+#### 其他优化
+
+- 拖入终端上传文件时会明确说明:即使终端已通过 `su` 或 `sudo` 切换用户,SFTP 仍使用 SSH 连接的原登录用户。
+
+#### 已知问题
+
+- macOS 发布包目前尚未完成代码签名和公证,Gatekeeper 可能提示 Terma 已损坏或无法打开。临时处理方法见 [Issue #36](https://github.com/zmide/Terma/issues/36)。
+
+#### 本次变更
+
+- 发布 v1.7.4,完善传输队列与任务中心([#38](https://github.com/zmide/Terma/pull/38),[@JunXiaoRuo](https://github.com/JunXiaoRuo))
+
+**完整变更**:[v1.7.3...v1.7.4](https://github.com/zmide/Terma/compare/v1.7.3...v1.7.4)
+
## v1.7.3
[English](#v1-7-3-english) · [简体中文](#v1-7-3-zh)
diff --git a/package-lock.json b/package-lock.json
index f7f0b84..2c4cfb3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "terma",
- "version": "1.7.3",
+ "version": "1.7.4",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "terma",
- "version": "1.7.3",
+ "version": "1.7.4",
"license": "GPL-3.0-only",
"dependencies": {
"@novnc/novnc": "^1.7.0",
diff --git a/package.json b/package.json
index 8a34036..f049a8b 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "terma",
- "version": "1.7.3",
+ "version": "1.7.4",
"releaseRevision": 1,
"private": true,
"license": "GPL-3.0-only",
@@ -32,7 +32,7 @@
"check": "tsc --noEmit",
"check:strict": "tsc -p tsconfig.strict.json",
"mcp:check": "npm run build && node scripts/mcp-check.js",
- "regression": "npm run build && npm run check:strict && node scripts/check-frontend.js && node scripts/i18n-check.js && node scripts/backend-public-error-check.js && node scripts/notification-i18n-check.js && node scripts/notification-source-i18n-check.js && node scripts/quick-terminal-check.js && node scripts/batch-command-check.js && node scripts/frontend-module-boundary-check.js && node scripts/update-markdown-check.js && node scripts/readme-bilingual-check.js && node scripts/release-bilingual-check.js && node scripts/workflow-action-pin-check.js && node scripts/release-publish-assets-check.js && node scripts/program-cache-check.js && node scripts/remote-install-ui-check.js && node scripts/remote-component-install-plans-check.js && node scripts/remote-privilege-check.js && node scripts/remote-protocol-check.js && node scripts/remote-probe-protocol-check.js && node scripts/remote-graphics-rendering-check.js && node scripts/remote-profile-migration-check.js && node scripts/remote-profile-from-ssh-check.js && node scripts/remote-host-group-check.js && node scripts/remote-client-adapter-check.js && node scripts/tigervnc-runtime-check.js && node scripts/desktop-browser-integration-check.js && node scripts/remote-offline-installer-check.js && node scripts/remote-offline-tasks-check.js && node scripts/xserver-runtime-check.js && node scripts/x11-clipboard-image-bridge-check.js && node scripts/remote-posix-check.js && node scripts/x11-application-check.js && node scripts/x11-sshd-config-check.js && node scripts/x11-clipboard-helper-check.js && node scripts/linux-desktop-manager-check.js && node scripts/xdmcp-manager-check.js && node scripts/vnc-handshake-check.js && node scripts/vnc-proxy-check.js && node scripts/vnc-server-manager-check.js && node scripts/vnc-credential-check.js && node scripts/vnc-clipboard-check.js && node scripts/vnc-clipboard-bridge-check.js && node scripts/vnc-clipboard-image-check.js && node scripts/vnc-workspace-reuse-check.js && node scripts/vnc-detached-window-check.js && node scripts/vnc-performance-check.js && node scripts/ui-state-revision-check.js && node scripts/persistent-ssh-command-check.js && node scripts/productivity-features-check.js && node scripts/terminal-tab-connection-check.js && node scripts/terminal-responsiveness-check.js && node scripts/terminal-output-flow-check.js && node scripts/forward-restart-recovery-check.js && node scripts/forward-reconfigure-check.js && node scripts/forward-url-path-check.js && node scripts/terminal-quick-command-check.js && node scripts/sftp-sync-check.js && node scripts/sftp-automation-check.js && node scripts/sftp-external-edit-check.js && node scripts/ssh-key-wizard-check.js && node scripts/identity-path-security-check.js && node scripts/identity-compatibility-check.js && node scripts/storage-restore-encryption-check.js && node scripts/config-encryption-boundary-check.js && node scripts/config-encryption-http-check.js && node scripts/storage-permissions-check.js && node scripts/workspace-docking-check.js && node scripts/workspace-pane-isolation-check.js && node scripts/terminal-pty-check.js && node scripts/terminal-zmodem-check.js && node scripts/ssh-capabilities-check.js && node scripts/ssh-connection-check.js && node scripts/connection-health-check.js && node scripts/ssh-auth-matrix-check.js && node scripts/terminal-session-check.js && node scripts/terminal-startup-check.js && node scripts/terminal-startup-api-check.js && node scripts/terminal-drop-upload-check.js && node scripts/terminal-clipboard-image-check.js && node scripts/local-files-check.js && node scripts/local-files-ui-check.js && node scripts/route-extraction-check.js && node scripts/sftp-local-delivery-check.js && node scripts/terminal-log-session-check.js && node scripts/websocket-frame-check.js && node scripts/socks-forward-check.js && node scripts/port-kill-security-check.js && node scripts/auth-protection-check.js && node scripts/security-boundary-check.js && node scripts/web-auth-integration-check.js && node scripts/log-reader-check.js && node scripts/database-transfer-check.js && node scripts/connection-duplicate-check.js && node scripts/sftp-encoding-check.js && node scripts/sftp-session-check.js && node scripts/sftp-download-integrity-check.js && node scripts/sftp-job-lifecycle-check.js && node scripts/sftp-checkpoint-transfer-check.js && node scripts/sftp-job-range-check.js && node scripts/sftp-range-cache-check.js && node scripts/sftp-transfer-concurrency-check.js && node scripts/sftp-backend-check.js && node scripts/sftp-delete-job-check.js && node scripts/sftp-native-drag-check.js && node native/win-sftp-drag/tests/source-contract-check.js && node scripts/native-sftp-drag-desktop-check.js && node scripts/fault-injection-check.js && node scripts/dependency-state-check.js && node scripts/brand-data-migration-check.js && node scripts/desktop-storage-migration-check.js && node scripts/desktop-startup-check.js && node scripts/source-runtime-path-check.js && node scripts/sftp-pagination-check.js && node scripts/sftp-native-performance-check.js && node scripts/update-checker-check.js && node scripts/update-installer-check.js && node scripts/runtime-settings-check.js && node scripts/ai-check.js && node scripts/mcp-check.js && node scripts/database-reopen-check.js && node scripts/database-credential-backup-check.js && node scripts/ssh-host-trust-check.js && node scripts/ssh2-error-isolation-check.js && node scripts/web-regression-harness.js",
+ "regression": "npm run build && npm run check:strict && node scripts/check-frontend.js && node scripts/i18n-check.js && node scripts/backend-public-error-check.js && node scripts/notification-i18n-check.js && node scripts/notification-source-i18n-check.js && node scripts/quick-terminal-check.js && node scripts/batch-command-check.js && node scripts/frontend-module-boundary-check.js && node scripts/update-markdown-check.js && node scripts/readme-bilingual-check.js && node scripts/release-bilingual-check.js && node scripts/workflow-action-pin-check.js && node scripts/release-publish-assets-check.js && node scripts/program-cache-check.js && node scripts/remote-install-ui-check.js && node scripts/remote-component-install-plans-check.js && node scripts/remote-privilege-check.js && node scripts/remote-protocol-check.js && node scripts/remote-probe-protocol-check.js && node scripts/remote-graphics-rendering-check.js && node scripts/remote-profile-migration-check.js && node scripts/remote-profile-from-ssh-check.js && node scripts/remote-host-group-check.js && node scripts/remote-client-adapter-check.js && node scripts/tigervnc-runtime-check.js && node scripts/desktop-browser-integration-check.js && node scripts/remote-offline-installer-check.js && node scripts/remote-offline-tasks-check.js && node scripts/xserver-runtime-check.js && node scripts/x11-clipboard-image-bridge-check.js && node scripts/remote-posix-check.js && node scripts/x11-application-check.js && node scripts/x11-sshd-config-check.js && node scripts/x11-clipboard-helper-check.js && node scripts/linux-desktop-manager-check.js && node scripts/xdmcp-manager-check.js && node scripts/vnc-handshake-check.js && node scripts/vnc-proxy-check.js && node scripts/vnc-server-manager-check.js && node scripts/vnc-credential-check.js && node scripts/vnc-clipboard-check.js && node scripts/vnc-clipboard-bridge-check.js && node scripts/vnc-clipboard-image-check.js && node scripts/vnc-workspace-reuse-check.js && node scripts/vnc-detached-window-check.js && node scripts/vnc-performance-check.js && node scripts/ui-state-revision-check.js && node scripts/persistent-ssh-command-check.js && node scripts/productivity-features-check.js && node scripts/terminal-tab-connection-check.js && node scripts/terminal-responsiveness-check.js && node scripts/terminal-output-flow-check.js && node scripts/forward-restart-recovery-check.js && node scripts/forward-reconfigure-check.js && node scripts/forward-url-path-check.js && node scripts/terminal-quick-command-check.js && node scripts/sftp-sync-check.js && node scripts/sftp-automation-check.js && node scripts/sftp-external-edit-check.js && node scripts/ssh-key-wizard-check.js && node scripts/identity-path-security-check.js && node scripts/identity-compatibility-check.js && node scripts/storage-restore-encryption-check.js && node scripts/config-encryption-boundary-check.js && node scripts/config-encryption-http-check.js && node scripts/storage-permissions-check.js && node scripts/workspace-docking-check.js && node scripts/workspace-pane-isolation-check.js && node scripts/terminal-pty-check.js && node scripts/terminal-zmodem-check.js && node scripts/ssh-capabilities-check.js && node scripts/ssh-connection-check.js && node scripts/connection-health-check.js && node scripts/ssh-auth-matrix-check.js && node scripts/terminal-session-check.js && node scripts/terminal-startup-check.js && node scripts/terminal-startup-api-check.js && node scripts/terminal-drop-upload-check.js && node scripts/terminal-clipboard-image-check.js && node scripts/local-files-check.js && node scripts/local-files-ui-check.js && node scripts/route-extraction-check.js && node scripts/sftp-local-delivery-check.js && node scripts/terminal-log-session-check.js && node scripts/websocket-frame-check.js && node scripts/socks-forward-check.js && node scripts/port-kill-security-check.js && node scripts/auth-protection-check.js && node scripts/security-boundary-check.js && node scripts/web-auth-integration-check.js && node scripts/log-reader-check.js && node scripts/database-transfer-check.js && node scripts/connection-duplicate-check.js && node scripts/sftp-encoding-check.js && node scripts/sftp-session-check.js && node scripts/sftp-channel-cancel-check.js && node scripts/sftp-download-integrity-check.js && node scripts/sftp-job-lifecycle-check.js && node scripts/sftp-checkpoint-transfer-check.js && node scripts/sftp-job-range-check.js && node scripts/sftp-range-cache-check.js && node scripts/sftp-transfer-concurrency-check.js && node scripts/sftp-backend-check.js && node scripts/sftp-delete-job-check.js && node scripts/sftp-native-drag-check.js && node native/win-sftp-drag/tests/source-contract-check.js && node scripts/native-sftp-drag-desktop-check.js && node scripts/fault-injection-check.js && node scripts/dependency-state-check.js && node scripts/brand-data-migration-check.js && node scripts/desktop-storage-migration-check.js && node scripts/desktop-startup-check.js && node scripts/source-runtime-path-check.js && node scripts/sftp-pagination-check.js && node scripts/sftp-native-performance-check.js && node scripts/update-checker-check.js && node scripts/update-installer-check.js && node scripts/runtime-settings-check.js && node scripts/ai-check.js && node scripts/mcp-check.js && node scripts/database-reopen-check.js && node scripts/database-credential-backup-check.js && node scripts/ssh-host-trust-check.js && node scripts/ssh2-error-isolation-check.js && node scripts/web-regression-harness.js",
"ui:smoke": "npm run build && node scripts/ui-smoke-harness.js",
"graphics:acceptance": "npm run build && node scripts/remote-graphics-real-acceptance.js --confirm-real-graphics",
"x11:acceptance": "npm run build && node scripts/x11-builtin-real-acceptance.js --confirm-real-x11",
diff --git a/public/app-docking.js b/public/app-docking.js
index 1770ac9..f1bf536 100644
--- a/public/app-docking.js
+++ b/public/app-docking.js
@@ -597,7 +597,7 @@ function workspaceTabHtml(tab, pane) {
].filter(Boolean).join(" - ");
const showsConnectionStatus = ["terminal", "quick-terminal", "sftp", "remote-terminal"].includes(tab.kind)
|| (tab.kind === "remote-desktop" && tab.protocol === "vnc");
- const connectionStatus = showsConnectionStatus ? (tab.connectionStatus || "connecting") : "";
+ const connectionStatus = showsConnectionStatus ? (tab.connectionStatus || "disconnected") : "";
const connectionStateText = tr(`common:auto.${connectionStatus === "connected" ? "connected" : connectionStatus === "disconnected" ? "disconnected" : "connecting"}`, {defaultValue:connectionStatus === "connected" ? "Connected" : connectionStatus === "disconnected" ? "Disconnected" : "Connecting"});
const connectionDot = connectionStatus
? ``
diff --git a/public/app-sftp-core.js b/public/app-sftp-core.js
index 02a27f8..d2291e2 100644
--- a/public/app-sftp-core.js
+++ b/public/app-sftp-core.js
@@ -4,6 +4,7 @@ const sftpKnownJobStatuses = new Map();
const sftpPendingDirectoryRefreshes = new Set();
const sftpDismissedFloatingJobIds = new Set();
const SFTP_ACTIVE_JOB_STATUSES = new Set(["running", "pending", "paused"]);
+const SFTP_TRANSFER_QUEUE_TYPES = new Set(["upload", "download", "cross-copy", "local-delivery", "native-drag"]);
const SFTP_MUTATING_JOB_TYPES = new Set(["upload", "copy", "cross-copy", "move", "extract", "compress", "delete", "sync", "local-delivery"]);
const SFTP_DIRECTORY_VIEW_CACHE_TTL_MS = 10 * 60 * 1000;
const SFTP_DIRECTORY_VIEW_CACHE_MAX_DIRECTORIES = 60;
diff --git a/public/app-sftp-task-center-layout.js b/public/app-sftp-task-center-layout.js
new file mode 100644
index 0000000..592556a
--- /dev/null
+++ b/public/app-sftp-task-center-layout.js
@@ -0,0 +1,214 @@
+const SFTP_TASK_CENTER_MIN_WIDTH = 340;
+const SFTP_TASK_CENTER_MIN_HEIGHT = 240;
+const SFTP_TASK_CENTER_VIEWPORT_GAP = 12;
+const SFTP_TASK_CENTER_SIZE_STORAGE_KEY = "sftpTaskCenterSizeV1";
+let sftpTaskCenterResize = null;
+let sftpTaskCenterBoundsObserver = null;
+let sftpTaskCenterBoundsFrame = 0;
+let sftpTaskCenterWindowResizeBound = false;
+
+function savedSftpTaskCenterSize() {
+ try {
+ const saved = JSON.parse(localStorage.getItem(SFTP_TASK_CENTER_SIZE_STORAGE_KEY) || "null");
+ const width = Number(saved?.width);
+ const height = Number(saved?.height);
+ return Number.isFinite(width) && Number.isFinite(height) ? {width, height} : null;
+ } catch {
+ return null;
+ }
+}
+
+function persistSftpTaskCenterSize(drawer) {
+ if (!drawer || isMobileLayout()) return;
+ const rect = drawer.getBoundingClientRect();
+ try {
+ localStorage.setItem(SFTP_TASK_CENTER_SIZE_STORAGE_KEY, JSON.stringify({
+ width:Math.round(rect.width),
+ height:Math.round(rect.height)
+ }));
+ } catch {}
+}
+
+function clearSftpTaskCenterSizeConstraints(drawer) {
+ drawer?.style.removeProperty("min-width");
+ drawer?.style.removeProperty("min-height");
+ drawer?.style.removeProperty("max-width");
+ drawer?.style.removeProperty("max-height");
+}
+
+function restoreSftpTaskCenterSize(drawer) {
+ if (!drawer) return false;
+ if (isMobileLayout()) {
+ drawer.style.removeProperty("width");
+ drawer.style.removeProperty("height");
+ clearSftpTaskCenterSizeConstraints(drawer);
+ return false;
+ }
+ const saved = savedSftpTaskCenterSize();
+ if (!saved) {
+ clampSftpTaskCenterSize(drawer, false);
+ return false;
+ }
+ const applied = applySftpTaskCenterSize(drawer, saved.width, saved.height);
+ if (Math.abs(applied.width - saved.width) > 1 || Math.abs(applied.height - saved.height) > 1) {
+ persistSftpTaskCenterSize(drawer);
+ }
+ return true;
+}
+
+function sftpTaskCenterResizeBounds(drawer) {
+ const rect = drawer.getBoundingClientRect();
+ const contentRect = drawer.closest(".content")?.getBoundingClientRect();
+ const viewportRight = Math.max(1, Number(window.innerWidth || document.documentElement.clientWidth || 1));
+ const viewportBottom = Math.max(1, Number(window.innerHeight || document.documentElement.clientHeight || 1));
+ const clipLeft = Math.max(0, Number(contentRect?.left || 0));
+ const clipRight = Math.min(viewportRight, Number(contentRect?.right || viewportRight));
+ const clipBottom = Math.min(viewportBottom, Number(contentRect?.bottom || viewportBottom));
+ const usableLeft = Math.min(clipRight, clipLeft + SFTP_TASK_CENTER_VIEWPORT_GAP);
+ const usableRight = Math.max(usableLeft, Math.min(rect.right, clipRight - SFTP_TASK_CENTER_VIEWPORT_GAP));
+ const usableBottom = Math.max(rect.top, clipBottom - SFTP_TASK_CENTER_VIEWPORT_GAP);
+ const maxWidth = Math.max(1, usableRight - usableLeft);
+ const maxHeight = Math.max(1, usableBottom - rect.top);
+ return {
+ minWidth:Math.min(SFTP_TASK_CENTER_MIN_WIDTH, maxWidth),
+ minHeight:Math.min(SFTP_TASK_CENTER_MIN_HEIGHT, maxHeight),
+ maxWidth,
+ maxHeight
+ };
+}
+
+function applySftpTaskCenterSize(drawer, width, height) {
+ const bounds = sftpTaskCenterResizeBounds(drawer);
+ const requestedWidth = Number(width);
+ const requestedHeight = Number(height);
+ const nextWidth = Math.max(bounds.minWidth, Math.min(bounds.maxWidth, Number.isFinite(requestedWidth) ? requestedWidth : bounds.minWidth));
+ const nextHeight = Math.max(bounds.minHeight, Math.min(bounds.maxHeight, Number.isFinite(requestedHeight) ? requestedHeight : bounds.minHeight));
+ drawer.style.minWidth = `${Math.round(bounds.minWidth)}px`;
+ drawer.style.minHeight = `${Math.round(bounds.minHeight)}px`;
+ drawer.style.maxWidth = `${Math.round(bounds.maxWidth)}px`;
+ drawer.style.maxHeight = `${Math.round(bounds.maxHeight)}px`;
+ drawer.style.width = `${Math.round(nextWidth)}px`;
+ drawer.style.height = `${Math.round(nextHeight)}px`;
+ return {width:Math.round(nextWidth), height:Math.round(nextHeight), bounds};
+}
+
+function clampSftpTaskCenterSize(drawer, persist=true) {
+ if (!drawer || drawer.hidden || isMobileLayout()) return false;
+ const before = drawer.getBoundingClientRect();
+ const applied = applySftpTaskCenterSize(drawer, before.width, before.height);
+ const changed = Math.abs(applied.width - before.width) > 1 || Math.abs(applied.height - before.height) > 1;
+ if (changed && persist) persistSftpTaskCenterSize(drawer);
+ return changed;
+}
+
+function scheduleSftpTaskCenterBoundsCheck() {
+ if (sftpTaskCenterBoundsFrame) return;
+ sftpTaskCenterBoundsFrame = requestAnimationFrame(() => {
+ sftpTaskCenterBoundsFrame = 0;
+ const drawer = document.getElementById("sftpTaskCenterDrawer");
+ if (drawer && !drawer.hidden) clampSftpTaskCenterSize(drawer);
+ });
+}
+
+function ensureSftpTaskCenterBoundsMonitoring(drawer) {
+ if (!drawer) return;
+ if (!sftpTaskCenterBoundsObserver && typeof ResizeObserver === "function") {
+ sftpTaskCenterBoundsObserver = new ResizeObserver(scheduleSftpTaskCenterBoundsCheck);
+ const content = drawer.closest(".content");
+ const topbar = drawer.closest(".topbar");
+ if (content) sftpTaskCenterBoundsObserver.observe(content);
+ if (topbar) sftpTaskCenterBoundsObserver.observe(topbar);
+ }
+ if (!sftpTaskCenterWindowResizeBound) {
+ sftpTaskCenterWindowResizeBound = true;
+ window.addEventListener("resize", scheduleSftpTaskCenterBoundsCheck, {passive:true});
+ }
+}
+
+function startSftpTaskCenterResize(event, handle=event.currentTarget) {
+ if (event.button !== 0 || isMobileLayout()) return;
+ const drawer = document.getElementById("sftpTaskCenterDrawer");
+ if (!drawer || drawer.hidden) return;
+ if (sftpTaskCenterResize) finishSftpTaskCenterResize(null, true);
+ event.preventDefault();
+ event.stopPropagation();
+ const rect = drawer.getBoundingClientRect();
+ sftpTaskCenterResize = {
+ drawer,
+ handle,
+ pointerId:event.pointerId,
+ startX:event.clientX,
+ startY:event.clientY,
+ startWidth:rect.width,
+ startHeight:rect.height
+ };
+ try { handle?.setPointerCapture?.(event.pointerId); } catch {}
+ document.body.classList.add("sftp-task-center-resizing");
+ window.addEventListener("pointermove", moveSftpTaskCenterResize, {passive:false});
+ window.addEventListener("pointerup", finishSftpTaskCenterResize);
+ window.addEventListener("pointercancel", cancelSftpTaskCenterResize);
+ window.addEventListener("blur", finishSftpTaskCenterResizeOnBlur);
+}
+
+function moveSftpTaskCenterResize(event) {
+ const drag = sftpTaskCenterResize;
+ if (!drag || event.pointerId !== drag.pointerId) return;
+ event.preventDefault();
+ applySftpTaskCenterSize(
+ drag.drawer,
+ drag.startWidth - (event.clientX - drag.startX),
+ drag.startHeight + (event.clientY - drag.startY)
+ );
+}
+
+function cancelSftpTaskCenterResize(event) {
+ finishSftpTaskCenterResize(event, true);
+}
+
+function finishSftpTaskCenterResizeOnBlur() {
+ finishSftpTaskCenterResize(null);
+}
+
+function finishSftpTaskCenterResize(event, cancelled=false) {
+ const drag = sftpTaskCenterResize;
+ if (!drag || event?.pointerId !== undefined && event.pointerId !== drag.pointerId) return;
+ window.removeEventListener("pointermove", moveSftpTaskCenterResize);
+ window.removeEventListener("pointerup", finishSftpTaskCenterResize);
+ window.removeEventListener("pointercancel", cancelSftpTaskCenterResize);
+ window.removeEventListener("blur", finishSftpTaskCenterResizeOnBlur);
+ sftpTaskCenterResize = null;
+ try {
+ if (drag.handle.hasPointerCapture?.(drag.pointerId)) drag.handle.releasePointerCapture(drag.pointerId);
+ } catch {}
+ document.body.classList.remove("sftp-task-center-resizing");
+ if (cancelled) applySftpTaskCenterSize(drag.drawer, drag.startWidth, drag.startHeight);
+ else persistSftpTaskCenterSize(drag.drawer);
+}
+
+function resetSftpTaskCenterSize(event) {
+ if (isMobileLayout()) return;
+ event?.preventDefault?.();
+ event?.stopPropagation?.();
+ const drawer = document.getElementById("sftpTaskCenterDrawer");
+ clearSftpTaskCenterSizeConstraints(drawer);
+ drawer?.style.removeProperty("width");
+ drawer?.style.removeProperty("height");
+ if (drawer && !drawer.hidden) clampSftpTaskCenterSize(drawer, false);
+ try { localStorage.removeItem(SFTP_TASK_CENTER_SIZE_STORAGE_KEY); } catch {}
+}
+
+function handleSftpTaskCenterResizeKey(event) {
+ if (isMobileLayout()) return;
+ if (event.key === "Home") return resetSftpTaskCenterSize(event);
+ if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
+ event.preventDefault();
+ event.stopPropagation();
+ const drawer = document.getElementById("sftpTaskCenterDrawer");
+ if (!drawer) return;
+ const rect = drawer.getBoundingClientRect();
+ const step = event.shiftKey ? 32 : 16;
+ const width = rect.width + (event.key === "ArrowLeft" ? step : event.key === "ArrowRight" ? -step : 0);
+ const height = rect.height + (event.key === "ArrowDown" ? step : event.key === "ArrowUp" ? -step : 0);
+ applySftpTaskCenterSize(drawer, width, height);
+ persistSftpTaskCenterSize(drawer);
+}
diff --git a/public/app-sftp-tasks.js b/public/app-sftp-tasks.js
index 0807d6e..895d52c 100644
--- a/public/app-sftp-tasks.js
+++ b/public/app-sftp-tasks.js
@@ -1,9 +1,4 @@
const sftpTaskLogViewStates = new Map();
-const SFTP_TASK_CENTER_MIN_WIDTH = 340;
-const SFTP_TASK_CENTER_MIN_HEIGHT = 240;
-const SFTP_TASK_CENTER_VIEWPORT_GAP = 12;
-const SFTP_TASK_CENTER_SIZE_STORAGE_KEY = "sftpTaskCenterSizeV1";
-let sftpTaskCenterResize = null;
let sftpBackgroundRefreshInFlight = false;
let sftpBackgroundVisibilityBound = false;
let sftpLastJobsPollAt = 0;
@@ -14,136 +9,6 @@ let sftpJobsRefreshTrailing = false;
let sftpDirectoryRefreshTimer = 0;
let sftpDirectoryRefreshActive = false;
let sftpPreferredDirectoryRefreshKey = "";
-function savedSftpTaskCenterSize() {
- try {
- const saved = JSON.parse(localStorage.getItem(SFTP_TASK_CENTER_SIZE_STORAGE_KEY) || "null");
- const width = Number(saved?.width);
- const height = Number(saved?.height);
- return Number.isFinite(width) && Number.isFinite(height) ? {width, height} : null;
- } catch {
- return null;
- }
-}
-function persistSftpTaskCenterSize(drawer) {
- if (!drawer || isMobileLayout()) return;
- const rect = drawer.getBoundingClientRect();
- try {
- localStorage.setItem(SFTP_TASK_CENTER_SIZE_STORAGE_KEY, JSON.stringify({
- width:Math.round(rect.width),
- height:Math.round(rect.height)
- }));
- } catch {}
-}
-function restoreSftpTaskCenterSize(drawer) {
- if (!drawer) return false;
- if (isMobileLayout()) {
- drawer.style.removeProperty("width");
- drawer.style.removeProperty("height");
- return false;
- }
- const saved = savedSftpTaskCenterSize();
- if (!saved) return false;
- applySftpTaskCenterSize(drawer, saved.width, saved.height);
- return true;
-}
-function sftpTaskCenterResizeBounds(drawer) {
- const rect = drawer.getBoundingClientRect();
- const maxWidth = Math.max(1, rect.right - SFTP_TASK_CENTER_VIEWPORT_GAP);
- const maxHeight = Math.max(1, window.innerHeight - rect.top - SFTP_TASK_CENTER_VIEWPORT_GAP);
- return {
- minWidth:Math.min(SFTP_TASK_CENTER_MIN_WIDTH, maxWidth),
- minHeight:Math.min(SFTP_TASK_CENTER_MIN_HEIGHT, maxHeight),
- maxWidth,
- maxHeight
- };
-}
-function applySftpTaskCenterSize(drawer, width, height) {
- const bounds = sftpTaskCenterResizeBounds(drawer);
- const nextWidth = Math.max(bounds.minWidth, Math.min(bounds.maxWidth, Number(width) || bounds.minWidth));
- const nextHeight = Math.max(bounds.minHeight, Math.min(bounds.maxHeight, Number(height) || bounds.minHeight));
- drawer.style.width = `${Math.round(nextWidth)}px`;
- drawer.style.height = `${Math.round(nextHeight)}px`;
-}
-function startSftpTaskCenterResize(event, handle=event.currentTarget) {
- if (event.button !== 0 || isMobileLayout()) return;
- const drawer = document.getElementById("sftpTaskCenterDrawer");
- if (!drawer || drawer.hidden) return;
- if (sftpTaskCenterResize) finishSftpTaskCenterResize(null, true);
- event.preventDefault();
- event.stopPropagation();
- const rect = drawer.getBoundingClientRect();
- sftpTaskCenterResize = {
- drawer,
- handle,
- pointerId:event.pointerId,
- startX:event.clientX,
- startY:event.clientY,
- startWidth:rect.width,
- startHeight:rect.height
- };
- try { handle?.setPointerCapture?.(event.pointerId); } catch {}
- document.body.classList.add("sftp-task-center-resizing");
- window.addEventListener("pointermove", moveSftpTaskCenterResize, {passive:false});
- window.addEventListener("pointerup", finishSftpTaskCenterResize);
- window.addEventListener("pointercancel", cancelSftpTaskCenterResize);
- window.addEventListener("blur", finishSftpTaskCenterResizeOnBlur);
-}
-function moveSftpTaskCenterResize(event) {
- const drag = sftpTaskCenterResize;
- if (!drag || event.pointerId !== drag.pointerId) return;
- event.preventDefault();
- applySftpTaskCenterSize(
- drag.drawer,
- drag.startWidth - (event.clientX - drag.startX),
- drag.startHeight + (event.clientY - drag.startY)
- );
-}
-function cancelSftpTaskCenterResize(event) {
- finishSftpTaskCenterResize(event, true);
-}
-function finishSftpTaskCenterResizeOnBlur() {
- finishSftpTaskCenterResize(null);
-}
-function finishSftpTaskCenterResize(event, cancelled=false) {
- const drag = sftpTaskCenterResize;
- if (!drag || event?.pointerId !== undefined && event.pointerId !== drag.pointerId) return;
- window.removeEventListener("pointermove", moveSftpTaskCenterResize);
- window.removeEventListener("pointerup", finishSftpTaskCenterResize);
- window.removeEventListener("pointercancel", cancelSftpTaskCenterResize);
- window.removeEventListener("blur", finishSftpTaskCenterResizeOnBlur);
- sftpTaskCenterResize = null;
- try {
- if (drag.handle.hasPointerCapture?.(drag.pointerId)) drag.handle.releasePointerCapture(drag.pointerId);
- } catch {}
- document.body.classList.remove("sftp-task-center-resizing");
- if (cancelled) applySftpTaskCenterSize(drag.drawer, drag.startWidth, drag.startHeight);
- else persistSftpTaskCenterSize(drag.drawer);
-}
-function resetSftpTaskCenterSize(event) {
- if (isMobileLayout()) return;
- event?.preventDefault?.();
- event?.stopPropagation?.();
- const drawer = document.getElementById("sftpTaskCenterDrawer");
- drawer?.style.removeProperty("width");
- drawer?.style.removeProperty("height");
- try { localStorage.removeItem(SFTP_TASK_CENTER_SIZE_STORAGE_KEY); } catch {}
-}
-function handleSftpTaskCenterResizeKey(event) {
- if (isMobileLayout()) return;
- if (event.key === "Home") return resetSftpTaskCenterSize(event);
- if (!["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return;
- event.preventDefault();
- event.stopPropagation();
- const drawer = document.getElementById("sftpTaskCenterDrawer");
- if (!drawer) return;
- const rect = drawer.getBoundingClientRect();
- const step = event.shiftKey ? 32 : 16;
- const width = rect.width + (event.key === "ArrowLeft" ? step : event.key === "ArrowRight" ? -step : 0);
- const height = rect.height + (event.key === "ArrowDown" ? step : event.key === "ArrowUp" ? -step : 0);
- applySftpTaskCenterSize(drawer, width, height);
- persistSftpTaskCenterSize(drawer);
-}
-
function startSftpJobsTimer() {
if (sftpJobsTimer) return;
const schedule = delay => {
@@ -307,6 +172,40 @@ function sftpTaskCollections(jobs=sftpLatestJobs) {
const failedCount = failed.length;
return {current, failed, history, activeCount, failedCount};
}
+function sftpTransferQueueJobs(jobs=sftpLatestJobs) {
+ return jobs.filter(job => SFTP_TRANSFER_QUEUE_TYPES.has(job.type) && SFTP_ACTIVE_JOB_STATUSES.has(job.status));
+}
+function sftpTransferJobCanPause(job) {
+ if (!job || job.resume_supported !== true) return false;
+ if (job.status === "running") return job.can_pause !== false;
+ return job.status === "pending" && ["download", "cross-copy"].includes(job.type);
+}
+function sftpTransferJobCanResume(job) {
+ return Boolean(job && job.status === "paused" && job.can_resume);
+}
+function sftpTransferJobCanCancel(job) {
+ return Boolean(job
+ && SFTP_ACTIVE_JOB_STATUSES.has(job.status)
+ && job.can_cancel !== false
+ && !(job.type === "upload" && job.phase === "committing"));
+}
+async function runSftpTransferJobBatch(jobs, operation, concurrency=6) {
+ let nextIndex = 0;
+ const results = new Array(jobs.length);
+ const workers = Array.from({length:Math.min(Math.max(1, Number(concurrency || 1)), jobs.length)}, async () => {
+ while (true) {
+ const index = nextIndex++;
+ if (index >= jobs.length) return;
+ try {
+ results[index] = {status:"fulfilled", value:await operation(jobs[index])};
+ } catch (reason) {
+ results[index] = {status:"rejected", reason};
+ }
+ }
+ });
+ await Promise.all(workers);
+ return results;
+}
function sftpTaskCenterProgress(jobs=sftpLatestJobs) {
const active = jobs.filter(job => SFTP_ACTIVE_JOB_STATUSES.has(job.status));
@@ -627,12 +526,26 @@ function renderSftpTaskCenterDrawer(jobs=sftpLatestJobs) {
failedCount ? tr("tasks:auto.failed_count", {count:failedCount, defaultValue:`${failedCount} 项失败`}) : "",
!activeCount && !failedCount ? tr("tasks:auto.empty_current", {defaultValue:"暂无进行中的任务"}) : ""
].filter(Boolean).join(" · ");
+ const transferQueue = sftpTransferQueueJobs(current);
+ const pausableCount = transferQueue.filter(sftpTransferJobCanPause).length;
+ const resumableCount = transferQueue.filter(sftpTransferJobCanResume).length;
+ const cancelableCount = transferQueue.filter(sftpTransferJobCanCancel).length;
+ const showingCurrentQueue = !showingHistory && !showingFailed && transferQueue.length > 0;
const footer = document.getElementById("sftpTaskCenterFooter");
- if (footer) footer.hidden = !((showingHistory && history.length) || (showingFailed && failed.length));
+ if (footer) footer.hidden = !(showingCurrentQueue || (showingHistory && history.length) || (showingFailed && failed.length));
+ const queueActions = document.getElementById("sftpTaskCenterQueueActions");
+ if (queueActions) queueActions.hidden = !showingCurrentQueue;
+ const pauseAllButton = document.getElementById("sftpTaskCenterPauseAllButton");
+ if (pauseAllButton) pauseAllButton.disabled = pausableCount === 0;
+ const resumeAllButton = document.getElementById("sftpTaskCenterResumeAllButton");
+ if (resumeAllButton) resumeAllButton.disabled = resumableCount === 0;
+ const clearQueueButton = document.getElementById("sftpTaskCenterClearQueueButton");
+ if (clearQueueButton) clearQueueButton.disabled = cancelableCount === 0;
const clearLabel = document.getElementById("sftpTaskCenterClearLabel");
if (clearLabel) clearLabel.textContent = tr(showingFailed ? "tasks:dialogs.clear_failed_action" : "tasks:dialogs.clear_history_action", {defaultValue:showingFailed ? "清空失败" : "清空历史"});
const clearButton = document.getElementById("sftpTaskCenterClearButton");
if (clearButton) {
+ clearButton.hidden = !showingHistory && !showingFailed;
clearButton.title = tr(showingFailed ? "tasks:auto.delete_all_failed" : "tasks:auto.delete_all_history", {defaultValue:showingFailed ? "删除全部失败任务记录" : "删除全部历史任务记录"});
clearButton.setAttribute("aria-label", clearButton.title);
}
@@ -693,6 +606,7 @@ function closeSftpTaskCenter() {
const button = document.getElementById("sftpTaskCenterButton");
if (sftpTaskCenterResize) finishSftpTaskCenterResize(null);
if (drawer) drawer.hidden = true;
+ document.body.classList.remove("sftp-task-center-open");
button?.setAttribute("aria-expanded", "false");
}
@@ -704,6 +618,8 @@ async function toggleSftpTaskCenter(event) {
if (!drawer || !button) return;
if (!drawer.hidden) return closeSftpTaskCenter();
drawer.hidden = false;
+ document.body.classList.add("sftp-task-center-open");
+ ensureSftpTaskCenterBoundsMonitoring(drawer);
restoreSftpTaskCenterSize(drawer);
button.setAttribute("aria-expanded", "true");
renderSftpTaskCenterDrawer();
@@ -713,7 +629,6 @@ async function toggleSftpTaskCenter(event) {
function trackSftpMutationJob(job) {
if (job?.id) sftpKnownJobStatuses.set(String(job.id), String(job.status || "running"));
}
-
function settleSftpJobFromNotification(event) {
const jobId = String(event?.action?.sftp_job_id || event?.action?.download_job_id || "");
if (!jobId) return false;
@@ -779,7 +694,6 @@ function flushPendingSftpDirectoryRefresh(tabKey="") {
if (sftpDirectoryRefreshActive || sftpDirectoryRefreshTimer) return;
sftpDirectoryRefreshTimer = setTimeout(runPendingSftpDirectoryRefresh, 0);
}
-
async function runPendingSftpDirectoryRefresh() {
sftpDirectoryRefreshTimer = 0;
if (sftpDirectoryRefreshActive) return;
@@ -817,7 +731,6 @@ function refreshSftpJobsIfStale(maxAge=2000) {
if (age >= 0 && age < Math.max(0, Number(maxAge || 0))) return Promise.resolve(sftpLatestJobs);
return refreshSftpJobs();
}
-
async function performSftpJobsRefresh() {
const requestSeq = ++sftpJobsRequestSeq;
const [transferJobs, syncJobs, desktopJobs, componentJobs] = await Promise.all([
@@ -1180,6 +1093,91 @@ async function resumeSftpJob(id, button=null) {
}
}
+async function pauseAllSftpTransferJobs(button=null) {
+ const actionKey = "sftp-task:pause-all";
+ if (!beginUiAction(actionKey, button, tr("sftp:task_ui.pausing", {defaultValue:"暂停中..."}))) return null;
+ try {
+ const queue = sftpTransferQueueJobs();
+ const candidates = queue.filter(sftpTransferJobCanPause);
+ const results = await runSftpTransferJobBatch(candidates, job => api(`/api/sftp/jobs/${encodeURIComponent(job.id)}/pause`, {method:"POST"}));
+ const completed = results.filter(result => result.status === "fulfilled" && result.value?.status === "paused").length;
+ const skipped = queue.length - completed;
+ await refreshSftpJobs();
+ notify(skipped
+ ? tr("tasks:notifications.queue_partial", {completed, skipped, defaultValue:`已处理 ${completed} 个传输任务,${skipped} 个当前阶段暂不能操作`})
+ : tr("tasks:notifications.queue_paused", {count:completed, defaultValue:`已暂停 ${completed} 个传输任务`}), skipped ? "info" : "success");
+ return {completed, skipped};
+ } catch (error) {
+ notify(error.message || tr("sftp:task_ui.pause_failed", {defaultValue:"暂停任务失败"}), "error");
+ return null;
+ } finally {
+ endUiAction(actionKey, button);
+ }
+}
+
+async function resumeAllSftpTransferJobs(button=null) {
+ const actionKey = "sftp-task:resume-all";
+ if (!beginUiAction(actionKey, button, tr("sftp:task_ui.processing", {defaultValue:"处理中..."}))) return null;
+ try {
+ const queue = sftpTransferQueueJobs();
+ const candidates = queue.filter(sftpTransferJobCanResume);
+ const results = await runSftpTransferJobBatch(candidates, job => api(`/api/sftp/jobs/${encodeURIComponent(job.id)}/resume`, {method:"POST"}));
+ const completed = results.filter(result => result.status === "fulfilled" && result.value?.ok !== false).length;
+ const skipped = queue.length - completed;
+ await refreshSftpJobs();
+ notify(skipped
+ ? tr("tasks:notifications.queue_partial", {completed, skipped, defaultValue:`已处理 ${completed} 个传输任务,${skipped} 个当前阶段暂不能操作`})
+ : tr("tasks:notifications.queue_resumed", {count:completed, defaultValue:`已继续 ${completed} 个传输任务`}), skipped ? "info" : "success");
+ return {completed, skipped};
+ } catch (error) {
+ notify(error.message || tr("sftp:task_ui.resume_failed", {defaultValue:"继续任务失败"}), "error");
+ return null;
+ } finally {
+ endUiAction(actionKey, button);
+ }
+}
+
+async function clearCurrentSftpTransferQueue(button=null) {
+ const actionKey = "sftp-task:clear-queue";
+ if (!beginUiAction(actionKey, button, tr("sftp:task_ui.stopping", {defaultValue:"停止中..."}))) return null;
+ try {
+ const queue = sftpTransferQueueJobs();
+ const candidates = queue.filter(sftpTransferJobCanCancel);
+ if (!candidates.length) return {completed:0, skipped:queue.length};
+ if (!await confirmModal(
+ tr("tasks:dialogs.clear_queue_message", {count:candidates.length, defaultValue:`取消当前队列中的 ${candidates.length} 个上传、下载或跨主机传输任务?已完成和失败记录不会被删除。`}),
+ tr("tasks:dialogs.clear_queue_title", {defaultValue:"清空传输队列"}),
+ tr("tasks:dialogs.clear_queue_action", {defaultValue:"取消并清空"}),
+ tr("common:actions.cancel", {defaultValue:"取消"}),
+ true
+ )) return null;
+ const results = await runSftpTransferJobBatch(candidates, async job => {
+ const key = String(job.id || "");
+ const upload = sftpUploadRequests.get(key);
+ if (upload) upload.cancelled = true;
+ try {
+ return await api(`/api/sftp/jobs/${encodeURIComponent(key)}/cancel`, {method:"POST"});
+ } finally {
+ try { upload?.xhr?.abort(); } catch {}
+ }
+ });
+ const completed = results.filter(result => result.status === "fulfilled"
+ && result.value?.ok !== false
+ && ["cancelled", "cancelling"].includes(result.value?.status)).length;
+ const skipped = queue.length - completed;
+ await refreshSftpJobs();
+ notify(skipped
+ ? tr("tasks:notifications.queue_partial", {completed, skipped, defaultValue:`已处理 ${completed} 个传输任务,${skipped} 个当前阶段暂不能操作`})
+ : tr("tasks:notifications.queue_cleared", {count:completed, defaultValue:`已取消 ${completed} 个传输任务`}), skipped ? "info" : "success");
+ return {completed, skipped};
+ } catch (error) {
+ notify(error.message || tr("tasks:notifications.clear_queue_failed", {defaultValue:"清空传输队列失败"}), "error");
+ return null;
+ } finally {
+ endUiAction(actionKey, button);
+ }
+}
+
async function deleteSftpJob(id, button=null) {
const key = String(id || "");
const actionKey = `sftp-task:delete:${key}`;
diff --git a/public/app-sftp-transfer.js b/public/app-sftp-transfer.js
index b7199b8..a832b75 100644
--- a/public/app-sftp-transfer.js
+++ b/public/app-sftp-transfer.js
@@ -156,7 +156,8 @@ async function downloadSftpSelection(tabKey=activeTabKey) {
body:JSON.stringify({paths:entries.map(item => item.path), mode, filename:archive?.filename, encoding:archive?.encoding})
});
if (mode === "separate") {
- trackSftpMutationJob(result);
+ const queuedJobs = Array.isArray(result?.jobs) ? result.jobs : [result];
+ for (const job of queuedJobs) trackSftpMutationJob(job);
notify(tr(entries.length === 1 ? "common:notifications.background_transfer_downloads_one" : "common:notifications.background_transfer_downloads_other", {count:entries.length, defaultValue:`后台传输已开始:${entries.length} 个项目到下载目录`}), "info");
} else {
trackSftpBrowserDownload(result);
@@ -577,25 +578,34 @@ async function uploadSftpFilesToDirectory(inputFiles, connectionId, root=".", op
const conflict = options.conflict || await chooseSftpUploadConflict(collisions);
if (collisions.length && !conflict) return;
try {
+ const uploads = [];
+ try {
+ for (const item of files) {
+ const parts = item.relativePath.split("/");
+ const filename = parts.pop() || item.file.name;
+ const targetDirectory = parts.length ? joinRemotePath(directory, parts.join("/")) : directory;
+ const started = await api(`/api/connections/${Number(connectionId)}/sftp/upload-job`, {
+ method:"POST",
+ body:JSON.stringify({
+ path:targetDirectory,
+ filename,
+ conflict:conflict || "error",
+ size:Number(item.file.size || 0),
+ private:options.private === true
+ })
+ });
+ trackSftpMutationJob(started);
+ uploads.push({item, started});
+ }
+ } catch (error) {
+ await Promise.allSettled(uploads.map(({started}) => api(`/api/sftp/jobs/${encodeURIComponent(started.id)}/cancel`, {method:"POST"})));
+ throw error;
+ }
+ await refreshSftpJobs();
+ startSftpJobsTimer();
let nextIndex = 0;
- let firstError = null;
- const uploadOne = async item => {
- const parts = item.relativePath.split("/");
- const filename = parts.pop() || item.file.name;
- const targetDirectory = parts.length ? joinRemotePath(directory, parts.join("/")) : directory;
- const started = await api(`/api/connections/${Number(connectionId)}/sftp/upload-job`, {
- method:"POST",
- body:JSON.stringify({
- path:targetDirectory,
- filename,
- conflict:conflict || "error",
- size:Number(item.file.size || 0),
- private:options.private === true
- })
- });
- trackSftpMutationJob(started);
- await refreshSftpJobs();
- startSftpJobsTimer();
+ const errors = [];
+ const uploadOne = async ({item, started}) => {
try {
const job = await uploadWithProgress(`/api/sftp/jobs/${encodeURIComponent(started.id)}/content`, item.file, started);
trackSftpMutationJob(job);
@@ -607,16 +617,16 @@ async function uploadSftpFilesToDirectory(inputFiles, connectionId, root=".", op
}
};
const concurrency = Math.max(1, Math.min(8, Number(runtimeSettings?.saved?.sftp_upload_concurrency || 3)));
- const workers = Array.from({length:Math.min(concurrency, files.length)}, async () => {
- while (!firstError) {
+ const workers = Array.from({length:Math.min(concurrency, uploads.length)}, async () => {
+ while (true) {
const index = nextIndex++;
- if (index >= files.length) return;
- try { await uploadOne(files[index]); }
- catch (error) { firstError = error; }
+ if (index >= uploads.length) return;
+ try { await uploadOne(uploads[index]); }
+ catch (error) { errors.push(error); }
}
});
await Promise.all(workers);
- if (firstError) throw firstError;
+ if (errors.length) throw errors[0];
} finally {
const input = sftpElement("sftpUpload", tabKey);
if (input) input.value = "";
diff --git a/public/app-static-actions.js b/public/app-static-actions.js
index dcdd1e2..47decc3 100644
--- a/public/app-static-actions.js
+++ b/public/app-static-actions.js
@@ -23,6 +23,18 @@ registerTermaAction("static-task-center-clear", ({event, element}) => {
event.stopPropagation();
return sftpTaskCenterView === "failed" ? clearFailedSftpJobs(element) : clearFinishedSftpJobs(element);
});
+registerTermaAction("static-task-center-pause-all", ({event, element}) => {
+ event.stopPropagation();
+ return pauseAllSftpTransferJobs(element);
+});
+registerTermaAction("static-task-center-resume-all", ({event, element}) => {
+ event.stopPropagation();
+ return resumeAllSftpTransferJobs(element);
+});
+registerTermaAction("static-task-center-clear-queue", ({event, element}) => {
+ event.stopPropagation();
+ return clearCurrentSftpTransferQueue(element);
+});
registerTermaAction("static-task-list-open", ({event}) => openSftpTaskList(event));
registerTermaAction("static-task-resize-start", ({event, element}) => startSftpTaskCenterResize(event, element));
registerTermaAction("static-task-resize-reset", ({event}) => resetSftpTaskCenterSize(event));
diff --git a/public/app-terminal-core.js b/public/app-terminal-core.js
index af3684e..07bb5af 100644
--- a/public/app-terminal-core.js
+++ b/public/app-terminal-core.js
@@ -612,12 +612,19 @@ function bindTerminalDropUpload(session, connection, key, mount) {
if (typeof collectDroppedFiles !== "function" || typeof uploadSftpFilesToDirectory !== "function") {
return notify(tr("terminal:drop.upload_unavailable", {defaultValue:"当前版本不支持终端文件上传"}), "error");
}
+ let files;
try {
- const files = await collectDroppedFiles(event.dataTransfer);
+ files = await collectDroppedFiles(event.dataTransfer);
if (!files.length) throw new Error(tr("terminal:drop.no_upload_files", {defaultValue:"没有找到可上传的文件"}));
+ } catch (error) {
+ return notify(error.message || tr("terminal:drop.upload_failed", {defaultValue:"终端文件上传失败"}), "error");
+ }
+ notify(tr("terminal:drop.sftp_login_identity_hint", {defaultValue:"拖入文件会通过 SFTP 上传,使用 SSH 连接的原登录用户,不会继承终端中通过 su 或 sudo 切换后的用户"}), "info");
+ try {
await uploadSftpFilesToDirectory(files, connection.id, directory);
} catch (error) {
- notify(error.message || tr("terminal:drop.upload_failed", {defaultValue:"终端文件上传失败"}), "error");
+ const detail = error.message || tr("terminal:drop.upload_failed", {defaultValue:"终端文件上传失败"});
+ notify(tr("terminal:drop.upload_failed_identity_hint", {error:detail, defaultValue:`${detail}。如果终端已切换用户,请选择原登录用户可写目录,或在上传后用终端移动文件或调整权限`}), "error");
}
});
}
diff --git a/public/app-workspace-groups.js b/public/app-workspace-groups.js
index 4b146f5..f5a2ea4 100644
--- a/public/app-workspace-groups.js
+++ b/public/app-workspace-groups.js
@@ -163,8 +163,17 @@ function workspaceGroupName() {
function workspaceGroupPersistableTab(tab) {
if (!tab?.kind || tab.transient || tab.kind === "quick-terminal") return null;
- const {key,title,subtitle,viewName,closable,kind,id,path,protocol,pinned,connectionStatus,sessionMode,sessionBackend,persistentSessionId,resumePolicy,lastKnownCwd} = tab;
- return {key,title,subtitle,viewName,closable,kind,id,path,protocol,pinned:Boolean(pinned),connectionStatus,sessionMode,sessionBackend,persistentSessionId,resumePolicy,lastKnownCwd};
+ const {key,title,subtitle,viewName,closable,kind,id,path,protocol,pinned,sessionMode,sessionBackend,persistentSessionId,resumePolicy,lastKnownCwd} = tab;
+ return {key,title,subtitle,viewName,closable,kind,id,path,protocol,pinned:Boolean(pinned),sessionMode,sessionBackend,persistentSessionId,resumePolicy,lastKnownCwd};
+}
+
+function restoreWorkspaceGroupTab(tab) {
+ const restored = {...tab};
+ const usesConnectionStatus = ["terminal", "quick-terminal", "sftp", "remote-terminal"].includes(restored.kind)
+ || (restored.kind === "remote-desktop" && restored.protocol === "vnc");
+ if (usesConnectionStatus) restored.connectionStatus = "disconnected";
+ else delete restored.connectionStatus;
+ return restored;
}
function workspaceFilterLayout(node, allowedKeys) {
diff --git a/public/app-workspace-persistence.js b/public/app-workspace-persistence.js
index 5c824e2..bed1f22 100644
--- a/public/app-workspace-persistence.js
+++ b/public/app-workspace-persistence.js
@@ -111,7 +111,7 @@ function restoreWorkspaceGroups(saved) {
const groupTabKeys = new Set();
for (const tab of Array.isArray(stored.tabs) ? stored.tabs : []) {
if (!tab || typeof tab !== "object" || typeof tab.key !== "string" || !tab.key || !tab.kind || groupTabKeys.has(tab.key)) continue;
- groupTabs.push({...tab});
+ groupTabs.push(restoreWorkspaceGroupTab(tab));
groupTabKeys.add(tab.key);
}
const storedName = normalizedWorkspaceGroupStoredName(groupId, stored.name);
@@ -129,7 +129,7 @@ function restoreWorkspaceGroups(saved) {
const legacyKeys = new Set();
for (const tab of Array.isArray(saved.tabs) ? saved.tabs : []) {
if (!tab || typeof tab !== "object" || typeof tab.key !== "string" || !tab.key || !tab.kind || legacyKeys.has(tab.key)) continue;
- legacyTabs.push({...tab});
+ legacyTabs.push(restoreWorkspaceGroupTab(tab));
legacyKeys.add(tab.key);
}
restoredGroups.push({
diff --git a/public/app-workspace.js b/public/app-workspace.js
index bdddbd6..105b477 100644
--- a/public/app-workspace.js
+++ b/public/app-workspace.js
@@ -330,6 +330,15 @@ function workspaceTabConnectionStateText(status) {
});
}
+function restoreLegacyWorkspaceTab(tab) {
+ const restored = {...tab};
+ const usesConnectionStatus = ["terminal", "quick-terminal", "sftp", "remote-terminal"].includes(restored.kind)
+ || (restored.kind === "remote-desktop" && restored.protocol === "vnc");
+ if (usesConnectionStatus) restored.connectionStatus = "disconnected";
+ else delete restored.connectionStatus;
+ return restored;
+}
+
function renderTabs() {
if (typeof syncSftpTabTitles === "function") syncSftpTabTitles();
const container = $("tabs");
@@ -340,7 +349,7 @@ function renderTabs() {
const fullTitle = [localizedTitle, localizedWorkspaceTabSubtitle(tab)].filter(Boolean).join(" - ");
const showsConnectionStatus = ["terminal", "quick-terminal", "sftp", "remote-terminal"].includes(tab.kind)
|| (tab.kind === "remote-desktop" && tab.protocol === "vnc");
- const connectionStatus = showsConnectionStatus ? (tab.connectionStatus || "connecting") : "";
+ const connectionStatus = showsConnectionStatus ? (tab.connectionStatus || "disconnected") : "";
const connectionStateText = workspaceTabConnectionStateText(connectionStatus);
const connectionDot = connectionStatus ? `` : "";
const closeText = tr("navigation:auto.close_tab", {defaultValue:"关闭标签"});
@@ -747,8 +756,8 @@ function showTabContextMenu(event, key) {
}
function persistableTabs() {
- return tabs.filter(tab => tab.kind && !tab.transient && tab.kind !== "quick-terminal").map(({key,title,subtitle,viewName,closable,kind,id,path,protocol,pinned,connectionStatus,sessionMode,sessionBackend,persistentSessionId,resumePolicy,lastKnownCwd}) => ({
- key,title,subtitle,viewName,closable,kind,id,path,protocol,pinned:Boolean(pinned),connectionStatus,
+ return tabs.filter(tab => tab.kind && !tab.transient && tab.kind !== "quick-terminal").map(({key,title,subtitle,viewName,closable,kind,id,path,protocol,pinned,sessionMode,sessionBackend,persistentSessionId,resumePolicy,lastKnownCwd}) => ({
+ key,title,subtitle,viewName,closable,kind,id,path,protocol,pinned:Boolean(pinned),
sessionMode,sessionBackend,persistentSessionId,resumePolicy,lastKnownCwd
}));
}
@@ -764,7 +773,7 @@ function restoreTabsState() {
try {
if (runtimeSettings?.saved?.restore_workspace_tabs === false) return false;
const saved = JSON.parse(localStorage.getItem("workspaceTabs") || "{}");
- const restored = (saved.tabs || []).filter(tab => tab.kind);
+ const restored = (saved.tabs || []).filter(tab => tab.kind).map(restoreLegacyWorkspaceTab);
if (!restored.length) return false;
window.restoringTabs = true;
tabs = restored;
diff --git a/public/app.css b/public/app.css
index 450a79c..2e35b78 100644
--- a/public/app.css
+++ b/public/app.css
@@ -956,7 +956,8 @@ body.operation-pane-resizing, body.operation-pane-resizing * { cursor:col-resize
.sftp-task-center-resize-handle:focus-visible { box-shadow:inset 0 0 0 2px color-mix(in srgb,var(--accent2) 72%,transparent); }
body.sftp-task-center-resizing, body.sftp-task-center-resizing * { cursor:nesw-resize !important; user-select:none !important; }
.sftp-task-center-head { min-width:0; display:flex; align-items:center; justify-content:space-between; gap:12px; padding:11px 12px 9px; }
-.sftp-task-center-head > div { min-width:0; }
+.sftp-task-center-head > div:first-child { min-width:0; flex:1 1 auto; }
+.sftp-task-center-head-actions { flex:0 0 auto; display:flex; align-items:center; gap:4px; }
.sftp-task-center-head strong, .sftp-task-center-head span { display:block; }
.sftp-task-center-head strong { font-size:14px; line-height:1.35; }
.sftp-task-center-head span { margin-top:2px; overflow:hidden; color:color-mix(in srgb,var(--muted) var(--terma-overlay-foreground-opacity),transparent); font-size:10px; line-height:1.35; text-overflow:ellipsis; white-space:nowrap; }
@@ -967,8 +968,10 @@ body.sftp-task-center-resizing, body.sftp-task-center-resizing * { cursor:nesw-r
.sftp-task-center-tabs button.active { border-bottom-color:var(--accent2); color:color-mix(in srgb,var(--text) var(--terma-overlay-foreground-opacity),transparent); }
.sftp-task-center-tabs small { min-width:18px; padding:1px 5px; border-radius:999px; color:color-mix(in srgb,var(--muted) var(--terma-overlay-foreground-opacity),transparent); background:color-mix(in srgb,var(--field) var(--terma-overlay-content-opacity),transparent); font-size:9px; }
.sftp-task-center-list { min-width:0; min-height:100px; display:grid; align-content:start; gap:6px; padding:8px; overflow:auto; background:color-mix(in srgb,var(--bg) var(--terma-overlay-content-opacity),transparent); }
-.sftp-task-center-footer { display:flex; justify-content:flex-end; padding:8px 10px; border-top:1px solid var(--line); background:color-mix(in srgb,var(--field) var(--terma-overlay-content-opacity),transparent); }
+.sftp-task-center-footer { display:flex; flex-wrap:wrap; align-items:center; justify-content:flex-end; gap:6px; padding:8px 10px; border-top:1px solid var(--line); background:color-mix(in srgb,var(--field) var(--terma-overlay-content-opacity),transparent); }
.sftp-task-center-footer[hidden] { display:none; }
+.sftp-task-center-queue-actions { min-width:0; display:flex; flex:1 1 auto; flex-wrap:wrap; gap:6px; }
+.sftp-task-center-queue-actions[hidden] { display:none; }
.sftp-task-center-footer button { min-height:28px; display:inline-flex; align-items:center; gap:5px; padding:4px 8px; font-size:11px; }
.sftp-task-center-footer svg { width:13px; height:13px; }
.sftp-task-center-drawer .sftp-job { color:color-mix(in srgb,var(--text) var(--terma-overlay-foreground-opacity),transparent); background:color-mix(in srgb,var(--panel) var(--terma-overlay-content-opacity),transparent); }
@@ -1271,6 +1274,7 @@ body.sftp-task-center-resizing, body.sftp-task-center-resizing * { cursor:nesw-r
.notice.error { border-color:var(--danger); background:rgba(196,50,43,.08); }
.notice.info { border-color:var(--warn); background:rgba(154,103,0,.08); }
.notification-stack { position:fixed; z-index:var(--z-notification); top:16px; right:16px; width:min(420px,calc(100vw - 32px)); max-height:calc(100dvh - 32px); display:grid; grid-auto-flow:row; align-content:start; gap:8px; overflow-y:auto; overscroll-behavior:contain; scrollbar-width:none; pointer-events:none; }
+body.sftp-task-center-open .notification-stack { z-index:9; }
.notification-stack::-webkit-scrollbar { display:none; }
.toast { width:100%; flex:0 0 auto; padding:10px 11px; border:1px solid var(--line); border-left:3px solid var(--warn); border-radius:6px; color:color-mix(in srgb,var(--text) var(--terma-overlay-foreground-opacity),transparent); background:color-mix(in srgb,var(--panel) var(--terma-overlay-opacity),transparent); box-shadow:0 12px 32px rgba(0,0,0,.2); backdrop-filter:blur(var(--terma-overlay-blur)) saturate(var(--terma-overlay-saturation)); -webkit-backdrop-filter:blur(var(--terma-overlay-blur)) saturate(var(--terma-overlay-saturation)); white-space:normal; pointer-events:auto; transform-origin:top right; will-change:transform,opacity; }
.toast.is-leaving { pointer-events:none; }
diff --git a/public/index.html b/public/index.html
index 1bdd359..c817e01 100644
--- a/public/index.html
+++ b/public/index.html
@@ -90,7 +90,10 @@
@@ -99,6 +102,11 @@
@@ -434,6 +442,7 @@
+
diff --git a/public/locales/en-US/common.json b/public/locales/en-US/common.json
index b035aff..f9a3979 100644
--- a/public/locales/en-US/common.json
+++ b/public/locales/en-US/common.json
@@ -747,6 +747,7 @@
"no_running_tasks": "No active tasks",
"adjust_task_center": "Resize task center",
"adjust_task_center_hint": "Drag to resize the task center; double-click to reset",
+ "reset_task_center_size": "Reset task center size",
"adjust_activity_bar": "Resize activity bar",
"adjust_activity_bar_hint": "Drag to resize the activity bar; double-click to reset",
"adjust_operation_pane": "Resize operation pane",
diff --git a/public/locales/en-US/errors.json b/public/locales/en-US/errors.json
index a1b4710..eb6ceb1 100644
--- a/public/locales/en-US/errors.json
+++ b/public/locales/en-US/errors.json
@@ -98,6 +98,7 @@
"sftp_automation_desktop_only": "SFTP scheduled transfers and folder sync only run in the Terma desktop app; Web requests cannot access local files.",
"sftp_local_sync_desktop_only": "Local directory sync is only available in the desktop app on this computer.",
"sftp_separate_download_desktop_only": "Downloading files and folders separately is only supported by the local desktop app. Use an archive download on this device.",
+ "sftp_separate_download_too_many": "You can download at most {{max}} files or folders separately at one time.",
"sftp_image_preview_unsupported": "This file is not a supported image format for preview.",
"sftp_drag_out_desktop_only": "Dragging remote files to the local computer is only available in the desktop app.",
"sftp_target_conflict": "The target directory already contains an item named {{name}}.",
diff --git a/public/locales/en-US/tasks.json b/public/locales/en-US/tasks.json
index 65b0b2d..bc5fc39 100644
--- a/public/locales/en-US/tasks.json
+++ b/public/locales/en-US/tasks.json
@@ -15,7 +15,10 @@
"stop": "Stop task",
"close_floating": "Close floating progress",
"mute_floating": "Mute floating progress card",
- "progress": "Task progress"
+ "progress": "Task progress",
+ "pause_all": "Pause all",
+ "resume_all": "Resume all",
+ "clear_queue": "Clear queue"
},
"phase": {
"archiving": "Creating archive",
@@ -49,7 +52,10 @@
"clear_history_action": "Clear history",
"clear_failed_title": "Clear failed tasks",
"clear_failed_message": "Delete all {{count}} failed task records? Other Task Center records will not be affected.",
- "clear_failed_action": "Clear failed"
+ "clear_failed_action": "Clear failed",
+ "clear_queue_title": "Clear transfer queue",
+ "clear_queue_message": "Cancel {{count}} uploads, downloads, or cross-host transfers in the current queue? Completed and failed records will be kept.",
+ "clear_queue_action": "Cancel and clear"
},
"logs": {
"batch_label": "Batch execution - {{date}} {{time}}",
@@ -85,7 +91,12 @@
,"desktop_job_line": "{{connection}} · Send to desktop",
"download_item": "Download {{name}}",
"connection_download_item": "{{connection}} · Download {{name}}",
- "downloading_item": "Downloading: {{name}}"
+ "downloading_item": "Downloading: {{name}}",
+ "queue_paused": "Paused {{count}} transfer tasks",
+ "queue_resumed": "Resumed {{count}} transfer tasks",
+ "queue_cleared": "Cancelled {{count}} transfer tasks",
+ "queue_partial": "Updated {{completed}} transfer tasks; {{skipped}} cannot be changed in their current stage",
+ "clear_queue_failed": "Failed to clear the transfer queue"
},
"auto": {
"background_count": "{{count}} background tasks",
diff --git a/public/locales/en-US/terminal.json b/public/locales/en-US/terminal.json
index fb40124..dbde680 100644
--- a/public/locales/en-US/terminal.json
+++ b/public/locales/en-US/terminal.json
@@ -87,8 +87,10 @@
"local_upload_failed": "Failed to upload Local Files into the terminal",
"sftp_drop_unavailable": "This version cannot drop SFTP items into a terminal",
"upload_unavailable": "This version cannot upload files into a terminal",
+ "sftp_login_identity_hint": "Dropped files are uploaded through SFTP with the SSH connection's original login user. They do not inherit a user selected with su or sudo in the terminal.",
"no_upload_files": "No uploadable files were found",
- "upload_failed": "Terminal file upload failed"
+ "upload_failed": "Terminal file upload failed",
+ "upload_failed_identity_hint": "{{error}}. If the terminal has switched users, choose a directory writable by the original login user, or move the uploaded file and adjust its permissions from the terminal."
},
"startup": {
"title": "Terminal startup",
diff --git a/public/locales/zh-CN/common.json b/public/locales/zh-CN/common.json
index 17117f0..df3b2ec 100644
--- a/public/locales/zh-CN/common.json
+++ b/public/locales/zh-CN/common.json
@@ -747,6 +747,7 @@
"no_running_tasks": "暂无进行中的任务",
"adjust_task_center": "调整任务中心大小",
"adjust_task_center_hint": "拖动调整任务中心大小,双击恢复默认",
+ "reset_task_center_size": "恢复任务中心默认大小",
"adjust_activity_bar": "调整活动栏宽度",
"adjust_activity_bar_hint": "拖动调整活动栏宽度,双击恢复默认",
"adjust_operation_pane": "调整操作区宽度",
diff --git a/public/locales/zh-CN/errors.json b/public/locales/zh-CN/errors.json
index 5f92ef5..032fc27 100644
--- a/public/locales/zh-CN/errors.json
+++ b/public/locales/zh-CN/errors.json
@@ -98,6 +98,7 @@
"sftp_automation_desktop_only": "SFTP 定时传输和文件夹同步仅在 Terma 桌面端运行;Web 端不会访问本机文件",
"sftp_local_sync_desktop_only": "本地目录同步只能在本机桌面端中使用",
"sftp_separate_download_desktop_only": "分别下载文件和目录仅支持本机桌面版;当前设备请使用打包下载",
+ "sftp_separate_download_too_many": "一次最多分别下载 {{max}} 个文件或目录",
"sftp_image_preview_unsupported": "该文件不是支持预览的图片格式",
"sftp_drag_out_desktop_only": "拖出到本机只能在桌面版中使用",
"sftp_target_conflict": "目标目录已存在同名项目:{{name}}",
diff --git a/public/locales/zh-CN/tasks.json b/public/locales/zh-CN/tasks.json
index ce4f681..4552e43 100644
--- a/public/locales/zh-CN/tasks.json
+++ b/public/locales/zh-CN/tasks.json
@@ -15,7 +15,10 @@
"stop": "停止任务",
"close_floating": "关闭当前悬浮进度",
"mute_floating": "静默悬浮进度卡",
- "progress": "任务进度"
+ "progress": "任务进度",
+ "pause_all": "全部暂停",
+ "resume_all": "全部继续",
+ "clear_queue": "清空队列"
},
"phase": {
"archiving": "正在生成压缩包",
@@ -49,7 +52,10 @@
"clear_history_action": "清空历史",
"clear_failed_title": "清空失败任务",
"clear_failed_message": "删除全部 {{count}} 条失败任务记录?此操作不会影响任务中心中的其他记录。",
- "clear_failed_action": "清空失败"
+ "clear_failed_action": "清空失败",
+ "clear_queue_title": "清空传输队列",
+ "clear_queue_message": "取消当前队列中的 {{count}} 个上传、下载或跨主机传输任务?已完成和失败记录不会被删除。",
+ "clear_queue_action": "取消并清空"
},
"logs": {
"batch_label": "批量执行-{{date}} {{time}}",
@@ -85,7 +91,12 @@
,"desktop_job_line": "{{connection}} · 发送到桌面",
"download_item": "下载 {{name}}",
"connection_download_item": "{{connection}} · 下载 {{name}}",
- "downloading_item": "正在下载:{{name}}"
+ "downloading_item": "正在下载:{{name}}",
+ "queue_paused": "已暂停 {{count}} 个传输任务",
+ "queue_resumed": "已继续 {{count}} 个传输任务",
+ "queue_cleared": "已取消 {{count}} 个传输任务",
+ "queue_partial": "已处理 {{completed}} 个传输任务,{{skipped}} 个当前阶段暂不能操作",
+ "clear_queue_failed": "清空传输队列失败"
},
"auto": {
"background_count": "{{count}} 个后台任务",
diff --git a/public/locales/zh-CN/terminal.json b/public/locales/zh-CN/terminal.json
index d6159d0..5ffa58a 100644
--- a/public/locales/zh-CN/terminal.json
+++ b/public/locales/zh-CN/terminal.json
@@ -87,8 +87,10 @@
"local_upload_failed": "上传本地文件到终端失败",
"sftp_drop_unavailable": "当前版本不支持 SFTP 项目拖入终端",
"upload_unavailable": "当前版本不支持终端文件上传",
+ "sftp_login_identity_hint": "拖入文件会通过 SFTP 上传,使用 SSH 连接的原登录用户,不会继承终端中通过 su 或 sudo 切换后的用户",
"no_upload_files": "没有找到可上传的文件",
- "upload_failed": "终端文件上传失败"
+ "upload_failed": "终端文件上传失败",
+ "upload_failed_identity_hint": "{{error}}。如果终端已切换用户,请选择原登录用户可写目录,或在上传后用终端移动文件或调整权限"
},
"startup": {
"title": "终端配置",
diff --git a/scripts/frontend-source.js b/scripts/frontend-source.js
index fdb00d0..d8ae668 100644
--- a/scripts/frontend-source.js
+++ b/scripts/frontend-source.js
@@ -63,6 +63,7 @@ const FRONTEND_DOMAINS = Object.freeze({
"app-sftp-operations.js",
"app-sftp-transfer.js",
"app-sftp-menus.js",
+ "app-sftp-task-center-layout.js",
"app-sftp-tasks.js"
],
productivity: [
diff --git a/scripts/local-files-check.js b/scripts/local-files-check.js
index 5ee3db4..c513cde 100644
--- a/scripts/local-files-check.js
+++ b/scripts/local-files-check.js
@@ -27,7 +27,8 @@ assert.match(routes, /!dependencies\.isDesktopRequest\(request\) \|\| !desktopIn
assert.match(routes, /startLocalDeliveryJob\(Number\(data\.connection_id\), data\.paths \|\| \[\], data\.target/);
assert.match(routes, /pathname === "\/api\/local-files\/receive"[\s\S]*?sendJson\(response, startLocalDeliveryJob\(/);
assert.match(routes, /pathname === "\/api\/local-files\/receive-desktop"[\s\S]*?deliveryMode:"desktop"/);
-assert.match(server, /data\.mode === "separate"[\s\S]*?startLocalDeliveryJob\(connectionId, paths, targetDirectory/);
+assert.match(server, /data\.mode === "separate"[\s\S]*?paths\.map\(\(remotePath: string\) => dependencies\.startLocalDeliveryJob\(connectionId, \[remotePath\], targetDirectory/);
+assert.match(server, /sendJson\(response, \{jobs, count:jobs\.length, status:"pending"\}, 202\)/);
assert.match(routes, /sendJson\(response, startLocalDeliveryJob\([\s\S]*?\), 202\)/);
const sftpJobs = readSftpJobSource(root);
assert.match(sftpJobs, /function startLocalDeliveryJob\(/);
diff --git a/scripts/regression-check.js b/scripts/regression-check.js
index 620c4e5..0cf66c3 100644
--- a/scripts/regression-check.js
+++ b/scripts/regression-check.js
@@ -235,6 +235,7 @@ async function main() {
ok("SFTP 列表展示权限/所有者并支持无感后台同步", sftpFrontend.includes("权限 / 所有者") && sftpFrontend.includes("captureSftpViewState") && sftpFrontend.includes("restoreSftpViewState") && sftpFrontend.includes("completedSftpMutationForCurrentView") && sftpFrontend.includes("sftpPendingDirectoryRefreshes") && sftpFrontend.includes('list.classList.toggle("is-refreshing", keepContents)'));
ok("SFTP 任务由标题栏全局入口集中展示并区分进行中、失败与历史", read("public/index.html").includes('id="sftpTaskCenterButton"') && read("public/index.html").includes('id="sftpTaskCenterDrawer"') && read("public/index.html").includes('id="sftpTaskCenterFailedTab"') && read("public/index.html").includes('id="sftpTaskCenterFailedCount"') && sftpFrontend.includes("function sftpTaskCollections") && sftpFrontend.includes("const current = jobs.filter(job => SFTP_ACTIVE_JOB_STATUSES.has(job.status))") && sftpFrontend.includes('const failed = jobs.filter(job => job.status === "failed"') && sftpFrontend.includes('const showingFailed = sftpTaskCenterView === "failed"') && sftpFrontend.includes('["done", "cancelled"].includes(job.status)') && !sftpFrontend.includes('id="sftpJobs"'));
ok("SFTP 全局任务中心保留进度及暂停继续取消等操作", sftpFrontend.includes("function updateSftpTaskCenter") && sftpFrontend.includes('const deletable = ["paused", "failed", "done", "cancelled"].includes(job.status)') && sftpFrontend.includes("pauseSftpJob") && sftpFrontend.includes("resumeSftpJob") && sftpFrontend.includes("cancelSftpJob") && sftpFrontend.includes("deleteSftpJob"));
+ ok("SFTP 多文件传输提前进入统一队列并支持批量控制", read("public/index.html").includes('id="sftpTaskCenterPauseAllButton"') && read("public/index.html").includes('id="sftpTaskCenterResumeAllButton"') && read("public/index.html").includes('id="sftpTaskCenterClearQueueButton"') && sftpFrontend.includes("function sftpTransferQueueJobs") && sftpFrontend.includes("function pauseAllSftpTransferJobs") && sftpFrontend.includes("function resumeAllSftpTransferJobs") && sftpFrontend.includes("function clearCurrentSftpTransferQueue") && sftpFrontend.includes("const uploads = []") && sftpFrontend.includes("uploads.push({item, started})") && sftpTransferRoutesSource.includes("paths.map((remotePath: string) => dependencies.startLocalDeliveryJob") && sftpTransferRoutesSource.includes('{jobs, count:jobs.length, status:"pending"}') && sftpJobsSource.includes("job.abortController?.abort?.()") && sftpSessionSource.includes("signal:progress?.signal"));
ok("SFTP 生成文件使用安全中文文件名并提供打开、删除入口", read("public/app-sftp-generated-tasks.js").includes('encodeURIComponent(String(filename') && read("public/app-sftp-generated-tasks.js").includes("deleteSftpGeneratedTaskFile") && read("src/routes/sftp-desktop-download-routes.ts").includes("decodeGeneratedFilename") && read("src/routes/sftp-desktop-download-routes.ts").includes('contentType.startsWith("image/svg+xml")') && read("src/routes/sftp-desktop-download-routes.ts").includes('pathname === "/api/sftp/download-settings/delete-generated"') && sftpFrontend.includes('job.type === "local-generated" && job.delivery_status === "saved"') && sftpFrontend.includes("openSftpGeneratedTaskDirectory"));
ok("任务中心失败页支持一键删除失败记录", read("public/index.html").includes('id="sftpTaskCenterClearLabel"') && sftpFrontend.includes('tr(showingFailed ? "tasks:dialogs.clear_failed_action" : "tasks:dialogs.clear_history_action"') && sftpFrontend.includes("function clearFailedSftpJobs") && sftpFrontend.includes("Promise.allSettled(failed.map(job => deleteSftpJobRecord(job.id)))") && read("public/app-static-actions.js").includes('sftpTaskCenterView === "failed" ? clearFailedSftpJobs'));
ok("任务中心接入 Linux 桌面安装与卸载任务", sftpFrontend.includes('/api/linux-desktop/tasks') && sftpFrontend.includes('type:"linux-desktop"') && sftpFrontend.includes("openLinuxDesktopTask") && remoteFrontend.includes("if (typeof refreshSftpJobs === \"function\") void refreshSftpJobs()") && remoteTaskRoutesSource.includes("clearFinishedLinuxDesktopTasks"));
@@ -342,7 +343,7 @@ async function main() {
appCss.indexOf("@container workspace-pane (max-width:1100px)"),
appCss.indexOf("@container workspace-pane (max-width:760px)")
);
- const sftpTasksFrontend = read("public/app-sftp-tasks.js");
+ const sftpTasksFrontend = sftpFrontend;
const storageRoutesSource = read("src/routes/storage-routes.ts");
const updateRouteSource = read("src/routes/update-routes.ts");
const notificationsSource = read("src/notifications.ts");
@@ -566,7 +567,7 @@ async function main() {
ok("终端工具栏桌面端使用纯图标并在窄屏分行保留全部按钮", appCss.includes("container-name:terminal-view") && appCss.includes("@container terminal-view (max-width:1080px)") && appCss.includes("@container terminal-toolbar (max-width:1080px)") && appCss.includes("@media (min-width:761px) and (hover:hover) and (pointer:fine)") && appCss.includes(".terminal-actions > button > span:not(.composite-icon)") && terminalFrontend.includes('tr("terminal:toolbar.forward_list"') && terminalFrontend.includes('class="terminal-action-forward-list"') && terminalFrontend.includes('icon("earth")'));
ok("X11、连接快捷入口和窗口标题保持统一", utilsFrontend.includes('name === "x11"') && settingsFrontend.includes('icon("x11")') && terminalFrontend.includes('icon("x11")') && productivityFrontend.includes('class="xserver-x-icon"') && productivityFrontend.includes('= 0 ? insertion + 1 : tabs.length") && dockingFrontend.includes("pane.tabs.splice(insertion >= 0 ? insertion + 1 : pane.tabs.length") && appEntry.includes("workspaceRestorePending = true") && workspaceFrontend.includes("if (window.workspaceRestorePending) return") && dockingFrontend.includes("if (window.workspaceRestorePending) return") && appEntry.includes("const restored = restoreTabsState()"));
- ok("SFTP 任务中心宽高跨重启持久化", sftpTasksFrontend.includes('SFTP_TASK_CENTER_SIZE_STORAGE_KEY = "sftpTaskCenterSizeV1"') && sftpTasksFrontend.includes("persistSftpTaskCenterSize") && sftpTasksFrontend.includes("restoreSftpTaskCenterSize") && sftpTasksFrontend.includes("localStorage.removeItem(SFTP_TASK_CENTER_SIZE_STORAGE_KEY)"));
+ ok("SFTP 任务中心宽高跨重启持久化且异常尺寸可恢复", sftpTasksFrontend.includes('SFTP_TASK_CENTER_SIZE_STORAGE_KEY = "sftpTaskCenterSizeV1"') && sftpTasksFrontend.includes("persistSftpTaskCenterSize") && sftpTasksFrontend.includes("restoreSftpTaskCenterSize") && sftpTasksFrontend.includes('drawer.closest(".content")') && sftpTasksFrontend.includes("ensureSftpTaskCenterBoundsMonitoring") && sftpTasksFrontend.includes("localStorage.removeItem(SFTP_TASK_CENTER_SIZE_STORAGE_KEY)") && indexHtml.includes('id="sftpTaskCenterResetSizeButton"'));
ok("移动端终端 SFTP 按钮保留完整文字宽度", appCss.includes("button.terminal-action-sftp { width:auto; min-width:84px; padding-inline:10px; }"));
ok("SFTP 打开文件时显示状态并阻止重复请求", sftpFrontend.includes("withSftpFileOpenFeedback") && sftpFrontend.includes('tr("sftp:auto.opening_file"') && sftpFrontend.includes('tr("sftp:auto.opening"'));
ok("SFTP 同一连接同一路径的文本编辑器复用并可恢复最小化窗口", sftpFrontend.includes("sftpTextEditorOpenKey") && sftpFrontend.includes("activateSftpTextEditor") && sftpFrontend.includes("sftpFloatingEditorRegistry") && sftpFrontend.includes("editorKey"));
@@ -583,8 +584,9 @@ async function main() {
ok("SFTP 支持可暂停续传的跨主机复制", sftpJobsSource.includes("createCheckpointTransfers") && sftpJobsSource.includes("checkpoint_manifest") && sftpJobsSource.includes("checkpoint_staging_path") && !sftpJobsSource.includes("source.stdout.pipe(target.stdin)") && sftpFrontend.includes("/sftp/cross-copy"));
ok("终端与批量命令共享严格 WebSocket 帧解析器", terminalSource.includes("WebSocketFrameParser") && read("src/commands.ts").includes("WebSocketFrameParser") && read("src/websocket.ts").includes("客户端 WebSocket 数据帧必须掩码") && read("src/websocket.ts").includes("fragmentOpcode"));
ok("终端 WebSocket 升级可安全处理客户端提前断开", terminalSource.includes('socket.on("error", () => {});') && read("src/websocket.ts").includes("try {\n return socket.write"));
+ ok("终端拖入继续使用 SFTP 并明确原登录用户权限边界", terminalFrontend.includes("uploadSftpFilesToDirectory(files, connection.id, directory)") && terminalFrontend.includes("terminal:drop.sftp_login_identity_hint") && terminalFrontend.includes("terminal:drop.upload_failed_identity_hint") && !terminalFrontend.includes("terminalZmodemUploadDroppedFiles"));
ok("连接列表消除转发 N+1 查询并补索引", dbSource.includes('all("SELECT * FROM connection_forwards ORDER BY connection_id,id")') && dbSource.includes("idx_connection_forwards_connection_id") && dbSource.includes("idx_connections_group_sort"));
- ok("SFTP 状态防抖原子写入且日志使用缓冲队列", sftpJobsSource.includes("setTimeout(() => persistJobs(true), 400)") && read("src/sftp-job-store.ts").includes("fs.renameSync(temporary, file)") && read("src/logs.ts").includes("queueLogWrite") && !read("src/logs.ts").includes("fs.appendFileSync(logFile"));
+ ok("SFTP 大文件任务使用单一防抖写入且桌面保存不阻塞主循环", sftpJobsSource.includes("persistRequestedAt") && sftpJobsSource.includes("const flush = () =>") && sftpJobsSource.includes('require("node:stream/promises")') && sftpJobsSource.includes("await pipeline(input, output, {signal})") && sftpJobsSource.includes('job.phase = "system-saving"') && !sftpJobsSource.includes("copyFileSync") && read("src/sftp-job-store.ts").includes("fs.renameSync(temporary, file)") && read("src/logs.ts").includes("queueLogWrite") && !read("src/logs.ts").includes("fs.appendFileSync(logFile"));
ok("同名私钥不会绕过连接级绑定", importerSource.includes("identity_file: null") && importerSource.includes("missing_identity: Boolean(keyName)") && !importerSource.includes("identityFileMap") && serverSource.includes("const target = requested ?") && !serverSource.includes("existingByName.get(keyName)"));
ok("私钥绑定只接受已枚举路径", serverSource.includes("allowedPaths.has(path.resolve(requested))") && serverSource.includes("私钥绑定无效,请重新选择"));
ok("SSH config 与数据库恢复允许保留未绑定私钥", !importFrontend.includes("个连接尚未绑定私钥") && importFrontend.includes("未重新绑定的普通私钥路径会被清除") && serverSource.includes("updateIdentity.run(null, item.connection_id)") && !serverSource.includes("数据库备份中的连接尚未全部绑定私钥"));
diff --git a/scripts/route-extraction-check.js b/scripts/route-extraction-check.js
index 7da404c..5def1fb 100644
--- a/scripts/route-extraction-check.js
+++ b/scripts/route-extraction-check.js
@@ -591,12 +591,15 @@ async function checkSftpTransferRoutes() {
const sent = [];
let json = {};
let cancelled = false;
+ let desktopIntegration = null;
+ let desktopRequest = false;
+ const localDeliveryCalls = [];
const response = {destroyed:false, writableEnded:false};
const dependencies = {
authorizeConnectionId:() => 7,
- getDesktopIntegration:() => null,
+ getDesktopIntegration:() => desktopIntegration,
invalidateRemoteDirectoryCache:() => {},
- isDesktopRequest:() => false,
+ isDesktopRequest:() => desktopRequest,
readJson:async () => json,
readRemoteDirectorySize:async () => ({bytes:4096}),
readRuntimeSettings:() => ({sftp_max_open_file_size_mb:8, sftp_recycle_bin_enabled:true}),
@@ -610,6 +613,10 @@ async function checkSftpTransferRoutes() {
safeUploadName:name => String(name).replace(/[^a-z0-9.]+/gi, "_"),
send:(_response, status, data, headers={}) => sent.push({status, data, headers}),
sendJson:output.sendJson,
+ startLocalDeliveryJob:(connectionId, paths, target, conflict, options) => {
+ localDeliveryCalls.push({connectionId, paths, target, conflict, options});
+ return {id:`local-${localDeliveryCalls.length}`, status:"pending"};
+ },
startUploadReceiveJob:() => ({id:"upload-1"})
};
@@ -647,6 +654,27 @@ async function checkSftpTransferRoutes() {
assert.equal(await handleSftpTransferRoutes({method:"POST"}, response, "/api/connections/7/sftp/directory-size", dependencies), true);
assert.deepEqual(sent.pop(), {status:200, data:{bytes:4096}, headers:{"Cache-Control":"no-store"}});
+ desktopIntegration = {getDownloadDirectory:() => "C:\\Downloads"};
+ desktopRequest = true;
+ json = {mode:"separate", paths:Array.from({length:200}, (_, index) => `/tmp/file-${index}.bin`)};
+ assert.equal(await handleSftpTransferRoutes({method:"POST"}, response, "/api/connections/7/sftp/download-batch", dependencies), true);
+ assert.equal(localDeliveryCalls.length, 200, "200 个分别下载项目必须全部创建任务");
+ assert.equal(output.sent.pop().status, 202);
+
+ localDeliveryCalls.length = 0;
+ json = {mode:"separate", paths:Array.from({length:201}, (_, index) => `/tmp/file-${index}.bin`)};
+ assert.equal(await handleSftpTransferRoutes({method:"POST"}, response, "/api/connections/7/sftp/download-batch", dependencies), true);
+ assert.equal(localDeliveryCalls.length, 0, "超过上限时不能创建任何分别下载任务");
+ assert.deepEqual(output.sent.pop(), {
+ data:{
+ error:"一次最多分别下载 200 个文件或目录",
+ code:"SFTP_SEPARATE_DOWNLOAD_TOO_MANY",
+ error_code:"sftp_separate_download_too_many",
+ error_params:{max:200}
+ },
+ status:400
+ });
+
json = {exists:false};
cancelled = true;
const uploadRequest = {method:"POST", url:"/api/connections/7/sftp/upload?path=/tmp", headers:{"x-file-name":"cancel.txt", "content-length":"3"}};
diff --git a/scripts/sftp-channel-cancel-check.js b/scripts/sftp-channel-cancel-check.js
new file mode 100644
index 0000000..385c936
--- /dev/null
+++ b/scripts/sftp-channel-cancel-check.js
@@ -0,0 +1,110 @@
+const assert = require("node:assert/strict");
+const { EventEmitter } = require("node:events");
+const fs = require("node:fs");
+const os = require("node:os");
+const path = require("node:path");
+
+const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "terma-sftp-channel-cancel-"));
+process.env.TERMA_DATA_DIR = path.join(temporaryRoot, "data");
+process.env.TERMA_SSH_DIR = path.join(temporaryRoot, ".ssh");
+fs.mkdirSync(process.env.TERMA_DATA_DIR, {recursive:true});
+fs.mkdirSync(process.env.TERMA_SSH_DIR, {recursive:true});
+
+const connection = {
+ id:99301,
+ name:"sftp-channel-cancel-check",
+ ssh_host:"127.0.0.1",
+ ssh_port:22,
+ ssh_user:"smoke",
+ auth_type:"password",
+ ssh_password:"smoke",
+ sftp_filename_encoding:"utf8"
+};
+
+const db = require("../dist/db");
+const dbModule = require.cache[require.resolve("../dist/db")];
+const originalGetConnection = dbModule.exports.getConnection;
+dbModule.exports.getConnection = id => Number(id) === connection.id ? connection : originalGetConnection(id);
+
+const ssh2Client = require("../dist/ssh2-client");
+const ssh2ClientModule = require.cache[require.resolve("../dist/ssh2-client")];
+const originalConnectSsh = ssh2ClientModule.exports.connectSsh;
+const originalNormalizeError = ssh2ClientModule.exports.normalizeSshTransportError;
+
+class FakeSftpClient extends EventEmitter {
+ constructor() {
+ super();
+ this.endCalls = 0;
+ this.sftpCalls = 0;
+ this.nextChannel = null;
+ }
+
+ end() {
+ this.endCalls += 1;
+ }
+
+ sftp(callback) {
+ this.sftpCalls += 1;
+ const channel = this.nextChannel;
+ setTimeout(() => callback(null, channel), 140);
+ }
+}
+
+function delay(milliseconds) {
+ return new Promise(resolve => setTimeout(resolve, milliseconds));
+}
+
+async function rejectsPromptly(promise, controller, message) {
+ const startedAt = Date.now();
+ setTimeout(() => controller.abort(), 15);
+ await assert.rejects(promise, error => error?.name === "AbortError", message);
+ assert.ok(Date.now() - startedAt < 120, `${message}:取消没有及时返回`);
+}
+
+async function main() {
+ const client = new FakeSftpClient();
+ ssh2ClientModule.exports.connectSsh = () => new Promise(resolve => setTimeout(() => resolve(client), 140));
+ ssh2ClientModule.exports.normalizeSshTransportError = error => error;
+ const session = require("../dist/sftp-session");
+ try {
+ const connectionAbort = new AbortController();
+ await rejectsPromptly(
+ session.openSftpChannel(connection.id, connectionAbort.signal),
+ connectionAbort,
+ "连接建立阶段必须支持取消"
+ );
+ await delay(180);
+ assert.equal(session.sftpSessionStatus(connection.id).connected, true, "取消单个等待者不能中断共享 SSH 会话");
+ assert.equal(client.endCalls, 0, "取消单个等待者不能关闭共享 SSH 客户端");
+ assert.equal(client.sftpCalls, 0, "连接等待已取消后不能继续打开 SFTP channel");
+
+ const lateChannel = {endCalls:0, end() { this.endCalls += 1; }};
+ client.nextChannel = lateChannel;
+ const channelAbort = new AbortController();
+ await rejectsPromptly(
+ session.stageSftpPaths(connection.id, ["/tmp/late.bin"], {signal:channelAbort.signal}),
+ channelAbort,
+ "SFTP channel 建立阶段必须支持取消"
+ );
+ await delay(180);
+ assert.equal(client.sftpCalls, 1, "取消后不能重试打开 SFTP channel");
+ assert.equal(lateChannel.endCalls, 1, "取消后迟到的 SFTP channel 必须立即关闭");
+ const stagingRoot = path.join(process.env.TERMA_DATA_DIR, "sftp-drag");
+ assert.deepEqual(fs.existsSync(stagingRoot) ? fs.readdirSync(stagingRoot) : [], [], "取消后必须清理未完成的本机暂存目录");
+ assert.equal(session.sftpSessionStatus(connection.id).connected, true, "channel 取消不能重置共享 SSH 会话");
+ console.log("SFTP channel cancellation check passed.");
+ } finally {
+ session.closeAllSftpSessions();
+ }
+}
+
+main().catch(error => {
+ console.error(error);
+ process.exitCode = 1;
+}).finally(() => {
+ ssh2ClientModule.exports.connectSsh = originalConnectSsh;
+ ssh2ClientModule.exports.normalizeSshTransportError = originalNormalizeError;
+ dbModule.exports.getConnection = originalGetConnection;
+ try { db.closeDatabase(); } catch {}
+ try { fs.rmSync(temporaryRoot, {recursive:true, force:true}); } catch {}
+});
diff --git a/scripts/sftp-download-integrity-check.js b/scripts/sftp-download-integrity-check.js
index 1aa1451..ada7281 100644
--- a/scripts/sftp-download-integrity-check.js
+++ b/scripts/sftp-download-integrity-check.js
@@ -122,6 +122,7 @@ async function main() {
const sessions = require("../dist/sftp-session");
const jobs = require("../dist/sftp-jobs");
let jobId = "";
+ let desktopJobId = "";
let growingJobId = "";
let archiveJobId = "";
try {
@@ -134,6 +135,15 @@ async function main() {
assert.equal(completed.transferred, payload.length, "completed transfer count must equal the remote size");
assert.equal(downloaded.length, payload.length, "a completed download must not be truncated");
assert.equal(hash(downloaded), hash(payload), "a completed download must preserve every byte");
+ const desktopDirectory = path.join(temporaryRoot, "desktop-downloads");
+ fs.mkdirSync(desktopDirectory, {recursive:true});
+ const desktop = jobs.startDownloadJob(connection.id, "/fixture/download.bin", {
+ deliveryMode:"desktop",
+ autoSaveDirectory:desktopDirectory
+ });
+ desktopJobId = desktop.id;
+ const saved = await waitForJobState(jobs, desktopJobId, item => item.status === "done" && item.delivery_status === "saved" && item.saved_path);
+ assert.equal(hash(fs.readFileSync(saved.saved_path)), hash(payload), "desktop delivery must save the completed file without blocking transfer state");
const growing = jobs.startDownloadJob(connection.id, "/fixture/growing.log", { deliveryMode:"browser" });
growingJobId = growing.id;
const changed = await waitForStatus(jobs, growingJobId, ["done"]);
@@ -175,6 +185,10 @@ async function main() {
const current = jobId && jobs.listSftpJobs().find(item => item.id === jobId);
if (current && ["running", "pending", "paused"].includes(current.status)) jobs.cancelSftpJob(jobId);
} catch {}
+ try {
+ const current = desktopJobId && jobs.listSftpJobs().find(item => item.id === desktopJobId);
+ if (current && ["running", "pending", "paused"].includes(current.status)) jobs.cancelSftpJob(desktopJobId);
+ } catch {}
try {
const current = growingJobId && jobs.listSftpJobs().find(item => item.id === growingJobId);
if (current && ["running", "pending", "paused"].includes(current.status)) jobs.cancelSftpJob(growingJobId);
@@ -184,6 +198,7 @@ async function main() {
if (current && ["running", "pending", "paused"].includes(current.status)) jobs.cancelSftpJob(archiveJobId);
} catch {}
try { if (jobId) jobs.deleteSftpJob(jobId); } catch {}
+ try { if (desktopJobId) jobs.deleteSftpJob(desktopJobId); } catch {}
try { if (growingJobId) jobs.deleteSftpJob(growingJobId); } catch {}
try { if (archiveJobId) jobs.deleteSftpJob(archiveJobId); } catch {}
sessions.closeAllSftpSessions();
diff --git a/scripts/sftp-transfer-concurrency-check.js b/scripts/sftp-transfer-concurrency-check.js
index db0a9d1..1fe6d41 100644
--- a/scripts/sftp-transfer-concurrency-check.js
+++ b/scripts/sftp-transfer-concurrency-check.js
@@ -3,7 +3,8 @@ const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const {EventEmitter} = require("node:events");
-const {PassThrough} = require("node:stream");
+const {PassThrough, Readable} = require("node:stream");
+const {decodeRemotePosixCommand} = require("./remote-posix-test-helper");
const temporaryRoot = fs.mkdtempSync(path.join(os.tmpdir(), "terma-sftp-transfer-concurrency-"));
process.env.TERMA_DATA_DIR = path.join(temporaryRoot, "data");
@@ -19,7 +20,10 @@ function writeLimits(download, upload) {
}), "utf8");
}
+const downloadablePayloads = new Map();
+
function fakeRemoteChild(command) {
+ const decodedCommand = decodeRemotePosixCommand(command);
const child = new EventEmitter();
child.stdin = new PassThrough();
child.stdout = new PassThrough();
@@ -33,10 +37,18 @@ function fakeRemoteChild(command) {
child.emit("close", null, signal);
return true;
};
- if (String(command).includes("wc -c")) {
+ const fixture = [...downloadablePayloads.entries()].find(([remotePath]) => decodedCommand.includes(remotePath));
+ if (decodedCommand.includes("wc -c")) {
setImmediate(() => {
if (child.killed) return;
- child.stdout.end("1024");
+ child.stdout.end(String(fixture ? fixture[1].length : 1024));
+ child.stderr.end();
+ child.emit("close", 0, null);
+ });
+ } else if (fixture && (decodedCommand.includes("cat --") || decodedCommand.includes("tail -c"))) {
+ setImmediate(() => {
+ if (child.killed) return;
+ child.stdout.end(fixture[1]);
child.stderr.end();
child.emit("close", 0, null);
});
@@ -73,9 +85,19 @@ async function main() {
const sessions = require("../dist/sftp-session");
const sessionModule = require.cache[require.resolve("../dist/sftp-session")];
const originalSpawn = sessionModule.exports.spawnSftpSessionCommand;
+ const originalDeliver = sessionModule.exports.deliverSftpPaths;
+ let localDeliverySignal = null;
sessionModule.exports.spawnSftpSessionCommand = (_connection, command) => fakeRemoteChild(command);
+ sessionModule.exports.deliverSftpPaths = (_connectionId, _paths, _targetDirectory, _conflictMode, progress={}) => new Promise((resolve, reject) => {
+ localDeliverySignal = progress.signal || null;
+ const abort = () => reject(Object.assign(new Error("cancelled"), {name:"AbortError"}));
+ if (localDeliverySignal?.aborted) return abort();
+ localDeliverySignal?.addEventListener("abort", abort, {once:true});
+ });
const jobs = require("../dist/sftp-jobs");
const ids = [];
+ const originalRename = fs.promises.rename;
+ const originalCreateReadStream = fs.createReadStream;
try {
const uploadIds = Array.from({length:4}, (_, index) => {
const localPath = path.join(temporaryRoot, `upload-${index}.bin`);
@@ -103,6 +125,96 @@ async function main() {
writeLimits(3, 3);
jobs.refreshSftpTransferQueues();
await waitUntil(() => counts(jobs, downloadIds).running === 3 && counts(jobs, downloadIds).pending === 1, "提高下载并发后未立即放行排队任务");
+ for (const id of downloadIds) if (["running", "pending"].includes(jobs.listSftpJobs().find(job => job.id === id)?.status)) jobs.cancelSftpJob(id);
+
+ const localDelivery = jobs.startLocalDeliveryJob(99201, ["/tmp/a.bin", "/tmp/b.bin"], temporaryRoot, "rename");
+ ids.push(localDelivery.id);
+ await waitUntil(() => jobs.listSftpJobs().find(job => job.id === localDelivery.id)?.status === "running", "本机分别下载任务未开始");
+ assert.equal(jobs.listSftpJobs().find(job => job.id === localDelivery.id)?.can_cancel, true, "本机分别下载任务必须可取消");
+ jobs.cancelSftpJob(localDelivery.id);
+ await waitUntil(() => jobs.listSftpJobs().find(job => job.id === localDelivery.id)?.status === "cancelled", "本机分别下载任务未进入取消状态");
+ assert.equal(localDeliverySignal?.aborted, true, "取消本机分别下载必须中止底层递归 SFTP 读取");
+
+ writeLimits(1, 3);
+ jobs.refreshSftpTransferQueues();
+ const autoSaveRemotePath = "/tmp/auto-save.bin";
+ const autoSavePayload = Buffer.alloc(768 * 1024, 0x5a);
+ downloadablePayloads.set(autoSaveRemotePath, autoSavePayload);
+ const autoSaveDirectory = path.join(temporaryRoot, "auto-save");
+ fs.mkdirSync(autoSaveDirectory, {recursive:true});
+ fs.promises.rename = async (source, target) => {
+ if (path.basename(String(target)) === "auto-save.bin") {
+ const error = new Error("cross-device move");
+ error.code = "EXDEV";
+ throw error;
+ }
+ if (path.basename(String(target)) === "save-failure.bin") {
+ const error = new Error("save denied");
+ error.code = "EACCES";
+ throw error;
+ }
+ return originalRename(source, target);
+ };
+ fs.createReadStream = (source, options) => {
+ if (String(source).includes("auto-save.bin")) {
+ const content = fs.readFileSync(source);
+ let offset = 0;
+ let pending = false;
+ return new Readable({
+ read() {
+ if (pending || this.destroyed) return;
+ pending = true;
+ setTimeout(() => {
+ pending = false;
+ if (this.destroyed) return;
+ if (offset >= content.length) {
+ this.push(null);
+ return;
+ }
+ const next = Math.min(content.length, offset + 16 * 1024);
+ this.push(content.subarray(offset, next));
+ offset = next;
+ }, 8);
+ }
+ });
+ }
+ return originalCreateReadStream(source, options);
+ };
+
+ const saving = jobs.startDownloadJob(99201, autoSaveRemotePath, {
+ deliveryMode:"desktop",
+ autoSaveDirectory:autoSaveDirectory
+ });
+ ids.push(saving.id);
+ const autoSaveTarget = path.join(autoSaveDirectory, "auto-save.bin");
+ await waitUntil(() => {
+ const job = jobs.listSftpJobs().find(item => item.id === saving.id);
+ return job?.status === "running" && job.phase === "system-saving" && fs.existsSync(autoSaveTarget) && fs.statSync(autoSaveTarget).size > 0;
+ }, "跨磁盘自动保存未进入可取消的 system-saving 阶段");
+ const queuedBehindSave = jobs.startDownloadJob(99201, "/tmp/queued-behind-save.bin", {deliveryMode:"browser"});
+ ids.push(queuedBehindSave.id);
+ assert.equal(jobs.listSftpJobs().find(job => job.id === queuedBehindSave.id)?.status, "pending", "自动保存期间必须继续占用下载并发名额");
+ assert.equal(jobs.refreshSftpTransferQueues().download.active, 1, "自动保存期间不能提前释放下载并发名额");
+ const cancelling = jobs.cancelSftpJob(saving.id);
+ assert.equal(cancelling.phase, "cancelling", "取消自动保存时必须等待异步复制清理完成");
+ await waitUntil(() => jobs.listSftpJobs().find(job => job.id === saving.id)?.status === "cancelled", "自动保存取消后未进入终态");
+ assert.equal(fs.existsSync(autoSaveTarget), false, "取消跨磁盘自动保存后不能留下部分目标文件");
+ assert.equal(jobs.listSftpJobs().find(job => job.id === saving.id)?.delivery_status, "cancelled", "异步保存回调不能把已取消任务覆盖为完成");
+ await waitUntil(() => jobs.listSftpJobs().find(job => job.id === queuedBehindSave.id)?.status === "running", "自动保存清理完成后未释放下载并发名额");
+ jobs.cancelSftpJob(queuedBehindSave.id);
+
+ const failedSaveRemotePath = "/tmp/save-failure.bin";
+ downloadablePayloads.set(failedSaveRemotePath, Buffer.alloc(32 * 1024, 0x33));
+ const failedSave = jobs.startDownloadJob(99201, failedSaveRemotePath, {
+ deliveryMode:"desktop",
+ autoSaveDirectory:autoSaveDirectory
+ });
+ ids.push(failedSave.id);
+ await waitUntil(() => {
+ const job = jobs.listSftpJobs().find(item => item.id === failedSave.id);
+ return job?.status === "done" && job.delivery_status === "failed";
+ }, "自动保存失败后任务未正确结束");
+ assert.equal(jobs.refreshSftpTransferQueues().download.active, 0, "自动保存失败后必须准确释放一次下载并发名额");
console.log("SFTP transfer concurrency check passed.");
} finally {
for (const id of ids) {
@@ -112,6 +224,9 @@ async function main() {
} catch {}
}
sessionModule.exports.spawnSftpSessionCommand = originalSpawn;
+ sessionModule.exports.deliverSftpPaths = originalDeliver;
+ fs.promises.rename = originalRename;
+ fs.createReadStream = originalCreateReadStream;
try { db.closeDatabase(); } catch {}
try { fs.rmSync(temporaryRoot, {recursive:true, force:true}); } catch {}
}
diff --git a/scripts/terminal-drop-upload-check.js b/scripts/terminal-drop-upload-check.js
index ff68a9d..1c24684 100644
--- a/scripts/terminal-drop-upload-check.js
+++ b/scripts/terminal-drop-upload-check.js
@@ -127,6 +127,9 @@ assert.match(terminal, /const markers = \[[^\]]*"% "/);
assert.match(terminal, /bindTerminalDropUpload/);
assert.match(terminal, /collectDroppedFiles/);
assert.match(terminal, /uploadSftpFilesToDirectory\(files, connection\.id, directory\)/);
+assert.match(terminal, /terminal:drop\.sftp_login_identity_hint/);
+assert.match(terminal, /terminal:drop\.upload_failed_identity_hint/);
+assert.match(terminal, /不会继承终端中通过 su 或 sudo 切换后的用户/);
assert.match(terminal, /terminalSftpDragPayload/);
assert.match(terminal, /copySftpDraggedItemsToDirectory\(drag, connection\.id, directory/);
assert.match(terminal, /setTerminalDropState\(session, true, drag \? "copy" : "upload"\)/);
@@ -147,6 +150,13 @@ assert.match(sftp, /item\.name \|\| item\.filename \|\| tr\("sftp:transfer\.unna
assert.doesNotMatch(sftp, /item\.name \|\| item\.filename \|\| "未命名项目"/);
assert.match(sftp, /createRemoteUploadDirectories\(Number\(connectionId\), directory, files\)/);
assert.match(sftp, /conflict:conflict \|\| "error"/);
+const uploadQueueStart = sftp.indexOf("async function uploadSftpFilesToDirectory");
+const uploadQueueEnd = sftp.indexOf("\nasync function uploadSftpFiles(", uploadQueueStart);
+const uploadQueueSource = sftp.slice(uploadQueueStart, uploadQueueEnd);
+assert.match(uploadQueueSource, /const uploads = \[\]/);
+assert.match(uploadQueueSource, /uploads\.push\(\{item, started\}\)/);
+assert.ok(uploadQueueSource.indexOf("/sftp/upload-job") < uploadQueueSource.indexOf("const workers ="), "所有拖入文件应先创建任务,再按并发数上传内容");
+assert.match(uploadQueueSource, /Promise\.allSettled\(uploads\.map\(\(\{started\}\) => api\(`\/api\/sftp\/jobs\/\$\{encodeURIComponent\(started\.id\)\}\/cancel`/);
assert.match(backend, /conflict !== "rename"/);
assert.match(backend, /return `\$\{base\} \(\$\{index\}\)\$\{extension\}`/);
assert.match(server, /\["overwrite", "rename"\]/);
@@ -159,4 +169,4 @@ assert.match(desktop, /sourceTabKey:String\(payload\?\.sourceTabKey/);
assert.match(css, /\.terminal-drop-overlay/);
assert.match(css, /\.terminal-drop-hint/);
-console.log("终端拖入上传检查通过:目录跟踪、递归上传、SFTP 内部复制、重名策略和拖入反馈");
+console.log("终端拖入上传检查通过:目录跟踪、SFTP 登录身份提示、递归上传、内部复制、重名策略和拖入反馈");
diff --git a/scripts/ui-smoke-electron.js b/scripts/ui-smoke-electron.js
index ce41d00..e83aab8 100644
--- a/scripts/ui-smoke-electron.js
+++ b/scripts/ui-smoke-electron.js
@@ -4798,6 +4798,7 @@ app.whenReady().then(async () => {
&&uploadPrompt?.values.includes('overwrite')
&&uploadPrompt?.values.includes('rename')
&&uploadPrompt?.values.includes('')
+ &&terminalDropCalls.notices.some(item=>item.type==='info'&&item.text.includes('SSH 连接的原登录用户')&&item.text.includes('su 或 sudo'))
&&dropOverlay?.hidden===true
);
dispatchDropEvent('dragenter',uploadDataTransfer);
@@ -8602,6 +8603,19 @@ app.whenReady().then(async () => {
resetSftpTaskCenterSize();
await toggleSftpTaskCenter();
const drawerOpened = Boolean(drawer && !drawer.hidden && button?.getAttribute('aria-expanded') === 'true');
+ notify('任务中心层级测试','info');
+ await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
+ const taskCenterLayerToast = document.querySelector('#toast .toast:last-child');
+ const taskCenterLayerToastRect = taskCenterLayerToast?.getBoundingClientRect();
+ const taskCenterLayerDrawerRect = drawer?.getBoundingClientRect();
+ const overlapX = Math.max(taskCenterLayerToastRect?.left||0,taskCenterLayerDrawerRect?.left||0)+12;
+ const overlapY = Math.max(taskCenterLayerToastRect?.top||0,taskCenterLayerDrawerRect?.top||0)+12;
+ const taskCenterAboveNotifications = Boolean(document.body.classList.contains('sftp-task-center-open')
+ && taskCenterLayerToastRect && taskCenterLayerDrawerRect
+ && overlapX < Math.min(taskCenterLayerToastRect.right,taskCenterLayerDrawerRect.right)
+ && overlapY < Math.min(taskCenterLayerToastRect.bottom,taskCenterLayerDrawerRect.bottom)
+ && document.elementFromPoint(overlapX,overlapY)?.closest?.('#sftpTaskCenterDrawer'));
+ dismissToast(taskCenterLayerToast);
const defaultDrawerRect = drawer?.getBoundingClientRect();
const drawerDefaultCompact = Boolean(defaultDrawerRect
&& defaultDrawerRect.width >= 338
@@ -8620,6 +8634,61 @@ app.whenReady().then(async () => {
&& !currentText.includes('失败任务')
&& !currentText.includes('完成历史任务')
&& !currentText.includes('取消历史任务');
+ const queueActions = document.querySelector('#sftpTaskCenterQueueActions');
+ const pauseAllButton = document.querySelector('#sftpTaskCenterPauseAllButton');
+ const resumeAllButton = document.querySelector('#sftpTaskCenterResumeAllButton');
+ const clearQueueButton = document.querySelector('#sftpTaskCenterClearQueueButton');
+ const currentQueueActions = Boolean(queueActions && !queueActions.hidden
+ && pauseAllButton && !pauseAllButton.disabled
+ && resumeAllButton?.disabled
+ && clearQueueButton && !clearQueueButton.disabled
+ && document.querySelector('#sftpTaskCenterClearButton')?.hidden);
+ const queueFixtures = [
+ {...jobFixtures[0],id:'queue-upload-running'},
+ {id:'queue-download-paused',status:'paused',type:'download',phase:'downloading',label:'暂停下载',connection_id:Number(connection.id),connection_name:'iMac',resume_supported:true,can_resume:true,can_cancel:true},
+ {id:'queue-cross-pending',status:'pending',type:'cross-copy',phase:'queued',label:'排队跨主机',connection_id:Number(connection.id),connection_name:'Mac mini',resume_supported:true,can_pause:false,can_cancel:true},
+ {id:'queue-local-running',status:'running',type:'local-delivery',phase:'local-saving',label:'分别下载到本机',connection_id:Number(connection.id),connection_name:'iMac',resume_supported:false,can_pause:false,can_cancel:true},
+ {id:'queue-cross-scanning',status:'running',type:'cross-copy',phase:'scanning',label:'扫描跨主机',connection_id:Number(connection.id),connection_name:'Mac mini',resume_supported:true,can_pause:false,can_cancel:true}
+ ];
+ const queueBatchCalls = [];
+ sftpLatestJobs = queueFixtures;
+ updateSftpTaskCenter(queueFixtures);
+ api = async (pathname, options={}) => {
+ if (pathname === '/api/sftp/jobs') return queueFixtures;
+ if (['/api/sftp/sync/jobs','/api/linux-desktop/tasks','/api/remote-component/tasks'].includes(pathname)) return [];
+ queueBatchCalls.push({pathname,method:options.method||'GET'});
+ if (pathname.endsWith('/pause')) return {ok:true,status:'paused'};
+ if (pathname.endsWith('/resume')) return {ok:true,status:'pending'};
+ if (pathname.endsWith('/cancel')) return {ok:true,status:'cancelled'};
+ return {ok:true};
+ };
+ confirmModal = async () => true;
+ const pauseBatchResult = await pauseAllSftpTransferJobs(pauseAllButton);
+ const resumeBatchResult = await resumeAllSftpTransferJobs(resumeAllButton);
+ const clearBatchResult = await clearCurrentSftpTransferQueue(clearQueueButton);
+ const queueBatchControls = Boolean(pauseBatchResult?.completed === 2
+ && pauseBatchResult?.skipped === 3
+ && resumeBatchResult?.completed === 1
+ && resumeBatchResult?.skipped === 4
+ && clearBatchResult?.completed === 5
+ && clearBatchResult?.skipped === 0
+ && queueBatchCalls.filter(call=>call.pathname.endsWith('/pause')).length === 2
+ && queueBatchCalls.filter(call=>call.pathname.endsWith('/resume')).length === 1
+ && queueBatchCalls.filter(call=>call.pathname.endsWith('/cancel')).length === 5
+ && !drawer.hidden);
+ const queueBatchDiagnostics = {
+ pauseBatchResult,
+ resumeBatchResult,
+ clearBatchResult,
+ pauseCalls:queueBatchCalls.filter(call=>call.pathname.endsWith('/pause')).length,
+ resumeCalls:queueBatchCalls.filter(call=>call.pathname.endsWith('/resume')).length,
+ cancelCalls:queueBatchCalls.filter(call=>call.pathname.endsWith('/cancel')).length,
+ drawerHidden:drawer.hidden
+ };
+ sftpLatestJobs = jobFixtures;
+ api = async pathname => pathname === '/api/sftp/jobs' ? jobFixtures : [];
+ confirmModal = previousConfirmModal;
+ updateSftpTaskCenter(jobFixtures);
setSftpTaskCenterView('failed');
const failedText = list?.textContent.replace(/\s+/g,' ').trim() || '';
const failedRows = [...(list?.querySelectorAll('.sftp-job') || [])];
@@ -8629,7 +8698,8 @@ app.whenReady().then(async () => {
const failedFooter = document.querySelector('#sftpTaskCenterFooter');
const failedClearAvailable = Boolean(failedFooter && !failedFooter.hidden
&& document.querySelector('#sftpTaskCenterClearLabel')?.textContent === '清空失败'
- && document.querySelector('#sftpTaskCenterClearButton')?.getAttribute('aria-label')?.includes('失败任务'));
+ && document.querySelector('#sftpTaskCenterClearButton')?.getAttribute('aria-label')?.includes('失败任务')
+ && document.querySelector('#sftpTaskCenterQueueActions')?.hidden);
const taskLogDetails=failedRow?.querySelector('.global-task-log');
taskLogDetails?.querySelector('summary')?.click();
await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
@@ -8693,6 +8763,62 @@ app.whenReady().then(async () => {
const drawerResizeReset = Boolean(Math.abs(resetDrawerRect.width - drawerBeforeResizeRect.width) <= 2
&& Math.abs(resetDrawerRect.height - drawerBeforeResizeRect.height) <= 2
&& localStorage.getItem(SFTP_TASK_CENTER_SIZE_STORAGE_KEY)===null);
+ localStorage.setItem(SFTP_TASK_CENTER_SIZE_STORAGE_KEY,JSON.stringify({width:window.innerWidth*4,height:window.innerHeight*4}));
+ clearSftpTaskCenterSizeConstraints(drawer);
+ drawer.style.removeProperty('width');
+ drawer.style.removeProperty('height');
+ const oversizedDrawerRestored=restoreSftpTaskCenterSize(drawer);
+ await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
+ const recoveredDrawerRect=drawer.getBoundingClientRect();
+ const recoveredContentRect=document.querySelector('.content')?.getBoundingClientRect();
+ const recoveredHandleRect=drawerResizeHandle?.getBoundingClientRect();
+ const recoveredHandleX=(recoveredHandleRect?.left||0)+(recoveredHandleRect?.width||0)/2;
+ const recoveredHandleY=(recoveredHandleRect?.top||0)+(recoveredHandleRect?.height||0)/2;
+ const correctedOversizedDrawerSize=savedSftpTaskCenterSize();
+ const drawerOversizeRecovers=Boolean(oversizedDrawerRestored
+ && recoveredContentRect
+ && recoveredHandleRect
+ && correctedOversizedDrawerSize
+ && recoveredDrawerRect.left >= recoveredContentRect.left + SFTP_TASK_CENTER_VIEWPORT_GAP - 1
+ && recoveredDrawerRect.right <= Math.min(window.innerWidth,recoveredContentRect.right) - SFTP_TASK_CENTER_VIEWPORT_GAP + 1
+ && recoveredDrawerRect.bottom <= Math.min(window.innerHeight,recoveredContentRect.bottom) - SFTP_TASK_CENTER_VIEWPORT_GAP + 1
+ && recoveredHandleX >= recoveredContentRect.left
+ && recoveredHandleY < window.innerHeight
+ && document.elementFromPoint(recoveredHandleX,recoveredHandleY)?.closest?.('#sftpTaskCenterResize')
+ && correctedOversizedDrawerSize.width <= recoveredDrawerRect.width + 1
+ && correctedOversizedDrawerSize.height <= recoveredDrawerRect.height + 1);
+ const drawerResetSizeButton=document.querySelector('#sftpTaskCenterResetSizeButton');
+ const drawerResetSizeButtonRect=drawerResetSizeButton?.getBoundingClientRect();
+ const drawerResetSizeButtonReachable=Boolean(drawerResetSizeButtonRect
+ && document.elementFromPoint(drawerResetSizeButtonRect.left+drawerResetSizeButtonRect.width/2,drawerResetSizeButtonRect.top+drawerResetSizeButtonRect.height/2)?.closest?.('#sftpTaskCenterResetSizeButton'));
+ drawerResetSizeButton?.click();
+ await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
+ const headerResetDrawerRect=drawer.getBoundingClientRect();
+ const drawerHeaderReset=Boolean(drawerResetSizeButtonReachable
+ && Math.abs(headerResetDrawerRect.width-drawerBeforeResizeRect.width)<=2
+ && Math.abs(headerResetDrawerRect.height-drawerBeforeResizeRect.height)<=2
+ && localStorage.getItem(SFTP_TASK_CENTER_SIZE_STORAGE_KEY)===null);
+ const boundedDragHandleRect=drawerResizeHandle?.getBoundingClientRect();
+ const boundedDragPointerId=452;
+ const boundedDragStartX=(boundedDragHandleRect?.left||0)+(boundedDragHandleRect?.width||0)/2;
+ const boundedDragStartY=(boundedDragHandleRect?.top||0)+(boundedDragHandleRect?.height||0)/2;
+ drawerResizeHandle?.dispatchEvent(new PointerEvent('pointerdown',{bubbles:true,cancelable:true,pointerId:boundedDragPointerId,pointerType:'mouse',button:0,buttons:1,clientX:boundedDragStartX,clientY:boundedDragStartY}));
+ window.dispatchEvent(new PointerEvent('pointermove',{bubbles:true,cancelable:true,pointerId:boundedDragPointerId,pointerType:'mouse',button:0,buttons:1,clientX:-window.innerWidth,clientY:window.innerHeight*2}));
+ window.dispatchEvent(new PointerEvent('pointerup',{bubbles:true,cancelable:true,pointerId:boundedDragPointerId,pointerType:'mouse',button:0,buttons:0,clientX:-window.innerWidth,clientY:window.innerHeight*2}));
+ await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
+ const boundedDragDrawerRect=drawer.getBoundingClientRect();
+ const boundedDragContentRect=document.querySelector('.content')?.getBoundingClientRect();
+ const boundedDragRecoveredHandleRect=drawerResizeHandle?.getBoundingClientRect();
+ const boundedDragHandleX=(boundedDragRecoveredHandleRect?.left||0)+(boundedDragRecoveredHandleRect?.width||0)/2;
+ const boundedDragHandleY=(boundedDragRecoveredHandleRect?.top||0)+(boundedDragRecoveredHandleRect?.height||0)/2;
+ const drawerResizeClamps=Boolean(boundedDragContentRect
+ && boundedDragRecoveredHandleRect
+ && boundedDragDrawerRect.left >= boundedDragContentRect.left + SFTP_TASK_CENTER_VIEWPORT_GAP - 1
+ && boundedDragDrawerRect.right <= Math.min(window.innerWidth,boundedDragContentRect.right) - SFTP_TASK_CENTER_VIEWPORT_GAP + 1
+ && boundedDragDrawerRect.bottom <= Math.min(window.innerHeight,boundedDragContentRect.bottom) - SFTP_TASK_CENTER_VIEWPORT_GAP + 1
+ && document.elementFromPoint(boundedDragHandleX,boundedDragHandleY)?.closest?.('#sftpTaskCenterResize'));
+ drawerResetSizeButton?.click();
+ await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));
const deleteFixture = {id:'delete-record-job',status:'failed',type:'upload',label:'待删除失败任务',connection_id:Number(connection.id),connection_name:'iMac'};
updateSftpTaskCenter([...refreshedJobFixtures, deleteFixture]);
@@ -8911,9 +9037,13 @@ app.whenReady().then(async () => {
floatingMutePersists,
floatingSettingRestores,
drawerOpened,
+ taskCenterAboveNotifications,
drawerDefaultCompact,
currentOnly,
currentActions,
+ currentQueueActions,
+ queueBatchControls,
+ queueBatchDiagnostics,
failedOnly,
failedActions,
failedClearAvailable,
@@ -8922,6 +9052,9 @@ app.whenReady().then(async () => {
drawerResizeAdaptive,
drawerResizePersists,
drawerResizeReset,
+ drawerOversizeRecovers,
+ drawerHeaderReset,
+ drawerResizeClamps,
deleteDuplicateBlocked,
deleteKeepsDrawerOpen,
taskLogInitialOpen,
@@ -12175,7 +12308,7 @@ app.whenReady().then(async () => {
const imagePreviewUi = sftpUi.imagePreviewUi || {};
const globalSettingsUi = sftpUi.globalSettingsUi || {};
const downloadNoticeUi = sftpUi.downloadNoticeUi || {};
- const jobUiFailed = !jobUi.found || !jobUi.singleGlobalEntry || !jobUi.noPaneTaskRegions || !jobUi.failedStatusVisible || !jobUi.totalProgressVisible || !jobUi.totalProgressIndeterminate || !jobUi.totalProgressHidesWhenIdle || !jobUi.floatingVisibleBelowHeader || !jobUi.floatingActions || !jobUi.floatingResumeAction || !jobUi.floatingProgress || !jobUi.floatingOpensTaskCenter || !jobUi.floatingCloseHidesCurrent || !jobUi.floatingNewTaskReopens || !jobUi.floatingMutePersists || !jobUi.floatingSettingRestores || !jobUi.drawerOpened || !jobUi.drawerDefaultCompact || !jobUi.currentOnly || !jobUi.currentActions || !jobUi.failedOnly || !jobUi.failedActions || !jobUi.failedClearAvailable || !jobUi.currentProgress || !jobUi.drawerResizable || !jobUi.drawerResizeAdaptive || !jobUi.drawerResizePersists || !jobUi.drawerResizeReset || !jobUi.deleteDuplicateBlocked || !jobUi.deleteKeepsDrawerOpen || !jobUi.taskLogInitialOpen || !jobUi.taskLogInitialBottom || !jobUi.taskLogRefreshKeepsOpen || !jobUi.taskLogRefreshShowsLatest || !jobUi.taskLogRefreshFollowsBottom || !jobUi.drawerFitsViewport || !jobUi.historyOnly || !jobUi.historyCounts || !jobUi.historyActions || !jobUi.generatedTaskUi?.found || !jobUi.generatedTaskUi?.savedPath || !jobUi.generatedTaskUi?.openFile || !jobUi.generatedTaskUi?.openDirectory || !jobUi.generatedTaskUi?.deleteFile || jobUi.generatedTaskUi?.buttonCount !== 3 || !jobUi.generatedTaskUi?.noDuplicateTaskDelete || !jobUi.outsideClickCloses || !jobUi.escapeCloses || !jobUi.runningStatusVisible || !jobUi.nativeDragTaskStopHidden || !jobUi.itemProgress || !jobUi.staleJobResponseIgnored || !jobUi.toastIconsAligned || !jobUi.toastOrderPreserved || !jobUi.toastStackedDown || !jobUi.toastAvoidsFloatingTask || !jobUi.toastExitAnimated || !jobUi.toastReflowAnimated || !jobUi.toastMovedUp;
+ const jobUiFailed = !jobUi.found || !jobUi.singleGlobalEntry || !jobUi.noPaneTaskRegions || !jobUi.failedStatusVisible || !jobUi.totalProgressVisible || !jobUi.totalProgressIndeterminate || !jobUi.totalProgressHidesWhenIdle || !jobUi.floatingVisibleBelowHeader || !jobUi.floatingActions || !jobUi.floatingResumeAction || !jobUi.floatingProgress || !jobUi.floatingOpensTaskCenter || !jobUi.floatingCloseHidesCurrent || !jobUi.floatingNewTaskReopens || !jobUi.floatingMutePersists || !jobUi.floatingSettingRestores || !jobUi.drawerOpened || !jobUi.taskCenterAboveNotifications || !jobUi.drawerDefaultCompact || !jobUi.currentOnly || !jobUi.currentActions || !jobUi.currentQueueActions || !jobUi.queueBatchControls || !jobUi.failedOnly || !jobUi.failedActions || !jobUi.failedClearAvailable || !jobUi.currentProgress || !jobUi.drawerResizable || !jobUi.drawerResizeAdaptive || !jobUi.drawerResizePersists || !jobUi.drawerResizeReset || !jobUi.drawerOversizeRecovers || !jobUi.drawerHeaderReset || !jobUi.drawerResizeClamps || !jobUi.deleteDuplicateBlocked || !jobUi.deleteKeepsDrawerOpen || !jobUi.taskLogInitialOpen || !jobUi.taskLogInitialBottom || !jobUi.taskLogRefreshKeepsOpen || !jobUi.taskLogRefreshShowsLatest || !jobUi.taskLogRefreshFollowsBottom || !jobUi.drawerFitsViewport || !jobUi.historyOnly || !jobUi.historyCounts || !jobUi.historyActions || !jobUi.generatedTaskUi?.found || !jobUi.generatedTaskUi?.savedPath || !jobUi.generatedTaskUi?.openFile || !jobUi.generatedTaskUi?.openDirectory || !jobUi.generatedTaskUi?.deleteFile || jobUi.generatedTaskUi?.buttonCount !== 3 || !jobUi.generatedTaskUi?.noDuplicateTaskDelete || !jobUi.outsideClickCloses || !jobUi.escapeCloses || !jobUi.runningStatusVisible || !jobUi.nativeDragTaskStopHidden || !jobUi.itemProgress || !jobUi.staleJobResponseIgnored || !jobUi.toastIconsAligned || !jobUi.toastOrderPreserved || !jobUi.toastStackedDown || !jobUi.toastAvoidsFloatingTask || !jobUi.toastExitAnimated || !jobUi.toastReflowAnimated || !jobUi.toastMovedUp;
const textEncodingUiFailed = !textEncodingUi.opened || !textEncodingUi.initialCentered || !textEncodingUi.aceLoaded || textEncodingUi.selected !== 'gbk' || !textEncodingUi.manualLanguage || !textEncodingUi.nonJsonFormattingHidden || !textEncodingUi.nonUtf8SaveAllowed || !textEncodingUi.nonUtf8SaveSubmitted || !textEncodingUi.remoteSaveKeepsOpen || !textEncodingUi.remoteEditorClosesExplicitly || !textEncodingUi.utf8LimitEnforced || !textEncodingUi.utf8BomIncludesPrefix || !textEncodingUi.normalizationOnlyLimitEnforced || !textEncodingUi.lineEndingLabelsLocalized || !textEncodingUi.lightPaged || !textEncodingUi.lightNextPage || !textEncodingUi.jsonFormatting || !textEncodingUi.jsonHiddenAfterLanguageChange || !textEncodingUi.json5FormattingHidden || !textEncodingUi.wordWrap || !textEncodingUi.persistDefault || !textEncodingUi.backup || !textEncodingUi.shellFormat || !textEncodingUi.shellNonLfWarningPrepared || !['lf','crlf','cr'].every(value=>textEncodingUi.lineEndings?.includes(value)) || !['utf8','utf8bom','gb18030','gbk','big5','shift_jis','euc-kr','latin1'].every(value=>textEncodingUi.options?.includes(value)) || !['auto','json','yaml','xml','sh','batchfile','powershell','javascript','java','c_cpp','sql','markdown'].every(value=>textEncodingUi.languageOptions?.includes(value));
const imagePreviewUiFailed = !imagePreviewUi.svgOpened || !imagePreviewUi.svgSanitized || !imagePreviewUi.svgEmbeddedStyles || !imagePreviewUi.svgOuterWindowStable || !imagePreviewUi.svgFitsCanvas || !imagePreviewUi.svgZoomButtons || !imagePreviewUi.svgCtrlWheel || !imagePreviewUi.svgSearchFocused || !imagePreviewUi.svgSearchLocated || !imagePreviewUi.svgSearchKeepsContext || !imagePreviewUi.svgSearchClearFits || !imagePreviewUi.svgColorModeNotClipped || !imagePreviewUi.svgInvertPreview || !imagePreviewUi.svgColorModePreservesView || !imagePreviewUi.svgPdfInvert || !imagePreviewUi.svgPdfUnicode || !imagePreviewUi.svgPdfLandscape || !imagePreviewUi.svgPreserveAspectRatio || !imagePreviewUi.svgModeControl || !imagePreviewUi.svgRemoteUsesEncodingAwareRead || !imagePreviewUi.svgRemoteEncodingPreserved || !imagePreviewUi.svgSplitEditor || !imagePreviewUi.svgSplitRefresh || !imagePreviewUi.svgLongLineSearch || !imagePreviewUi.svgSplitAutoFocus || !imagePreviewUi.svgSplitClickToEditor || !imagePreviewUi.svgSplitOneLineSourceFocus || !imagePreviewUi.svgSplitUseIdClickToEditor || !imagePreviewUi.svgSplitUseReferenceClickToEditor || !imagePreviewUi.svgSplitMarkerVisibleWithoutAutoFocus || !imagePreviewUi.svgEditorControlsWrapFromFirstColumn || !imagePreviewUi.svgSplitManualZoomDisablesAutoFocus || !imagePreviewUi.svgModeRoundTrip || !imagePreviewUi.svgInlineModeKeepsContentWithoutRemoteRead || !imagePreviewUi.svgModePreservesUnsaved || !imagePreviewUi.notificationAboveFullscreen || !imagePreviewUi.rasterOpened || !imagePreviewUi.rasterZoom;
const nativeDragUiFailed = !nativeDragUi.found || !nativeDragUi.webExternalDragBlocked || !nativeDragUi.linuxFallbackNoticeOnce || !nativeDragUi.linuxFallbackUsesCompatibilityMode || !nativeDragUi.streamingPreparesOnPointerDown || !nativeDragUi.streamingThresholdActivatesOnce || !nativeDragUi.streamingCaptureCancelSurvives || !nativeDragUi.pointerUpCancelsPending || !nativeDragUi.streamingSkipsStage || !nativeDragUi.streamingNativeBlocksParallelBrowserDrag || !nativeDragUi.nativeIdleHintStable || !nativeDragUi.nativeOutsideHintStaysStable || !nativeDragUi.nativeMotionTargetsSftp || !nativeDragUi.nativeTransientMissKeepsTarget || !nativeDragUi.nativeFinalTransientMissKeepsTarget || !nativeDragUi.nativeReleasedClearsStaleTarget || !nativeDragUi.nativeResultCopiesOnce || !nativeDragUi.firstDragOnlyStages || !nativeDragUi.firstDragReset || !nativeDragUi.cacheReused || !nativeDragUi.cachedUnarmedStaysInternal || !nativeDragUi.sameWindowDropDoesNotArm || !nativeDragUi.armedDragStartsSynchronously || !nativeDragUi.failureRearmed || !nativeDragUi.successClearsState || !nativeDragUi.finderRenameNoticeShown;
@@ -12292,7 +12425,7 @@ app.whenReady().then(async () => {
columnLayoutFalse:Object.entries(sftpUi.columnLayoutUi || {}).filter(([, value]) => value === false).map(([name]) => name),
connectionSessionFalse:Object.entries(connectionSessionUi).filter(([, value]) => value === false).map(([name]) => name),
jobUiFalse:Object.entries(jobUi).filter(([, value]) => value === false).map(([name]) => name),
- jobUiDiagnostics:{historyActionDiagnostics:jobUi.historyActionDiagnostics,stackedToastTitles:jobUi.stackedToastTitles},
+ jobUiDiagnostics:{historyActionDiagnostics:jobUi.historyActionDiagnostics,queueBatchDiagnostics:jobUi.queueBatchDiagnostics,stackedToastTitles:jobUi.stackedToastTitles},
textEncodingUiFalse:Object.entries(textEncodingUi).filter(([, value]) => value === false).map(([name]) => name),
reusedWithoutDirectoryReload:sftpUi.directoryActionsUi?.reusedWithoutDirectoryReload,
directoryActionsFalse:Object.entries(sftpUi.directoryActionsUi || {}).filter(([, value]) => value === false).map(([name]) => name),
diff --git a/scripts/workspace-docking-check.js b/scripts/workspace-docking-check.js
index 7c545cc..d945f7c 100644
--- a/scripts/workspace-docking-check.js
+++ b/scripts/workspace-docking-check.js
@@ -115,6 +115,8 @@ function loadDockingModel() {
workspaceTabByKey,
workspaceHasTabKey,
serializeWorkspaceLayout,
+ workspaceGroupPersistableTab,
+ restoreWorkspaceGroupTab,
restoreWorkspaceLayoutNode,
workspaceDropZoneAtPoint,
applyWorkspaceTabDrop,
@@ -128,6 +130,8 @@ function loadDockingModel() {
serializeWorkspaceGroupsForPreset,
applyWorkspaceGroupPreset,
restoreWorkspaceGroups,
+ saveTabsState,
+ restoreTabsState,
switchWorkspaceGroup,
beginWorkspaceGroupSelection,
cancelWorkspaceGroupSelection,
@@ -162,6 +166,7 @@ function loadDockingModel() {
function loadLegacyWorkspaceModel() {
const noop = () => {};
+ const storage = new Map();
const sandbox = {
console,
Map,
@@ -169,18 +174,23 @@ function loadLegacyWorkspaceModel() {
Date,
Math,
JSON,
+ storage,
tabs:[],
activeTabKey:"",
activeView:"welcome",
responsiveLayoutMobile:false,
+ runtimeSettings:{saved:{restore_workspace_tabs:true}},
terminalSessions:new Map(),
sftpDisconnectedTabs:new Set(),
sftpViewStates:new Map(),
isMobileLayout:() => false,
requestAnimationFrame:callback => { callback(); return 1; },
cancelAnimationFrame:noop,
- localStorage:{getItem:() => null, setItem:noop},
- window:{addEventListener:noop, removeEventListener:noop},
+ localStorage:{
+ getItem:key => storage.get(key) || null,
+ setItem:(key, value) => storage.set(key, String(value))
+ },
+ window:{restoringTabs:false, addEventListener:noop, removeEventListener:noop},
document:{body:{classList:{add:noop, remove:noop, toggle:noop}}}
};
sandbox.globalThis = sandbox;
@@ -197,8 +207,13 @@ function loadLegacyWorkspaceModel() {
addTab,
activateTab,
closeTabsByKey,
+ persistableTabs,
+ saveTabsState,
+ restoreTabsState,
+ setTabs:value => { tabs = value; },
getTabs:() => tabs,
- getActiveTabKey:() => activeTabKey
+ getActiveTabKey:() => activeTabKey,
+ storage
};`;
vm.runInNewContext(source, sandbox, {filename, timeout:5000});
@@ -444,6 +459,52 @@ function runWorkspaceDockingChecks({silent=false}={}) {
assert.equal(api.getWorkspaceGroups()[1].tabs[0].key, "settings");
});
+ check("restored workspace tabs discard stale connection colors and do not persist runtime status", () => {
+ const restoredModel = loadDockingModel();
+ const restoredTabs = [
+ {key:"terminal-restored", kind:"terminal", connectionStatus:"connected"},
+ {key:"sftp-restored", kind:"sftp", connectionStatus:"connecting"},
+ {key:"remote-terminal-restored", kind:"remote-terminal", connectionStatus:"connected"},
+ {key:"vnc-restored", kind:"remote-desktop", protocol:"vnc", connectionStatus:"connected"},
+ {key:"rdp-restored", kind:"remote-desktop", protocol:"rdp", connectionStatus:"connected"}
+ ];
+ restoredModel.storage.set("workspaceTabs", JSON.stringify({
+ activeWorkspaceGroupId:"workspace-main",
+ workspaceGroups:[{
+ id:"workspace-main",
+ name:"",
+ tabs:restoredTabs,
+ layout:pane("pane-restored", restoredTabs.map(tab => tab.key)),
+ activeTabKey:"terminal-restored",
+ focusedPaneId:"pane-restored"
+ }]
+ }));
+
+ assert.equal(restoredModel.api.restoreTabsState(), true);
+ const byKey = new Map(restoredModel.api.getTabs().map(tab => [tab.key, tab]));
+ for (const key of ["terminal-restored", "sftp-restored", "remote-terminal-restored", "vnc-restored"]) {
+ assert.equal(byKey.get(key).connectionStatus, "disconnected", `${key} must restart disconnected`);
+ }
+ assert.equal(Object.hasOwn(byKey.get("rdp-restored"), "connectionStatus"), false);
+ const saved = JSON.parse(restoredModel.storage.get("workspaceTabs"));
+ assert.equal(saved.workspaceGroups[0].tabs.every(tab => !Object.hasOwn(tab, "connectionStatus")), true);
+ });
+
+ check("legacy workspace restore also resets and omits connection status", () => {
+ const legacy = loadLegacyWorkspaceModel();
+ legacy.storage.set("workspaceTabs", JSON.stringify({
+ activeTabKey:"terminal-legacy",
+ tabs:[
+ {key:"terminal-legacy", kind:"terminal", connectionStatus:"connected"},
+ {key:"sftp-legacy", kind:"sftp", connectionStatus:"connecting"}
+ ]
+ }));
+ assert.equal(legacy.restoreTabsState(), true);
+ assert.equal(legacy.getTabs().every(tab => tab.connectionStatus === "disconnected"), true);
+ const saved = JSON.parse(legacy.storage.get("workspaceTabs"));
+ assert.equal(saved.tabs.every(tab => !Object.hasOwn(tab, "connectionStatus")), true);
+ });
+
check("background sessions can resolve and update tabs in inactive workspace groups", () => {
const active = {id:"workspace-main", name:"Main", tabs:[{key:"active", kind:"terminal"}], layout:pane("pane-active", ["active"]), activeTabKey:"active", focusedPaneId:"pane-active"};
const hidden = {id:"workspace-group-3", name:"Hidden", tabs:[{key:"hidden", kind:"sftp", connectionStatus:"connecting"}], layout:pane("pane-hidden", ["hidden"]), activeTabKey:"hidden", focusedPaneId:"pane-hidden"};
diff --git a/src/routes/sftp-transfer-routes.ts b/src/routes/sftp-transfer-routes.ts
index 2b1a973..80b6040 100644
--- a/src/routes/sftp-transfer-routes.ts
+++ b/src/routes/sftp-transfer-routes.ts
@@ -3,6 +3,8 @@ import { IncomingMessage, ServerResponse } from "node:http";
import { URL } from "node:url";
import { publicErrorBody } from "../public-error";
+const SFTP_SEPARATE_DOWNLOAD_LIMIT = 200;
+
interface SftpTransferRouteDependencies {
authorizeConnectionId(request: IncomingMessage, value: string): number;
clearRemoteRecycleItems(connectionId: number): Promise;
@@ -169,6 +171,14 @@ export async function handleSftpTransferRoutes(
if (method === "POST" && parts[4] === "download-batch") {
const data = await dependencies.readJson(request);
const paths = Array.isArray(data.paths) ? data.paths : [];
+ if (data.mode === "separate" && paths.length > SFTP_SEPARATE_DOWNLOAD_LIMIT) {
+ dependencies.sendJson(response, publicErrorBody(
+ "SFTP_SEPARATE_DOWNLOAD_TOO_MANY",
+ `一次最多分别下载 ${SFTP_SEPARATE_DOWNLOAD_LIMIT} 个文件或目录`,
+ {max:SFTP_SEPARATE_DOWNLOAD_LIMIT}
+ ), 400);
+ return true;
+ }
const saved = dependencies.readRuntimeSettings(dependencies.runtimeSettingsFile);
const desktop = Boolean(dependencies.isDesktopRequest(request) && desktopIntegration?.getDownloadDirectory);
const defaultDirectory = desktop ? await Promise.resolve(desktopIntegration.getDownloadDirectory()) : "";
@@ -178,10 +188,10 @@ export async function handleSftpTransferRoutes(
dependencies.sendJson(response, publicErrorBody("SFTP_SEPARATE_DOWNLOAD_DESKTOP_ONLY", "分别下载文件和目录仅支持本机桌面版;当前设备请使用打包下载"), 400);
return true;
}
- dependencies.sendJson(response, dependencies.startLocalDeliveryJob(connectionId, paths, targetDirectory, "rename", {
- label:"批量下载到本机",
+ const jobs = paths.map((remotePath: string) => dependencies.startLocalDeliveryJob(connectionId, [remotePath], targetDirectory, "rename", {
deliveryMode:"download-directory"
- }), 202);
+ }));
+ dependencies.sendJson(response, {jobs, count:jobs.length, status:"pending"}, 202);
return true;
}
dependencies.sendJson(response, dependencies.startArchiveDownloadJob(connectionId, paths, {
diff --git a/src/sftp-download-jobs.ts b/src/sftp-download-jobs.ts
index dbd7b57..f9c30bc 100644
--- a/src/sftp-download-jobs.ts
+++ b/src/sftp-download-jobs.ts
@@ -1,6 +1,8 @@
const crypto = require("node:crypto");
const fs = require("node:fs");
+const fsp = fs.promises;
const path = require("node:path");
+const { pipeline } = require("node:stream/promises");
const { remotePathOperand, shellQuote, spawnRemote } = require("./sftp-job-paths");
const { clearSftpJobIssue, setSftpJobIssue } = require("./sftp-job-issues");
const { archiveTarCreateOptions, normalizeArchiveFilenameEncoding, resolveArchiveFilenameEncoding } = require("./sftp-operation-commands");
@@ -12,6 +14,47 @@ function codedSftpJobError(message: string, code: string, params: any = {}) {
return error;
}
+function downloadAbortError() {
+ return Object.assign(new Error("SFTP download save aborted"), {name:"AbortError"});
+}
+
+function throwIfDownloadAborted(signal: any) {
+ if (signal?.aborted) throw downloadAbortError();
+}
+
+async function moveDownloadedFile(source: string, target: string, signal: any = null) {
+ throwIfDownloadAborted(signal);
+ try {
+ await fsp.rename(source, target);
+ if (signal?.aborted) {
+ try { await fsp.unlink(target); } catch {}
+ throw downloadAbortError();
+ }
+ return;
+ } catch (error: any) {
+ if (error?.code !== "EXDEV") throw error;
+ }
+
+ let targetOpened = false;
+ const input = fs.createReadStream(source);
+ const output = fs.createWriteStream(target, {flags:"wx"});
+ output.once("open", () => { targetOpened = true; });
+ try {
+ await pipeline(input, output, {signal});
+ throwIfDownloadAborted(signal);
+ await fsp.unlink(source);
+ if (signal?.aborted) {
+ try { await fsp.unlink(target); } catch {}
+ throw downloadAbortError();
+ }
+ } catch (error) {
+ if (targetOpened) {
+ try { await fsp.unlink(target); } catch {}
+ }
+ throw error;
+ }
+}
+
function createSftpDownloadJobs(dependencies: any) {
const {
deliverSftpPaths,
@@ -86,25 +129,23 @@ function createSftpDownloadJobs(dependencies: any) {
return target;
}
- function autoSaveDownloadedFile(job: any) {
+ async function autoSaveDownloadedFile(job: any, signal: any = null) {
const directory = String(job.auto_save_directory || "").trim();
if (!directory || !job.temp_path || !fs.existsSync(job.temp_path)) return;
try {
- fs.mkdirSync(directory, { recursive:true });
+ throwIfDownloadAborted(signal);
+ await fsp.mkdir(directory, { recursive:true });
+ throwIfDownloadAborted(signal);
const target = availableLocalPath(directory, job.download_name || path.posix.basename(job.remote_path || "download"));
- try {
- fs.renameSync(job.temp_path, target);
- } catch (error: any) {
- if (error?.code !== "EXDEV") throw error;
- fs.copyFileSync(job.temp_path, target, fs.constants.COPYFILE_EXCL);
- fs.unlinkSync(job.temp_path);
- }
+ await moveDownloadedFile(job.temp_path, target, signal);
+ throwIfDownloadAborted(signal);
job.saved_path = target;
job.delivery_status = "saved";
job.delivered_at = Date.now();
job.temp_path = "";
clearSftpJobIssue(job, "delivery_error");
} catch (error: any) {
+ if (error?.name === "AbortError") throw error;
job.delivery_status = "failed";
setSftpJobIssue(job, "delivery_error", error.message || String(error));
}
@@ -134,6 +175,88 @@ function createSftpDownloadJobs(dependencies: any) {
return job?.saved_path ? `已保存到 ${job.saved_path}` : `${job.connection_name} · ${job.label}`;
}
+ function persistAndNotifyDownload(job: any, status: string) {
+ persistJobs(status !== "paused");
+ if ((status === "done" || status === "failed") && !job.silent_notifications) {
+ notifyEvent({
+ type:"sftp",
+ level:status === "done" ? "success" : "error",
+ title:job.archive_download
+ ? (status === "done" ? "SFTP 打包下载已完成" : "SFTP 打包下载失败")
+ : (status === "done" ? "SFTP 下载已完成" : "SFTP 下载失败"),
+ message:`${status === "done" ? downloadCompletionMessage(job) : `${job.connection_name} · ${job.label}`}${job.warning ? `\n${job.warning}` : ""}${job.delivery_error ? `\n自动保存失败:${job.delivery_error}` : ""}${job.error ? `\n${job.error}` : ""}`,
+ action:status === "done" ? downloadCompletionAction(job) : {view:"sftp", connection_id:job.connection_id, sftp_job_id:job.id}
+ }, {cooldown_ms:0});
+ }
+ }
+
+ function finalizeDownloadTransfer(job: any, status: string, error = "", errorCode = "", errorParams: any = {}) {
+ if (job.status === "cancelled" || job.phase === "cancelling") return false;
+ if (status === "done") {
+ if (job.temp_path && fs.existsSync(job.temp_path)) job.transferred = fs.statSync(job.temp_path).size;
+ else if (job.size_known) job.transferred = Math.max(Number(job.transferred || 0), Number(job.size || 0));
+ job.progress = 100;
+ }
+ job.status = status;
+ if (status !== "paused") {
+ job.phase = "";
+ job.current = status === "done" ? (job.archive_download ? "打包下载已完成" : "下载已完成") : "";
+ job.can_pause = false;
+ job.can_cancel = false;
+ }
+ if (error || status === "done") setSftpJobIssue(job, "error", error, errorCode, errorParams);
+ if (status !== "paused") job.finished_at = Date.now();
+ if (status !== "paused") finishTransferMetrics(job);
+ if (status === "done" && job.archive_download) cleanupRemoteArchiveArtifact(job);
+ releaseTransferSlot(job);
+ persistAndNotifyDownload(job, status);
+ return true;
+ }
+
+ function finishCancelledDesktopSave(job: any) {
+ try { if (job.temp_path) fs.unlinkSync(job.temp_path); } catch {}
+ if (job.archive_download) cleanupRemoteArchiveArtifact(job);
+ job.status = "cancelled";
+ job.phase = "";
+ job.current = "";
+ job.can_pause = false;
+ job.can_cancel = false;
+ job.delivery_status = "cancelled";
+ job.finished_at = Date.now();
+ finishTransferMetrics(job);
+ releaseTransferSlot(job);
+ persistJobs(true);
+ }
+
+ function autoSaveAndFinishDownload(job: any) {
+ if (job.download_finalizing) return;
+ job.download_finalizing = true;
+ const abortController = new AbortController();
+ job.abortController = abortController;
+ job.status = "running";
+ job.phase = "system-saving";
+ job.current = "正在保存到本机";
+ job.can_pause = false;
+ job.can_cancel = true;
+ job.delivery_status = "saving";
+ job.finished_at = null;
+ persistJobs(true);
+ void autoSaveDownloadedFile(job, abortController.signal).catch((error: any) => {
+ if (error?.name !== "AbortError") {
+ job.delivery_status = "failed";
+ setSftpJobIssue(job, "delivery_error", error?.message || String(error));
+ }
+ }).finally(() => {
+ job.abortController = null;
+ job.download_finalizing = false;
+ if (abortController.signal.aborted || job.phase === "cancelling" || job.status === "cancelled") {
+ finishCancelledDesktopSave(job);
+ return;
+ }
+ finalizeDownloadTransfer(job, "done");
+ });
+ }
+
function startLocalDeliveryJob(connectionId: number, remotePaths: unknown, targetDirectory: string, conflictMode = "rename", options: any = {}) {
const connection = getSftpConnection(connectionId);
const paths = Array.isArray(remotePaths) ? remotePaths.map(item => String(item || "")).filter(Boolean) : [];
@@ -156,7 +279,7 @@ function createSftpDownloadJobs(dependencies: any) {
status:"pending",
phase:"preparing",
current:"正在准备下载",
- can_cancel:false,
+ can_cancel:true,
can_pause:false,
resume_supported:false,
stdout:"",
@@ -180,11 +303,15 @@ function createSftpDownloadJobs(dependencies: any) {
queueTransferJob("download", job, async () => {
const current = jobs.get(id);
if (!current) return;
+ const abortController = new AbortController();
+ current.abortController = abortController;
current.phase = "local-saving";
current.current = "正在下载到本机";
+ current.can_cancel = true;
persistJobs();
try {
const result = await deliverSftpPaths(connectionId, paths, current.target_directory, current.conflict_mode, {
+ signal:abortController.signal,
onFile: ({size}: any) => {
if (current.status !== "running") return;
const bytes = Math.max(0, Number(size || 0));
@@ -200,6 +327,13 @@ function createSftpDownloadJobs(dependencies: any) {
recordTransferred(current, Math.max(0, Number(bytes || 0)));
persistJobs();
},
+ onCommit: () => {
+ if (current.status !== "running") return;
+ current.phase = "system-saving";
+ current.current = "正在保存到本机";
+ current.can_cancel = false;
+ persistJobs(true);
+ },
onItem: () => {
if (current.status !== "running") return;
current.item_transferred = Math.min(current.item_count, Number(current.item_transferred || 0) + 1);
@@ -207,6 +341,7 @@ function createSftpDownloadJobs(dependencies: any) {
persistJobs();
}
});
+ if (current.status === "cancelled") return;
current.status = "done";
current.phase = "";
current.current = "已保存到本机";
@@ -237,6 +372,7 @@ function createSftpDownloadJobs(dependencies: any) {
}, {cooldown_ms:0});
}
} catch (error: any) {
+ if (current.status === "cancelled" || error?.name === "AbortError") return;
current.status = "failed";
current.phase = "";
current.current = "";
@@ -256,6 +392,8 @@ function createSftpDownloadJobs(dependencies: any) {
action:{view:"sftp", connection_id:current.connection_id, sftp_job_id:current.id}
}, {cooldown_ms:0});
}
+ } finally {
+ current.abortController = null;
}
}, {phase:"local-saving", current:"正在下载到本机"});
@@ -373,33 +511,22 @@ function createSftpDownloadJobs(dependencies: any) {
let outputEnding = false;
let closeCode: number | null = null;
let closeSignal: string | null = null;
+ let finishStarted = false;
const finish = (status: string, error = "", errorCode = "", errorParams: any = {}) => {
if (ignoreStoppedTransferFinish(job, status)) return;
+ if (finishStarted) return;
if (job.finished_at && status !== "paused") return;
+ finishStarted = true;
try { out.destroy(); } catch {}
if (status !== "paused") try { child.kill("SIGTERM"); } catch {}
if (status === "done") {
job.transferred = (job.size || fs.existsSync(job.temp_path)) ? fs.statSync(job.temp_path).size : job.transferred;
job.progress = 100;
}
- job.status = status;
- if (error || status === "done") setSftpJobIssue(job, "error", error, errorCode, errorParams);
- if (status !== "paused") job.finished_at = Date.now();
- if (status !== "paused") finishTransferMetrics(job);
- if (status === "done" && job.delivery_mode === "desktop") autoSaveDownloadedFile(job);
- if (status === "done" && job.archive_download) cleanupRemoteArchiveArtifact(job);
- releaseTransferSlot(job);
- persistJobs(status !== "paused");
- if ((status === "done" || status === "failed") && !job.silent_notifications) {
- notifyEvent({
- type:"sftp",
- level:status === "done" ? "success" : "error",
- title:job.archive_download
- ? (status === "done" ? "SFTP 打包下载已完成" : "SFTP 打包下载失败")
- : (status === "done" ? "SFTP 下载已完成" : "SFTP 下载失败"),
- message:`${status === "done" ? downloadCompletionMessage(job) : `${job.connection_name} · ${job.label}`}${job.warning ? `\n${job.warning}` : ""}${job.delivery_error ? `\n自动保存失败:${job.delivery_error}` : ""}${job.error ? `\n${job.error}` : ""}`,
- action:status === "done" ? downloadCompletionAction(job) : {view:"sftp", connection_id:job.connection_id, sftp_job_id:job.id}
- }, {cooldown_ms:0});
+ if (status === "done" && job.delivery_mode === "desktop") {
+ autoSaveAndFinishDownload(job);
+ } else {
+ finalizeDownloadTransfer(job, status, error, errorCode, errorParams);
}
};
const completeAfterStreams = () => {
@@ -498,6 +625,7 @@ function createSftpDownloadJobs(dependencies: any) {
function finishDownloadJob(id: string, complete: boolean) {
const job = jobs.get(id);
if (!job || job.status === "paused" || job.status === "cancelled") return;
+ if (job.download_finalizing) return;
try { job.out?.destroy(); } catch {}
try { job.child?.kill("SIGTERM"); } catch {}
if (complete) {
@@ -507,21 +635,8 @@ function createSftpDownloadJobs(dependencies: any) {
}
job.transferred = fs.existsSync(job.temp_path) ? fs.statSync(job.temp_path).size : job.transferred;
job.progress = 100;
- job.status = "done";
- job.phase = "";
- job.current = job.archive_download ? "打包下载已完成" : "下载已完成";
- clearSftpJobIssue(job, "error");
- job.can_pause = false;
- job.can_cancel = false;
- job.finished_at = Date.now();
- finishTransferMetrics(job);
- if (job.delivery_mode === "desktop") autoSaveDownloadedFile(job);
- if (job.archive_download) cleanupRemoteArchiveArtifact(job);
- releaseTransferSlot(job);
- persistJobs(true);
- if (!job.silent_notifications) {
- notifyEvent({type:"sftp", level:"success", title:job.archive_download ? "SFTP 打包下载已完成" : "SFTP 下载已完成", message:`${downloadCompletionMessage(job)}${job.delivery_error ? `\n自动保存失败:${job.delivery_error}` : ""}`, action:downloadCompletionAction(job)}, {cooldown_ms:0});
- }
+ if (job.delivery_mode === "desktop") autoSaveAndFinishDownload(job);
+ else finalizeDownloadTransfer(job, "done");
}
}
diff --git a/src/sftp-jobs.ts b/src/sftp-jobs.ts
index 5b8acc1..98441c6 100644
--- a/src/sftp-jobs.ts
+++ b/src/sftp-jobs.ts
@@ -28,6 +28,7 @@ const ACTIVE_STATUSES = new Set(["running", "pending", "paused"]);
const DELETE_COMMAND_BATCH_BYTES = 24 * 1024;
let historyCache: any[] | null = null;
let persistTimer: any = null;
+let persistRequestedAt = 0;
let downloadCacheService: any = null;
function uploadJobOwnsLocalPath(job) {
@@ -58,6 +59,8 @@ function serializableJob(source) {
transfer_slot_kind,
transfer_start_phase,
transfer_start_current,
+ abortController,
+ download_finalizing,
native_drag_token,
native_drag_ranges,
...job
@@ -231,12 +234,23 @@ function readHistory(): any[] {
function persistJobs(immediate = false) {
if (!immediate) {
- clearTimeout(persistTimer);
- persistTimer = setTimeout(() => persistJobs(true), 400);
+ persistRequestedAt = Date.now();
+ if (persistTimer) return;
+ const flush = () => {
+ const remaining = Math.max(0, 400 - (Date.now() - persistRequestedAt));
+ if (remaining > 0) {
+ persistTimer = setTimeout(flush, remaining);
+ return;
+ }
+ persistTimer = null;
+ persistJobs(true);
+ };
+ persistTimer = setTimeout(flush, 400);
return;
}
clearTimeout(persistTimer);
persistTimer = null;
+ persistRequestedAt = 0;
const active: any[] = [...jobs.values()].map(serializableJob);
const byId = new Map(readHistory().map((job: any) => [job.id, job]));
for (const job of active) byId.set(job.id, job);
@@ -699,6 +713,18 @@ function cancelSftpJob(id) {
if (job.type === "native-drag" && job.native_drag_token) {
return requestNativeSftpDragCancel(job);
}
+ if (job.type === "download" && job.download_finalizing === true) {
+ removeQueuedTransfer(job);
+ rejectTransferWaiters(job, transferCancelledError());
+ job.phase = "cancelling";
+ job.current = "正在取消保存";
+ job.can_pause = false;
+ job.can_cancel = false;
+ setSftpJobIssue(job, "error", "用户已取消", "sftp_user_cancelled");
+ try { job.abortController?.abort?.(); } catch {}
+ persistJobs(true);
+ return {ok:true, status:job.status, phase:job.phase};
+ }
const uploadPhase = job.type === "upload" ? job.phase : "";
removeQueuedTransfer(job);
rejectTransferWaiters(job, job.type === "upload" ? uploadReceiveCancelledError() : transferCancelledError());
@@ -712,6 +738,7 @@ function cancelSftpJob(id) {
try { job.out?.destroy(); } catch {}
try { job.responder?.kill?.("SIGTERM"); } catch {}
try { job.responder?.destroy?.(); } catch {}
+ try { job.abortController?.abort?.(); } catch {}
if (job.streams instanceof Set) {
for (const stream of job.streams) {
try { stream.destroy(); } catch {}
diff --git a/src/sftp-session.ts b/src/sftp-session.ts
index 5a32771..08d63ae 100644
--- a/src/sftp-session.ts
+++ b/src/sftp-session.ts
@@ -155,11 +155,39 @@ function openSftpDirectoryChannel(record, connection, signal = null) {
});
}
+function waitForSftpConnection(connectionPromise, signal = null) {
+ if (!signal) return connectionPromise;
+ if (signal.aborted) return Promise.reject(sftpAbortError());
+ return new Promise((resolve, reject) => {
+ let settled = false;
+ const cleanup = () => signal.removeEventListener?.("abort", onAbort);
+ const onAbort = () => {
+ if (settled) return;
+ settled = true;
+ cleanup();
+ reject(sftpAbortError());
+ };
+ signal.addEventListener?.("abort", onAbort, {once:true});
+ Promise.resolve(connectionPromise).then(value => {
+ if (settled) return;
+ settled = true;
+ cleanup();
+ resolve(value);
+ }, error => {
+ if (settled) return;
+ settled = true;
+ cleanup();
+ reject(error);
+ });
+ });
+}
+
async function acquireSftpDirectoryChannel(connectionId, signal = null) {
if (signal?.aborted) throw Object.assign(new Error("SFTP directory request aborted"), {name:"AbortError"});
const id = Number(connectionId);
const connection = getSftpConnection(id);
- await connectSftpSession(id);
+ await waitForSftpConnection(connectSftpSession(id), signal);
+ if (signal?.aborted) throw sftpAbortError();
const record = sessions.get(id);
if (!record?.client || record.status !== "connected") throw new Error("SFTP 会话未连接");
for (const entry of record.directoryChannels || []) {
@@ -957,7 +985,7 @@ function clearSftpDragCache(now = Date.now()) {
return sftpDragCacheInfo(now);
}
-async function openSftpChannel(connectionId) {
+async function openSftpChannel(connectionId, signal = null) {
const id = Number(connectionId);
const connection = getSftpConnection(id);
let lastError = null;
@@ -966,13 +994,13 @@ async function openSftpChannel(connectionId) {
// A channel failure must not tear down the shared SSH transport. Several
// SFTP tabs can be opening channels at the same time; reconnecting with
// force=true here would invalidate the channels owned by the other tabs.
- await connectSftpSession(id);
+ await waitForSftpConnection(connectSftpSession(id), signal);
+ if (signal?.aborted) throw sftpAbortError();
const record = sessions.get(id);
if (!record?.client || record.status !== "connected") throw new Error("SFTP 会话未连接");
- return await new Promise((resolve, reject) => {
- record.client.sftp((error, channel) => error ? reject(normalizeSshTransportError(error, connection)) : resolve(channel));
- });
+ return await openSftpDirectoryChannel(record, connection, signal);
} catch (error) {
+ if (error?.name === "AbortError") throw error;
lastError = error;
const record = sessions.get(id);
// Only reset a session that has already been marked disconnected. An
@@ -993,6 +1021,30 @@ function sftpLstat(channel, remotePath) {
return new Promise((resolve, reject) => channel.lstat(remotePath, (error, stats) => error ? reject(error) : resolve(stats)));
}
+function sftpLstatCancellable(channel, remotePath, signal = null) {
+ return new Promise((resolve, reject) => {
+ let settled = false;
+ const cleanup = () => signal?.removeEventListener?.("abort", onAbort);
+ const finish = (error = null, stats = null) => {
+ if (settled) return;
+ settled = true;
+ cleanup();
+ error ? reject(error) : resolve(stats);
+ };
+ const onAbort = () => {
+ try { channel.end(); } catch {}
+ finish(sftpAbortError());
+ };
+ if (signal?.aborted) return onAbort();
+ signal?.addEventListener?.("abort", onAbort, {once:true});
+ try {
+ channel.lstat(remotePath, (error, stats) => finish(error, stats));
+ } catch (error) {
+ finish(error);
+ }
+ });
+}
+
function sftpStat(channel, remotePath) {
return new Promise((resolve, reject) => channel.stat(remotePath, (error, stats) => error ? reject(error) : resolve(stats)));
}
@@ -1118,6 +1170,8 @@ async function readSftpDirectory(connectionId, remotePath = ".", options: any =
function sftpFastGet(channel, remotePath, localPath, options: any = {}) {
const expectedSize = Math.max(0, Number(options.size || 0));
let reported = 0;
+ let settled = false;
+ const signal = options.signal || null;
const report = (transferred) => {
const value = Math.max(0, Number(transferred || 0));
const delta = Math.max(0, value - reported);
@@ -1125,34 +1179,51 @@ function sftpFastGet(channel, remotePath, localPath, options: any = {}) {
if (delta > 0) options.onBytes?.(delta);
};
return new Promise((resolve, reject) => {
+ const cleanup = () => signal?.removeEventListener?.("abort", onAbort);
+ const finish = (error = null) => {
+ if (settled) return;
+ settled = true;
+ cleanup();
+ error ? reject(error) : resolve(undefined);
+ };
+ const onAbort = () => {
+ try { channel.end(); } catch {}
+ finish(sftpAbortError());
+ };
+ if (signal?.aborted) return onAbort();
+ signal?.addEventListener?.("abort", onAbort, {once:true});
try {
channel.fastGet(remotePath, localPath, {
step: (transferred) => report(transferred)
}, error => {
if (!error && expectedSize > reported) report(expectedSize);
- error ? reject(error) : resolve(undefined);
+ finish(error);
});
} catch (error) {
- reject(error);
+ finish(error);
}
});
}
async function downloadSftpEntry(channel, remotePath, localPath, counter, progress: any = null) {
+ if (progress?.signal?.aborted) throw sftpAbortError();
counter.value += 1;
if (counter.value > 10000) throw new Error("一次拖出最多处理 10000 个文件和目录");
- const linkStats: any = await sftpLstat(channel, remotePath);
- const stats: any = linkStats?.isSymbolicLink?.() ? await sftpStat(channel, remotePath) : linkStats;
+ const linkStats: any = await sftpLstatCancellable(channel, remotePath, progress?.signal);
+ if (progress?.signal?.aborted) throw sftpAbortError();
+ const stats: any = linkStats?.isSymbolicLink?.() ? await sftpStatCancellable(channel, remotePath, progress?.signal) : linkStats;
+ if (progress?.signal?.aborted) throw sftpAbortError();
if (!stats?.isDirectory?.()) {
fs.mkdirSync(path.dirname(localPath), {recursive:true});
const size = Math.max(0, Number(stats?.size || 0));
progress?.onFile?.({path:remotePath, size});
- await sftpFastGet(channel, remotePath, localPath, {size, onBytes:progress?.onBytes});
+ await sftpFastGet(channel, remotePath, localPath, {size, onBytes:progress?.onBytes, signal:progress?.signal});
return;
}
fs.mkdirSync(localPath, {recursive:true});
- const entries: any[] = await sftpReaddir(channel, remotePath) as any[];
+ const entries: any[] = await sftpReaddirCancellable(channel, remotePath, progress?.signal) as any[];
for (const entry of entries) {
+ if (progress?.signal?.aborted) throw sftpAbortError();
const name = String(entry?.filename || "");
if (!name || name === "." || name === "..") continue;
await downloadSftpEntry(channel, path.posix.join(remotePath, name), availableLocalEntry(localPath, name), counter, progress);
@@ -1161,6 +1232,7 @@ async function downloadSftpEntry(channel, remotePath, localPath, counter, progre
async function stageSftpPaths(connectionId, remotePaths, progress: any = null) {
const paths = normalizeSftpDeliveryPaths(remotePaths);
+ if (progress?.signal?.aborted) throw sftpAbortError();
cleanupSftpDragStaging();
fs.mkdirSync(SFTP_DRAG_ROOT, {recursive:true});
const directory = path.join(SFTP_DRAG_ROOT, crypto.randomUUID());
@@ -1170,9 +1242,10 @@ async function stageSftpPaths(connectionId, remotePaths, progress: any = null) {
let staged = false;
const files = [];
try {
- channel = await openSftpChannel(connectionId);
+ channel = await openSftpChannel(connectionId, progress?.signal);
const counter = {value:0};
for (const remotePath of paths) {
+ if (progress?.signal?.aborted) throw sftpAbortError();
const baseName = path.posix.basename(remotePath.replace(/\/+$/, "")) || "download";
const localPath = availableLocalEntry(directory, baseName);
await downloadSftpEntry(channel, remotePath, localPath, counter, progress);
@@ -1198,7 +1271,10 @@ async function deliverSftpPaths(connectionId, remotePaths, targetDirectory, conf
const staged = await stageSftpPaths(connectionId, remotePaths, progress);
const saved = [];
try {
+ if (progress?.signal?.aborted) throw sftpAbortError();
+ progress?.onCommit?.();
for (const source of staged.files) {
+ if (progress?.signal?.aborted) throw sftpAbortError();
const requestedTarget = path.join(directory, safeLocalEntryName(path.basename(source)));
if (conflict === "error" && fs.existsSync(requestedTarget)) {
const error: any = new Error(`本地目录已存在同名项目:${path.basename(requestedTarget)}`);