From d3df349ab15ae1d4932fd6dfff2c16a3aefc6fb5 Mon Sep 17 00:00:00 2001 From: chaos-zhu Date: Sun, 24 May 2026 16:17:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20sftp=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/shell/sftp_session_manager.dart | 104 ++++++++++++------ mobile/lib/features/shell/sftp_tab.dart | 43 ++++++-- mobile/lib/l10n/strings_en.dart | 2 + mobile/lib/l10n/strings_zh.dart | 2 + 4 files changed, 103 insertions(+), 48 deletions(-) diff --git a/mobile/lib/features/shell/sftp_session_manager.dart b/mobile/lib/features/shell/sftp_session_manager.dart index a684e1d..e10da90 100644 --- a/mobile/lib/features/shell/sftp_session_manager.dart +++ b/mobile/lib/features/shell/sftp_session_manager.dart @@ -13,6 +13,13 @@ import 'editor/editor_language.dart'; enum SftpConnectionStatus { disconnected, connecting, connected, error } +class SftpConnectTimeoutException implements Exception { + const SftpConnectTimeoutException(); + + @override + String toString() => 'SFTP connection timed out'; +} + class SftpFileEntry { const SftpFileEntry({ required this.name, @@ -160,6 +167,7 @@ class SftpSessionManager extends ChangeNotifier { required ServerModel server, required SshConnectionConfig config, Future> Function(String hostId)? loadFavorites, + Duration timeout = const Duration(seconds: 10), }) async { final existing = _states[server.id]; if (existing?.status == SftpConnectionStatus.connected) { @@ -182,47 +190,22 @@ class SftpSessionManager extends ChangeNotifier { try { await _connections.remove(server.id)?.close(); - final transport = await _transportFactory.open(config); - final identities = config.authType == 'privateKey' - ? SSHKeyPair.fromPem(config.privateKey, config.privateKeyPassphrase) - : null; - final client = SSHClient( - transport.socket, - username: config.username, - onPasswordRequest: config.authType == 'password' - ? () => config.password - : null, - identities: identities, - ); - state.connectPhase = 'channel'; - notifyListeners(); - final sftp = await client.sftp(); - _connections[server.id] = _SftpConnection( - client: client, - sftp: sftp, - transport: transport, - ); - - state.connectPhase = 'listing'; - notifyListeners(); - final results = await Future.wait([ - _resolveInitialPathAndList(sftp, config.username, state.showHidden), - _safeLoadFavorites(loadFavorites, server.id), - ]); - final listing = results[0] as _InitialListing; - final favorites = results[1] as List; + return await _runConnect( + server: server, + config: config, + state: state, + loadFavorites: loadFavorites, + ).timeout(timeout); + } on TimeoutException { + await _connections.remove(server.id)?.close(); state.connectPhase = null; state.update( - status: SftpConnectionStatus.connected, - currentPath: listing.path, - entries: listing.entries, - favorites: favorites, + status: SftpConnectionStatus.error, loadingDirectory: false, - lastError: null, - selectedNames: {}, + lastError: 'timeout', ); notifyListeners(); - return state; + throw const SftpConnectTimeoutException(); } catch (error) { await _connections.remove(server.id)?.close(); state.connectPhase = null; @@ -236,6 +219,55 @@ class SftpSessionManager extends ChangeNotifier { } } + Future _runConnect({ + required ServerModel server, + required SshConnectionConfig config, + required SftpSessionState state, + required Future> Function(String hostId)? loadFavorites, + }) async { + final transport = await _transportFactory.open(config); + final identities = config.authType == 'privateKey' + ? SSHKeyPair.fromPem(config.privateKey, config.privateKeyPassphrase) + : null; + final client = SSHClient( + transport.socket, + username: config.username, + onPasswordRequest: config.authType == 'password' + ? () => config.password + : null, + identities: identities, + ); + state.connectPhase = 'channel'; + notifyListeners(); + final sftp = await client.sftp(); + _connections[server.id] = _SftpConnection( + client: client, + sftp: sftp, + transport: transport, + ); + + state.connectPhase = 'listing'; + notifyListeners(); + final results = await Future.wait([ + _resolveInitialPathAndList(sftp, config.username, state.showHidden), + _safeLoadFavorites(loadFavorites, server.id), + ]); + final listing = results[0] as _InitialListing; + final favorites = results[1] as List; + state.connectPhase = null; + state.update( + status: SftpConnectionStatus.connected, + currentPath: listing.path, + entries: listing.entries, + favorites: favorites, + loadingDirectory: false, + lastError: null, + selectedNames: {}, + ); + notifyListeners(); + return state; + } + void activate(String hostId) { if (_states[hostId] == null) return; _activeHostId = hostId; diff --git a/mobile/lib/features/shell/sftp_tab.dart b/mobile/lib/features/shell/sftp_tab.dart index c19d37c..84cb6d2 100644 --- a/mobile/lib/features/shell/sftp_tab.dart +++ b/mobile/lib/features/shell/sftp_tab.dart @@ -88,7 +88,11 @@ class _SftpTabState extends ConsumerState { await ref.read(authProvider.notifier).signOut(); return; } - _showSnack(error.toString()); + if (error is SftpConnectTimeoutException) { + _showSnack(l.tr('sftp.connectTimeout')); + } else { + _showSnack(error.toString()); + } } finally { if (mounted) setState(() => _connecting = false); } @@ -114,7 +118,7 @@ class _SftpTabState extends ConsumerState { final session = manager.activeSession; final showSelector = !_connecting && session != null && - session.status == SftpConnectionStatus.connected; + session.status != SftpConnectionStatus.connecting; return Column( children: [ TabHeader( @@ -176,6 +180,18 @@ class _SftpTabState extends ConsumerState { ], ); } + if (session.status == SftpConnectionStatus.error) { + return _SftpMessageList( + message: session.lastError == 'timeout' + ? l.tr('sftp.connectTimeout') + : (session.lastError ?? + l.tr('sftp.connectFailed')), + action: TextButton( + onPressed: () => _connectOrActivate(session.server), + child: Text(l.tr('common.retry')), + ), + ); + } return _SftpConnectedView( session: session, manager: manager, @@ -647,8 +663,14 @@ class _SftpHeaderSelector extends StatelessWidget { @override Widget build(BuildContext context) { + final isError = session.status == SftpConnectionStatus.error; + final dotColor = isError ? _SftpPalette.danger : _SftpPalette.success; + final screenWidth = MediaQuery.sizeOf(context).width; return ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 200), + constraints: BoxConstraints( + minWidth: screenWidth / 4, + maxWidth: screenWidth / 2, + ), child: Material( color: _SftpPalette.card, shape: RoundedRectangleBorder( @@ -663,18 +685,17 @@ class _SftpHeaderSelector extends StatelessWidget { child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10), child: Row( - mainAxisSize: MainAxisSize.min, children: [ Container( width: 6, height: 6, - decoration: const BoxDecoration( - color: _SftpPalette.success, + decoration: BoxDecoration( + color: dotColor, shape: BoxShape.circle, ), ), const SizedBox(width: 6), - Flexible( + Expanded( child: Text( session.server.displayName, maxLines: 1, @@ -2130,12 +2151,10 @@ class _SftpServerRow extends StatelessWidget { color: _SftpPalette.success, size: 20, ) - else + else if (!connected) _SftpStatusPill( - label: connected - ? l.tr('sftp.statusConnected') - : l.tr('sftp.statusDisconnected'), - connected: connected, + label: l.tr('sftp.statusDisconnected'), + connected: false, ), ], ), diff --git a/mobile/lib/l10n/strings_en.dart b/mobile/lib/l10n/strings_en.dart index 6780efc..180459c 100644 --- a/mobile/lib/l10n/strings_en.dart +++ b/mobile/lib/l10n/strings_en.dart @@ -152,6 +152,8 @@ const Map stringsEn = { 'sftp.phase.transport': 'Connecting to server...', 'sftp.phase.channel': 'Opening SFTP channel...', 'sftp.phase.listing': 'Reading directory...', + 'sftp.connectTimeout': 'Connection timed out (10s)', + 'sftp.connectFailed': 'Connection failed', 'sftp.emptyDirectory': 'This directory is empty.', 'sftp.upload': 'Upload', 'sftp.newItem': 'New', diff --git a/mobile/lib/l10n/strings_zh.dart b/mobile/lib/l10n/strings_zh.dart index 57812fd..dda3a41 100644 --- a/mobile/lib/l10n/strings_zh.dart +++ b/mobile/lib/l10n/strings_zh.dart @@ -142,6 +142,8 @@ const Map stringsZh = { 'sftp.phase.transport': '正在连接服务器...', 'sftp.phase.channel': '正在打开 SFTP 通道...', 'sftp.phase.listing': '正在读取目录...', + 'sftp.connectTimeout': '连接超时(10s)', + 'sftp.connectFailed': '连接失败', 'sftp.emptyDirectory': '当前目录为空', 'sftp.upload': '上传', 'sftp.newItem': '新建',