feat: 重构Native sftp&docker实例共享逻辑

This commit is contained in:
chaoszhu
2026-07-25 19:03:11 +08:00
parent 6dbccd615b
commit 234f3d6dcb
10 changed files with 201 additions and 30 deletions
@@ -238,6 +238,11 @@ class DockerSessionManager extends ChangeNotifier {
notifyListeners(); notifyListeners();
} }
void disconnectAll() {
_disconnectAll();
notifyListeners();
}
void _disconnect(String hostId) { void _disconnect(String hostId) {
final session = _sessions.remove(hostId); final session = _sessions.remove(hostId);
if (session == null) return; if (session == null) return;
+22 -8
View File
@@ -39,12 +39,14 @@ class DockerPanel extends ConsumerStatefulWidget {
this.initialHostId, this.initialHostId,
this.lockToHost = false, this.lockToHost = false,
this.allowDisconnect = true, this.allowDisconnect = true,
this.sessionManager,
}); });
final bool showHeader; final bool showHeader;
final String? initialHostId; final String? initialHostId;
final bool lockToHost; final bool lockToHost;
final bool allowDisconnect; final bool allowDisconnect;
final DockerSessionManager? sessionManager;
@override @override
ConsumerState<DockerPanel> createState() => _DockerPanelState(); ConsumerState<DockerPanel> createState() => _DockerPanelState();
@@ -54,13 +56,16 @@ class _DockerPanelState extends ConsumerState<DockerPanel> {
bool _connecting = false; bool _connecting = false;
String? _autoConnectAttemptedHostId; String? _autoConnectAttemptedHostId;
DockerSessionManager get _manager =>
widget.sessionManager ?? ref.read(dockerSessionManagerProvider);
Future<void> _refreshHosts() => runRefreshWithFeedback( Future<void> _refreshHosts() => runRefreshWithFeedback(
context, context,
() => ref.read(hostListProvider.notifier).refresh(throwOnError: true), () => ref.read(hostListProvider.notifier).refresh(throwOnError: true),
); );
Future<void> _openServerPicker() async { Future<void> _openServerPicker() async {
final manager = ref.read(dockerSessionManagerProvider); final manager = _manager;
final selected = await showModalBottomSheet<ServerModel>( final selected = await showModalBottomSheet<ServerModel>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
@@ -83,7 +88,7 @@ class _DockerPanelState extends ConsumerState<DockerPanel> {
Future<void> _connectOrActivate(ServerModel server) async { Future<void> _connectOrActivate(ServerModel server) async {
final l = AppLocalizations.of(context); final l = AppLocalizations.of(context);
final manager = ref.read(dockerSessionManagerProvider); final manager = _manager;
if (manager.isConnected(server.id)) { if (manager.isConnected(server.id)) {
manager.activate(server.id); manager.activate(server.id);
return; return;
@@ -132,7 +137,8 @@ class _DockerPanelState extends ConsumerState<DockerPanel> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l = AppLocalizations.of(context); final l = AppLocalizations.of(context);
final hostsAsync = ref.watch(hostListProvider); final hostsAsync = ref.watch(hostListProvider);
final manager = ref.watch(dockerSessionManagerProvider); final DockerSessionManager manager =
widget.sessionManager ?? ref.watch(dockerSessionManagerProvider);
final isPlusActive = ref.watch(isPlusActiveProvider); final isPlusActive = ref.watch(isPlusActiveProvider);
return AnimatedBuilder( return AnimatedBuilder(
@@ -276,15 +282,18 @@ class _DockerPanelState extends ConsumerState<DockerPanel> {
String? lockedHostId, String? lockedHostId,
DockerContainer container, DockerContainer container,
) async { ) async {
final manager = ref.read(dockerSessionManagerProvider); final manager = _manager;
manager.getLogs(container, hostId: lockedHostId); manager.getLogs(container, hostId: lockedHostId);
await showModalBottomSheet<void>( await showModalBottomSheet<void>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
barrierColor: Colors.black.withValues(alpha: 0.34), barrierColor: Colors.black.withValues(alpha: 0.34),
builder: (_) => builder: (_) => _DockerLogsSheet(
_DockerLogsSheet(hostId: lockedHostId, container: container), hostId: lockedHostId,
container: container,
manager: manager,
),
); );
} }
} }
@@ -1380,10 +1389,15 @@ class _DockerCircleAction extends StatelessWidget {
} }
class _DockerLogsSheet extends ConsumerStatefulWidget { class _DockerLogsSheet extends ConsumerStatefulWidget {
const _DockerLogsSheet({required this.hostId, required this.container}); const _DockerLogsSheet({
required this.hostId,
required this.container,
required this.manager,
});
final String? hostId; final String? hostId;
final DockerContainer container; final DockerContainer container;
final DockerSessionManager manager;
@override @override
ConsumerState<_DockerLogsSheet> createState() => _DockerLogsSheetState(); ConsumerState<_DockerLogsSheet> createState() => _DockerLogsSheetState();
@@ -1398,7 +1412,7 @@ class _DockerLogsSheetState extends ConsumerState<_DockerLogsSheet> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_manager = ref.read(dockerSessionManagerProvider); _manager = widget.manager;
_manager.addListener(_onLogsChanged); _manager.addListener(_onLogsChanged);
_autoRefreshTimer = Timer.periodic(const Duration(seconds: 3), (_) { _autoRefreshTimer = Timer.periodic(const Duration(seconds: 3), (_) {
final session = widget.hostId == null final session = widget.hostId == null
@@ -302,11 +302,32 @@ class SftpSessionManager extends ChangeNotifier {
Future<void> disconnectActive() async { Future<void> disconnectActive() async {
final hostId = _activeHostId; final hostId = _activeHostId;
if (hostId == null) return; if (hostId == null) return;
await disconnect(hostId);
}
Future<void> disconnect(String hostId) async {
final connection = _connections.remove(hostId); final connection = _connections.remove(hostId);
final state = _states.remove(hostId); final state = _states.remove(hostId);
await connection?.close(); await connection?.close();
state?.dispose(); state?.dispose();
_activeHostId = _states.isEmpty ? null : _states.keys.first; if (_activeHostId == hostId) {
_activeHostId = _states.isEmpty ? null : _states.keys.first;
}
notifyListeners();
}
Future<void> disconnectAll() async {
final connections = _connections.values.toList(growable: false);
final states = _states.values.toList(growable: false);
_connections.clear();
_states.clear();
_activeHostId = null;
for (final state in states) {
state.dispose();
}
for (final connection in connections) {
await connection.close();
}
notifyListeners(); notifyListeners();
} }
@@ -795,10 +816,10 @@ class SftpSessionManager extends ChangeNotifier {
@override @override
void dispose() { void dispose() {
for (final connection in _connections.values) { for (final connection in _connections.values.toList(growable: false)) {
unawaited(connection.close()); unawaited(connection.close());
} }
for (final state in _states.values) { for (final state in _states.values.toList(growable: false)) {
state.dispose(); state.dispose();
} }
_connections.clear(); _connections.clear();
+10 -4
View File
@@ -46,6 +46,7 @@ class SftpPanel extends ConsumerStatefulWidget {
this.lockToHost = false, this.lockToHost = false,
this.onExecCommand, this.onExecCommand,
this.onCommandPanelClose, this.onCommandPanelClose,
this.sessionManager,
}); });
final bool showHeader; final bool showHeader;
@@ -54,6 +55,7 @@ class SftpPanel extends ConsumerStatefulWidget {
final bool lockToHost; final bool lockToHost;
final void Function(String command)? onExecCommand; final void Function(String command)? onExecCommand;
final VoidCallback? onCommandPanelClose; final VoidCallback? onCommandPanelClose;
final SftpSessionManager? sessionManager;
@override @override
ConsumerState<SftpPanel> createState() => _SftpPanelState(); ConsumerState<SftpPanel> createState() => _SftpPanelState();
@@ -63,13 +65,16 @@ class _SftpPanelState extends ConsumerState<SftpPanel> {
bool _connecting = false; bool _connecting = false;
String? _autoConnectAttemptedHostId; String? _autoConnectAttemptedHostId;
SftpSessionManager get _manager =>
widget.sessionManager ?? ref.read(sftpSessionManagerProvider);
Future<void> _refresh() => runRefreshWithFeedback( Future<void> _refresh() => runRefreshWithFeedback(
context, context,
() => ref.read(hostListProvider.notifier).refresh(throwOnError: true), () => ref.read(hostListProvider.notifier).refresh(throwOnError: true),
); );
Future<void> _openServerPicker() async { Future<void> _openServerPicker() async {
final manager = ref.read(sftpSessionManagerProvider); final manager = _manager;
final selected = await showModalBottomSheet<ServerModel>( final selected = await showModalBottomSheet<ServerModel>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
@@ -92,7 +97,7 @@ class _SftpPanelState extends ConsumerState<SftpPanel> {
Future<void> _connectOrActivate(ServerModel server) async { Future<void> _connectOrActivate(ServerModel server) async {
final l = AppLocalizations.of(context); final l = AppLocalizations.of(context);
final manager = ref.read(sftpSessionManagerProvider); final manager = _manager;
if (manager.isConnected(server.id)) { if (manager.isConnected(server.id)) {
manager.activate(server.id); manager.activate(server.id);
return; return;
@@ -143,7 +148,8 @@ class _SftpPanelState extends ConsumerState<SftpPanel> {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final l = AppLocalizations.of(context); final l = AppLocalizations.of(context);
final hostsAsync = ref.watch(hostListProvider); final hostsAsync = ref.watch(hostListProvider);
final manager = ref.watch(sftpSessionManagerProvider); final SftpSessionManager manager =
widget.sessionManager ?? ref.watch(sftpSessionManagerProvider);
return AnimatedBuilder( return AnimatedBuilder(
animation: manager, animation: manager,
@@ -265,7 +271,7 @@ class _SftpPanelState extends ConsumerState<SftpPanel> {
final hostId = widget.initialHostId; final hostId = widget.initialHostId;
if (hostId == null || hostId.isEmpty) return; if (hostId == null || hostId.isEmpty) return;
if (_autoConnectAttemptedHostId == hostId) return; if (_autoConnectAttemptedHostId == hostId) return;
final manager = ref.read(sftpSessionManagerProvider); final manager = _manager;
if (manager.activeHostId == hostId && manager.activeSession != null) { if (manager.activeHostId == hostId && manager.activeSession != null) {
_autoConnectAttemptedHostId = hostId; _autoConnectAttemptedHostId = hostId;
return; return;
@@ -266,6 +266,12 @@ class _TerminalBottomBarState extends ConsumerState<TerminalBottomBar> {
Future<void> _showSftp(BuildContext context) async { Future<void> _showSftp(BuildContext context) async {
final l = AppLocalizations.of(context); final l = AppLocalizations.of(context);
final hostId = ref
.read(terminalSessionManagerProvider)
.activeSession
?.config
.hostId;
if (hostId == null) return;
await showModalBottomSheet<void>( await showModalBottomSheet<void>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
@@ -277,11 +283,10 @@ class _TerminalBottomBarState extends ConsumerState<TerminalBottomBar> {
title: l.tr('terminal.menu.sftp'), title: l.tr('terminal.menu.sftp'),
icon: Icons.folder_outlined, icon: Icons.folder_outlined,
child: SftpPanel( child: SftpPanel(
initialHostId: ref initialHostId: hostId,
.read(terminalSessionManagerProvider) sessionManager: ref
.activeSession .read(terminalSessionResourcesProvider)
?.config .sftpForHost(hostId),
.hostId,
allowDisconnect: false, allowDisconnect: false,
lockToHost: true, lockToHost: true,
onExecCommand: (command) { onExecCommand: (command) {
@@ -304,6 +309,12 @@ class _TerminalBottomBarState extends ConsumerState<TerminalBottomBar> {
await SystemChannels.textInput.invokeMethod<void>('TextInput.hide'); await SystemChannels.textInput.invokeMethod<void>('TextInput.hide');
if (!context.mounted) return; if (!context.mounted) return;
final l = AppLocalizations.of(context); final l = AppLocalizations.of(context);
final hostId = ref
.read(terminalSessionManagerProvider)
.activeSession
?.config
.hostId;
if (hostId == null) return;
await showModalBottomSheet<void>( await showModalBottomSheet<void>(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
@@ -315,11 +326,10 @@ class _TerminalBottomBarState extends ConsumerState<TerminalBottomBar> {
title: l.tr('terminal.menu.docker'), title: l.tr('terminal.menu.docker'),
iconWidget: const DockerIcon(), iconWidget: const DockerIcon(),
child: DockerPanel( child: DockerPanel(
initialHostId: ref initialHostId: hostId,
.read(terminalSessionManagerProvider) sessionManager: ref
.activeSession .read(terminalSessionResourcesProvider)
?.config .dockerForHost(hostId),
.hostId,
allowDisconnect: false, allowDisconnect: false,
lockToHost: true, lockToHost: true,
), ),
@@ -10,20 +10,24 @@ import 'server_status_monitor_manager.dart';
import 'terminal_session.dart'; import 'terminal_session.dart';
typedef ShouldAutoStartStatusMonitor = bool Function(); typedef ShouldAutoStartStatusMonitor = bool Function();
typedef OnLastSessionForHostClosed = Future<void> Function(String hostId);
class TerminalSessionManager extends ChangeNotifier { class TerminalSessionManager extends ChangeNotifier {
TerminalSessionManager({ TerminalSessionManager({
Uuid? uuid, Uuid? uuid,
ServerStatusMonitorManager? statusMonitorManager, ServerStatusMonitorManager? statusMonitorManager,
ShouldAutoStartStatusMonitor? shouldAutoStartStatusMonitor, ShouldAutoStartStatusMonitor? shouldAutoStartStatusMonitor,
OnLastSessionForHostClosed? onLastSessionForHostClosed,
}) : _uuid = uuid ?? const Uuid(), }) : _uuid = uuid ?? const Uuid(),
_statusMonitorManager = statusMonitorManager, _statusMonitorManager = statusMonitorManager,
_shouldAutoStartStatusMonitor = _shouldAutoStartStatusMonitor =
shouldAutoStartStatusMonitor ?? (() => false); shouldAutoStartStatusMonitor ?? (() => false),
_onLastSessionForHostClosed = onLastSessionForHostClosed;
final Uuid _uuid; final Uuid _uuid;
final ServerStatusMonitorManager? _statusMonitorManager; final ServerStatusMonitorManager? _statusMonitorManager;
final ShouldAutoStartStatusMonitor _shouldAutoStartStatusMonitor; final ShouldAutoStartStatusMonitor _shouldAutoStartStatusMonitor;
final OnLastSessionForHostClosed? _onLastSessionForHostClosed;
final List<TerminalSession> _sessions = []; final List<TerminalSession> _sessions = [];
String? _activeId; String? _activeId;
@@ -101,6 +105,7 @@ class TerminalSessionManager extends ChangeNotifier {
if (_activeId == id) { if (_activeId == id) {
_activeId = _sessions.isEmpty ? null : _sessions.first.id; _activeId = _sessions.isEmpty ? null : _sessions.first.id;
} }
await _closeDependentSessionsIfLastForHost(session.config.hostId);
notifyListeners(); notifyListeners();
} }
@@ -108,15 +113,25 @@ class TerminalSessionManager extends ChangeNotifier {
final copy = List<TerminalSession>.from(_sessions); final copy = List<TerminalSession>.from(_sessions);
_sessions.clear(); _sessions.clear();
_activeId = null; _activeId = null;
final hostIds = <String>{};
for (final session in copy) { for (final session in copy) {
hostIds.add(session.config.hostId);
await _detachStatusMonitor(session); await _detachStatusMonitor(session);
await session.controller.disconnect(); await session.controller.disconnect();
session.viewController.dispose(); session.viewController.dispose();
session.scrollController.dispose(); session.scrollController.dispose();
} }
for (final hostId in hostIds) {
await _onLastSessionForHostClosed?.call(hostId);
}
notifyListeners(); notifyListeners();
} }
Future<void> _closeDependentSessionsIfLastForHost(String hostId) async {
if (_sessions.any((session) => session.config.hostId == hostId)) return;
await _onLastSessionForHostClosed?.call(hostId);
}
Future<void> _connect(TerminalSession session) async { Future<void> _connect(TerminalSession session) async {
final controller = session.controller; final controller = session.controller;
session.status = TerminalSessionStatus.connecting; session.status = TerminalSessionStatus.connecting;
@@ -126,8 +141,7 @@ class TerminalSessionManager extends ChangeNotifier {
await controller.connect(); await controller.connect();
// After await, verify this controller is still the active one — a // After await, verify this controller is still the active one — a
// concurrent reconnect may have replaced it. // concurrent reconnect may have replaced it.
if (!_sessions.contains(session) || if (!_sessions.contains(session) || session.controller != controller) {
session.controller != controller) {
return; return;
} }
session.status = TerminalSessionStatus.connected; session.status = TerminalSessionStatus.connected;
@@ -141,8 +155,7 @@ class TerminalSessionManager extends ChangeNotifier {
); );
} }
} catch (error) { } catch (error) {
if (!_sessions.contains(session) || if (!_sessions.contains(session) || session.controller != controller) {
session.controller != controller) {
return; return;
} }
session.status = TerminalSessionStatus.error; session.status = TerminalSessionStatus.error;
@@ -0,0 +1,35 @@
import '../docker/docker_session_manager.dart';
import '../shell/sftp_session_manager.dart';
/// Owns auxiliary connections opened from terminal pages. These connections
/// are deliberately separate from the managers used by the bottom-nav tabs.
class TerminalSessionResources {
final Map<String, SftpSessionManager> _sftpManagers = {};
final Map<String, DockerSessionManager> _dockerManagers = {};
SftpSessionManager sftpForHost(String hostId) =>
_sftpManagers.putIfAbsent(hostId, SftpSessionManager.new);
DockerSessionManager dockerForHost(String hostId) =>
_dockerManagers.putIfAbsent(hostId, DockerSessionManager.new);
Future<void> disposeHost(String hostId) async {
final sftpManager = _sftpManagers.remove(hostId);
await sftpManager?.disconnectAll();
sftpManager?.dispose();
final dockerManager = _dockerManagers.remove(hostId);
dockerManager?.dispose();
}
void dispose() {
for (final manager in _sftpManagers.values) {
manager.dispose();
}
for (final manager in _dockerManagers.values) {
manager.dispose();
}
_sftpManagers.clear();
_dockerManagers.clear();
}
}
+7
View File
@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../core/api/api_client.dart'; import '../core/api/api_client.dart';
import '../features/auth/auth_session.dart'; import '../features/auth/auth_session.dart';
import 'docker_providers.dart';
import 'auth_state.dart'; import 'auth_state.dart';
import 'plus_info_notifier.dart'; import 'plus_info_notifier.dart';
import 'storage_providers.dart'; import 'storage_providers.dart';
@@ -63,6 +64,12 @@ class AuthNotifier extends StateNotifier<AuthState> {
try { try {
await _ref.read(terminalSessionManagerProvider).closeAll(); await _ref.read(terminalSessionManagerProvider).closeAll();
} catch (_) {} } catch (_) {}
try {
await _ref.read(sftpSessionManagerProvider).disconnectAll();
} catch (_) {}
try {
_ref.read(dockerSessionManagerProvider).disconnectAll();
} catch (_) {}
try { try {
final secureStorage = _ref.read(secureStorageProvider); final secureStorage = _ref.read(secureStorageProvider);
await secureStorage.deleteToken(); await secureStorage.deleteToken();
+11
View File
@@ -3,21 +3,32 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../features/shell/sftp_session_manager.dart'; import '../features/shell/sftp_session_manager.dart';
import '../features/terminal/server_status_monitor_manager.dart'; import '../features/terminal/server_status_monitor_manager.dart';
import '../features/terminal/terminal_session_manager.dart'; import '../features/terminal/terminal_session_manager.dart';
import '../features/terminal/terminal_session_resources.dart';
import 'terminal_settings_notifier.dart'; import 'terminal_settings_notifier.dart';
/// Single TerminalSessionManager for the whole app. Used to be passed /// Single TerminalSessionManager for the whole app. Used to be passed
/// through constructors; now lives here so any page can reach it. Disposed /// through constructors; now lives here so any page can reach it. Disposed
/// alongside the ProviderScope at app teardown. /// alongside the ProviderScope at app teardown.
final terminalSessionManagerProvider = Provider<TerminalSessionManager>((ref) { final terminalSessionManagerProvider = Provider<TerminalSessionManager>((ref) {
final resources = ref.watch(terminalSessionResourcesProvider);
final manager = TerminalSessionManager( final manager = TerminalSessionManager(
statusMonitorManager: ref.watch(serverStatusMonitorManagerProvider), statusMonitorManager: ref.watch(serverStatusMonitorManagerProvider),
shouldAutoStartStatusMonitor: () => shouldAutoStartStatusMonitor: () =>
ref.read(terminalSettingsProvider).autoServerStatus, ref.read(terminalSettingsProvider).autoServerStatus,
onLastSessionForHostClosed: resources.disposeHost,
); );
ref.onDispose(manager.dispose); ref.onDispose(manager.dispose);
return manager; return manager;
}); });
final terminalSessionResourcesProvider = Provider<TerminalSessionResources>((
ref,
) {
final resources = TerminalSessionResources();
ref.onDispose(resources.dispose);
return resources;
});
final serverStatusMonitorManagerProvider = Provider<ServerStatusMonitorManager>( final serverStatusMonitorManagerProvider = Provider<ServerStatusMonitorManager>(
(ref) { (ref) {
final manager = ServerStatusMonitorManager(); final manager = ServerStatusMonitorManager();
@@ -0,0 +1,49 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:easynode_native/features/terminal/terminal_session_resources.dart';
void main() {
group('TerminalSessionResources', () {
test('reuses terminal-scoped managers for the same host', () {
final resources = TerminalSessionResources();
expect(
identical(
resources.sftpForHost('host-a'),
resources.sftpForHost('host-a'),
),
isTrue,
);
expect(
identical(
resources.dockerForHost('host-a'),
resources.dockerForHost('host-a'),
),
isTrue,
);
resources.dispose();
});
test('keeps managers for different hosts independent', () {
final resources = TerminalSessionResources();
expect(
identical(
resources.sftpForHost('host-a'),
resources.sftpForHost('host-b'),
),
isFalse,
);
expect(
identical(
resources.dockerForHost('host-a'),
resources.dockerForHost('host-b'),
),
isFalse,
);
resources.dispose();
});
});
}