diff --git a/CHANGELOG.md b/CHANGELOG.md index f3eb939..402a6eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,11 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Terminal search jump-to-match no longer breaks text selection (anchor ownership). +- Selection handles stay aligned while scrolling the terminal scrollback. + +### Changed + +- Copy from the terminal selection toolbar clears the selection and shows a character count. +- Mouse/tap events to the remote session are paused while a selection is active. + + - - - - ## [1.2.1] - 2026-10-13 ### Changed @@ -38,9 +45,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Remove empty assets directory declaration causing CI analyze failure - - - + + + ## [1.0.3] - 2026-07-13 ### Changed diff --git a/lib/screens/home_screen.dart b/lib/screens/home_screen.dart index 0cd2cd8..14ef568 100644 --- a/lib/screens/home_screen.dart +++ b/lib/screens/home_screen.dart @@ -32,6 +32,7 @@ import '../utils/terminal_themes.dart'; import '../utils/enter_mapping_input_handler.dart'; import '../utils/terminal_enter_mapping.dart'; import '../widgets/terminal_selection_handles.dart'; +import '../widgets/terminal_selection_input_guard.dart'; import '../widgets/terminal_selection_toolbar.dart'; enum AppTab { client, agents, logs } @@ -549,6 +550,8 @@ class _ClientTabState extends State { {}; final Map> _terminalViewKeys = >{}; + final Map _scrollControllers = + {}; bool _searchVisible = false; final Map _enterHandlers = {}; @@ -558,6 +561,9 @@ class _ClientTabState extends State { for (final controller in _controllers.values) { controller.dispose(); } + for (final scrollController in _scrollControllers.values) { + scrollController.dispose(); + } super.dispose(); } @@ -568,6 +574,13 @@ class _ClientTabState extends State { ); } + ScrollController _scrollControllerFor(String sessionId) { + return _scrollControllers.putIfAbsent( + sessionId, + ScrollController.new, + ); + } + TerminalController _controllerFor( String sessionId, bool sendMouseTaps, @@ -683,6 +696,13 @@ class _ClientTabState extends State { return true; }); _terminalViewKeys.removeWhere((id, _) => !activeIds.contains(id)); + _scrollControllers.removeWhere((id, scrollController) { + if (activeIds.contains(id)) { + return false; + } + scrollController.dispose(); + return true; + }); final terminalTheme = settings.appTheme == AppTheme.hacker && settings.terminalColorTheme == TerminalColorTheme.standard @@ -785,62 +805,70 @@ class _ClientTabState extends State { settings.sendMouseTaps, ); - return Container( - color: terminalTheme.background, - child: Column( - children: [ - TerminalSelectionToolbar( - terminal: active.terminal, - controller: controller, - onPaste: () async { - final data = - await Clipboard.getData(Clipboard.kTextPlain); - final text = data?.text; - if (text == null || text.isEmpty) { - if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Clipboard is empty'), - ), - ); - } - return; - } - ssh.sendString(text); - }, - ), - Expanded( - child: Stack( - children: [ - TerminalView( - key: _viewKeyFor(active.id), - active.terminal, - controller: controller, - padding: const EdgeInsets.all(8), - theme: terminalTheme, - textStyle: - TerminalStyleBuilder.buildTerminalStyle( - settings, - ), - autoResize: true, - onSecondaryTapDown: (_, __) { - active.terminal.keyInput( - TerminalKey.f10, - shift: true, + final ScrollController scrollController = + _scrollControllerFor(active.id); + + return TerminalSelectionInputGuard( + controller: controller, + child: Container( + color: terminalTheme.background, + child: Column( + children: [ + TerminalSelectionToolbar( + terminal: active.terminal, + controller: controller, + onPaste: () async { + final data = + await Clipboard.getData(Clipboard.kTextPlain); + final text = data?.text; + if (text == null || text.isEmpty) { + if (context.mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Clipboard is empty'), + ), ); - }, - ), - Positioned.fill( - child: TerminalSelectionHandles( - viewKey: _viewKeyFor(active.id), - terminal: active.terminal, + } + return; + } + ssh.sendString(text); + }, + ), + Expanded( + child: Stack( + children: [ + TerminalView( + key: _viewKeyFor(active.id), + active.terminal, controller: controller, + scrollController: scrollController, + padding: const EdgeInsets.all(8), + theme: terminalTheme, + textStyle: + TerminalStyleBuilder.buildTerminalStyle( + settings, + ), + autoResize: true, + onSecondaryTapDown: (_, __) { + active.terminal.keyInput( + TerminalKey.f10, + shift: true, + ); + }, ), - ), - ], + Positioned.fill( + child: TerminalSelectionHandles( + viewKey: _viewKeyFor(active.id), + terminal: active.terminal, + controller: controller, + scrollController: scrollController, + ), + ), + ], + ), ), - ), - ], + ], + ), ), ); }), diff --git a/lib/utils/terminal_selection.dart b/lib/utils/terminal_selection.dart new file mode 100644 index 0000000..a0ed3a5 --- /dev/null +++ b/lib/utils/terminal_selection.dart @@ -0,0 +1,14 @@ +import 'package:xterm/xterm.dart'; + +/// Returns the plain text for the controller's current selection, if any. +String? selectedTerminalText(Terminal terminal, TerminalController controller) { + final BufferRange? selection = controller.selection; + if (selection == null) { + return null; + } + final String text = terminal.buffer.getText(selection); + if (text.isEmpty) { + return null; + } + return text; +} diff --git a/lib/widgets/terminal_search_bar.dart b/lib/widgets/terminal_search_bar.dart index 3335f50..428b580 100644 --- a/lib/widgets/terminal_search_bar.dart +++ b/lib/widgets/terminal_search_bar.dart @@ -71,15 +71,12 @@ class TerminalSearchSession { if (match.line < 0 || match.line >= terminal.buffer.height) { return; } - final BufferLine line = terminal.buffer.lines[match.line]; - final CellAnchor start = line.createAnchor(match.startX); - final CellAnchor end = line.createAnchor(match.endX); - try { - controller.setSelection(start, end); - } finally { - start.dispose(); - end.dispose(); - } + final CellOffset begin = CellOffset(match.startX, match.line); + final CellOffset end = CellOffset(match.endX, match.line); + controller.setSelection( + terminal.buffer.createAnchorFromOffset(begin), + terminal.buffer.createAnchorFromOffset(end), + ); } void _refresh() { diff --git a/lib/widgets/terminal_selection_handles.dart b/lib/widgets/terminal_selection_handles.dart index 535cf5a..3c8736a 100644 --- a/lib/widgets/terminal_selection_handles.dart +++ b/lib/widgets/terminal_selection_handles.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:xterm/xterm.dart'; /// Draggable Android-style selection handles for the terminal. @@ -13,12 +14,14 @@ class TerminalSelectionHandles extends StatefulWidget { required this.viewKey, required this.terminal, required this.controller, + this.scrollController, super.key, }); final GlobalKey viewKey; final Terminal terminal; final TerminalController controller; + final ScrollController? scrollController; @override State createState() => @@ -47,11 +50,14 @@ class _TerminalListenable extends ChangeNotifier { class _TerminalSelectionHandlesState extends State { late final _TerminalListenable _terminalListenable; + ScrollController? _scrollController; + VoidCallback? _scrollListener; @override void initState() { super.initState(); _terminalListenable = _TerminalListenable(widget.terminal); + _attachScrollController(widget.scrollController); } @override @@ -61,14 +67,40 @@ class _TerminalSelectionHandlesState extends State { _terminalListenable.dispose(); _terminalListenable = _TerminalListenable(widget.terminal); } + if (!identical(oldWidget.scrollController, widget.scrollController)) { + _detachScrollController(); + _attachScrollController(widget.scrollController); + } } @override void dispose() { + _detachScrollController(); _terminalListenable.dispose(); super.dispose(); } + void _attachScrollController(ScrollController? controller) { + _scrollController = controller; + if (controller == null) { + return; + } + _scrollListener = () { + if (mounted) { + setState(() {}); + } + }; + controller.addListener(_scrollListener!); + } + + void _detachScrollController() { + if (_scrollController != null && _scrollListener != null) { + _scrollController!.removeListener(_scrollListener!); + } + _scrollController = null; + _scrollListener = null; + } + /// Pixel position of [cell] within this layer, or null when the terminal /// view has no laid-out render object yet. /// @@ -90,6 +122,10 @@ class _TerminalSelectionHandlesState extends State { return self.globalToLocal(global); } + void _onHandlePanStart() { + HapticFeedback.selectionClick(); + } + void _moveHandle({ required DragUpdateDetails details, required bool isBase, @@ -158,6 +194,7 @@ class _TerminalSelectionHandlesState extends State { position: base, color: accent, below: true, + onPanStart: _onHandlePanStart, onPanUpdate: (DragUpdateDetails d) => _moveHandle(details: d, isBase: true), ), @@ -165,6 +202,7 @@ class _TerminalSelectionHandlesState extends State { position: extent, color: accent, below: false, + onPanStart: _onHandlePanStart, onPanUpdate: (DragUpdateDetails d) => _moveHandle(details: d, isBase: false), ), @@ -192,12 +230,14 @@ class _Handle extends StatelessWidget { required this.position, required this.color, required this.below, + required this.onPanStart, required this.onPanUpdate, }); final Offset position; final Color color; final bool below; + final VoidCallback onPanStart; final GestureDragUpdateCallback onPanUpdate; @override @@ -231,6 +271,7 @@ class _Handle extends StatelessWidget { below ? 'selection-handle-base' : 'selection-handle-extent', ), behavior: HitTestBehavior.translucent, + onPanStart: (_) => onPanStart(), onPanUpdate: onPanUpdate, child: Center( child: below diff --git a/lib/widgets/terminal_selection_input_guard.dart b/lib/widgets/terminal_selection_input_guard.dart new file mode 100644 index 0000000..937b6b6 --- /dev/null +++ b/lib/widgets/terminal_selection_input_guard.dart @@ -0,0 +1,64 @@ +import 'package:flutter/material.dart'; +import 'package:xterm/xterm.dart'; + +/// Suspends mouse/tap forwarding to the remote terminal while text is selected. +/// +/// Prevents accidental clicks to vim/tmux when using the selection toolbar or +/// dragging handles. xterm still handles long-press and drag selection locally. +class TerminalSelectionInputGuard extends StatefulWidget { + const TerminalSelectionInputGuard({ + required this.controller, + required this.child, + super.key, + }); + + final TerminalController controller; + final Widget child; + + @override + State createState() => + _TerminalSelectionInputGuardState(); +} + +class _TerminalSelectionInputGuardState + extends State { + bool _suspended = false; + + @override + void initState() { + super.initState(); + widget.controller.addListener(_syncSuspend); + _syncSuspend(); + } + + @override + void didUpdateWidget(TerminalSelectionInputGuard oldWidget) { + super.didUpdateWidget(oldWidget); + if (!identical(oldWidget.controller, widget.controller)) { + oldWidget.controller.removeListener(_syncSuspend); + widget.controller.addListener(_syncSuspend); + _syncSuspend(); + } + } + + @override + void dispose() { + widget.controller.removeListener(_syncSuspend); + if (_suspended) { + widget.controller.setSuspendPointerInput(false); + } + super.dispose(); + } + + void _syncSuspend() { + final bool shouldSuspend = widget.controller.selection != null; + if (shouldSuspend == _suspended) { + return; + } + _suspended = shouldSuspend; + widget.controller.setSuspendPointerInput(shouldSuspend); + } + + @override + Widget build(BuildContext context) => widget.child; +} diff --git a/lib/widgets/terminal_selection_toolbar.dart b/lib/widgets/terminal_selection_toolbar.dart index 39a44ef..c3ac8d8 100644 --- a/lib/widgets/terminal_selection_toolbar.dart +++ b/lib/widgets/terminal_selection_toolbar.dart @@ -2,6 +2,8 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:xterm/xterm.dart'; +import 'package:ssh_app/utils/terminal_selection.dart'; + /// Compact action bar shown while the terminal has an active selection. class TerminalSelectionToolbar extends StatelessWidget { const TerminalSelectionToolbar({ @@ -15,27 +17,21 @@ class TerminalSelectionToolbar extends StatelessWidget { final TerminalController controller; final Future Function() onPaste; - String? _selectedText() { - final BufferRange? selection = controller.selection; - if (selection == null) { - return null; - } - final String text = terminal.buffer.getText(selection); - if (text.isEmpty) { - return null; - } - return text; - } - - Future _copy(BuildContext context) async { - final String? text = _selectedText(); + Future _copy(BuildContext context, {required bool clearAfter}) async { + final String? text = selectedTerminalText(terminal, controller); if (text == null) { return; } await Clipboard.setData(ClipboardData(text: text)); + if (clearAfter) { + controller.clearSelection(); + } if (context.mounted) { + final int chars = text.length; + final String label = + chars == 1 ? 'Copied 1 character' : 'Copied $chars characters'; ScaffoldMessenger.of(context).showSnackBar( - const SnackBar(content: Text('Copied to clipboard')), + SnackBar(content: Text(label)), ); } } @@ -63,8 +59,10 @@ class TerminalSelectionToolbar extends StatelessWidget { if (!hasSelection) { return const SizedBox.shrink(); } + final ColorScheme colors = Theme.of(context).colorScheme; return Material( - color: const Color(0xFF16213E), + elevation: 2, + color: colors.surfaceContainerHigh, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), child: SingleChildScrollView( @@ -73,7 +71,7 @@ class TerminalSelectionToolbar extends StatelessWidget { mainAxisSize: MainAxisSize.min, children: [ TextButton.icon( - onPressed: () => _copy(context), + onPressed: () => _copy(context, clearAfter: true), icon: const Icon(Icons.copy, size: 16), label: const Text('Copy'), ), diff --git a/pubspec.lock b/pubspec.lock index 0818b02..30952ee 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,26 +5,26 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: fdcd9f70f9eb80df3bc5ed0fa67280df2595fd481948df8a9ab082e6a40ad04b + sha256: "48e48f73a431eb269eec66d243a63a07ed90ef9417a74047a3b98d52338626ad" url: "https://pub.dev" source: hosted - version: "108.0.0" + version: "109.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: a51c769bff3b6dfbe9d60199b8606d808290702a296bef0c26a4ca391d414e46 + sha256: "6d377901d198536b648172974d9d4f277910502cad92193466869cac0ff34990" url: "https://pub.dev" source: hosted - version: "14.4.0" + version: "14.5.0" android_file_picker: dependency: transitive description: name: android_file_picker - sha256: "14ab27769b54c48d5a8a71aa9858372b7a3ae77572ea0a8aee744643c5d8c53d" + sha256: "0aac28063a1b19893f02a55dc8e2318799814881b7f920df2d12d0d043c8fbbb" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.1.0" ansicolor: dependency: transitive description: @@ -37,10 +37,10 @@ packages: dependency: transitive description: name: archive - sha256: "6c5bcd986e06b94e3c40244af471750840a3d2341d1f9763a1100a14add517b4" + sha256: "0d58d1fa1fddf6588251ccf2819f3fb3b6d0452c1b2813ac7413a05592bfbefe" url: "https://pub.dev" source: hosted - version: "4.3.0" + version: "4.5.2" args: dependency: transitive description: @@ -101,10 +101,10 @@ packages: dependency: "direct dev" description: name: build_runner - sha256: "894c243f6bc32015fec466ce30a6925bd537a77a426ee5bf481120477eb3de67" + sha256: "6feadd32d90a68253c1f7b4fcff76adb4b41ad32a53f8bad835dc65f01ac9dc5" url: "https://pub.dev" source: hosted - version: "2.16.1" + version: "2.16.2" built_collection: dependency: transitive description: @@ -309,34 +309,34 @@ packages: dependency: transitive description: name: file_picker_darwin - sha256: "51aef9f4c80449c736e7cc02198675fda73689f0bd45a84da642cfeab0250d46" + sha256: c40e125317842be577a05ffa111655094d7ff52734e3bbb328829c0d5c23fe14 url: "https://pub.dev" source: hosted - version: "2.1.2" + version: "2.3.0" file_picker_linux: dependency: transitive description: name: file_picker_linux - sha256: "9c870771500c758eb46506c98532c3ee60e6e51d0a62dcde24a849ef213ea5ce" + sha256: c6562fc7d426db2fb85ea66fe2627953d2709932df5584ceaacf0aa5e39cf4da url: "https://pub.dev" source: hosted - version: "2.0.1" + version: "2.0.2" file_picker_platform_interface: dependency: transitive description: name: file_picker_platform_interface - sha256: bbdc085a6f168e63f147e9ce8988f79fa0800a58e7f25a48f762c60837107ba5 + sha256: "336e7168306834a2b68e6168ed768e00d3fabeac23154d5793e277ca9f97cf20" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.2.0" file_picker_web: dependency: transitive description: name: file_picker_web - sha256: b3004268da0c1b52baa18df430e7ec4438194de855a807d26b447cbf07ef3496 + sha256: "32af8262aca907b02b8970b32328b0312db0b42ba6f930be6d27fde179d59b7d" url: "https://pub.dev" source: hosted - version: "4.0.0" + version: "4.1.0" fixnum: dependency: transitive description: @@ -507,10 +507,10 @@ packages: dependency: transitive description: name: image - sha256: a1e7f4951e538a568e14b856702afc9ae1d2f4b202daced8d22c1b9cd211ce89 + sha256: "89ed75b2f8231fe676808c5564f9590d728024ded7ac782a7ed8474f1c95b72a" url: "https://pub.dev" source: hosted - version: "4.10.1" + version: "4.12.0" intl: dependency: "direct main" description: @@ -531,18 +531,18 @@ packages: dependency: transitive description: name: jni - sha256: f038e58b4dc2c9037f50e233175086337e0b305e356d28211bf55f21c504cbd3 + sha256: "6b9ca0602fef0230e83149afbe3f9f8227e94f2bbe84a1da4f14d699742f402d" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.1.0" jni_flutter: dependency: transitive description: name: jni_flutter - sha256: b2310cdd4c18c65c081ab141a41efa94aa26c65431803703ece51996f174f351 + sha256: "0dd33d67797560f66e961a6053882d4df54bd345bf063b8fe7ea133f7ad8b191" url: "https://pub.dev" source: hosted - version: "1.0.3" + version: "1.0.4+1" jni_util: dependency: transitive description: @@ -691,10 +691,10 @@ packages: dependency: transitive description: name: objective_c - sha256: ad56fd53a78ff6b1472fa59ff2a4e8b8ccabafc586fc263a1dfad0b99b5553e3 + sha256: "5c80c2ad6e2c0397de5db38c6d653e69616a3397e13aa7633bf39eb668f2041b" url: "https://pub.dev" source: hosted - version: "9.6.0" + version: "9.6.2" opencode_api: dependency: "direct main" description: @@ -907,10 +907,10 @@ packages: dependency: "direct main" description: name: shared_preferences - sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + sha256: "1e85e0dccf5b4a016077fea7dfbb4a7cb39c759ac1596fcf60c06deadb7da91f" url: "https://pub.dev" source: hosted - version: "2.5.5" + version: "2.5.6" shared_preferences_android: dependency: transitive description: @@ -1064,10 +1064,10 @@ packages: dependency: "direct main" description: name: url_launcher - sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + sha256: e8cc955c97e06df9879140a1fe301f3d493f01b7c62dec7f46834be31d7e9fda url: "https://pub.dev" source: hosted - version: "6.3.2" + version: "6.3.3" url_launcher_android: dependency: transitive description: @@ -1200,10 +1200,10 @@ packages: dependency: transitive description: name: windows_file_picker - sha256: "9f3aa833068b09e380fdc59560ad260a4a14e9397173abb533699f967443602e" + sha256: "18452aa293de3b6961c6d05b0e71b4be7bb90c67635abdd3aa5c4e75dd875072" url: "https://pub.dev" source: hosted - version: "2.0.0" + version: "2.0.1" xdg_directories: dependency: transitive description: diff --git a/test/utils/terminal_selection_test.dart b/test/utils/terminal_selection_test.dart new file mode 100644 index 0000000..bb6d160 --- /dev/null +++ b/test/utils/terminal_selection_test.dart @@ -0,0 +1,26 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:xterm/xterm.dart'; + +import 'package:ssh_app/utils/terminal_selection.dart'; + +void main() { + test('selectedTerminalText returns buffer text for active selection', () { + final Terminal terminal = Terminal(maxLines: 100); + terminal.write('hello world\r\n'); + final TerminalController controller = TerminalController(); + controller.setSelection( + terminal.buffer.createAnchor(0, 0), + terminal.buffer.createAnchor(5, 0), + ); + + expect(selectedTerminalText(terminal, controller), contains('hello')); + }); + + test('selectedTerminalText is null when selection cleared', () { + final Terminal terminal = Terminal(maxLines: 100); + terminal.write('hello\r\n'); + final TerminalController controller = TerminalController(); + + expect(selectedTerminalText(terminal, controller), isNull); + }); +} diff --git a/test/widgets/terminal_search_scroll_selection_test.dart b/test/widgets/terminal_search_scroll_selection_test.dart new file mode 100644 index 0000000..e2b1f75 --- /dev/null +++ b/test/widgets/terminal_search_scroll_selection_test.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:xterm/xterm.dart'; + +import 'package:ssh_app/widgets/terminal_search_bar.dart'; + +void main() { + test('search scroll keeps selection anchors attached', () { + final Terminal terminal = Terminal(maxLines: 100); + terminal.write('find me here\r\nsecond line\r\n'); + final TerminalController controller = TerminalController(); + final TerminalSearchSession session = TerminalSearchSession( + terminal: terminal, + controller: controller, + ); + + session.search('find'); + expect(session.hasResults, isTrue); + expect(controller.selection, isNotNull); + + session.dispose(); + }); +} diff --git a/test/widgets/terminal_selection_toolbar_test.dart b/test/widgets/terminal_selection_toolbar_test.dart index 7b0ccdc..e857243 100644 --- a/test/widgets/terminal_selection_toolbar_test.dart +++ b/test/widgets/terminal_selection_toolbar_test.dart @@ -50,6 +50,7 @@ void main() { expect(clipboardText, isNotNull); expect(clipboardText, contains('hello')); + expect(controller.selection, isNull); }); testWidgets('hides when selection is cleared', (WidgetTester tester) async {