Simplify terminal session actions
This commit is contained in:
parent
0de2cfe262
commit
39ab30d715
@ -1,227 +0,0 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum TerminalInputMode { buffered, direct }
|
||||
|
||||
typedef TerminalBufferedSubmit = FutureOr<void> Function(String command);
|
||||
typedef TerminalDirectInputSink = void Function(String input);
|
||||
|
||||
class TerminalInputController extends ChangeNotifier {
|
||||
TerminalInputController({
|
||||
required TerminalBufferedSubmit onBufferedSubmit,
|
||||
required TerminalDirectInputSink onDirectInput,
|
||||
}) : _onBufferedSubmit = onBufferedSubmit,
|
||||
_onDirectInput = onDirectInput {
|
||||
focusNode.addListener(notifyListeners);
|
||||
textController.addListener(_handleTextChanged);
|
||||
_applyEditingValue(_bufferedEditingValue);
|
||||
}
|
||||
|
||||
static const String _directInputSentinel = ' ';
|
||||
static const int _directInputSelectionOffset = 2;
|
||||
|
||||
final FocusNode focusNode = FocusNode();
|
||||
final TextEditingController textController = TextEditingController();
|
||||
final TerminalBufferedSubmit _onBufferedSubmit;
|
||||
final TerminalDirectInputSink _onDirectInput;
|
||||
|
||||
TerminalInputMode _mode = TerminalInputMode.buffered;
|
||||
String _bufferedDraft = '';
|
||||
String? _composingText;
|
||||
bool _isApplyingValue = false;
|
||||
bool _isDisposed = false;
|
||||
|
||||
TerminalInputMode get mode => _mode;
|
||||
|
||||
bool get isDirectInputEnabled => _mode == TerminalInputMode.direct;
|
||||
|
||||
String? get composingText => _composingText;
|
||||
|
||||
String get hintText => isDirectInputEnabled
|
||||
? 'Direct mode: keys send immediately'
|
||||
: 'Buffered mode: type a command and send';
|
||||
|
||||
Future<void> submit() async {
|
||||
if (isDirectInputEnabled) {
|
||||
_composingText = null;
|
||||
_onDirectInput('\r');
|
||||
_restoreDirectEditingValue();
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final command = _bufferedDraft;
|
||||
if (command.trim().isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _onBufferedSubmit(command);
|
||||
_bufferedDraft = '';
|
||||
_applyEditingValue(_bufferedEditingValue);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void toggleMode() {
|
||||
setMode(
|
||||
isDirectInputEnabled
|
||||
? TerminalInputMode.buffered
|
||||
: TerminalInputMode.direct,
|
||||
);
|
||||
}
|
||||
|
||||
void setMode(TerminalInputMode mode) {
|
||||
if (_mode == mode) {
|
||||
if (mode == TerminalInputMode.direct) {
|
||||
_restoreDirectEditingValue();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_mode = mode;
|
||||
_composingText = null;
|
||||
_applyEditingValue(
|
||||
mode == TerminalInputMode.direct
|
||||
? _directEditingValue
|
||||
: _bufferedEditingValue,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void requestFocus() {
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
|
||||
void syncDirectSelection() {
|
||||
if (!isDirectInputEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
_restoreDirectEditingValue();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_isDisposed = true;
|
||||
focusNode.removeListener(notifyListeners);
|
||||
textController.removeListener(_handleTextChanged);
|
||||
focusNode.dispose();
|
||||
textController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleTextChanged() {
|
||||
if (_isApplyingValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
final value = textController.value;
|
||||
|
||||
if (!isDirectInputEnabled) {
|
||||
_bufferedDraft = value.text;
|
||||
final nextComposing = value.composing.isCollapsed
|
||||
? null
|
||||
: value.composing.textInside(value.text);
|
||||
if (_composingText != nextComposing) {
|
||||
_composingText = nextComposing;
|
||||
notifyListeners();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value.composing.isCollapsed) {
|
||||
final nextComposing = value.composing.textInside(value.text);
|
||||
if (_composingText != nextComposing) {
|
||||
_composingText = nextComposing;
|
||||
notifyListeners();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var didChange = false;
|
||||
if (_composingText != null) {
|
||||
_composingText = null;
|
||||
didChange = true;
|
||||
}
|
||||
|
||||
final backspaceCount = _detectBackspaceCount(value.text);
|
||||
if (backspaceCount > 0) {
|
||||
for (var index = 0; index < backspaceCount; index += 1) {
|
||||
_onDirectInput('\x7f');
|
||||
}
|
||||
_restoreDirectEditingValue();
|
||||
if (didChange) {
|
||||
notifyListeners();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final insertedText = _extractInsertedText(value.text);
|
||||
if (insertedText.isNotEmpty) {
|
||||
_onDirectInput(insertedText);
|
||||
_restoreDirectEditingValue();
|
||||
if (didChange) {
|
||||
notifyListeners();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.text != _directInputSentinel ||
|
||||
value.selection.baseOffset != _directInputSelectionOffset ||
|
||||
value.selection.extentOffset != _directInputSelectionOffset) {
|
||||
_restoreDirectEditingValue();
|
||||
if (didChange) {
|
||||
notifyListeners();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (didChange) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
int _detectBackspaceCount(String text) {
|
||||
if (text.length >= _directInputSentinel.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return _directInputSentinel.length - text.length;
|
||||
}
|
||||
|
||||
String _extractInsertedText(String text) {
|
||||
if (text.isEmpty || text == _directInputSentinel) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (text.startsWith(_directInputSentinel)) {
|
||||
return text.substring(_directInputSentinel.length);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
void _restoreDirectEditingValue() {
|
||||
_applyEditingValue(_directEditingValue);
|
||||
}
|
||||
|
||||
void _applyEditingValue(TextEditingValue value) {
|
||||
if (_isDisposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
_isApplyingValue = true;
|
||||
textController.value = value;
|
||||
_isApplyingValue = false;
|
||||
}
|
||||
|
||||
TextEditingValue get _bufferedEditingValue => TextEditingValue(
|
||||
text: _bufferedDraft,
|
||||
selection: TextSelection.collapsed(offset: _bufferedDraft.length),
|
||||
);
|
||||
|
||||
TextEditingValue get _directEditingValue => const TextEditingValue(
|
||||
text: _directInputSentinel,
|
||||
selection: TextSelection.collapsed(offset: _directInputSelectionOffset),
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
@ -9,6 +8,7 @@ import '../../app/app_theme.dart';
|
||||
import '../../app/ui_shell.dart';
|
||||
import '../../core/network/agent_connection_providers.dart';
|
||||
import '../../core/network/agent_error_formatter.dart';
|
||||
import '../presets/preset_command.dart';
|
||||
import '../presets/preset_management_page.dart';
|
||||
import '../presets/preset_panel.dart';
|
||||
import '../presets/preset_providers.dart';
|
||||
@ -17,7 +17,6 @@ import '../sessions/session.dart';
|
||||
import 'terminal_diagnostic_log.dart';
|
||||
import 'history_window.dart';
|
||||
import 'repeatable_terminal_key_button.dart';
|
||||
import 'terminal_input_controller.dart';
|
||||
import 'terminal_interaction_controller.dart';
|
||||
import 'terminal_session_coordinator.dart';
|
||||
import 'terminal_socket_session.dart';
|
||||
@ -98,14 +97,12 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
final FocusNode _terminalFocusNode = FocusNode(canRequestFocus: false);
|
||||
final ScrollController _terminalScrollController = ScrollController();
|
||||
late final TerminalSessionCoordinator _coordinator;
|
||||
late final TerminalInputController _inputController;
|
||||
late final Listenable _pageStateListenable;
|
||||
Timer? _historySeedTimer;
|
||||
String? _pendingHistorySeed;
|
||||
bool _receivedSocketFrame = false;
|
||||
bool _historySeeded = false;
|
||||
bool _awaitingAttachReplayFrame = true;
|
||||
bool _isKeyTrayVisible = false;
|
||||
bool _shouldReconnectOnResume = false;
|
||||
TerminalConnectionState? _lastConnectionState;
|
||||
|
||||
@ -127,23 +124,14 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
rows: terminal.viewHeight,
|
||||
),
|
||||
);
|
||||
_inputController = TerminalInputController(
|
||||
onBufferedSubmit: (command) => _sendLine(command),
|
||||
onDirectInput: _sendDirectInput,
|
||||
);
|
||||
_pageStateListenable = Listenable.merge([
|
||||
controller,
|
||||
_coordinator,
|
||||
_inputController,
|
||||
]);
|
||||
_pageStateListenable = Listenable.merge([controller, _coordinator]);
|
||||
_pageStateListenable.addListener(_handlePageStateChanged);
|
||||
terminal.onResize = (width, height, _, _) {
|
||||
_coordinator.handleTerminalResize(width, height);
|
||||
};
|
||||
terminal.onOutput = (data) {
|
||||
final normalizedData = _normalizeTerminalDirectInput(data);
|
||||
_diagnosticLog.add('ui.terminal.key', normalizedData);
|
||||
_coordinator.sendInput(normalizedData);
|
||||
_diagnosticLog.add('ui.terminal.key', data);
|
||||
_coordinator.sendInput(data);
|
||||
};
|
||||
unawaited(_coordinator.start());
|
||||
}
|
||||
@ -154,7 +142,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
_pageStateListenable.removeListener(_handlePageStateChanged);
|
||||
_historySeedTimer?.cancel();
|
||||
_terminalFocusNode.dispose();
|
||||
_inputController.dispose();
|
||||
_terminalScrollController.dispose();
|
||||
unawaited(_coordinator.close());
|
||||
controller.dispose();
|
||||
@ -244,12 +231,16 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _submitInput() async {
|
||||
void _sendEnter() {
|
||||
if (!_canSendInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _inputController.submit();
|
||||
_sendTerminalInput(
|
||||
'\r',
|
||||
diagnosticEvent: 'ui.input.send',
|
||||
detail: r'\r',
|
||||
);
|
||||
}
|
||||
|
||||
void _sendQuickKey(_QuickTerminalKey quickKey) {
|
||||
@ -269,48 +260,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
_coordinator.sendInput(input);
|
||||
}
|
||||
|
||||
void _sendDirectInput(String input) {
|
||||
_sendTerminalInput(
|
||||
input,
|
||||
diagnosticEvent: 'ui.input.direct',
|
||||
detail: _describeTerminalInput(input),
|
||||
);
|
||||
}
|
||||
|
||||
String _normalizeTerminalDirectInput(String data) {
|
||||
if (!_inputController.isDirectInputEnabled || data.isEmpty) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return data.replaceAll('\r\n', '\r').replaceAll('\n', '\r');
|
||||
}
|
||||
|
||||
void _toggleDirectInput() {
|
||||
setState(_inputController.toggleMode);
|
||||
if (_canSendInput) {
|
||||
_inputController.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleKeyTray() {
|
||||
setState(() {
|
||||
_isKeyTrayVisible = !_isKeyTrayVisible;
|
||||
});
|
||||
}
|
||||
|
||||
void _handleTerminalSurfaceTap() {
|
||||
if (!_canSendInput || !_inputController.isDirectInputEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
_inputController.requestFocus();
|
||||
_inputController.syncDirectSelection();
|
||||
}
|
||||
|
||||
String _describeTerminalInput(String input) {
|
||||
return input.replaceAll('\r', r'\r').replaceAll('\n', r'\n');
|
||||
}
|
||||
|
||||
_QuickTerminalKey _quickKey(String keyId) {
|
||||
return <_QuickTerminalKey>[
|
||||
..._symbolTerminalKeys,
|
||||
@ -505,7 +454,17 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showToolsSheet() {
|
||||
Future<void> _showActionsSheet() async {
|
||||
var presets = const <PresetCommand>[];
|
||||
try {
|
||||
presets = await ref.read(presetRepositoryProvider).listPresets();
|
||||
} catch (_) {
|
||||
presets = const <PresetCommand>[];
|
||||
}
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: const Color(0xFF13191F),
|
||||
@ -519,14 +478,14 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: Column(
|
||||
key: const Key('terminal_tools_sheet'),
|
||||
key: const Key('terminal_actions_sheet'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Terminal tools',
|
||||
'Terminal actions',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const Spacer(),
|
||||
@ -538,35 +497,15 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
'Session',
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_buildModeButton(closeSheetOnPressed: true),
|
||||
_buildIconActionButton(
|
||||
key: const Key('terminal_keys_toggle_button'),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_toggleKeyTray();
|
||||
},
|
||||
tooltip: _isKeyTrayVisible
|
||||
? 'Hide quick keys'
|
||||
: 'Show quick keys',
|
||||
icon: Icon(
|
||||
_isKeyTrayVisible
|
||||
? Icons.keyboard_arrow_down
|
||||
: Icons.keyboard_command_key,
|
||||
),
|
||||
),
|
||||
_buildIconActionButton(
|
||||
key: const Key('terminal_presets_button'),
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
unawaited(_showPresetsSheet());
|
||||
},
|
||||
tooltip: 'Show presets',
|
||||
icon: const Icon(Icons.flash_on_outlined),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
@ -603,6 +542,30 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Quick keys',
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_buildQuickKeysSection(),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Presets',
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
PresetPanel(
|
||||
presets: presets,
|
||||
onPresetSelected: (preset) {
|
||||
Navigator.of(context).pop();
|
||||
unawaited(_sendLine(preset.commandText));
|
||||
},
|
||||
onManagePressed: () {
|
||||
Navigator.of(context).pop();
|
||||
unawaited(_openPresetManagementPage());
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_coordinator.connectionStatus,
|
||||
@ -619,43 +582,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showPresetsSheet() async {
|
||||
final presets = await ref.read(presetRepositoryProvider).listPresets();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: const Color(0xFF13191F),
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: Column(
|
||||
key: const Key('terminal_presets_sheet'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
PresetPanel(
|
||||
presets: presets,
|
||||
onPresetSelected: (preset) {
|
||||
Navigator.of(context).pop();
|
||||
unawaited(_sendLine(preset.commandText));
|
||||
},
|
||||
onManagePressed: () {
|
||||
Navigator.of(context).pop();
|
||||
unawaited(_openPresetManagementPage());
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openPresetManagementPage() {
|
||||
return Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const PresetManagementPage()),
|
||||
@ -672,9 +598,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
widget.session.workingDirectory ??
|
||||
'';
|
||||
|
||||
return CallbackShortcuts(
|
||||
bindings: _shortcutBindings(),
|
||||
child: Scaffold(
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
toolbarHeight: 44,
|
||||
titleSpacing: 0,
|
||||
@ -743,6 +667,13 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
key: const Key('terminal_actions_button'),
|
||||
onPressed: _showActionsSheet,
|
||||
visualDensity: VisualDensity.compact,
|
||||
icon: const Icon(Icons.tune),
|
||||
tooltip: 'Show actions',
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
@ -752,41 +683,36 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
body: SafeArea(
|
||||
top: false,
|
||||
minimum: AppTheme.pagePadding,
|
||||
child: TextFieldTapRegion(
|
||||
child: Column(
|
||||
child: Column(
|
||||
children: [
|
||||
_buildScrollbackSection(context, isCompact),
|
||||
Expanded(
|
||||
child: GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onTap: _handleTerminalSurfaceTap,
|
||||
child: Container(
|
||||
key: const Key('terminal_surface_panel'),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
const Color(0xFF090B0E),
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.zero,
|
||||
border: Border.all(color: const Color(0xFF332B22)),
|
||||
child: Container(
|
||||
key: const Key('terminal_surface_panel'),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHighest,
|
||||
const Color(0xFF090B0E),
|
||||
],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 8,
|
||||
),
|
||||
child: TerminalView(
|
||||
terminal,
|
||||
focusNode: _terminalFocusNode,
|
||||
autofocus: false,
|
||||
scrollController: _terminalScrollController,
|
||||
),
|
||||
borderRadius: BorderRadius.zero,
|
||||
border: Border.all(color: const Color(0xFF332B22)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 8,
|
||||
),
|
||||
child: TerminalView(
|
||||
terminal,
|
||||
focusNode: _terminalFocusNode,
|
||||
autofocus: false,
|
||||
scrollController: _terminalScrollController,
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -795,10 +721,8 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
_buildCommandDeck(context, isCompact),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildScrollbackSection(BuildContext context, bool isCompact) {
|
||||
@ -1031,68 +955,22 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
builder: (context, _) {
|
||||
return Column(
|
||||
key: const Key('terminal_action_bar'),
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(child: _buildInputField(context)),
|
||||
const SizedBox(width: 8),
|
||||
_buildCommandDeckAction(
|
||||
IconButton.filledTonal(
|
||||
key: const Key('terminal_toggle_actions_button'),
|
||||
onPressed: _showToolsSheet,
|
||||
visualDensity: VisualDensity.compact,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF241F1A),
|
||||
foregroundColor: const Color(0xFFD7C4A0),
|
||||
),
|
||||
icon: const Icon(Icons.tune),
|
||||
tooltip: 'Show tools',
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_buildCommandDeckAction(
|
||||
FilledButton(
|
||||
key: const Key('terminal_send_button'),
|
||||
onPressed: _canSendInput ? _submitInput : null,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: Size(isCompact ? 40 : 0, 38),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: isCompact ? 12 : 16,
|
||||
),
|
||||
),
|
||||
child: isCompact
|
||||
? Icon(
|
||||
_inputController.isDirectInputEnabled
|
||||
? Icons.keyboard_return
|
||||
: Icons.send,
|
||||
)
|
||||
: Text(
|
||||
_inputController.isDirectInputEnabled
|
||||
? 'Enter'
|
||||
: 'Send',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_isKeyTrayVisible) ...[
|
||||
const SizedBox(height: 8),
|
||||
_buildKeyTray(),
|
||||
],
|
||||
if (_inputController.isDirectInputEnabled &&
|
||||
_inputController.composingText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Text(
|
||||
'Composing: ${_inputController.composingText}',
|
||||
key: const Key('terminal_direct_input_composing_label'),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
_buildCommandDeckAction(
|
||||
FilledButton.icon(
|
||||
key: const Key('terminal_send_button'),
|
||||
onPressed: _canSendInput ? _sendEnter : null,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(0, 42),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
),
|
||||
icon: const Icon(Icons.keyboard_return),
|
||||
label: Text(isCompact ? 'Enter' : 'Send Enter'),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
@ -1100,128 +978,33 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInputField(BuildContext context) {
|
||||
final isDirectInputEnabled = _inputController.isDirectInputEnabled;
|
||||
final baseStyle = Theme.of(context).textTheme.bodyMedium;
|
||||
|
||||
return TextField(
|
||||
controller: _inputController.textController,
|
||||
focusNode: _inputController.focusNode,
|
||||
enabled: _canSendInput,
|
||||
keyboardType: TextInputType.text,
|
||||
textInputAction: TextInputAction.send,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
enableIMEPersonalizedLearning: false,
|
||||
smartDashesType: SmartDashesType.disabled,
|
||||
smartQuotesType: SmartQuotesType.disabled,
|
||||
textCapitalization: TextCapitalization.none,
|
||||
enableInteractiveSelection: !isDirectInputEnabled,
|
||||
showCursor: !isDirectInputEnabled,
|
||||
selectAllOnFocus: false,
|
||||
style: isDirectInputEnabled
|
||||
? baseStyle?.copyWith(color: Colors.transparent)
|
||||
: baseStyle,
|
||||
cursorColor: isDirectInputEnabled
|
||||
? Colors.transparent
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
decoration: InputDecoration(
|
||||
hintText: _inputController.hintText,
|
||||
isDense: true,
|
||||
),
|
||||
onTap: _inputController.syncDirectSelection,
|
||||
onEditingComplete: () {},
|
||||
onSubmitted: (_) => _submitInput(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildModeButton({bool closeSheetOnPressed = false}) {
|
||||
return KeyedSubtree(
|
||||
key: const Key('terminal_direct_input_toggle'),
|
||||
child: _buildIconActionButton(
|
||||
key: const Key('terminal_mode_button'),
|
||||
onPressed: _canSendInput
|
||||
? () {
|
||||
if (closeSheetOnPressed) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
_toggleDirectInput();
|
||||
}
|
||||
: null,
|
||||
tooltip: _inputController.isDirectInputEnabled
|
||||
? 'Switch to buffered mode'
|
||||
: 'Switch to direct mode',
|
||||
icon: Icon(
|
||||
_inputController.isDirectInputEnabled
|
||||
? Icons.keyboard_hide
|
||||
: Icons.keyboard,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildIconActionButton({
|
||||
required Key key,
|
||||
required VoidCallback? onPressed,
|
||||
required String tooltip,
|
||||
required Widget icon,
|
||||
}) {
|
||||
return _buildCommandDeckAction(
|
||||
IconButton.filledTonal(
|
||||
key: key,
|
||||
onPressed: onPressed,
|
||||
visualDensity: VisualDensity.compact,
|
||||
style: IconButton.styleFrom(
|
||||
backgroundColor: const Color(0xFF241F1A),
|
||||
foregroundColor: const Color(0xFFD7C4A0),
|
||||
),
|
||||
tooltip: tooltip,
|
||||
icon: icon,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildKeyTray() {
|
||||
return Container(
|
||||
key: const Key('terminal_key_tray'),
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF11161B),
|
||||
border: Border.all(color: const Color(0xFF3F3428)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_buildKeyTrayRow(_symbolTerminalKeys),
|
||||
const SizedBox(height: 8),
|
||||
_buildKeyTrayRow(_controlTerminalKeys),
|
||||
],
|
||||
),
|
||||
Widget _buildQuickKeysSection() {
|
||||
return Column(
|
||||
children: [
|
||||
_buildKeyTrayRow(_controlTerminalKeys),
|
||||
const SizedBox(height: 8),
|
||||
_buildKeyTrayRow(_symbolTerminalKeys),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildKeyTrayRow(List<_QuickTerminalKey> keys) {
|
||||
return SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: keys
|
||||
.map((quickKey) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: _buildCommandDeckAction(
|
||||
RepeatableTerminalKeyButton(
|
||||
key: Key('terminal_quick_key_${quickKey.keyId}'),
|
||||
enabled: _canSendInput,
|
||||
repeatable: quickKey.repeatable,
|
||||
label: quickKey.label,
|
||||
onPressed: () => _sendQuickKey(quickKey),
|
||||
),
|
||||
),
|
||||
);
|
||||
})
|
||||
.toList(growable: false),
|
||||
),
|
||||
return Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: keys
|
||||
.map((quickKey) {
|
||||
return _buildCommandDeckAction(
|
||||
RepeatableTerminalKeyButton(
|
||||
key: Key('terminal_quick_key_${quickKey.keyId}'),
|
||||
enabled: _canSendInput,
|
||||
repeatable: quickKey.repeatable,
|
||||
label: quickKey.label,
|
||||
onPressed: () => _sendQuickKey(quickKey),
|
||||
),
|
||||
);
|
||||
})
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
|
||||
@ -1229,42 +1012,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
return ExcludeFocus(child: child);
|
||||
}
|
||||
|
||||
Map<ShortcutActivator, VoidCallback> _shortcutBindings() {
|
||||
if (!_inputController.isDirectInputEnabled || !_canSendInput) {
|
||||
return const <ShortcutActivator, VoidCallback>{};
|
||||
}
|
||||
|
||||
return <ShortcutActivator, VoidCallback>{
|
||||
const SingleActivator(LogicalKeyboardKey.escape): () {
|
||||
_sendQuickKey(_quickKey('esc'));
|
||||
},
|
||||
const SingleActivator(LogicalKeyboardKey.tab): () {
|
||||
_sendQuickKey(_quickKey('tab'));
|
||||
},
|
||||
const SingleActivator(LogicalKeyboardKey.arrowUp): () {
|
||||
_sendQuickKey(_quickKey('up'));
|
||||
},
|
||||
const SingleActivator(LogicalKeyboardKey.arrowDown): () {
|
||||
_sendQuickKey(_quickKey('down'));
|
||||
},
|
||||
const SingleActivator(LogicalKeyboardKey.arrowLeft): () {
|
||||
_sendQuickKey(_quickKey('left'));
|
||||
},
|
||||
const SingleActivator(LogicalKeyboardKey.arrowRight): () {
|
||||
_sendQuickKey(_quickKey('right'));
|
||||
},
|
||||
const SingleActivator(LogicalKeyboardKey.keyC, control: true): () {
|
||||
_sendQuickKey(_quickKey('ctrl_c'));
|
||||
},
|
||||
const SingleActivator(LogicalKeyboardKey.keyD, control: true): () {
|
||||
_sendQuickKey(_quickKey('ctrl_d'));
|
||||
},
|
||||
const SingleActivator(LogicalKeyboardKey.keyL, control: true): () {
|
||||
_sendQuickKey(_quickKey('ctrl_l'));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
String get _statusLabel => switch (_connectionState) {
|
||||
TerminalConnectionState.connecting => 'Connecting',
|
||||
TerminalConnectionState.connected => 'Connected',
|
||||
|
||||
@ -1,67 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_input_controller.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test(
|
||||
'direct mode emits inserted text, backspace, and carriage return',
|
||||
() async {
|
||||
final directInputs = <String>[];
|
||||
final bufferedCommands = <String>[];
|
||||
final controller = TerminalInputController(
|
||||
onBufferedSubmit: bufferedCommands.add,
|
||||
onDirectInput: directInputs.add,
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
controller.setMode(TerminalInputMode.direct);
|
||||
|
||||
controller.textController.value = const TextEditingValue(
|
||||
text: 'ls',
|
||||
selection: TextSelection.collapsed(offset: 2),
|
||||
);
|
||||
expect(directInputs, ['ls']);
|
||||
expect(controller.textController.text, ' ');
|
||||
expect(controller.textController.selection.baseOffset, 2);
|
||||
|
||||
controller.textController.value = const TextEditingValue(
|
||||
text: ' ',
|
||||
selection: TextSelection.collapsed(offset: 1),
|
||||
);
|
||||
expect(directInputs, ['ls', '\x7f']);
|
||||
expect(controller.textController.text, ' ');
|
||||
expect(controller.textController.selection.baseOffset, 2);
|
||||
|
||||
await controller.submit();
|
||||
expect(directInputs, ['ls', '\x7f', '\r']);
|
||||
expect(bufferedCommands, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'switching modes preserves the buffered draft at the end of the field',
|
||||
() {
|
||||
final controller = TerminalInputController(
|
||||
onBufferedSubmit: (_) {},
|
||||
onDirectInput: (_) {},
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
controller.textController.value = const TextEditingValue(
|
||||
text: 'git status',
|
||||
selection: TextSelection.collapsed(offset: 10),
|
||||
);
|
||||
|
||||
controller.setMode(TerminalInputMode.direct);
|
||||
expect(controller.textController.text, ' ');
|
||||
expect(controller.textController.selection.baseOffset, 2);
|
||||
|
||||
controller.setMode(TerminalInputMode.buffered);
|
||||
expect(controller.textController.text, 'git status');
|
||||
expect(controller.textController.selection.baseOffset, 10);
|
||||
expect(controller.textController.selection.extentOffset, 10);
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -1,7 +1,6 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:term_remote_ctl/core/network/agent_api_client.dart';
|
||||
@ -16,76 +15,41 @@ import 'package:term_remote_ctl/features/sessions/session.dart';
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
testWidgets('terminal keeps the extra key tray hidden until requested', (
|
||||
testWidgets('terminal page removes the bottom text input field', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpTerminalPage(tester);
|
||||
|
||||
expect(find.byKey(const Key('terminal_key_tray')), findsNothing);
|
||||
expect(find.byKey(const Key('terminal_keys_toggle_button')), findsNothing);
|
||||
|
||||
await tester.tap(find.byKey(const Key('terminal_toggle_actions_button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('terminal_keys_toggle_button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('terminal_key_tray')), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const Key('terminal_quick_key_symbol_at')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.byKey(const Key('terminal_quick_key_symbol_slash')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byType(TextField), findsNothing);
|
||||
expect(find.byKey(const Key('terminal_send_button')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('terminal long press repeats arrow keys', (tester) async {
|
||||
testWidgets('terminal actions sheet unifies session actions and quick keys', (
|
||||
tester,
|
||||
) async {
|
||||
final transportFactory = _QueuedTerminalSocketTransportFactory();
|
||||
|
||||
await _pumpTerminalPage(tester, socketFactory: transportFactory.factory);
|
||||
|
||||
await tester.tap(find.byKey(const Key('terminal_toggle_actions_button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('terminal_keys_toggle_button')));
|
||||
await tester.tap(find.byKey(const Key('terminal_actions_button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final gesture = await tester.startGesture(
|
||||
tester.getCenter(find.byKey(const Key('terminal_quick_key_up'))),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 700));
|
||||
await gesture.up();
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const Key('terminal_actions_sheet')), findsOneWidget);
|
||||
expect(find.text('Reconnect'), findsOneWidget);
|
||||
expect(find.text('Latest'), findsOneWidget);
|
||||
expect(find.byKey(const Key('terminal_quick_key_ctrl_c')), findsOneWidget);
|
||||
expect(find.byKey(const Key('terminal_quick_key_up')), findsOneWidget);
|
||||
|
||||
final sentInputs = transportFactory.createdTransports.single.sentMessages
|
||||
.where((message) => message.contains('"type":"input"'))
|
||||
.toList(growable: false);
|
||||
|
||||
expect(sentInputs.length, greaterThanOrEqualTo(2));
|
||||
expect(sentInputs.last, contains(r'"input":"\u001b[A"'));
|
||||
});
|
||||
|
||||
testWidgets('terminal sends hardware escape in direct mode', (tester) async {
|
||||
final transportFactory = _QueuedTerminalSocketTransportFactory();
|
||||
|
||||
await _pumpTerminalPage(tester, socketFactory: transportFactory.factory);
|
||||
|
||||
await tester.tap(find.byKey(const Key('terminal_toggle_actions_button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('terminal_mode_button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byType(TextField));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
|
||||
await tester.tap(find.byKey(const Key('terminal_quick_key_up')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
transportFactory.createdTransports.single.sentMessages.last,
|
||||
contains(r'"input":"\u001b"'),
|
||||
contains(r'"input":"\u001b[A"'),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('terminal opens the presets sheet and launches management', (
|
||||
testWidgets('terminal actions sheet opens preset management', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpTerminalPage(
|
||||
@ -99,12 +63,9 @@ void main() {
|
||||
]),
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const Key('terminal_toggle_actions_button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('terminal_presets_button')));
|
||||
await tester.tap(find.byKey(const Key('terminal_actions_button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('terminal_presets_sheet')), findsOneWidget);
|
||||
expect(find.text('ssh prod'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const Key('terminal_manage_presets_button')));
|
||||
|
||||
@ -9,6 +9,9 @@ import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:term_remote_ctl/app/app.dart';
|
||||
import 'package:term_remote_ctl/core/network/agent_api_client.dart';
|
||||
import 'package:term_remote_ctl/core/network/agent_connection_providers.dart';
|
||||
import 'package:term_remote_ctl/features/presets/preset_command.dart';
|
||||
import 'package:term_remote_ctl/features/presets/preset_providers.dart';
|
||||
import 'package:term_remote_ctl/features/presets/preset_repository.dart';
|
||||
import 'package:term_remote_ctl/features/projects/project.dart';
|
||||
import 'package:term_remote_ctl/features/projects/project_detail_page.dart';
|
||||
import 'package:term_remote_ctl/features/projects/project_repository.dart';
|
||||
@ -79,10 +82,7 @@ void main() {
|
||||
expect(find.byKey(const Key('terminal_surface_panel')), findsOneWidget);
|
||||
expect(find.byKey(const Key('terminal_command_deck')), findsOneWidget);
|
||||
expect(find.byKey(const Key('terminal_status_summary')), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const Key('terminal_toggle_actions_button')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byKey(const Key('terminal_actions_button')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('project list deletes a project after confirmation', (
|
||||
@ -269,31 +269,22 @@ void main() {
|
||||
|
||||
await _openProjectTerminal(tester);
|
||||
|
||||
expect(find.byKey(const Key('terminal_tools_sheet')), findsNothing);
|
||||
expect(
|
||||
find.byKey(const Key('terminal_toggle_actions_button')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byKey(const Key('terminal_action_bar')), findsOneWidget);
|
||||
expect(find.byKey(const Key('terminal_send_button')), findsOneWidget);
|
||||
expect(find.byKey(const Key('terminal_command_deck')), findsOneWidget);
|
||||
expect(find.byKey(const Key('terminal_key_tray')), findsNothing);
|
||||
expect(find.byKey(const Key('terminal_mode_button')), findsNothing);
|
||||
expect(find.byKey(const Key('terminal_keys_toggle_button')), findsNothing);
|
||||
expect(find.byKey(const Key('terminal_presets_button')), findsNothing);
|
||||
expect(find.byType(TextField), findsNothing);
|
||||
expect(find.byKey(const Key('terminal_actions_sheet')), findsNothing);
|
||||
expect(find.byKey(const Key('terminal_actions_button')), findsOneWidget);
|
||||
expect(find.byKey(const Key('terminal_toggle_actions_button')), findsNothing);
|
||||
|
||||
await tester.tap(find.byKey(const Key('terminal_toggle_actions_button')));
|
||||
await tester.tap(find.byKey(const Key('terminal_actions_button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('terminal_tools_sheet')), findsOneWidget);
|
||||
expect(find.byKey(const Key('terminal_actions_sheet')), findsOneWidget);
|
||||
expect(find.text('Reconnect'), findsOneWidget);
|
||||
expect(find.text('Latest'), findsOneWidget);
|
||||
expect(find.byKey(const Key('terminal_mode_button')), findsOneWidget);
|
||||
expect(
|
||||
find.byKey(const Key('terminal_keys_toggle_button')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byKey(const Key('terminal_presets_button')), findsOneWidget);
|
||||
expect(find.byKey(const Key('terminal_quick_key_ctrl_c')), findsOneWidget);
|
||||
expect(find.text('ssh prod'), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
@ -311,14 +302,9 @@ void main() {
|
||||
|
||||
await _openProjectTerminal(tester);
|
||||
|
||||
expect(find.byKey(const Key('terminal_key_tray')), findsNothing);
|
||||
expect(find.byKey(const Key('terminal_keys_toggle_button')), findsNothing);
|
||||
await tester.tap(find.byKey(const Key('terminal_toggle_actions_button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('terminal_keys_toggle_button')));
|
||||
await tester.tap(find.byKey(const Key('terminal_actions_button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.byKey(const Key('terminal_tools_sheet')), findsNothing);
|
||||
expect(find.byKey(const Key('terminal_quick_key_esc')), findsOneWidget);
|
||||
expect(find.byKey(const Key('terminal_quick_key_tab')), findsOneWidget);
|
||||
expect(find.byKey(const Key('terminal_quick_key_ctrl_c')), findsOneWidget);
|
||||
@ -338,7 +324,7 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('terminal direct input mode is explicit and toggleable', (
|
||||
testWidgets('terminal page no longer exposes input mode switching', (
|
||||
tester,
|
||||
) async {
|
||||
await _pumpApp(
|
||||
@ -349,33 +335,10 @@ void main() {
|
||||
|
||||
await _openProjectTerminal(tester);
|
||||
|
||||
final commandField = tester.widget<TextField>(find.byType(TextField).last);
|
||||
expect(
|
||||
commandField.decoration?.hintText,
|
||||
'Buffered mode: type a command and send',
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const Key('terminal_toggle_actions_button')));
|
||||
expect(find.byType(TextField), findsNothing);
|
||||
await tester.tap(find.byKey(const Key('terminal_actions_button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('terminal_mode_button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final directField = tester.widget<TextField>(find.byType(TextField).last);
|
||||
expect(
|
||||
directField.decoration?.hintText,
|
||||
'Direct mode: keys send immediately',
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const Key('terminal_toggle_actions_button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('terminal_mode_button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final bufferedField = tester.widget<TextField>(find.byType(TextField).last);
|
||||
expect(
|
||||
bufferedField.decoration?.hintText,
|
||||
'Buffered mode: type a command and send',
|
||||
);
|
||||
expect(find.byKey(const Key('terminal_mode_button')), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
@ -414,25 +377,21 @@ void main() {
|
||||
);
|
||||
|
||||
await _openProjectTerminal(tester);
|
||||
await tester.enterText(find.byType(TextField).last, 'dir');
|
||||
await tester.tap(find.byKey(const Key('terminal_send_button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('terminal_toggle_actions_button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('terminal_keys_toggle_button')));
|
||||
await tester.tap(find.byKey(const Key('terminal_actions_button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('terminal_quick_key_ctrl_l')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
transportFactory.createdTransports.single.emit('command-output');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('terminal_toggle_actions_button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('terminal_diagnostics_button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('ui.input.quick | Ctrl+L'), findsOneWidget);
|
||||
expect(find.textContaining('socket.input.tx | '), findsWidgets);
|
||||
expect(find.textContaining(r'socket.input.tx | \r'), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining('socket.frame.rx | command-output'),
|
||||
findsOneWidget,
|
||||
@ -440,7 +399,7 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('terminal send keeps the command input focused', (tester) async {
|
||||
testWidgets('terminal send button writes carriage return', (tester) async {
|
||||
final transportFactory = _QueuedTerminalSocketTransportFactory();
|
||||
|
||||
await _pumpApp(
|
||||
@ -453,129 +412,13 @@ void main() {
|
||||
);
|
||||
|
||||
await _openProjectTerminal(tester);
|
||||
|
||||
final commandField = find.byType(TextField).last;
|
||||
final editableField = find.byType(EditableText).last;
|
||||
|
||||
await tester.tap(commandField);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.widget<EditableText>(editableField).focusNode.hasFocus,
|
||||
isTrue,
|
||||
);
|
||||
|
||||
await tester.enterText(commandField, 'dir');
|
||||
await tester.tap(find.byKey(const Key('terminal_send_button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
tester.widget<EditableText>(editableField).focusNode.hasFocus,
|
||||
isTrue,
|
||||
);
|
||||
expect(tester.widget<TextField>(commandField).controller?.text, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('terminal keyboard submit keeps the command input focused', (
|
||||
tester,
|
||||
) async {
|
||||
final transportFactory = _QueuedTerminalSocketTransportFactory();
|
||||
|
||||
await _pumpApp(
|
||||
tester,
|
||||
projectRepository: _FakeProjectRepository(),
|
||||
sessionRepository: _FakeSessionRepository(),
|
||||
socketFactory: TerminalSocketSessionFactory(
|
||||
transportFactory: transportFactory.create,
|
||||
),
|
||||
);
|
||||
|
||||
await _openProjectTerminal(tester);
|
||||
|
||||
final commandField = find.byType(TextField).last;
|
||||
final editableField = find.byType(EditableText).last;
|
||||
|
||||
await tester.tap(commandField);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(commandField, 'dir');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.send);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
tester.widget<EditableText>(editableField).focusNode.hasFocus,
|
||||
isTrue,
|
||||
);
|
||||
expect(tester.widget<TextField>(commandField).controller?.text, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('terminal done action keeps the command input focused', (
|
||||
tester,
|
||||
) async {
|
||||
final transportFactory = _QueuedTerminalSocketTransportFactory();
|
||||
|
||||
await _pumpApp(
|
||||
tester,
|
||||
projectRepository: _FakeProjectRepository(),
|
||||
sessionRepository: _FakeSessionRepository(),
|
||||
socketFactory: TerminalSocketSessionFactory(
|
||||
transportFactory: transportFactory.create,
|
||||
),
|
||||
);
|
||||
|
||||
await _openProjectTerminal(tester);
|
||||
|
||||
final commandField = find.byType(TextField).last;
|
||||
final editableField = find.byType(EditableText).last;
|
||||
|
||||
await tester.tap(commandField);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(commandField, 'dir');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
tester.widget<EditableText>(editableField).focusNode.hasFocus,
|
||||
isTrue,
|
||||
);
|
||||
expect(tester.widget<TextField>(commandField).controller?.text, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('terminal direct input keyboard action sends carriage return', (
|
||||
tester,
|
||||
) async {
|
||||
final transportFactory = _QueuedTerminalSocketTransportFactory();
|
||||
|
||||
await _pumpApp(
|
||||
tester,
|
||||
projectRepository: _FakeProjectRepository(),
|
||||
sessionRepository: _FakeSessionRepository(),
|
||||
socketFactory: TerminalSocketSessionFactory(
|
||||
transportFactory: transportFactory.create,
|
||||
),
|
||||
);
|
||||
|
||||
await _openProjectTerminal(tester);
|
||||
final commandField = find.byType(TextField).last;
|
||||
final editableField = find.byType(EditableText).last;
|
||||
|
||||
await tester.tap(find.byKey(const Key('terminal_toggle_actions_button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.byKey(const Key('terminal_mode_button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(commandField);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(commandField, 'pwd');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
transportFactory.createdTransports.single.sentMessages.last,
|
||||
contains(r'"input":"\r"'),
|
||||
);
|
||||
expect(
|
||||
tester.widget<EditableText>(editableField).focusNode.hasFocus,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('terminal page reconnects after the socket closes', (
|
||||
@ -600,7 +443,7 @@ void main() {
|
||||
await transportFactory.createdTransports.first.close();
|
||||
await tester.pump();
|
||||
|
||||
await tester.tap(find.byKey(const Key('terminal_toggle_actions_button')));
|
||||
await tester.tap(find.byKey(const Key('terminal_actions_button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Connection lost. Reconnecting...'), findsOneWidget);
|
||||
@ -691,7 +534,7 @@ void main() {
|
||||
await _openProjectTerminal(tester);
|
||||
expect(sessionRepository.createCount, 1);
|
||||
|
||||
await tester.tap(find.byKey(const Key('terminal_toggle_actions_button')));
|
||||
await tester.tap(find.byKey(const Key('terminal_actions_button')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('New terminal'));
|
||||
await tester.pumpAndSettle();
|
||||
@ -1076,6 +919,9 @@ Future<void> _pumpApp(
|
||||
),
|
||||
projectRepositoryProvider.overrideWithValue(projectRepository),
|
||||
sessionRepositoryProvider.overrideWithValue(sessionRepository),
|
||||
presetRepositoryProvider.overrideWithValue(
|
||||
_MemoryPresetRepository(const <PresetCommand>[]),
|
||||
),
|
||||
terminalSocketSessionFactoryProvider.overrideWithValue(
|
||||
socketFactory ??
|
||||
TerminalSocketSessionFactory(
|
||||
@ -1111,6 +957,9 @@ Future<void> _pumpTerminalPage(
|
||||
agentApiClientProvider.overrideWithValue(
|
||||
apiClient ?? _FakeAgentApiClient(),
|
||||
),
|
||||
presetRepositoryProvider.overrideWithValue(
|
||||
_MemoryPresetRepository(const <PresetCommand>[]),
|
||||
),
|
||||
terminalSocketSessionFactoryProvider.overrideWithValue(
|
||||
socketFactory ??
|
||||
TerminalSocketSessionFactory(
|
||||
@ -1260,6 +1109,18 @@ Session _session(String sessionId, String name) {
|
||||
);
|
||||
}
|
||||
|
||||
class _MemoryPresetRepository extends PresetRepository {
|
||||
_MemoryPresetRepository(List<PresetCommand> presets)
|
||||
: _presets = List<PresetCommand>.of(presets);
|
||||
|
||||
final List<PresetCommand> _presets;
|
||||
|
||||
@override
|
||||
Future<List<PresetCommand>> listPresets() async {
|
||||
return List<PresetCommand>.of(_presets);
|
||||
}
|
||||
}
|
||||
|
||||
int _countOccurrences(String source, String pattern) {
|
||||
if (pattern.isEmpty) {
|
||||
return 0;
|
||||
|
||||
Loading…
Reference in New Issue
Block a user