diff --git a/flutter/lib/desktop/pages/terminal_page.dart b/flutter/lib/desktop/pages/terminal_page.dart index 17bd86eef56..0070cd73b80 100644 --- a/flutter/lib/desktop/pages/terminal_page.dart +++ b/flutter/lib/desktop/pages/terminal_page.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hbb/common.dart'; @@ -15,6 +16,7 @@ class TerminalPage extends StatefulWidget { required this.tabController, required this.isSharedPassword, required this.terminalId, + required this.tabKey, this.forceRelay, this.connToken, }) : super(key: key); @@ -25,6 +27,8 @@ class TerminalPage extends StatefulWidget { final bool? isSharedPassword; final String? connToken; final int terminalId; + /// Tab key for focus management, passed from parent to avoid duplicate construction + final String tabKey; final SimpleWrapper?> _lastState = SimpleWrapper(null); FFI get ffi => (_lastState.value! as _TerminalPageState)._ffi; @@ -42,11 +46,16 @@ class _TerminalPageState extends State late FFI _ffi; late TerminalModel _terminalModel; double? _cellHeight; + final FocusNode _terminalFocusNode = FocusNode(canRequestFocus: false); + StreamSubscription? _tabStateSubscription; @override void initState() { super.initState(); + // Listen for tab selection changes to request focus + _tabStateSubscription = widget.tabController.state.listen(_onTabStateChanged); + // Use shared FFI instance from connection manager _ffi = TerminalConnectionManager.getConnection( peerId: widget.id, @@ -64,6 +73,13 @@ class _TerminalPageState extends State _terminalModel.onResizeExternal = (w, h, pw, ph) { _cellHeight = ph * 1.0; + // Enable focus once terminal has valid dimensions (first valid resize) + if (!_terminalFocusNode.canRequestFocus && w > 0 && h > 0) { + _terminalFocusNode.canRequestFocus = true; + // Auto-focus if this tab is currently selected + _requestFocusIfSelected(); + } + // Schedule the setState for the next frame WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { @@ -99,14 +115,42 @@ class _TerminalPageState extends State @override void dispose() { + // Cancel tab state subscription to prevent memory leak + _tabStateSubscription?.cancel(); // Unregister terminal model from FFI _ffi.unregisterTerminalModel(widget.terminalId); _terminalModel.dispose(); + _terminalFocusNode.dispose(); // Release connection reference instead of closing directly TerminalConnectionManager.releaseConnection(widget.id); super.dispose(); } + void _onTabStateChanged(DesktopTabState state) { + // Check if this tab is now selected and request focus + if (state.selected >= 0 && state.selected < state.tabs.length) { + final selectedTab = state.tabs[state.selected]; + if (selectedTab.key == widget.tabKey && mounted) { + _requestFocusIfSelected(); + } + } + } + + void _requestFocusIfSelected() { + if (!mounted || !_terminalFocusNode.canRequestFocus) return; + // Use post-frame callback to ensure widget is fully laid out in focus tree + WidgetsBinding.instance.addPostFrameCallback((_) { + // Re-check conditions after frame: mounted, focusable, still selected, not already focused + if (!mounted || !_terminalFocusNode.canRequestFocus || _terminalFocusNode.hasFocus) return; + final state = widget.tabController.state.value; + if (state.selected >= 0 && state.selected < state.tabs.length) { + if (state.tabs[state.selected].key == widget.tabKey) { + _terminalFocusNode.requestFocus(); + } + } + }); + } + // This method ensures that the number of visible rows is an integer by computing the // extra space left after dividing the available height by the height of a single // terminal row (`_cellHeight`) and distributing it evenly as top and bottom padding. @@ -131,7 +175,9 @@ class _TerminalPageState extends State return TerminalView( _terminalModel.terminal, controller: _terminalModel.terminalController, - autofocus: true, + focusNode: _terminalFocusNode, + // Note: autofocus is not used here because focus is managed manually + // via _onTabStateChanged() to handle tab switching properly. backgroundOpacity: 0.7, padding: _calculatePadding(heightPx), onSecondaryTapDown: (details, offset) async { diff --git a/flutter/lib/desktop/pages/terminal_tab_page.dart b/flutter/lib/desktop/pages/terminal_tab_page.dart index cd8d84abedb..a204b867898 100644 --- a/flutter/lib/desktop/pages/terminal_tab_page.dart +++ b/flutter/lib/desktop/pages/terminal_tab_page.dart @@ -92,6 +92,7 @@ class _TerminalTabPageState extends State { key: ValueKey(tabKey), id: peerId, terminalId: terminalId, + tabKey: tabKey, password: password, isSharedPassword: isSharedPassword, tabController: tabController, diff --git a/flutter/lib/models/terminal_model.dart b/flutter/lib/models/terminal_model.dart index ca4f2c11df5..764528ab61e 100644 --- a/flutter/lib/models/terminal_model.dart +++ b/flutter/lib/models/terminal_model.dart @@ -24,6 +24,13 @@ class TerminalModel with ChangeNotifier { bool _disposed = false; final _inputBuffer = []; + // Buffer for output data received before terminal view has valid dimensions. + // This prevents NaN errors when writing to terminal before layout is complete. + final _pendingOutputChunks = []; + int _pendingOutputSize = 0; + static const int _kMaxOutputBufferChars = 8 * 1024; + // View ready state: true when terminal has valid dimensions, safe to write + bool _terminalViewReady = false; bool get isPeerWindows => parent.ffiModel.pi.platform == kPeerPlatformWindows; @@ -74,6 +81,12 @@ class TerminalModel with ChangeNotifier { // This piece of code must be placed before the conditional check in order to initialize properly. onResizeExternal?.call(w, h, pw, ph); + // Mark terminal view as ready and flush any buffered output on first valid resize. + // Must be after onResizeExternal so the view layer has valid dimensions before flushing. + if (!_terminalViewReady) { + _markViewReady(); + } + if (_terminalOpened) { // Notify remote terminal of resize try { @@ -141,7 +154,7 @@ class TerminalModel with ChangeNotifier { debugPrint('[TerminalModel] Error calling sessionOpenTerminal: $e'); // Optionally show error to user if (e is TimeoutException) { - terminal.write('Failed to open terminal: Connection timeout\r\n'); + _writeToTerminal('Failed to open terminal: Connection timeout\r\n'); } } } @@ -283,7 +296,7 @@ class TerminalModel with ChangeNotifier { })); } } else { - terminal.write('Failed to open terminal: $message\r\n'); + _writeToTerminal('Failed to open terminal: $message\r\n'); } } @@ -327,29 +340,83 @@ class TerminalModel with ChangeNotifier { return; } - terminal.write(text); + _writeToTerminal(text); } catch (e) { debugPrint('[TerminalModel] Failed to process terminal data: $e'); } } } + /// Write text to terminal, buffering if the view is not yet ready. + /// All terminal output should go through this method to avoid NaN errors + /// from writing before the terminal view has valid layout dimensions. + void _writeToTerminal(String text) { + if (!_terminalViewReady) { + // If a single chunk exceeds the cap, keep only its tail. + // Note: truncation may split a multi-byte ANSI escape sequence, + // which can cause a brief visual glitch on flush. This is acceptable + // because it only affects the pre-layout buffering window and the + // terminal will self-correct on subsequent output. + if (text.length >= _kMaxOutputBufferChars) { + final truncated = + text.substring(text.length - _kMaxOutputBufferChars); + _pendingOutputChunks + ..clear() + ..add(truncated); + _pendingOutputSize = truncated.length; + } else { + _pendingOutputChunks.add(text); + _pendingOutputSize += text.length; + // Drop oldest chunks if exceeds limit (whole chunks to preserve ANSI sequences) + while (_pendingOutputSize > _kMaxOutputBufferChars && + _pendingOutputChunks.length > 1) { + final removed = _pendingOutputChunks.removeAt(0); + _pendingOutputSize -= removed.length; + } + } + return; + } + terminal.write(text); + } + + void _flushOutputBuffer() { + if (_pendingOutputChunks.isEmpty) return; + debugPrint( + '[TerminalModel] Flushing $_pendingOutputSize buffered chars (${_pendingOutputChunks.length} chunks)'); + for (final chunk in _pendingOutputChunks) { + terminal.write(chunk); + } + _pendingOutputChunks.clear(); + _pendingOutputSize = 0; + } + + /// Mark terminal view as ready and flush buffered output. + void _markViewReady() { + if (_terminalViewReady) return; + _terminalViewReady = true; + _flushOutputBuffer(); + } + void _handleTerminalClosed(Map evt) { final int exitCode = evt['exit_code'] ?? 0; - terminal.write('\r\nTerminal closed with exit code: $exitCode\r\n'); + _writeToTerminal('\r\nTerminal closed with exit code: $exitCode\r\n'); _terminalOpened = false; notifyListeners(); } void _handleTerminalError(Map evt) { final String message = evt['message'] ?? 'Unknown error'; - terminal.write('\r\nTerminal error: $message\r\n'); + _writeToTerminal('\r\nTerminal error: $message\r\n'); } @override void dispose() { if (_disposed) return; _disposed = true; + // Clear buffers to free memory + _inputBuffer.clear(); + _pendingOutputChunks.clear(); + _pendingOutputSize = 0; // Terminal cleanup is handled server-side when service closes super.dispose(); }