Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 14 additions & 7 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
132 changes: 80 additions & 52 deletions lib/screens/home_screen.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand Down Expand Up @@ -549,6 +550,8 @@ class _ClientTabState extends State<ClientTab> {
<String, TerminalController>{};
final Map<String, GlobalKey<TerminalViewState>> _terminalViewKeys =
<String, GlobalKey<TerminalViewState>>{};
final Map<String, ScrollController> _scrollControllers =
<String, ScrollController>{};
bool _searchVisible = false;
final Map<String, EnterMappingInputHandler> _enterHandlers =
<String, EnterMappingInputHandler>{};
Expand All @@ -558,6 +561,9 @@ class _ClientTabState extends State<ClientTab> {
for (final controller in _controllers.values) {
controller.dispose();
}
for (final scrollController in _scrollControllers.values) {
scrollController.dispose();
}
super.dispose();
}

Expand All @@ -568,6 +574,13 @@ class _ClientTabState extends State<ClientTab> {
);
}

ScrollController _scrollControllerFor(String sessionId) {
return _scrollControllers.putIfAbsent(
sessionId,
ScrollController.new,
);
}

TerminalController _controllerFor(
String sessionId,
bool sendMouseTaps,
Expand Down Expand Up @@ -683,6 +696,13 @@ class _ClientTabState extends State<ClientTab> {
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
Expand Down Expand Up @@ -785,62 +805,70 @@ class _ClientTabState extends State<ClientTab> {
settings.sendMouseTaps,
);

return Container(
color: terminalTheme.background,
child: Column(
children: <Widget>[
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: <Widget>[
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: <Widget>[
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: <Widget>[
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,
),
),
],
),
),
),
],
],
),
),
);
}),
Expand Down
14 changes: 14 additions & 0 deletions lib/utils/terminal_selection.dart
Original file line number Diff line number Diff line change
@@ -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;
}
15 changes: 6 additions & 9 deletions lib/widgets/terminal_search_bar.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
41 changes: 41 additions & 0 deletions lib/widgets/terminal_selection_handles.dart
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -13,12 +14,14 @@ class TerminalSelectionHandles extends StatefulWidget {
required this.viewKey,
required this.terminal,
required this.controller,
this.scrollController,
super.key,
});

final GlobalKey<TerminalViewState> viewKey;
final Terminal terminal;
final TerminalController controller;
final ScrollController? scrollController;

@override
State<TerminalSelectionHandles> createState() =>
Expand Down Expand Up @@ -47,11 +50,14 @@ class _TerminalListenable extends ChangeNotifier {

class _TerminalSelectionHandlesState extends State<TerminalSelectionHandles> {
late final _TerminalListenable _terminalListenable;
ScrollController? _scrollController;
VoidCallback? _scrollListener;

@override
void initState() {
super.initState();
_terminalListenable = _TerminalListenable(widget.terminal);
_attachScrollController(widget.scrollController);
}

@override
Expand All @@ -61,14 +67,40 @@ class _TerminalSelectionHandlesState extends State<TerminalSelectionHandles> {
_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.
///
Expand All @@ -90,6 +122,10 @@ class _TerminalSelectionHandlesState extends State<TerminalSelectionHandles> {
return self.globalToLocal(global);
}

void _onHandlePanStart() {
HapticFeedback.selectionClick();
}

void _moveHandle({
required DragUpdateDetails details,
required bool isBase,
Expand Down Expand Up @@ -158,13 +194,15 @@ class _TerminalSelectionHandlesState extends State<TerminalSelectionHandles> {
position: base,
color: accent,
below: true,
onPanStart: _onHandlePanStart,
onPanUpdate: (DragUpdateDetails d) =>
_moveHandle(details: d, isBase: true),
),
_Handle(
position: extent,
color: accent,
below: false,
onPanStart: _onHandlePanStart,
onPanUpdate: (DragUpdateDetails d) =>
_moveHandle(details: d, isBase: false),
),
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading