diff --git a/application/i18n/locales/en.ts b/application/i18n/locales/en.ts index cabc336a9..1e9a5426c 100644 --- a/application/i18n/locales/en.ts +++ b/application/i18n/locales/en.ts @@ -542,6 +542,12 @@ const en: Messages = { 'sftp.autoSync.success': 'File synced to remote: {fileName}', 'sftp.autoSync.error': 'Failed to sync file: {error}', + // SFTP Reconnecting + 'sftp.reconnecting.title': 'Reconnecting...', + 'sftp.reconnecting.desc': 'Connection lost, attempting to reconnect', + 'sftp.reconnected': 'Connection restored', + 'sftp.error.reconnectFailed': 'Failed to reconnect. Please try again.', + // Settings > SFTP Show Hidden Files 'settings.sftp.showHiddenFiles': 'Show hidden files', 'settings.sftp.showHiddenFiles.desc': 'Display files with the Windows hidden attribute in the SFTP file browser when browsing local Windows filesystem.', diff --git a/application/i18n/locales/zh-CN.ts b/application/i18n/locales/zh-CN.ts index 54bbe83b1..3461649e1 100644 --- a/application/i18n/locales/zh-CN.ts +++ b/application/i18n/locales/zh-CN.ts @@ -780,6 +780,12 @@ const zhCN: Messages = { 'sftp.autoSync.success': '文件已同步到远程:{fileName}', 'sftp.autoSync.error': '同步文件失败:{error}', + // SFTP Reconnecting + 'sftp.reconnecting.title': '正在重连...', + 'sftp.reconnecting.desc': '连接已断开,正在尝试重新连接', + 'sftp.reconnected': '连接已恢复', + 'sftp.error.reconnectFailed': '重连失败,请重试。', + // Settings > SFTP Show Hidden Files 'settings.sftp.showHiddenFiles': '显示隐藏文件', 'settings.sftp.showHiddenFiles.desc': '在浏览本地 Windows 文件系统时,显示具有 Windows 隐藏属性的文件。', diff --git a/components/SFTPModal.tsx b/components/SFTPModal.tsx index 5233963a8..74d914136 100644 --- a/components/SFTPModal.tsx +++ b/components/SFTPModal.tsx @@ -325,6 +325,12 @@ const SFTPModal: React.FC = ({ const lastSelectedIndexRef = useRef(null); const localHomeRef = useRef(null); + // Reconnect state + const [reconnecting, setReconnecting] = useState(false); + const reconnectingRef = useRef(false); + const reconnectAttemptsRef = useRef(0); + const MAX_RECONNECT_ATTEMPTS = 3; + // Rename dialog state const [showRenameDialog, setShowRenameDialog] = useState(false); const [renameTarget, setRenameTarget] = useState(null); @@ -536,6 +542,40 @@ const SFTPModal: React.FC = ({ openSftp, ]); + // Check if an error indicates a stale/lost SFTP session + const isSessionError = useCallback((err: unknown): boolean => { + if (!(err instanceof Error)) return false; + const msg = err.message.toLowerCase(); + return ( + msg.includes("session not found") || + msg.includes("sftp session") || + msg.includes("not found") || + msg.includes("closed") || + msg.includes("connection reset") || + msg.includes("eof") + ); + }, []); + + // Handle session error - triggers auto-reconnect + const handleSessionError = useCallback(() => { + if (reconnectingRef.current) return; // Prevent duplicate reconnect attempts + + if (reconnectAttemptsRef.current >= MAX_RECONNECT_ATTEMPTS) { + toast.error(t("sftp.error.reconnectFailed"), "SFTP"); + setReconnecting(false); + reconnectingRef.current = false; + return; + } + + // Clear stale session reference + sftpIdRef.current = null; + + // Set reconnecting state + reconnectingRef.current = true; + reconnectAttemptsRef.current++; + setReconnecting(true); + }, [t]); + const loadFiles = useCallback( async (path: string, options?: { force?: boolean }) => { const requestId = ++loadSeqRef.current; @@ -569,6 +609,14 @@ const SFTPModal: React.FC = ({ setSelectedFiles(new Set()); } catch (e) { if (loadSeqRef.current !== requestId) return; + + // Check if this is a session error that can trigger auto-reconnect + if (!isLocalSession && isSessionError(e) && files.length > 0) { + logger.info("[SFTP] Session lost, attempting to reconnect..."); + handleSessionError(); + return; + } + logger.error("Failed to load files", e); toast.error( e instanceof Error ? e.message : t("sftp.error.loadFailed"), @@ -581,7 +629,7 @@ const SFTPModal: React.FC = ({ } } }, - [ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t], + [ensureSftp, host.id, isLocalSession, listLocalDir, listSftp, t, isSessionError, handleSessionError, files.length], ); useLayoutEffect(() => { @@ -615,6 +663,71 @@ const SFTPModal: React.FC = ({ }; }, [closeSftpSession]); + // Auto-reconnect effect + useEffect(() => { + if (!reconnecting || !reconnectingRef.current || isLocalSession) return; + + const attemptReconnect = async () => { + // Small delay before reconnecting + await new Promise((resolve) => setTimeout(resolve, 1000)); + + if (!reconnectingRef.current) return; // May have been cancelled + + try { + // Re-establish SFTP connection + const sftpId = await openSftp({ + sessionId: `sftp-modal-${host.id}`, + hostname: credentials.hostname, + username: credentials.username || "root", + port: credentials.port || 22, + password: credentials.password, + privateKey: credentials.privateKey, + certificate: credentials.certificate, + passphrase: credentials.passphrase, + publicKey: credentials.publicKey, + keyId: credentials.keyId, + keySource: credentials.keySource, + proxy: credentials.proxy, + jumpHosts: credentials.jumpHosts, + }); + sftpIdRef.current = sftpId; + + // Refresh current directory + const list = await listSftp(sftpId, currentPath); + dirCacheRef.current.set(`${host.id}::${currentPath}`, { + files: list, + timestamp: Date.now(), + }); + setFiles(list); + setSelectedFiles(new Set()); + + // Reconnect successful + reconnectingRef.current = false; + reconnectAttemptsRef.current = 0; + setReconnecting(false); + toast.success(t("sftp.reconnected"), "SFTP"); + } catch (e) { + logger.error("[SFTP] Reconnect failed", e); + // Check if we can retry + if (reconnectAttemptsRef.current < MAX_RECONNECT_ATTEMPTS) { + // Trigger another attempt + sftpIdRef.current = null; + reconnectingRef.current = false; // Reset to allow handleSessionError to work + handleSessionError(); + } else { + // Max retries reached + reconnectingRef.current = false; + setReconnecting(false); + toast.error(t("sftp.error.reconnectFailed"), "SFTP"); + } + } finally { + setLoading(false); + } + }; + + attemptReconnect(); + }, [reconnecting, isLocalSession, host.id, credentials, openSftp, listSftp, currentPath, t, handleSessionError]); + useEffect(() => { if (open) { // Check if we need to reinitialize (either first time or initialPath changed) @@ -1703,7 +1816,7 @@ const SFTPModal: React.FC = ({ className="h-7 w-7" onClick={() => loadFiles(currentPath, { force: true })} > - + {/* Editable Breadcrumbs */} @@ -1897,6 +2010,19 @@ const SFTPModal: React.FC = ({ )} + {/* Reconnecting overlay */} + {reconnecting && ( +
+
+ +
+
{t("sftp.reconnecting.title")}
+
{t("sftp.reconnecting.desc")}
+
+
+
+ )} + {files.length === 0 && !loading && (
diff --git a/components/SftpView.tsx b/components/SftpView.tsx index 1aa800249..945b5b279 100644 --- a/components/SftpView.tsx +++ b/components/SftpView.tsx @@ -1291,7 +1291,7 @@ const SftpPaneViewInner: React.FC = ({
- ) : pane.error ? ( + ) : pane.error && !pane.reconnecting ? (
{pane.error} @@ -1341,12 +1341,25 @@ const SftpPaneViewInner: React.FC = ({
{/* Loading overlay - covers entire pane when navigating directories */} - {pane.loading && sortedDisplayFiles.length > 0 && ( + {pane.loading && sortedDisplayFiles.length > 0 && !pane.reconnecting && (
)} + {/* Reconnecting overlay - shows when SFTP connection is lost and reconnecting */} + {pane.reconnecting && ( +
+
+ +
+
{t("sftp.reconnecting.title")}
+
{t("sftp.reconnecting.desc")}
+
+
+
+ )} + {/* Dialogs */} diff --git a/electron-builder.config.cjs b/electron-builder.config.cjs index d0e0cd2d0..1f306992b 100644 --- a/electron-builder.config.cjs +++ b/electron-builder.config.cjs @@ -1,3 +1,4 @@ +/* global __dirname */ const path = require('path'); /** diff --git a/package-lock.json b/package-lock.json index a6547fd3f..83a9cb0e5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7212,6 +7212,7 @@ "integrity": "sha512-ce4Ogns4VMeisIuCSK0C62umG0lFy012jd8LMZ6w/veHUeX4fqfDrGe+HTWALAEwK6JwKP+dhPvizhArSOsFbg==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "app-builder-lib": "26.4.0", "builder-util": "26.3.4",