fix(terminal): fix new tab auto-focus and NaN error on data before layout (#14357)

- Fix new tab not auto-focusing: add FocusNode to TerminalView and
  request focus when tab is selected via tab state listener
- Fix NaN error when data arrives before terminal view layout: buffer
  output data until terminal view has valid dimensions, flush on first
  valid resize callback

Signed-off-by: fufesou <linlong1266@gmail.com>
This commit is contained in:
fufesou
2026-02-20 14:44:25 +08:00
committed by GitHub
parent 34ceeac36e
commit 483fe80308
3 changed files with 120 additions and 6 deletions

View File

@@ -1,3 +1,4 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_hbb/common.dart'; import 'package:flutter_hbb/common.dart';
@@ -15,6 +16,7 @@ class TerminalPage extends StatefulWidget {
required this.tabController, required this.tabController,
required this.isSharedPassword, required this.isSharedPassword,
required this.terminalId, required this.terminalId,
required this.tabKey,
this.forceRelay, this.forceRelay,
this.connToken, this.connToken,
}) : super(key: key); }) : super(key: key);
@@ -25,6 +27,8 @@ class TerminalPage extends StatefulWidget {
final bool? isSharedPassword; final bool? isSharedPassword;
final String? connToken; final String? connToken;
final int terminalId; final int terminalId;
/// Tab key for focus management, passed from parent to avoid duplicate construction
final String tabKey;
final SimpleWrapper<State<TerminalPage>?> _lastState = SimpleWrapper(null); final SimpleWrapper<State<TerminalPage>?> _lastState = SimpleWrapper(null);
FFI get ffi => (_lastState.value! as _TerminalPageState)._ffi; FFI get ffi => (_lastState.value! as _TerminalPageState)._ffi;
@@ -42,11 +46,16 @@ class _TerminalPageState extends State<TerminalPage>
late FFI _ffi; late FFI _ffi;
late TerminalModel _terminalModel; late TerminalModel _terminalModel;
double? _cellHeight; double? _cellHeight;
final FocusNode _terminalFocusNode = FocusNode(canRequestFocus: false);
StreamSubscription<DesktopTabState>? _tabStateSubscription;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Listen for tab selection changes to request focus
_tabStateSubscription = widget.tabController.state.listen(_onTabStateChanged);
// Use shared FFI instance from connection manager // Use shared FFI instance from connection manager
_ffi = TerminalConnectionManager.getConnection( _ffi = TerminalConnectionManager.getConnection(
peerId: widget.id, peerId: widget.id,
@@ -64,6 +73,13 @@ class _TerminalPageState extends State<TerminalPage>
_terminalModel.onResizeExternal = (w, h, pw, ph) { _terminalModel.onResizeExternal = (w, h, pw, ph) {
_cellHeight = ph * 1.0; _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 // Schedule the setState for the next frame
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) { if (mounted) {
@@ -99,14 +115,42 @@ class _TerminalPageState extends State<TerminalPage>
@override @override
void dispose() { void dispose() {
// Cancel tab state subscription to prevent memory leak
_tabStateSubscription?.cancel();
// Unregister terminal model from FFI // Unregister terminal model from FFI
_ffi.unregisterTerminalModel(widget.terminalId); _ffi.unregisterTerminalModel(widget.terminalId);
_terminalModel.dispose(); _terminalModel.dispose();
_terminalFocusNode.dispose();
// Release connection reference instead of closing directly // Release connection reference instead of closing directly
TerminalConnectionManager.releaseConnection(widget.id); TerminalConnectionManager.releaseConnection(widget.id);
super.dispose(); 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 // 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 // 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. // terminal row (`_cellHeight`) and distributing it evenly as top and bottom padding.
@@ -131,7 +175,9 @@ class _TerminalPageState extends State<TerminalPage>
return TerminalView( return TerminalView(
_terminalModel.terminal, _terminalModel.terminal,
controller: _terminalModel.terminalController, 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, backgroundOpacity: 0.7,
padding: _calculatePadding(heightPx), padding: _calculatePadding(heightPx),
onSecondaryTapDown: (details, offset) async { onSecondaryTapDown: (details, offset) async {

View File

@@ -92,6 +92,7 @@ class _TerminalTabPageState extends State<TerminalTabPage> {
key: ValueKey(tabKey), key: ValueKey(tabKey),
id: peerId, id: peerId,
terminalId: terminalId, terminalId: terminalId,
tabKey: tabKey,
password: password, password: password,
isSharedPassword: isSharedPassword, isSharedPassword: isSharedPassword,
tabController: tabController, tabController: tabController,

View File

@@ -24,6 +24,13 @@ class TerminalModel with ChangeNotifier {
bool _disposed = false; bool _disposed = false;
final _inputBuffer = <String>[]; final _inputBuffer = <String>[];
// 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 = <String>[];
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; 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. // This piece of code must be placed before the conditional check in order to initialize properly.
onResizeExternal?.call(w, h, pw, ph); 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) { if (_terminalOpened) {
// Notify remote terminal of resize // Notify remote terminal of resize
try { try {
@@ -141,7 +154,7 @@ class TerminalModel with ChangeNotifier {
debugPrint('[TerminalModel] Error calling sessionOpenTerminal: $e'); debugPrint('[TerminalModel] Error calling sessionOpenTerminal: $e');
// Optionally show error to user // Optionally show error to user
if (e is TimeoutException) { 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 { } 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; return;
} }
terminal.write(text); _writeToTerminal(text);
} catch (e) { } catch (e) {
debugPrint('[TerminalModel] Failed to process terminal data: $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<String, dynamic> evt) { void _handleTerminalClosed(Map<String, dynamic> evt) {
final int exitCode = evt['exit_code'] ?? 0; 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; _terminalOpened = false;
notifyListeners(); notifyListeners();
} }
void _handleTerminalError(Map<String, dynamic> evt) { void _handleTerminalError(Map<String, dynamic> evt) {
final String message = evt['message'] ?? 'Unknown error'; final String message = evt['message'] ?? 'Unknown error';
terminal.write('\r\nTerminal error: $message\r\n'); _writeToTerminal('\r\nTerminal error: $message\r\n');
} }
@override @override
void dispose() { void dispose() {
if (_disposed) return; if (_disposed) return;
_disposed = true; _disposed = true;
// Clear buffers to free memory
_inputBuffer.clear();
_pendingOutputChunks.clear();
_pendingOutputSize = 0;
// Terminal cleanup is handled server-side when service closes // Terminal cleanup is handled server-side when service closes
super.dispose(); super.dispose();
} }