feat: sftp优化

This commit is contained in:
chaos-zhu
2026-05-24 16:17:23 +08:00
parent 165cbc631c
commit d3df349ab1
4 changed files with 103 additions and 48 deletions
@@ -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<List<SftpFavorite>> 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<Object?>([
_resolveInitialPathAndList(sftp, config.username, state.showHidden),
_safeLoadFavorites(loadFavorites, server.id),
]);
final listing = results[0] as _InitialListing;
final favorites = results[1] as List<SftpFavorite>;
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: <String>{},
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<SftpSessionState> _runConnect({
required ServerModel server,
required SshConnectionConfig config,
required SftpSessionState state,
required Future<List<SftpFavorite>> 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<Object?>([
_resolveInitialPathAndList(sftp, config.username, state.showHidden),
_safeLoadFavorites(loadFavorites, server.id),
]);
final listing = results[0] as _InitialListing;
final favorites = results[1] as List<SftpFavorite>;
state.connectPhase = null;
state.update(
status: SftpConnectionStatus.connected,
currentPath: listing.path,
entries: listing.entries,
favorites: favorites,
loadingDirectory: false,
lastError: null,
selectedNames: <String>{},
);
notifyListeners();
return state;
}
void activate(String hostId) {
if (_states[hostId] == null) return;
_activeHostId = hostId;
+31 -12
View File
@@ -88,7 +88,11 @@ class _SftpTabState extends ConsumerState<SftpTab> {
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<SftpTab> {
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<SftpTab> {
],
);
}
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,
),
],
),
+2
View File
@@ -152,6 +152,8 @@ const Map<String, String> 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',
+2
View File
@@ -142,6 +142,8 @@ const Map<String, String> stringsZh = {
'sftp.phase.transport': '正在连接服务器...',
'sftp.phase.channel': '正在打开 SFTP 通道...',
'sftp.phase.listing': '正在读取目录...',
'sftp.connectTimeout': '连接超时(10s)',
'sftp.connectFailed': '连接失败',
'sftp.emptyDirectory': '当前目录为空',
'sftp.upload': '上传',
'sftp.newItem': '新建',