From 0a1b4d4b8d67e77b8e8928eb7151733eceb43310 Mon Sep 17 00:00:00 2001 From: chaoszhu Date: Sat, 13 Jun 2026 15:57:12 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=BA=95=E9=83=A8=E6=A0=8F=E4=BC=98?= =?UTF-8?q?=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../terminal/terminal_bottom_menu.dart | 460 ++++++++---------- .../terminal/terminal_shell_page.dart | 10 - native/lib/l10n/strings_en.dart | 3 + native/lib/l10n/strings_zh.dart | 3 + 4 files changed, 213 insertions(+), 263 deletions(-) diff --git a/native/lib/features/terminal/terminal_bottom_menu.dart b/native/lib/features/terminal/terminal_bottom_menu.dart index aebaf9e..37d3c9b 100644 --- a/native/lib/features/terminal/terminal_bottom_menu.dart +++ b/native/lib/features/terminal/terminal_bottom_menu.dart @@ -67,13 +67,9 @@ class _TerminalBottomBarState extends ConsumerState { icon: Icons.code_outlined, onTap: () => _showScripts(context), ), - _BarIcon( - icon: Icons.add_link_outlined, - onTap: () => _showNewConnection(context), - ), _BarIcon( icon: Icons.layers_outlined, - onTap: () => _showCurrentConnections(context), + onTap: () => _showConnections(context), ), _BarIcon( icon: Icons.folder_outlined, @@ -235,25 +231,7 @@ class _TerminalBottomBarState extends ConsumerState { return true; } - Future _showCurrentConnections(BuildContext context) { - final l = AppLocalizations.of(context); - return showModalBottomSheet( - context: context, - isScrollControlled: true, - backgroundColor: Colors.transparent, - barrierColor: Colors.black.withValues(alpha: 0.34), - builder: (_) => FractionallySizedBox( - heightFactor: 0.70, - child: _TerminalSheetFrame( - title: l.tr('terminal.menu.current'), - icon: Icons.layers_outlined, - child: _CurrentConnectionsSheet(manager: widget.manager), - ), - ), - ); - } - - Future _showNewConnection(BuildContext context) { + Future _showConnections(BuildContext context) { final l = AppLocalizations.of(context); return showModalBottomSheet( context: context, @@ -263,9 +241,9 @@ class _TerminalBottomBarState extends ConsumerState { builder: (_) => FractionallySizedBox( heightFactor: 0.82, child: _TerminalSheetFrame( - title: l.tr('terminal.menu.newConnection'), - icon: Icons.add_link_outlined, - child: const _NewConnectionSheet(), + title: l.tr('terminal.menu.connections'), + icon: Icons.layers_outlined, + child: _ConnectionsSheet(manager: widget.manager), ), ), ); @@ -579,166 +557,16 @@ class _ToggleKeyBtn extends StatelessWidget { // Bottom sheet contents (unchanged) // --------------------------------------------------------------------------- -class _CurrentConnectionsSheet extends StatelessWidget { - const _CurrentConnectionsSheet({required this.manager}); +class _ConnectionsSheet extends ConsumerStatefulWidget { + const _ConnectionsSheet({required this.manager}); final TerminalSessionManager manager; @override - Widget build(BuildContext context) { - return AnimatedBuilder( - animation: manager, - builder: (context, _) { - final sessions = manager.sessions.toList(growable: false); - final l = AppLocalizations.of(context); - if (sessions.isEmpty) { - return _MessageList(message: l.tr('terminal.noActive')); - } - return Column( - children: [ - if (sessions.length > 1) - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 8), - child: Align( - alignment: Alignment.centerRight, - child: TextButton.icon( - onPressed: () => _confirmCloseAll(context, manager), - icon: const Icon(Icons.layers_clear_outlined), - label: Text(l.tr('terminal.closeAllTerminals')), - ), - ), - ), - Expanded( - child: ListView.separated( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), - itemCount: sessions.length, - separatorBuilder: (_, _) => const SizedBox(height: 8), - itemBuilder: (context, index) { - final session = sessions[index]; - final active = session.id == manager.activeId; - return _SessionRow( - session: session, - active: active, - onSelect: () => manager.setActive(session.id), - onReconnect: () => manager.reconnect(session.id), - onClose: () => manager.closeSession(session.id), - ); - }, - ), - ), - ], - ); - }, - ); - } - - Future _confirmCloseAll( - BuildContext context, - TerminalSessionManager manager, - ) async { - final l = AppLocalizations.of(context); - final sessions = manager.sessions.toList(growable: false); - final confirmed = await showDialog( - context: context, - builder: (dialogContext) => AlertDialog( - title: Text(l.tr('terminal.closeAllTitle')), - content: Text(l.trf('terminal.closeAllBodyMany', [sessions.length])), - actions: [ - TextButton( - onPressed: () => Navigator.of(dialogContext).pop(false), - child: Text(l.tr('common.cancel')), - ), - FilledButton( - onPressed: () => Navigator.of(dialogContext).pop(true), - child: Text(l.tr('common.closeAll')), - ), - ], - ), - ); - if (confirmed == true) { - await manager.closeAll(); - if (context.mounted) Navigator.of(context).pop(); - } - } + ConsumerState<_ConnectionsSheet> createState() => _ConnectionsSheetState(); } -class _SessionRow extends StatelessWidget { - const _SessionRow({ - required this.session, - required this.active, - required this.onSelect, - required this.onReconnect, - required this.onClose, - }); - - final TerminalSession session; - final bool active; - final VoidCallback onSelect; - final Future Function() onReconnect; - final Future Function() onClose; - - @override - Widget build(BuildContext context) { - final l = AppLocalizations.of(context); - final colors = Theme.of(context).colorScheme; - return Material( - color: active ? colors.primaryContainer : colors.surfaceContainerHighest, - borderRadius: BorderRadius.circular(12), - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: onSelect, - child: Padding( - padding: const EdgeInsets.fromLTRB(12, 10, 6, 10), - child: Row( - children: [ - _StatusDot(status: session.status), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - session.displayName, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontWeight: FontWeight.w800), - ), - const SizedBox(height: 3), - Text( - _statusText(l, session.status), - style: TextStyle(color: colors.onSurfaceVariant), - ), - ], - ), - ), - if (active) const Icon(Icons.check_rounded), - IconButton( - tooltip: l.tr('terminal.reconnect'), - onPressed: () => onReconnect(), - icon: const Icon(Icons.refresh), - ), - IconButton( - tooltip: l.tr('terminal.closeTerminal'), - onPressed: () => onClose(), - icon: const Icon(Icons.close), - ), - ], - ), - ), - ), - ); - } -} - -class _NewConnectionSheet extends ConsumerStatefulWidget { - const _NewConnectionSheet(); - - @override - ConsumerState<_NewConnectionSheet> createState() => - _NewConnectionSheetState(); -} - -class _NewConnectionSheetState extends ConsumerState<_NewConnectionSheet> { +class _ConnectionsSheetState extends ConsumerState<_ConnectionsSheet> { final TextEditingController _searchCtrl = TextEditingController(); String _query = ''; String? _connectingId; @@ -788,101 +616,227 @@ class _NewConnectionSheetState extends ConsumerState<_NewConnectionSheet> { Widget build(BuildContext context) { final hostsAsync = ref.watch(hostListProvider); final l = AppLocalizations.of(context); - return Column( - children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 10), - child: SizedBox( - height: 42, - child: TextField( - controller: _searchCtrl, - decoration: InputDecoration( - hintText: l.tr('servers.searchHint'), - prefixIcon: const Icon(Icons.search, size: 19), - isDense: true, - filled: true, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - borderSide: BorderSide.none, + final colors = Theme.of(context).colorScheme; + return AnimatedBuilder( + animation: widget.manager, + builder: (context, _) { + return Column( + children: [ + Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 10), + child: SizedBox( + height: 42, + child: TextField( + controller: _searchCtrl, + decoration: InputDecoration( + hintText: l.tr('servers.searchHint'), + prefixIcon: const Icon(Icons.search, size: 19), + isDense: true, + filled: true, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + ), + onChanged: (value) => + setState(() => _query = value.trim().toLowerCase()), ), ), - onChanged: (value) => - setState(() => _query = value.trim().toLowerCase()), ), - ), - ), - Expanded( - child: hostsAsync.when( - loading: () => const Center(child: CircularProgressIndicator()), - error: (error, _) => _MessageList( - message: l.trf('terminal.loadServersFailed', [error.toString()]), - action: TextButton( - onPressed: () => ref.read(hostListProvider.notifier).refresh(), - child: Text(l.tr('common.retry')), + Expanded( + child: hostsAsync.when( + loading: () => + const Center(child: CircularProgressIndicator()), + error: (error, _) => _MessageList( + message: l.trf( + 'terminal.loadServersFailed', + [error.toString()], + ), + action: TextButton( + onPressed: () => + ref.read(hostListProvider.notifier).refresh(), + child: Text(l.tr('common.retry')), + ), + ), + data: (servers) => + _buildList(context, servers, colors, l), ), ), - data: (servers) { - final filtered = _filterServers(servers); - if (filtered.isEmpty) { - return _MessageList(message: l.tr('servers.emptyFiltered')); - } - return ListView.separated( - padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), - itemCount: filtered.length, - separatorBuilder: (_, _) => const SizedBox(height: 8), - itemBuilder: (context, index) { - final server = filtered[index]; - final disabled = server.isWindows || !server.canConnect; - return _ServerRow( - server: server, - disabled: disabled, - connecting: _connectingId == server.id, - onTap: _connectingId == null - ? () => _connect(server) - : null, - ); - }, - ); - }, - ), - ), - ], + ], + ); + }, ); } - List _filterServers(List servers) { - if (_query.isEmpty) return servers; - return servers - .where((server) { - final haystack = [ - server.displayName, - server.host, - server.username, - server.group, - ...server.tag, - ].join(' ').toLowerCase(); - return haystack.contains(_query); - }) - .toList(growable: false); + Widget _buildList( + BuildContext context, + List servers, + ColorScheme colors, + AppLocalizations l, + ) { + final sessions = widget.manager.sessions.toList(growable: false); + final connectedHostIds = { + for (final s in sessions) s.config.hostId, + }; + + final disconnectedServers = servers.where((server) { + if (connectedHostIds.contains(server.id)) return false; + if (_query.isEmpty) return true; + final haystack = [ + server.displayName, + server.host, + server.username, + server.group, + ...server.tag, + ].join(' ').toLowerCase(); + return haystack.contains(_query); + }).toList(growable: false); + + if (sessions.isEmpty && disconnectedServers.isEmpty) { + return _MessageList(message: l.tr('servers.emptyFiltered')); + } + + return ListView( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 24), + children: [ + if (sessions.isNotEmpty) ...[ + _SectionLabel(l.tr('terminal.connections.connected')), + for (final session in sessions) ...[ + _ConnectedRow( + session: session, + active: session.id == widget.manager.activeId, + onSelect: () { + widget.manager.setActive(session.id); + Navigator.of(context).pop(); + }, + onReconnect: () => widget.manager.reconnect(session.id), + onClose: () => widget.manager.closeSession(session.id), + ), + const SizedBox(height: 8), + ], + ], + if (disconnectedServers.isNotEmpty) ...[ + _SectionLabel(l.tr('terminal.connections.disconnected')), + for (final server in disconnectedServers) ...[ + _DisconnectedRow( + server: server, + connecting: _connectingId == server.id, + onTap: _connectingId == null + ? () => _connect(server) + : null, + ), + const SizedBox(height: 8), + ], + ], + ], + ); } } -class _ServerRow extends StatelessWidget { - const _ServerRow({ +class _SectionLabel extends StatelessWidget { + const _SectionLabel(this.text); + final String text; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(4, 8, 0, 8), + child: Text( + text, + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w800, + color: Theme.of(context).colorScheme.outline, + ), + ), + ); + } +} + +class _ConnectedRow extends StatelessWidget { + const _ConnectedRow({ + required this.session, + required this.active, + required this.onSelect, + required this.onReconnect, + required this.onClose, + }); + + final TerminalSession session; + final bool active; + final VoidCallback onSelect; + final Future Function() onReconnect; + final Future Function() onClose; + + @override + Widget build(BuildContext context) { + final l = AppLocalizations.of(context); + final colors = Theme.of(context).colorScheme; + return Material( + color: active ? colors.primaryContainer : colors.surfaceContainerHighest, + borderRadius: BorderRadius.circular(12), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: onSelect, + child: Padding( + padding: const EdgeInsets.fromLTRB(12, 10, 6, 10), + child: Row( + children: [ + _StatusDot(status: session.status), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + session.displayName, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: const TextStyle(fontWeight: FontWeight.w800), + ), + const SizedBox(height: 3), + Text( + _statusText(l, session.status), + style: TextStyle(color: colors.onSurfaceVariant), + ), + ], + ), + ), + if (active) + const Icon(Icons.check_rounded), + IconButton( + tooltip: l.tr('terminal.reconnect'), + onPressed: () => onReconnect(), + icon: const Icon(Icons.refresh, size: 20), + ), + IconButton( + tooltip: l.tr('terminal.closeTerminal'), + onPressed: () => onClose(), + icon: const Icon(Icons.close, size: 20), + ), + ], + ), + ), + ), + ); + } +} + +class _DisconnectedRow extends StatelessWidget { + const _DisconnectedRow({ required this.server, - required this.disabled, required this.connecting, required this.onTap, }); final ServerModel server; - final bool disabled; final bool connecting; final VoidCallback? onTap; @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; + final disabled = server.isWindows || !server.canConnect; final textColor = disabled ? colors.outline : colors.onSurface; return Material( color: colors.surfaceContainerHighest, diff --git a/native/lib/features/terminal/terminal_shell_page.dart b/native/lib/features/terminal/terminal_shell_page.dart index 1012bf2..e1ecf67 100644 --- a/native/lib/features/terminal/terminal_shell_page.dart +++ b/native/lib/features/terminal/terminal_shell_page.dart @@ -571,9 +571,6 @@ class _TerminalShellPageState extends ConsumerState { showSearchBar: _showSearchBar, onClose: _closeActive, onSearchToggle: active == null ? null : _toggleSearchBar, - onReconnect: active == null - ? null - : () => manager.reconnect(active.id), ), Expanded( child: ColoredBox( @@ -742,14 +739,12 @@ class _TerminalTopBar extends StatelessWidget { required this.showSearchBar, required this.onClose, required this.onSearchToggle, - required this.onReconnect, }); final TerminalSession? active; final bool showSearchBar; final VoidCallback onClose; final VoidCallback? onSearchToggle; - final VoidCallback? onReconnect; @override Widget build(BuildContext context) { @@ -805,11 +800,6 @@ class _TerminalTopBar extends StatelessWidget { : null, ), ), - IconButton( - tooltip: l.tr('terminal.reconnect'), - onPressed: onReconnect, - icon: const Icon(Icons.refresh), - ), IconButton( tooltip: l.tr('terminal.closeTerminal'), onPressed: activeSession == null ? null : onClose, diff --git a/native/lib/l10n/strings_en.dart b/native/lib/l10n/strings_en.dart index 2b23009..b8370b0 100644 --- a/native/lib/l10n/strings_en.dart +++ b/native/lib/l10n/strings_en.dart @@ -473,6 +473,9 @@ const Map stringsEn = { 'terminal.menu.newConnection': 'New', 'terminal.menu.sftp': 'SFTP', 'terminal.menu.commandInput': 'Command Input', + 'terminal.menu.connections': 'Connections', + 'terminal.connections.connected': 'Connected', + 'terminal.connections.disconnected': 'Disconnected', 'terminal.commandInput.hint': 'Enter command...', 'terminal.commandInput.send': 'Send', 'terminal.script.send': 'Send', diff --git a/native/lib/l10n/strings_zh.dart b/native/lib/l10n/strings_zh.dart index a4fba1e..8e83229 100644 --- a/native/lib/l10n/strings_zh.dart +++ b/native/lib/l10n/strings_zh.dart @@ -446,6 +446,9 @@ const Map stringsZh = { 'terminal.menu.newConnection': '新建连接', 'terminal.menu.sftp': 'SFTP', 'terminal.menu.commandInput': '长命令输入', + 'terminal.menu.connections': '连接', + 'terminal.connections.connected': '已连接', + 'terminal.connections.disconnected': '未连接', 'terminal.commandInput.hint': '输入命令...', 'terminal.commandInput.send': '发送', 'terminal.script.send': '发送',