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 @@

</div
+
@@ -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)}`);