From d1cd33de073c0a03fc1bbac934045f60f1b77ff1 Mon Sep 17 00:00:00 2001 From: CNWei Date: Sat, 1 Aug 2026 21:10:23 +0800 Subject: [PATCH] fix: debounce resize and enlarge broadcast buffer for TUI resize stability --- desktop/integration_test/cd_resize_test.dart | 91 ++++++++++++++++++++ desktop/integration_test/e2e_test.dart | 3 +- desktop/lib/src/terminal_screen.dart | 32 +++++-- src/ws/handler.rs | 2 +- 4 files changed, 116 insertions(+), 12 deletions(-) create mode 100644 desktop/integration_test/cd_resize_test.dart diff --git a/desktop/integration_test/cd_resize_test.dart b/desktop/integration_test/cd_resize_test.dart new file mode 100644 index 0000000..28394b3 --- /dev/null +++ b/desktop/integration_test/cd_resize_test.dart @@ -0,0 +1,91 @@ +// 专项验证:cd 跨目录 + resize 后内容不重复。 +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_alacritty/flutter_alacritty.dart'; +import 'package:integration_test/integration_test.dart'; + +import 'package:rtty_desktop/main.dart' as app; +import 'package:rtty_desktop/src/terminal_screen.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('cd 跨目录 + resize 内容完整性', (WidgetTester tester) async { + await app.main(); + await tester.pumpAndSettle(); + await tester.tap(find.text('CONNECT')); + await tester.pump(); + + // 等待连接。 + final deadline = DateTime.now().add(const Duration(seconds: 8)); + while (DateTime.now().isBefore(deadline)) { + await tester.pump(const Duration(milliseconds: 200)); + if (find.text('AWAITING CONNECTION').evaluate().isEmpty) break; + } + expect(find.text('AWAITING CONNECTION'), findsNothing); + + final engine = tester + .state(find.byType(TerminalView)) + .widget + .engine; + engine.initializeEmpty(24, 80); + + void send(String s) => + engine.write(Uint8List.fromList(s.codeUnits)); + + String gridText() { + final g = engine.gridForView; + final sb = StringBuffer(); + for (var r = 0; r < g.rows; r++) { + for (var c = 0; c < g.columns; c++) { + final cp = g.codepointAt(r, c); + sb.writeCharCode(cp == 0 ? 32 : cp); + } + sb.write('\n'); + } + return sb.toString(); + } + + Future waitFor(bool Function(String) pred, + {int secs = 6}) async { + final end = DateTime.now().add(Duration(seconds: secs)); + while (DateTime.now().isBefore(end)) { + await tester.pump(const Duration(milliseconds: 200)); + if (pred(gridText())) return true; + } + return pred(gridText()); + } + + // ---- 1. cd 跨目录 ---- + final target = r'C:\Users\NianJiu\rttytest\subdir'; + send('cd $target\r\n'); + final cdOk = await waitFor((t) => t.contains('rttytest\\subdir')); + debugPrint('cd 后 prompt 含目标目录: $cdOk'); + if (!cdOk) debugPrint('--- grid ---\n${gridText()}'); + expect(cdOk, isTrue, reason: 'cd 到绝对路径后 prompt 应显示新目录'); + + // ---- 2. resize 后内容不重复 ---- + // 记录 resize 前的非空行。 + send('dir\r\n'); + final beforeOk = await waitFor((t) => t.contains('subdir') && t.contains('dir')); + debugPrint('dir 输出: $beforeOk'); + + // 连续 resize 数次(模拟窗口拖动),验证内容不产生重复/错乱。 + for (final (c, r) in [(100, 30), (120, 35), (90, 28), (110, 32)]) { + engine.resize(columns: c, rows: r); + await tester.pump(const Duration(milliseconds: 150)); + } + engine.resize(columns: 100, rows: 30); + await tester.pump(const Duration(milliseconds: 300)); + + // 重置引擎网格尺寸后,发送简单命令,验证仍能正常回显(无重复错乱)。 + send('echo RESIZE_OK\r\n'); + final ok = await waitFor((t) => t.contains('RESIZE_OK')); + debugPrint('resize 后命令回显: $ok'); + expect(ok, isTrue, reason: 'resize 后命令应正常回显且无重复'); + + debugPrint('--- 最终 grid ---\n${gridText()}'); + }); +} diff --git a/desktop/integration_test/e2e_test.dart b/desktop/integration_test/e2e_test.dart index 18ce4af..0745a02 100644 --- a/desktop/integration_test/e2e_test.dart +++ b/desktop/integration_test/e2e_test.dart @@ -5,7 +5,6 @@ // // 运行前需先启动 Rust 服务端(cd .. && cargo run)。 // 运行:flutter test integration_test -d windows -import 'dart:async'; import 'dart:typed_data'; import 'package:flutter/foundation.dart'; @@ -74,7 +73,7 @@ String _gridText(TerminalEngine engine) { final sb = StringBuffer(); for (var r = 0; r < grid.rows; r++) { for (var c = 0; c < grid.columns; c++) { - final cp = grid.codepointAt(r, c) as int; + final cp = grid.codepointAt(r, c); sb.writeCharCode(cp == 0 ? 32 : cp); } sb.write('\n'); diff --git a/desktop/lib/src/terminal_screen.dart b/desktop/lib/src/terminal_screen.dart index e018d93..488c927 100644 --- a/desktop/lib/src/terminal_screen.dart +++ b/desktop/lib/src/terminal_screen.dart @@ -31,6 +31,11 @@ class _TerminalScreenState extends State { StreamSubscription? _engineOutputSub; StreamSubscription? _backendOutputSub; + /// resize 防抖:窗口拖动时 onPtyResize 会高频触发,合并为最后一次。 + Timer? _resizeDebounce; + int _pendingCols = 0; + int _pendingRows = 0; + final _host = TextEditingController(text: '127.0.0.1'); final _port = TextEditingController(text: '8080'); final _session = TextEditingController(); @@ -101,6 +106,8 @@ class _TerminalScreenState extends State { void _disconnect() => _tearDownSession(); void _tearDownSession() { + _resizeDebounce?.cancel(); + _resizeDebounce = null; _engineOutputSub?.cancel(); _engineOutputSub = null; _backendOutputSub?.cancel(); @@ -121,15 +128,22 @@ class _TerminalScreenState extends State { } void _handlePtyResize(int cols, int rows) { - final b = _backend; - if (b == null) return; - b.resize(rows, cols); - if (mounted) { - setState(() { - _cols = cols; - _rows = rows; - }); - } + if (!mounted) return; + setState(() { + _cols = cols; + _rows = rows; + }); + + // 合并连续 resize:只发送最后一次,避免 TUI 频繁 SIGWINCH 重绘错乱。 + _pendingCols = cols; + _pendingRows = rows; + _resizeDebounce?.cancel(); + _resizeDebounce = Timer(const Duration(milliseconds: 80), () { + final b = _backend; + if (b == null) return; + // PtyBackend.resize(rows, columns) — 注意参数顺序为行/列。 + b.resize(_pendingRows, _pendingCols); + }); } @override diff --git a/src/ws/handler.rs b/src/ws/handler.rs index 3efc2ab..de9adcf 100644 --- a/src/ws/handler.rs +++ b/src/ws/handler.rs @@ -276,7 +276,7 @@ fn create_session( ))); let reader = pty.take_reader().ok_or_else(|| anyhow::anyhow!("pty reader already taken"))?; - let (output, _) = broadcast::channel(256); + let (output, _) = broadcast::channel(4096); let session = Arc::new(Session { id, pty: Arc::new(Mutex::new(pty)),