mirror of
https://github.com/chaos-zhu/easynode.git
synced 2026-10-06 12:33:59 +08:00
feat: 重构Native sftp&docker实例共享逻辑
This commit is contained in:
@@ -238,6 +238,11 @@ class DockerSessionManager extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void disconnectAll() {
|
||||
_disconnectAll();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void _disconnect(String hostId) {
|
||||
final session = _sessions.remove(hostId);
|
||||
if (session == null) return;
|
||||
|
||||
@@ -39,12 +39,14 @@ class DockerPanel extends ConsumerStatefulWidget {
|
||||
this.initialHostId,
|
||||
this.lockToHost = false,
|
||||
this.allowDisconnect = true,
|
||||
this.sessionManager,
|
||||
});
|
||||
|
||||
final bool showHeader;
|
||||
final String? initialHostId;
|
||||
final bool lockToHost;
|
||||
final bool allowDisconnect;
|
||||
final DockerSessionManager? sessionManager;
|
||||
|
||||
@override
|
||||
ConsumerState<DockerPanel> createState() => _DockerPanelState();
|
||||
@@ -54,13 +56,16 @@ class _DockerPanelState extends ConsumerState<DockerPanel> {
|
||||
bool _connecting = false;
|
||||
String? _autoConnectAttemptedHostId;
|
||||
|
||||
DockerSessionManager get _manager =>
|
||||
widget.sessionManager ?? ref.read(dockerSessionManagerProvider);
|
||||
|
||||
Future<void> _refreshHosts() => runRefreshWithFeedback(
|
||||
context,
|
||||
() => ref.read(hostListProvider.notifier).refresh(throwOnError: true),
|
||||
);
|
||||
|
||||
Future<void> _openServerPicker() async {
|
||||
final manager = ref.read(dockerSessionManagerProvider);
|
||||
final manager = _manager;
|
||||
final selected = await showModalBottomSheet<ServerModel>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
@@ -83,7 +88,7 @@ class _DockerPanelState extends ConsumerState<DockerPanel> {
|
||||
|
||||
Future<void> _connectOrActivate(ServerModel server) async {
|
||||
final l = AppLocalizations.of(context);
|
||||
final manager = ref.read(dockerSessionManagerProvider);
|
||||
final manager = _manager;
|
||||
if (manager.isConnected(server.id)) {
|
||||
manager.activate(server.id);
|
||||
return;
|
||||
@@ -132,7 +137,8 @@ class _DockerPanelState extends ConsumerState<DockerPanel> {
|
||||
Widget build(BuildContext context) {
|
||||
final l = AppLocalizations.of(context);
|
||||
final hostsAsync = ref.watch(hostListProvider);
|
||||
final manager = ref.watch(dockerSessionManagerProvider);
|
||||
final DockerSessionManager manager =
|
||||
widget.sessionManager ?? ref.watch(dockerSessionManagerProvider);
|
||||
final isPlusActive = ref.watch(isPlusActiveProvider);
|
||||
|
||||
return AnimatedBuilder(
|
||||
@@ -276,15 +282,18 @@ class _DockerPanelState extends ConsumerState<DockerPanel> {
|
||||
String? lockedHostId,
|
||||
DockerContainer container,
|
||||
) async {
|
||||
final manager = ref.read(dockerSessionManagerProvider);
|
||||
final manager = _manager;
|
||||
manager.getLogs(container, hostId: lockedHostId);
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: Colors.transparent,
|
||||
barrierColor: Colors.black.withValues(alpha: 0.34),
|
||||
builder: (_) =>
|
||||
_DockerLogsSheet(hostId: lockedHostId, container: container),
|
||||
builder: (_) => _DockerLogsSheet(
|
||||
hostId: lockedHostId,
|
||||
container: container,
|
||||
manager: manager,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1380,10 +1389,15 @@ class _DockerCircleAction extends StatelessWidget {
|
||||
}
|
||||
|
||||
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 DockerContainer container;
|
||||
final DockerSessionManager manager;
|
||||
|
||||
@override
|
||||
ConsumerState<_DockerLogsSheet> createState() => _DockerLogsSheetState();
|
||||
@@ -1398,7 +1412,7 @@ class _DockerLogsSheetState extends ConsumerState<_DockerLogsSheet> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_manager = ref.read(dockerSessionManagerProvider);
|
||||
_manager = widget.manager;
|
||||
_manager.addListener(_onLogsChanged);
|
||||
_autoRefreshTimer = Timer.periodic(const Duration(seconds: 3), (_) {
|
||||
final session = widget.hostId == null
|
||||
|
||||
@@ -302,11 +302,32 @@ class SftpSessionManager extends ChangeNotifier {
|
||||
Future<void> disconnectActive() async {
|
||||
final hostId = _activeHostId;
|
||||
if (hostId == null) return;
|
||||
await disconnect(hostId);
|
||||
}
|
||||
|
||||
Future<void> disconnect(String hostId) async {
|
||||
final connection = _connections.remove(hostId);
|
||||
final state = _states.remove(hostId);
|
||||
await connection?.close();
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -795,10 +816,10 @@ class SftpSessionManager extends ChangeNotifier {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final connection in _connections.values) {
|
||||
for (final connection in _connections.values.toList(growable: false)) {
|
||||
unawaited(connection.close());
|
||||
}
|
||||
for (final state in _states.values) {
|
||||
for (final state in _states.values.toList(growable: false)) {
|
||||
state.dispose();
|
||||
}
|
||||
_connections.clear();
|
||||
|
||||
@@ -46,6 +46,7 @@ class SftpPanel extends ConsumerStatefulWidget {
|
||||
this.lockToHost = false,
|
||||
this.onExecCommand,
|
||||
this.onCommandPanelClose,
|
||||
this.sessionManager,
|
||||
});
|
||||
|
||||
final bool showHeader;
|
||||
@@ -54,6 +55,7 @@ class SftpPanel extends ConsumerStatefulWidget {
|
||||
final bool lockToHost;
|
||||
final void Function(String command)? onExecCommand;
|
||||
final VoidCallback? onCommandPanelClose;
|
||||
final SftpSessionManager? sessionManager;
|
||||
|
||||
@override
|
||||
ConsumerState<SftpPanel> createState() => _SftpPanelState();
|
||||
@@ -63,13 +65,16 @@ class _SftpPanelState extends ConsumerState<SftpPanel> {
|
||||
bool _connecting = false;
|
||||
String? _autoConnectAttemptedHostId;
|
||||
|
||||
SftpSessionManager get _manager =>
|
||||
widget.sessionManager ?? ref.read(sftpSessionManagerProvider);
|
||||
|
||||
Future<void> _refresh() => runRefreshWithFeedback(
|
||||
context,
|
||||
() => ref.read(hostListProvider.notifier).refresh(throwOnError: true),
|
||||
);
|
||||
|
||||
Future<void> _openServerPicker() async {
|
||||
final manager = ref.read(sftpSessionManagerProvider);
|
||||
final manager = _manager;
|
||||
final selected = await showModalBottomSheet<ServerModel>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
@@ -92,7 +97,7 @@ class _SftpPanelState extends ConsumerState<SftpPanel> {
|
||||
|
||||
Future<void> _connectOrActivate(ServerModel server) async {
|
||||
final l = AppLocalizations.of(context);
|
||||
final manager = ref.read(sftpSessionManagerProvider);
|
||||
final manager = _manager;
|
||||
if (manager.isConnected(server.id)) {
|
||||
manager.activate(server.id);
|
||||
return;
|
||||
@@ -143,7 +148,8 @@ class _SftpPanelState extends ConsumerState<SftpPanel> {
|
||||
Widget build(BuildContext context) {
|
||||
final l = AppLocalizations.of(context);
|
||||
final hostsAsync = ref.watch(hostListProvider);
|
||||
final manager = ref.watch(sftpSessionManagerProvider);
|
||||
final SftpSessionManager manager =
|
||||
widget.sessionManager ?? ref.watch(sftpSessionManagerProvider);
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: manager,
|
||||
@@ -265,7 +271,7 @@ class _SftpPanelState extends ConsumerState<SftpPanel> {
|
||||
final hostId = widget.initialHostId;
|
||||
if (hostId == null || hostId.isEmpty) return;
|
||||
if (_autoConnectAttemptedHostId == hostId) return;
|
||||
final manager = ref.read(sftpSessionManagerProvider);
|
||||
final manager = _manager;
|
||||
if (manager.activeHostId == hostId && manager.activeSession != null) {
|
||||
_autoConnectAttemptedHostId = hostId;
|
||||
return;
|
||||
|
||||
@@ -266,6 +266,12 @@ class _TerminalBottomBarState extends ConsumerState<TerminalBottomBar> {
|
||||
|
||||
Future<void> _showSftp(BuildContext context) async {
|
||||
final l = AppLocalizations.of(context);
|
||||
final hostId = ref
|
||||
.read(terminalSessionManagerProvider)
|
||||
.activeSession
|
||||
?.config
|
||||
.hostId;
|
||||
if (hostId == null) return;
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
@@ -277,11 +283,10 @@ class _TerminalBottomBarState extends ConsumerState<TerminalBottomBar> {
|
||||
title: l.tr('terminal.menu.sftp'),
|
||||
icon: Icons.folder_outlined,
|
||||
child: SftpPanel(
|
||||
initialHostId: ref
|
||||
.read(terminalSessionManagerProvider)
|
||||
.activeSession
|
||||
?.config
|
||||
.hostId,
|
||||
initialHostId: hostId,
|
||||
sessionManager: ref
|
||||
.read(terminalSessionResourcesProvider)
|
||||
.sftpForHost(hostId),
|
||||
allowDisconnect: false,
|
||||
lockToHost: true,
|
||||
onExecCommand: (command) {
|
||||
@@ -304,6 +309,12 @@ class _TerminalBottomBarState extends ConsumerState<TerminalBottomBar> {
|
||||
await SystemChannels.textInput.invokeMethod<void>('TextInput.hide');
|
||||
if (!context.mounted) return;
|
||||
final l = AppLocalizations.of(context);
|
||||
final hostId = ref
|
||||
.read(terminalSessionManagerProvider)
|
||||
.activeSession
|
||||
?.config
|
||||
.hostId;
|
||||
if (hostId == null) return;
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
@@ -315,11 +326,10 @@ class _TerminalBottomBarState extends ConsumerState<TerminalBottomBar> {
|
||||
title: l.tr('terminal.menu.docker'),
|
||||
iconWidget: const DockerIcon(),
|
||||
child: DockerPanel(
|
||||
initialHostId: ref
|
||||
.read(terminalSessionManagerProvider)
|
||||
.activeSession
|
||||
?.config
|
||||
.hostId,
|
||||
initialHostId: hostId,
|
||||
sessionManager: ref
|
||||
.read(terminalSessionResourcesProvider)
|
||||
.dockerForHost(hostId),
|
||||
allowDisconnect: false,
|
||||
lockToHost: true,
|
||||
),
|
||||
|
||||
@@ -10,20 +10,24 @@ import 'server_status_monitor_manager.dart';
|
||||
import 'terminal_session.dart';
|
||||
|
||||
typedef ShouldAutoStartStatusMonitor = bool Function();
|
||||
typedef OnLastSessionForHostClosed = Future<void> Function(String hostId);
|
||||
|
||||
class TerminalSessionManager extends ChangeNotifier {
|
||||
TerminalSessionManager({
|
||||
Uuid? uuid,
|
||||
ServerStatusMonitorManager? statusMonitorManager,
|
||||
ShouldAutoStartStatusMonitor? shouldAutoStartStatusMonitor,
|
||||
OnLastSessionForHostClosed? onLastSessionForHostClosed,
|
||||
}) : _uuid = uuid ?? const Uuid(),
|
||||
_statusMonitorManager = statusMonitorManager,
|
||||
_shouldAutoStartStatusMonitor =
|
||||
shouldAutoStartStatusMonitor ?? (() => false);
|
||||
shouldAutoStartStatusMonitor ?? (() => false),
|
||||
_onLastSessionForHostClosed = onLastSessionForHostClosed;
|
||||
|
||||
final Uuid _uuid;
|
||||
final ServerStatusMonitorManager? _statusMonitorManager;
|
||||
final ShouldAutoStartStatusMonitor _shouldAutoStartStatusMonitor;
|
||||
final OnLastSessionForHostClosed? _onLastSessionForHostClosed;
|
||||
final List<TerminalSession> _sessions = [];
|
||||
String? _activeId;
|
||||
|
||||
@@ -101,6 +105,7 @@ class TerminalSessionManager extends ChangeNotifier {
|
||||
if (_activeId == id) {
|
||||
_activeId = _sessions.isEmpty ? null : _sessions.first.id;
|
||||
}
|
||||
await _closeDependentSessionsIfLastForHost(session.config.hostId);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@@ -108,15 +113,25 @@ class TerminalSessionManager extends ChangeNotifier {
|
||||
final copy = List<TerminalSession>.from(_sessions);
|
||||
_sessions.clear();
|
||||
_activeId = null;
|
||||
final hostIds = <String>{};
|
||||
for (final session in copy) {
|
||||
hostIds.add(session.config.hostId);
|
||||
await _detachStatusMonitor(session);
|
||||
await session.controller.disconnect();
|
||||
session.viewController.dispose();
|
||||
session.scrollController.dispose();
|
||||
}
|
||||
for (final hostId in hostIds) {
|
||||
await _onLastSessionForHostClosed?.call(hostId);
|
||||
}
|
||||
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 {
|
||||
final controller = session.controller;
|
||||
session.status = TerminalSessionStatus.connecting;
|
||||
@@ -126,8 +141,7 @@ class TerminalSessionManager extends ChangeNotifier {
|
||||
await controller.connect();
|
||||
// After await, verify this controller is still the active one — a
|
||||
// concurrent reconnect may have replaced it.
|
||||
if (!_sessions.contains(session) ||
|
||||
session.controller != controller) {
|
||||
if (!_sessions.contains(session) || session.controller != controller) {
|
||||
return;
|
||||
}
|
||||
session.status = TerminalSessionStatus.connected;
|
||||
@@ -141,8 +155,7 @@ class TerminalSessionManager extends ChangeNotifier {
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
if (!_sessions.contains(session) ||
|
||||
session.controller != controller) {
|
||||
if (!_sessions.contains(session) || session.controller != controller) {
|
||||
return;
|
||||
}
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../core/api/api_client.dart';
|
||||
import '../features/auth/auth_session.dart';
|
||||
import 'docker_providers.dart';
|
||||
import 'auth_state.dart';
|
||||
import 'plus_info_notifier.dart';
|
||||
import 'storage_providers.dart';
|
||||
@@ -63,6 +64,12 @@ class AuthNotifier extends StateNotifier<AuthState> {
|
||||
try {
|
||||
await _ref.read(terminalSessionManagerProvider).closeAll();
|
||||
} catch (_) {}
|
||||
try {
|
||||
await _ref.read(sftpSessionManagerProvider).disconnectAll();
|
||||
} catch (_) {}
|
||||
try {
|
||||
_ref.read(dockerSessionManagerProvider).disconnectAll();
|
||||
} catch (_) {}
|
||||
try {
|
||||
final secureStorage = _ref.read(secureStorageProvider);
|
||||
await secureStorage.deleteToken();
|
||||
|
||||
@@ -3,21 +3,32 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import '../features/shell/sftp_session_manager.dart';
|
||||
import '../features/terminal/server_status_monitor_manager.dart';
|
||||
import '../features/terminal/terminal_session_manager.dart';
|
||||
import '../features/terminal/terminal_session_resources.dart';
|
||||
import 'terminal_settings_notifier.dart';
|
||||
|
||||
/// Single TerminalSessionManager for the whole app. Used to be passed
|
||||
/// through constructors; now lives here so any page can reach it. Disposed
|
||||
/// alongside the ProviderScope at app teardown.
|
||||
final terminalSessionManagerProvider = Provider<TerminalSessionManager>((ref) {
|
||||
final resources = ref.watch(terminalSessionResourcesProvider);
|
||||
final manager = TerminalSessionManager(
|
||||
statusMonitorManager: ref.watch(serverStatusMonitorManagerProvider),
|
||||
shouldAutoStartStatusMonitor: () =>
|
||||
ref.read(terminalSettingsProvider).autoServerStatus,
|
||||
onLastSessionForHostClosed: resources.disposeHost,
|
||||
);
|
||||
ref.onDispose(manager.dispose);
|
||||
return manager;
|
||||
});
|
||||
|
||||
final terminalSessionResourcesProvider = Provider<TerminalSessionResources>((
|
||||
ref,
|
||||
) {
|
||||
final resources = TerminalSessionResources();
|
||||
ref.onDispose(resources.dispose);
|
||||
return resources;
|
||||
});
|
||||
|
||||
final serverStatusMonitorManagerProvider = Provider<ServerStatusMonitorManager>(
|
||||
(ref) {
|
||||
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();
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user