From 2215def240ce07d1300d1d1227551bf186d34e8a Mon Sep 17 00:00:00 2001 From: sladro Date: Sat, 4 Apr 2026 10:38:05 +0800 Subject: [PATCH] feat: improve mobile terminal input workflow --- .../lib/features/presets/preset_command.dart | 26 +- .../presets/preset_management_page.dart | 233 ++++++ .../lib/features/presets/preset_panel.dart | 50 +- .../features/presets/preset_providers.dart | 7 + .../features/presets/preset_repository.dart | 55 +- .../repeatable_terminal_key_button.dart | 72 ++ .../terminal/terminal_input_controller.dart | 4 +- .../lib/features/terminal/terminal_page.dart | 691 +++++++++++------- apps/mobile_app/pubspec.lock | 300 +++++--- apps/mobile_app/pubspec.yaml | 1 + .../presets/preset_repository_test.dart | 92 +++ .../terminal/terminal_page_input_test.dart | 226 ++++++ apps/mobile_app/test/widget_test.dart | 43 +- 13 files changed, 1399 insertions(+), 401 deletions(-) create mode 100644 apps/mobile_app/lib/features/presets/preset_management_page.dart create mode 100644 apps/mobile_app/lib/features/presets/preset_providers.dart create mode 100644 apps/mobile_app/lib/features/terminal/repeatable_terminal_key_button.dart create mode 100644 apps/mobile_app/test/features/presets/preset_repository_test.dart create mode 100644 apps/mobile_app/test/features/terminal/terminal_page_input_test.dart diff --git a/apps/mobile_app/lib/features/presets/preset_command.dart b/apps/mobile_app/lib/features/presets/preset_command.dart index 9b09daf..e9de4ee 100644 --- a/apps/mobile_app/lib/features/presets/preset_command.dart +++ b/apps/mobile_app/lib/features/presets/preset_command.dart @@ -1,5 +1,5 @@ class PresetCommand { - PresetCommand({ + const PresetCommand({ required this.id, required this.label, required this.commandText, @@ -8,4 +8,28 @@ class PresetCommand { final String id; final String label; final String commandText; + + PresetCommand copyWith({String? id, String? label, String? commandText}) { + return PresetCommand( + id: id ?? this.id, + label: label ?? this.label, + commandText: commandText ?? this.commandText, + ); + } + + factory PresetCommand.fromJson(Map json) { + return PresetCommand( + id: json['id']?.toString() ?? '', + label: json['label']?.toString() ?? '', + commandText: json['commandText']?.toString() ?? '', + ); + } + + Map toJson() { + return { + 'id': id, + 'label': label, + 'commandText': commandText, + }; + } } diff --git a/apps/mobile_app/lib/features/presets/preset_management_page.dart b/apps/mobile_app/lib/features/presets/preset_management_page.dart new file mode 100644 index 0000000..27b4af8 --- /dev/null +++ b/apps/mobile_app/lib/features/presets/preset_management_page.dart @@ -0,0 +1,233 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import '../../app/ui_shell.dart'; +import 'preset_command.dart'; +import 'preset_providers.dart'; + +class PresetManagementPage extends ConsumerStatefulWidget { + const PresetManagementPage({super.key}); + + @override + ConsumerState createState() => + _PresetManagementPageState(); +} + +class _PresetManagementPageState extends ConsumerState { + late Future> _presetsFuture; + + @override + void initState() { + super.initState(); + _presetsFuture = _loadPresets(); + } + + Future> _loadPresets() { + return ref.read(presetRepositoryProvider).listPresets(); + } + + Future _refreshPresets() async { + setState(() { + _presetsFuture = _loadPresets(); + }); + await _presetsFuture; + } + + Future _openEditor({PresetCommand? existing}) async { + final labelController = TextEditingController(text: existing?.label ?? ''); + final commandController = TextEditingController( + text: existing?.commandText ?? '', + ); + + try { + final shouldSave = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: Text(existing == null ? 'New preset' : 'Edit preset'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + TextField( + controller: labelController, + autofocus: true, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'Label', + hintText: 'ssh prod', + ), + ), + const SizedBox(height: 12), + TextField( + controller: commandController, + minLines: 2, + maxLines: 4, + textInputAction: TextInputAction.newline, + decoration: const InputDecoration( + labelText: 'Command', + hintText: 'ssh admin@prod', + ), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Save'), + ), + ], + ); + }, + ); + + if (shouldSave != true) { + return; + } + + final label = labelController.text.trim(); + final commandText = commandController.text.trim(); + if (label.isEmpty || commandText.isEmpty) { + _showMessage('Label and command are required.'); + return; + } + + final preset = PresetCommand( + id: existing?.id ?? DateTime.now().microsecondsSinceEpoch.toString(), + label: label, + commandText: commandText, + ); + + await ref.read(presetRepositoryProvider).savePreset(preset); + await _refreshPresets(); + } finally { + labelController.dispose(); + commandController.dispose(); + } + } + + Future _deletePreset(PresetCommand preset) async { + final shouldDelete = await showDialog( + context: context, + builder: (context) { + return AlertDialog( + title: const Text('Delete preset'), + content: Text('Delete "${preset.label}"?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Cancel'), + ), + FilledButton( + onPressed: () => Navigator.of(context).pop(true), + child: const Text('Delete'), + ), + ], + ); + }, + ); + + if (shouldDelete != true) { + return; + } + + await ref.read(presetRepositoryProvider).deletePreset(preset.id); + await _refreshPresets(); + } + + void _showMessage(String message) { + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(message))); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + key: const Key('preset_management_page'), + appBar: AppBar( + title: const Text('Presets'), + actions: [ + IconButton( + onPressed: _refreshPresets, + tooltip: 'Refresh presets', + icon: const Icon(Icons.refresh), + ), + ], + ), + floatingActionButton: FloatingActionButton.extended( + onPressed: _openEditor, + icon: const Icon(Icons.add), + label: const Text('New preset'), + ), + body: FutureBuilder>( + future: _presetsFuture, + builder: (context, snapshot) { + if (snapshot.connectionState != ConnectionState.done) { + return const Center(child: CircularProgressIndicator()); + } + + if (snapshot.hasError) { + return Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text('Failed to load presets: ${snapshot.error}'), + ), + ); + } + + final presets = snapshot.data ?? const []; + if (presets.isEmpty) { + return const Padding( + padding: EdgeInsets.all(24), + child: AppEmptyState( + icon: Icons.flash_on_outlined, + title: 'No presets yet', + message: 'Create reusable terminal commands for quick access.', + ), + ); + } + + return ListView.separated( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), + itemCount: presets.length, + itemBuilder: (context, index) { + final preset = presets[index]; + return AppPanel( + child: ListTile( + key: Key('preset_list_tile_${preset.id}'), + contentPadding: EdgeInsets.zero, + title: Text(preset.label), + subtitle: Text( + preset.commandText, + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + trailing: Wrap( + spacing: 4, + children: [ + IconButton( + onPressed: () => _openEditor(existing: preset), + tooltip: 'Edit preset', + icon: const Icon(Icons.edit_outlined), + ), + IconButton( + onPressed: () => _deletePreset(preset), + tooltip: 'Delete preset', + icon: const Icon(Icons.delete_outline), + ), + ], + ), + ), + ); + }, + separatorBuilder: (context, index) => const SizedBox(height: 12), + ); + }, + ), + ); + } +} diff --git a/apps/mobile_app/lib/features/presets/preset_panel.dart b/apps/mobile_app/lib/features/presets/preset_panel.dart index e921dda..de38eed 100644 --- a/apps/mobile_app/lib/features/presets/preset_panel.dart +++ b/apps/mobile_app/lib/features/presets/preset_panel.dart @@ -1,21 +1,53 @@ import 'package:flutter/material.dart'; -import 'package:term_remote_ctl/features/presets/preset_command.dart'; + +import 'preset_command.dart'; class PresetPanel extends StatelessWidget { - const PresetPanel({super.key, required this.presets}); + const PresetPanel({ + super.key, + required this.presets, + required this.onPresetSelected, + required this.onManagePressed, + }); final List presets; + final ValueChanged onPresetSelected; + final VoidCallback onManagePressed; @override Widget build(BuildContext context) { - return Wrap( - spacing: 8, - runSpacing: 8, + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - for (final preset in presets) - FilledButton( - onPressed: () {}, - child: Text(preset.label), + Row( + children: [ + Text('Presets', style: Theme.of(context).textTheme.titleMedium), + const Spacer(), + TextButton( + key: const Key('terminal_manage_presets_button'), + onPressed: onManagePressed, + child: const Text('Manage'), + ), + ], + ), + const SizedBox(height: 8), + if (presets.isEmpty) + Text( + 'No presets yet. Create one in Manage.', + style: Theme.of(context).textTheme.bodySmall, + ) + else + Wrap( + spacing: 8, + runSpacing: 8, + children: [ + for (final preset in presets) + FilledButton.tonal( + onPressed: () => onPresetSelected(preset), + child: Text(preset.label), + ), + ], ), ], ); diff --git a/apps/mobile_app/lib/features/presets/preset_providers.dart b/apps/mobile_app/lib/features/presets/preset_providers.dart new file mode 100644 index 0000000..5a9ace7 --- /dev/null +++ b/apps/mobile_app/lib/features/presets/preset_providers.dart @@ -0,0 +1,7 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; + +import 'preset_repository.dart'; + +final presetRepositoryProvider = Provider((ref) { + return PresetRepository(); +}); diff --git a/apps/mobile_app/lib/features/presets/preset_repository.dart b/apps/mobile_app/lib/features/presets/preset_repository.dart index be97ca6..d639611 100644 --- a/apps/mobile_app/lib/features/presets/preset_repository.dart +++ b/apps/mobile_app/lib/features/presets/preset_repository.dart @@ -1,9 +1,56 @@ -import 'package:term_remote_ctl/features/presets/preset_command.dart'; +import 'dart:convert'; + +import 'package:shared_preferences/shared_preferences.dart'; + +import 'preset_command.dart'; class PresetRepository { - const PresetRepository(); + PresetRepository({ + Future Function()? sharedPreferencesLoader, + }) : _sharedPreferencesLoader = + sharedPreferencesLoader ?? SharedPreferences.getInstance; - List listPresets() { - return const []; + static const String storageKey = 'preset_commands_v1'; + + final Future Function() _sharedPreferencesLoader; + + Future> listPresets() async { + final sharedPreferences = await _sharedPreferencesLoader(); + final storedItems = + sharedPreferences.getStringList(storageKey) ?? const []; + return storedItems + .map((item) => jsonDecode(item)) + .whereType>() + .map(PresetCommand.fromJson) + .where((preset) => preset.id.isNotEmpty) + .toList(growable: false); + } + + Future savePreset(PresetCommand preset) async { + final presets = List.of(await listPresets()); + final existingIndex = presets.indexWhere((entry) => entry.id == preset.id); + if (existingIndex >= 0) { + presets[existingIndex] = preset; + } else { + presets.add(preset); + } + + await _persistPresets(presets); + return preset; + } + + Future deletePreset(String presetId) async { + final presets = await listPresets(); + await _persistPresets( + presets.where((preset) => preset.id != presetId).toList(growable: false), + ); + } + + Future _persistPresets(List presets) async { + final sharedPreferences = await _sharedPreferencesLoader(); + await sharedPreferences.setStringList( + storageKey, + presets.map((preset) => jsonEncode(preset.toJson())).toList(), + ); } } diff --git a/apps/mobile_app/lib/features/terminal/repeatable_terminal_key_button.dart b/apps/mobile_app/lib/features/terminal/repeatable_terminal_key_button.dart new file mode 100644 index 0000000..bb05056 --- /dev/null +++ b/apps/mobile_app/lib/features/terminal/repeatable_terminal_key_button.dart @@ -0,0 +1,72 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +class RepeatableTerminalKeyButton extends StatefulWidget { + const RepeatableTerminalKeyButton({ + super.key, + required this.label, + required this.onPressed, + this.enabled = true, + this.repeatable = false, + }); + + final String label; + final VoidCallback onPressed; + final bool enabled; + final bool repeatable; + + @override + State createState() => + _RepeatableTerminalKeyButtonState(); +} + +class _RepeatableTerminalKeyButtonState + extends State { + Timer? _repeatTimer; + + @override + void dispose() { + _repeatTimer?.cancel(); + super.dispose(); + } + + void _startRepeating() { + if (!widget.enabled || !widget.repeatable) { + return; + } + + widget.onPressed(); + _repeatTimer?.cancel(); + _repeatTimer = Timer.periodic(const Duration(milliseconds: 90), (_) { + widget.onPressed(); + }); + } + + void _stopRepeating() { + _repeatTimer?.cancel(); + _repeatTimer = null; + } + + @override + Widget build(BuildContext context) { + return GestureDetector( + onLongPressStart: (_) => _startRepeating(), + onLongPressEnd: (_) => _stopRepeating(), + onLongPressCancel: _stopRepeating, + child: OutlinedButton( + onPressed: widget.enabled ? widget.onPressed : null, + style: OutlinedButton.styleFrom( + foregroundColor: const Color(0xFFE8DED1), + backgroundColor: const Color(0xFF151A20), + side: const BorderSide(color: Color(0xFF3F3428)), + minimumSize: const Size(0, 34), + padding: const EdgeInsets.symmetric(horizontal: 10), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + child: Text(widget.label), + ), + ); + } +} diff --git a/apps/mobile_app/lib/features/terminal/terminal_input_controller.dart b/apps/mobile_app/lib/features/terminal/terminal_input_controller.dart index 67b326f..dc5e6a5 100644 --- a/apps/mobile_app/lib/features/terminal/terminal_input_controller.dart +++ b/apps/mobile_app/lib/features/terminal/terminal_input_controller.dart @@ -39,8 +39,8 @@ class TerminalInputController extends ChangeNotifier { String? get composingText => _composingText; String get hintText => isDirectInputEnabled - ? 'Keyboard sends straight to terminal' - : 'Send command or input'; + ? 'Direct mode: keys send immediately' + : 'Buffered mode: type a command and send'; Future submit() async { if (isDirectInputEnabled) { diff --git a/apps/mobile_app/lib/features/terminal/terminal_page.dart b/apps/mobile_app/lib/features/terminal/terminal_page.dart index f3564ef..2bdc28f 100644 --- a/apps/mobile_app/lib/features/terminal/terminal_page.dart +++ b/apps/mobile_app/lib/features/terminal/terminal_page.dart @@ -1,6 +1,7 @@ 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'; @@ -8,10 +9,15 @@ 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_management_page.dart'; +import '../presets/preset_panel.dart'; +import '../presets/preset_providers.dart'; +import '../presets/preset_repository.dart'; import '../projects/project.dart'; 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'; @@ -36,16 +42,54 @@ class TerminalPage extends ConsumerStatefulWidget { class _TerminalPageState extends ConsumerState with WidgetsBindingObserver { static const Duration _historySeedDelay = Duration(milliseconds: 120); - static const List<_QuickTerminalKey> _quickTerminalKeys = [ + static const List<_QuickTerminalKey> _symbolTerminalKeys = [ + _QuickTerminalKey(keyId: 'symbol_at', label: '@', input: '@'), + _QuickTerminalKey(keyId: 'symbol_slash', label: '/', input: '/'), + _QuickTerminalKey(keyId: 'symbol_dash', label: '-', input: '-'), + _QuickTerminalKey(keyId: 'symbol_underscore', label: '_', input: '_'), + _QuickTerminalKey(keyId: 'symbol_dot', label: '.', input: '.'), + _QuickTerminalKey(keyId: 'symbol_colon', label: ':', input: ':'), + _QuickTerminalKey(keyId: 'symbol_tilde', label: '~', input: '~'), + _QuickTerminalKey(keyId: 'symbol_backslash', label: r'\', input: r'\'), + _QuickTerminalKey(keyId: 'symbol_pipe', label: '|', input: '|'), + _QuickTerminalKey(keyId: 'symbol_dollar', label: r'$', input: r'$'), + ]; + static const List<_QuickTerminalKey> _controlTerminalKeys = [ _QuickTerminalKey(keyId: 'esc', label: 'Esc', input: '\u001b'), _QuickTerminalKey(keyId: 'tab', label: 'Tab', input: '\t'), _QuickTerminalKey(keyId: 'ctrl_c', label: 'Ctrl+C', input: '\u0003'), _QuickTerminalKey(keyId: 'ctrl_d', label: 'Ctrl+D', input: '\u0004'), _QuickTerminalKey(keyId: 'ctrl_l', label: 'Ctrl+L', input: '\u000c'), - _QuickTerminalKey(keyId: 'up', label: 'Up', input: '\u001b[A'), - _QuickTerminalKey(keyId: 'down', label: 'Down', input: '\u001b[B'), - _QuickTerminalKey(keyId: 'left', label: 'Left', input: '\u001b[D'), - _QuickTerminalKey(keyId: 'right', label: 'Right', input: '\u001b[C'), + _QuickTerminalKey( + keyId: 'backspace', + label: 'Backspace', + input: '\x7f', + repeatable: true, + ), + _QuickTerminalKey( + keyId: 'up', + label: 'Up', + input: '\u001b[A', + repeatable: true, + ), + _QuickTerminalKey( + keyId: 'down', + label: 'Down', + input: '\u001b[B', + repeatable: true, + ), + _QuickTerminalKey( + keyId: 'left', + label: 'Left', + input: '\u001b[D', + repeatable: true, + ), + _QuickTerminalKey( + keyId: 'right', + label: 'Right', + input: '\u001b[C', + repeatable: true, + ), ]; final Terminal terminal = Terminal(maxLines: 1000); @@ -61,6 +105,7 @@ class _TerminalPageState extends ConsumerState String? _pendingHistorySeed; bool _receivedSocketFrame = false; bool _historySeeded = false; + bool _isKeyTrayVisible = false; bool _shouldReconnectOnResume = false; @override @@ -246,6 +291,12 @@ class _TerminalPageState extends ConsumerState } } + void _toggleKeyTray() { + setState(() { + _isKeyTrayVisible = !_isKeyTrayVisible; + }); + } + void _handleTerminalSurfaceTap() { if (!_canSendInput || !_inputController.isDirectInputEnabled) { return; @@ -259,6 +310,13 @@ class _TerminalPageState extends ConsumerState return input.replaceAll('\r', r'\r').replaceAll('\n', r'\n'); } + _QuickTerminalKey _quickKey(String keyId) { + return <_QuickTerminalKey>[ + ..._symbolTerminalKeys, + ..._controlTerminalKeys, + ].singleWhere((key) => key.keyId == keyId); + } + void _handleTerminalFrame(String frame) { _receivedSocketFrame = true; _cancelHistorySeedTimer(); @@ -321,7 +379,8 @@ class _TerminalPageState extends ConsumerState _historySeedTimer = null; } - bool get _terminalHasVisibleContent => terminal.buffer.getText().trim().isNotEmpty; + bool get _terminalHasVisibleContent => + terminal.buffer.getText().trim().isNotEmpty; Future _showDiagnostics() { return showModalBottomSheet( @@ -474,6 +533,49 @@ class _TerminalPageState extends ConsumerState ); } + Future _showPresetsSheet() async { + final presets = await ref.read(presetRepositoryProvider).listPresets(); + if (!mounted) { + return; + } + + await showModalBottomSheet( + 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 _openPresetManagementPage() { + return Navigator.of(context).push( + MaterialPageRoute(builder: (context) => const PresetManagementPage()), + ); + } + @override Widget build(BuildContext context) { final width = MediaQuery.sizeOf(context).width; @@ -484,124 +586,129 @@ class _TerminalPageState extends ConsumerState widget.session.workingDirectory ?? ''; - return Scaffold( - appBar: AppBar( - toolbarHeight: 44, - titleSpacing: 0, - title: Container( - key: const Key('terminal_header_panel'), - padding: const EdgeInsets.symmetric(vertical: 2), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text( - widget.session.name, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.titleSmall, - ), - if (!isTight) + return CallbackShortcuts( + bindings: _shortcutBindings(), + child: Scaffold( + appBar: AppBar( + toolbarHeight: 44, + titleSpacing: 0, + title: Container( + key: const Key('terminal_header_panel'), + padding: const EdgeInsets.symmetric(vertical: 2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ Text( - workingDirectory, + widget.session.name, maxLines: 1, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.bodySmall, + style: Theme.of(context).textTheme.titleSmall, ), - ], + if (!isTight) + Text( + workingDirectory, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), ), - ), - actions: [ - AnimatedBuilder( - animation: controller, - builder: (context, _) { - final mode = controller.isFollowingLiveOutput - ? 'Live' - : 'Scrollback'; - final modeLabel = isCompact - ? mode - : '$mode | ${controller.liveLines.length} lines'; - final statusLabel = isCompact - ? _compactStatusLabel - : _statusLabel; + actions: [ + AnimatedBuilder( + animation: controller, + builder: (context, _) { + final mode = controller.isFollowingLiveOutput + ? 'Live' + : 'Scrollback'; + final modeLabel = isCompact + ? mode + : '$mode | ${controller.liveLines.length} lines'; + final statusLabel = isCompact + ? _compactStatusLabel + : _statusLabel; - return Row( - key: const Key('terminal_status_summary'), - mainAxisSize: MainAxisSize.min, - children: [ - TextButton( - onPressed: controller.isFollowingLiveOutput - ? controller.enterScrollback - : controller.jumpToLive, - style: TextButton.styleFrom( - foregroundColor: const Color(0xFFD8C4A0), - minimumSize: const Size(0, 32), - padding: const EdgeInsets.symmetric(horizontal: 8), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, + return Row( + key: const Key('terminal_status_summary'), + mainAxisSize: MainAxisSize.min, + children: [ + TextButton( + onPressed: controller.isFollowingLiveOutput + ? controller.enterScrollback + : controller.jumpToLive, + style: TextButton.styleFrom( + foregroundColor: const Color(0xFFD8C4A0), + minimumSize: const Size(0, 32), + padding: const EdgeInsets.symmetric(horizontal: 8), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + visualDensity: VisualDensity.compact, + ), + child: Text(modeLabel), ), - child: Text(modeLabel), - ), - SizedBox(width: isCompact ? 2 : 4), - Padding( - padding: EdgeInsets.only(right: isCompact ? 4 : 8), - child: Center( - child: StatusPill( - label: statusLabel, - icon: _statusIcon, - color: _statusColor(context), + SizedBox(width: isCompact ? 2 : 4), + Padding( + padding: EdgeInsets.only(right: isCompact ? 4 : 8), + child: Center( + child: StatusPill( + label: statusLabel, + icon: _statusIcon, + color: _statusColor(context), + ), ), ), - ), - ], - ); - }, - ), - ], - ), - body: SafeArea( - top: false, - minimum: AppTheme.pagePadding, - child: TextFieldTapRegion( - 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), - ], + ], + ); + }, + ), + ], + ), + body: SafeArea( + top: false, + minimum: AppTheme.pagePadding, + child: TextFieldTapRegion( + 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)), ), - 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, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 10, + vertical: 8, + ), + child: TerminalView( + terminal, + focusNode: _terminalFocusNode, + autofocus: false, + scrollController: _terminalScrollController, + ), ), ), ), ), - ), - const SizedBox(height: 6), - _buildCommandDeck(context, isCompact), - ], + const SizedBox(height: 6), + _buildCommandDeck(context, isCompact), + ], + ), ), ), ), @@ -831,7 +938,7 @@ class _TerminalPageState extends ConsumerState return AppPanel( key: const Key('terminal_command_deck'), tone: AppPanelTone.emphasis, - padding: const EdgeInsets.fromLTRB(8, 6, 8, 6), + padding: const EdgeInsets.fromLTRB(8, 8, 8, 8), borderRadius: BorderRadius.zero, child: AnimatedBuilder( animation: _pageStateListenable, @@ -841,183 +948,81 @@ class _TerminalPageState extends ConsumerState crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ - Text( - 'Command deck', - style: Theme.of(context).textTheme.labelLarge?.copyWith( - color: const Color(0xFFD1C2A7), - ), - ), - const SizedBox(height: 8), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - child: Row( - children: _quickTerminalKeys - .map((quickKey) { - return Padding( - padding: const EdgeInsets.only(right: 8), - child: _buildCommandDeckAction( - OutlinedButton( - key: Key('terminal_quick_key_${quickKey.keyId}'), - onPressed: _canSendInput - ? () => _sendQuickKey(quickKey) - : null, - style: OutlinedButton.styleFrom( - foregroundColor: const Color(0xFFE8DED1), - backgroundColor: const Color(0xFF151A20), - side: const BorderSide( - color: Color(0xFF3F3428), - ), - minimumSize: const Size(0, 34), - padding: const EdgeInsets.symmetric( - horizontal: 10, - ), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, - ), - child: Text(quickKey.label), - ), - ), - ); - }) - .toList(growable: false), - ), - ), - const SizedBox(height: 4), - if (isCompact) - Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _buildInputField(context), - const SizedBox(height: 6), - Row( - children: [ - Expanded( - child: _buildCommandDeckAction( - FilledButton( - key: const Key('terminal_send_button'), - onPressed: _canSendInput ? _submitInput : null, - style: FilledButton.styleFrom( - minimumSize: const Size(0, 38), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, - ), - child: Text( - _inputController.isDirectInputEnabled - ? 'Enter' - : 'Send', - ), - ), - ), + Row( + children: [ + Expanded(child: _buildInputField(context)), + const SizedBox(width: 8), + _buildModeButton(), + const SizedBox(width: 6), + _buildIconActionButton( + key: const Key('terminal_keys_toggle_button'), + onPressed: _toggleKeyTray, + tooltip: _isKeyTrayVisible + ? 'Hide quick keys' + : 'Show quick keys', + icon: Icon( + _isKeyTrayVisible + ? Icons.keyboard_arrow_down + : Icons.keyboard_command_key, + ), + ), + const SizedBox(width: 6), + _buildIconActionButton( + key: const Key('terminal_presets_button'), + onPressed: _showPresetsSheet, + tooltip: 'Show presets', + icon: const Icon(Icons.flash_on_outlined), + ), + const SizedBox(width: 6), + _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, ), - const SizedBox(width: 8), - _buildCommandDeckAction( - IconButton.filledTonal( - key: const Key('terminal_direct_input_toggle'), - onPressed: _canSendInput - ? _toggleDirectInput - : null, - visualDensity: VisualDensity.compact, - style: IconButton.styleFrom( - backgroundColor: const Color(0xFF241F1A), - foregroundColor: const Color(0xFFD7C4A0), - ), - icon: Icon( + ), + child: isCompact + ? Icon( _inputController.isDirectInputEnabled - ? Icons.keyboard_hide - : Icons.keyboard, + ? Icons.keyboard_return + : Icons.send, + ) + : Text( + _inputController.isDirectInputEnabled + ? 'Enter' + : 'Send', ), - tooltip: _inputController.isDirectInputEnabled - ? 'Disable direct input' - : 'Enable direct input', - ), - ), - 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', - ), - ), - ], ), - ], - ) - else - Row( - children: [ - Expanded(child: _buildInputField(context)), - const SizedBox(width: 8), - _buildCommandDeckAction( - FilledButton( - key: const Key('terminal_send_button'), - onPressed: _canSendInput ? _submitInput : null, - style: FilledButton.styleFrom( - minimumSize: const Size(0, 38), - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - visualDensity: VisualDensity.compact, - ), - child: Text( - _inputController.isDirectInputEnabled - ? 'Enter' - : 'Send', - ), - ), - ), - const SizedBox(width: 8), - _buildCommandDeckAction( - IconButton.filledTonal( - key: const Key('terminal_direct_input_toggle'), - onPressed: _canSendInput ? _toggleDirectInput : null, - visualDensity: VisualDensity.compact, - style: IconButton.styleFrom( - backgroundColor: const Color(0xFF241F1A), - foregroundColor: const Color(0xFFD7C4A0), - ), - icon: Icon( - _inputController.isDirectInputEnabled - ? Icons.keyboard_hide - : Icons.keyboard, - ), - tooltip: _inputController.isDirectInputEnabled - ? 'Disable direct input' - : 'Enable direct input', - ), - ), - 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(height: 4), - Text( - _inputController.isDirectInputEnabled - ? 'Direct input enabled' - : 'Browse mode', - key: const Key('terminal_input_mode_label'), - style: Theme.of(context).textTheme.bodySmall, + ), + ], ), + if (_isKeyTrayVisible) ...[ + const SizedBox(height: 8), + _buildKeyTray(), + ], if (_inputController.isDirectInputEnabled && _inputController.composingText != null) Padding( - padding: const EdgeInsets.only(top: 2), + padding: const EdgeInsets.only(top: 6), child: Text( 'Composing: ${_inputController.composingText}', key: const Key('terminal_direct_input_composing_label'), @@ -1056,17 +1061,139 @@ class _TerminalPageState extends ConsumerState cursorColor: isDirectInputEnabled ? Colors.transparent : Theme.of(context).colorScheme.primary, - decoration: InputDecoration(hintText: _inputController.hintText), + decoration: InputDecoration( + hintText: _inputController.hintText, + isDense: true, + ), onTap: _inputController.syncDirectSelection, onEditingComplete: () {}, onSubmitted: (_) => _submitInput(), ); } + Widget _buildModeButton() { + return KeyedSubtree( + key: const Key('terminal_direct_input_toggle'), + child: _buildIconActionButton( + key: const Key('terminal_mode_button'), + onPressed: _canSendInput ? _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 _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), + ), + ); + } + Widget _buildCommandDeckAction(Widget child) { return ExcludeFocus(child: child); } + Map _shortcutBindings() { + if (!_inputController.isDirectInputEnabled || !_canSendInput) { + return const {}; + } + + return { + 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', @@ -1109,9 +1236,11 @@ class _QuickTerminalKey { required this.keyId, required this.label, required this.input, + this.repeatable = false, }); final String keyId; final String label; final String input; + final bool repeatable; } diff --git a/apps/mobile_app/pubspec.lock b/apps/mobile_app/pubspec.lock index 6c1530c..e1b3849 100644 --- a/apps/mobile_app/pubspec.lock +++ b/apps/mobile_app/pubspec.lock @@ -6,7 +6,7 @@ packages: description: name: _fe_analyzer_shared sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "85.0.0" analyzer: @@ -14,7 +14,7 @@ packages: description: name: analyzer sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "7.7.1" args: @@ -22,7 +22,7 @@ packages: description: name: args sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.7.0" async: @@ -30,7 +30,7 @@ packages: description: name: async sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.13.1" boolean_selector: @@ -38,7 +38,7 @@ packages: description: name: boolean_selector sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.1.2" build: @@ -46,7 +46,7 @@ packages: description: name: build sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.5.4" build_config: @@ -54,7 +54,7 @@ packages: description: name: build_config sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.1.2" build_daemon: @@ -62,7 +62,7 @@ packages: description: name: build_daemon sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "4.1.1" build_resolvers: @@ -70,7 +70,7 @@ packages: description: name: build_resolvers sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.5.4" build_runner: @@ -78,7 +78,7 @@ packages: description: name: build_runner sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.5.4" build_runner_core: @@ -86,7 +86,7 @@ packages: description: name: build_runner_core sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "9.1.2" built_collection: @@ -94,7 +94,7 @@ packages: description: name: built_collection sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "5.1.1" built_value: @@ -102,23 +102,23 @@ packages: description: name: built_value sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "8.12.5" characters: dependency: transitive description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b - url: "https://pub.dev" + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.flutter-io.cn" source: hosted - version: "1.4.1" + version: "1.4.0" checked_yaml: dependency: transitive description: name: checked_yaml sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.0.4" clock: @@ -126,7 +126,7 @@ packages: description: name: clock sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.1.2" code_builder: @@ -134,7 +134,7 @@ packages: description: name: code_builder sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "4.11.1" collection: @@ -142,7 +142,7 @@ packages: description: name: collection sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.19.1" convert: @@ -150,7 +150,7 @@ packages: description: name: convert sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.1.2" crypto: @@ -158,7 +158,7 @@ packages: description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.0.7" cupertino_icons: @@ -166,7 +166,7 @@ packages: description: name: cupertino_icons sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.0.9" dart_style: @@ -174,7 +174,7 @@ packages: description: name: dart_style sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.1.1" dio: @@ -182,7 +182,7 @@ packages: description: name: dio sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "5.9.2" dio_web_adapter: @@ -190,7 +190,7 @@ packages: description: name: dio_web_adapter sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.1.2" equatable: @@ -198,7 +198,7 @@ packages: description: name: equatable sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.0.8" fake_async: @@ -206,15 +206,23 @@ packages: description: name: fake_async sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.3.3" + ffi: + dependency: transitive + description: + name: ffi + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.0" file: dependency: transitive description: name: file sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "7.0.1" fixnum: @@ -222,7 +230,7 @@ packages: description: name: fixnum sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.1.1" flutter: @@ -235,7 +243,7 @@ packages: description: name: flutter_lints sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "5.0.0" flutter_riverpod: @@ -243,7 +251,7 @@ packages: description: name: flutter_riverpod sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.6.1" flutter_test: @@ -261,7 +269,7 @@ packages: description: name: freezed sha256: "59a584c24b3acdc5250bb856d0d3e9c0b798ed14a4af1ddb7dc1c7b41df91c9c" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.5.8" freezed_annotation: @@ -269,7 +277,7 @@ packages: description: name: freezed_annotation sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.4.4" frontend_server_client: @@ -277,7 +285,7 @@ packages: description: name: frontend_server_client sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "4.0.0" glob: @@ -285,7 +293,7 @@ packages: description: name: glob sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.1.3" go_router: @@ -293,7 +301,7 @@ packages: description: name: go_router sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "14.8.1" graphs: @@ -301,7 +309,7 @@ packages: description: name: graphs sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.3.2" http: @@ -309,7 +317,7 @@ packages: description: name: http sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.6.0" http_multi_server: @@ -317,7 +325,7 @@ packages: description: name: http_multi_server sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.2.2" http_parser: @@ -325,7 +333,7 @@ packages: description: name: http_parser sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "4.1.2" io: @@ -333,7 +341,7 @@ packages: description: name: io sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.0.5" js: @@ -341,7 +349,7 @@ packages: description: name: js sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.7.2" json_annotation: @@ -349,7 +357,7 @@ packages: description: name: json_annotation sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "4.9.0" json_serializable: @@ -357,7 +365,7 @@ packages: description: name: json_serializable sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "6.9.5" leak_tracker: @@ -365,7 +373,7 @@ packages: description: name: leak_tracker sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "11.0.2" leak_tracker_flutter_testing: @@ -373,7 +381,7 @@ packages: description: name: leak_tracker_flutter_testing sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.0.10" leak_tracker_testing: @@ -381,7 +389,7 @@ packages: description: name: leak_tracker_testing sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.0.2" lints: @@ -389,7 +397,7 @@ packages: description: name: lints sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "5.1.1" logging: @@ -397,39 +405,39 @@ packages: description: name: logging sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.3.0" matcher: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 - url: "https://pub.dev" + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.flutter-io.cn" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" - url: "https://pub.dev" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.flutter-io.cn" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" - url: "https://pub.dev" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.flutter-io.cn" source: hosted - version: "1.17.0" + version: "1.16.0" mime: dependency: transitive description: name: mime sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.0.0" mocktail: @@ -437,7 +445,7 @@ packages: description: name: mocktail sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.0.4" package_config: @@ -445,7 +453,7 @@ packages: description: name: package_config sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.2.0" path: @@ -453,15 +461,55 @@ packages: description: name: path sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.9.1" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: bd6f00dbd873bfb70d0761682da2b3a2c2fccc2b9e84c495821639601d81afe7 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.0" + platform: + dependency: transitive + description: + name: platform + sha256: "5d6b1b0036a5f331ebc77c850ebc8506cbc1e9416c27e59b439f917a902a4984" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.6" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.8" pool: dependency: transitive description: name: pool sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.5.2" pub_semver: @@ -469,7 +517,7 @@ packages: description: name: pub_semver sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.2.0" pubspec_parse: @@ -477,7 +525,7 @@ packages: description: name: pubspec_parse sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.5.0" quiver: @@ -485,7 +533,7 @@ packages: description: name: quiver sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.2.2" riverpod: @@ -493,15 +541,71 @@ packages: description: name: riverpod sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.6.1" + shared_preferences: + dependency: "direct main" + description: + name: shared_preferences + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.5" + shared_preferences_android: + dependency: transitive + description: + name: shared_preferences_android + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.23" + shared_preferences_foundation: + dependency: transitive + description: + name: shared_preferences_foundation + sha256: "4e7eaffc2b17ba398759f1151415869a34771ba11ebbccd1b0145472a619a64f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.5.6" + shared_preferences_linux: + dependency: transitive + description: + name: shared_preferences_linux + sha256: "580abfd40f415611503cae30adf626e6656dfb2f0cee8f465ece7b6defb40f2f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" + shared_preferences_platform_interface: + dependency: transitive + description: + name: shared_preferences_platform_interface + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.2" + shared_preferences_web: + dependency: transitive + description: + name: shared_preferences_web + sha256: c49bd060261c9a3f0ff445892695d6212ff603ef3115edbb448509d407600019 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.3" + shared_preferences_windows: + dependency: transitive + description: + name: shared_preferences_windows + sha256: "94ef0f72b2d71bc3e700e025db3710911bd51a71cefb65cc609dd0d9a982e3c1" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.4.1" shelf: dependency: transitive description: name: shelf sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.4.2" shelf_web_socket: @@ -509,7 +613,7 @@ packages: description: name: shelf_web_socket sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.0.0" sky_engine: @@ -522,7 +626,7 @@ packages: description: name: source_gen sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.0.0" source_helper: @@ -530,7 +634,7 @@ packages: description: name: source_helper sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.3.7" source_span: @@ -538,7 +642,7 @@ packages: description: name: source_span sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.10.2" stack_trace: @@ -546,7 +650,7 @@ packages: description: name: stack_trace sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.12.1" state_notifier: @@ -554,7 +658,7 @@ packages: description: name: state_notifier sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.0.0" stream_channel: @@ -562,7 +666,7 @@ packages: description: name: stream_channel sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.1.4" stream_transform: @@ -570,7 +674,7 @@ packages: description: name: stream_transform sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.1.1" string_scanner: @@ -578,7 +682,7 @@ packages: description: name: string_scanner sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.4.1" term_glyph: @@ -586,23 +690,23 @@ packages: description: name: term_glyph sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.2.2" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" - url: "https://pub.dev" + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.flutter-io.cn" source: hosted - version: "0.7.10" + version: "0.7.6" timing: dependency: transitive description: name: timing sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.0.2" typed_data: @@ -610,7 +714,7 @@ packages: description: name: typed_data sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.4.0" vector_math: @@ -618,7 +722,7 @@ packages: description: name: vector_math sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "2.2.0" vm_service: @@ -626,7 +730,7 @@ packages: description: name: vm_service sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "15.0.2" watcher: @@ -634,7 +738,7 @@ packages: description: name: watcher sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.2.1" web: @@ -642,7 +746,7 @@ packages: description: name: web sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.1.1" web_socket: @@ -650,7 +754,7 @@ packages: description: name: web_socket sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "1.0.1" web_socket_channel: @@ -658,15 +762,23 @@ packages: description: name: web_socket_channel sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.0.3" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: "7a3f37b05d989967cdddcbb571f1ea834867ae2faa29725fd085180e0883aa15" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.0" xterm: dependency: "direct main" description: name: xterm sha256: "168dfedca77cba33fdb6f52e2cd001e9fde216e398e89335c19b524bb22da3a2" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "4.0.0" yaml: @@ -674,7 +786,7 @@ packages: description: name: yaml sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "3.1.3" zmodem: @@ -682,9 +794,9 @@ packages: description: name: zmodem sha256: "3b7e5b29f3a7d8aee472029b05165a68438eff2f3f7766edf13daba1e297adbf" - url: "https://pub.dev" + url: "https://pub.flutter-io.cn" source: hosted version: "0.0.6" sdks: dart: ">=3.9.2 <4.0.0" - flutter: ">=3.22.0" + flutter: ">=3.35.0" diff --git a/apps/mobile_app/pubspec.yaml b/apps/mobile_app/pubspec.yaml index 651f70d..38eadc5 100644 --- a/apps/mobile_app/pubspec.yaml +++ b/apps/mobile_app/pubspec.yaml @@ -37,6 +37,7 @@ dependencies: freezed_annotation: ^2.4.4 json_annotation: ^4.9.0 xterm: ^4.0.0 + shared_preferences: ^2.5.3 cupertino_icons: ^1.0.8 dev_dependencies: diff --git a/apps/mobile_app/test/features/presets/preset_repository_test.dart b/apps/mobile_app/test/features/presets/preset_repository_test.dart new file mode 100644 index 0000000..0364200 --- /dev/null +++ b/apps/mobile_app/test/features/presets/preset_repository_test.dart @@ -0,0 +1,92 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:term_remote_ctl/features/presets/preset_command.dart'; +import 'package:term_remote_ctl/features/presets/preset_repository.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + test('listPresets restores persisted presets from local storage', () async { + SharedPreferences.setMockInitialValues({ + 'preset_commands_v1': [ + '{"id":"preset-1","label":"ssh prod","commandText":"ssh admin@prod"}', + ], + }); + + final repository = PresetRepository(); + final presets = await repository.listPresets(); + + expect(presets, [ + isA() + .having((preset) => preset.id, 'id', 'preset-1') + .having((preset) => preset.label, 'label', 'ssh prod') + .having( + (preset) => preset.commandText, + 'commandText', + 'ssh admin@prod', + ), + ]); + }); + + test('savePreset appends a new preset and persists it', () async { + final repository = PresetRepository(); + + final savedPreset = await repository.savePreset( + const PresetCommand( + id: 'preset-1', + label: 'flutter test', + commandText: 'C:\\tools\\flutter\\bin\\flutter.bat test', + ), + ); + final reloaded = await repository.listPresets(); + + expect(savedPreset.label, 'flutter test'); + expect(reloaded.map((preset) => preset.id), ['preset-1']); + expect(reloaded.map((preset) => preset.commandText), [ + 'C:\\tools\\flutter\\bin\\flutter.bat test', + ]); + }); + + test('savePreset updates an existing preset in place', () async { + SharedPreferences.setMockInitialValues({ + 'preset_commands_v1': [ + '{"id":"preset-1","label":"ssh prod","commandText":"ssh admin@prod"}', + ], + }); + + final repository = PresetRepository(); + + await repository.savePreset( + const PresetCommand( + id: 'preset-1', + label: 'ssh production', + commandText: 'ssh ops@prod', + ), + ); + final reloaded = await repository.listPresets(); + + expect(reloaded, hasLength(1)); + expect(reloaded.single.label, 'ssh production'); + expect(reloaded.single.commandText, 'ssh ops@prod'); + }); + + test('deletePreset removes a persisted preset', () async { + SharedPreferences.setMockInitialValues({ + 'preset_commands_v1': [ + '{"id":"preset-1","label":"ssh prod","commandText":"ssh admin@prod"}', + '{"id":"preset-2","label":"git pull","commandText":"git pull --ff-only"}', + ], + }); + + final repository = PresetRepository(); + + await repository.deletePreset('preset-1'); + final reloaded = await repository.listPresets(); + + expect(reloaded.map((preset) => preset.id), ['preset-2']); + }); +} diff --git a/apps/mobile_app/test/features/terminal/terminal_page_input_test.dart b/apps/mobile_app/test/features/terminal/terminal_page_input_test.dart new file mode 100644 index 0000000..5d3cca6 --- /dev/null +++ b/apps/mobile_app/test/features/terminal/terminal_page_input_test.dart @@ -0,0 +1,226 @@ +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'; +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/terminal/terminal_page.dart'; +import 'package:term_remote_ctl/features/terminal/terminal_socket_session.dart'; +import 'package:term_remote_ctl/features/sessions/session.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('terminal keeps the extra key tray hidden until requested', ( + tester, + ) async { + await _pumpTerminalPage(tester); + + expect(find.byKey(const Key('terminal_key_tray')), findsNothing); + expect( + find.byKey(const Key('terminal_keys_toggle_button')), + findsOneWidget, + ); + + 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, + ); + }); + + testWidgets('terminal long press repeats arrow keys', (tester) async { + final transportFactory = _QueuedTerminalSocketTransportFactory(); + + await _pumpTerminalPage(tester, socketFactory: transportFactory.factory); + + await tester.tap(find.byKey(const Key('terminal_keys_toggle_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(); + + 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_mode_button'))); + await tester.pumpAndSettle(); + await tester.tap(find.byType(TextField)); + await tester.pumpAndSettle(); + await tester.sendKeyEvent(LogicalKeyboardKey.escape); + await tester.pumpAndSettle(); + + expect( + transportFactory.createdTransports.single.sentMessages.last, + contains(r'"input":"\u001b"'), + ); + }); + + testWidgets('terminal opens the presets sheet and launches management', ( + tester, + ) async { + await _pumpTerminalPage( + tester, + presetRepository: _MemoryPresetRepository([ + const PresetCommand( + id: 'preset-1', + label: 'ssh prod', + commandText: 'ssh admin@prod', + ), + ]), + ); + + await tester.tap(find.byKey(const Key('terminal_presets_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'))); + await tester.pumpAndSettle(); + + expect(find.byKey(const Key('preset_management_page')), findsOneWidget); + expect(find.byKey(const Key('preset_list_tile_preset-1')), findsOneWidget); + }); +} + +Future _pumpTerminalPage( + WidgetTester tester, { + TerminalSocketSessionFactory? socketFactory, + PresetRepository? presetRepository, +}) async { + await tester.pumpWidget( + ProviderScope( + overrides: [ + agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()), + terminalSocketSessionFactoryProvider.overrideWithValue( + socketFactory ?? + TerminalSocketSessionFactory( + transportFactory: (_) => + _FakeTerminalSocketTransport(autoAttach: true), + ), + ), + presetRepositoryProvider.overrideWithValue( + presetRepository ?? _MemoryPresetRepository(const []), + ), + ], + child: MaterialApp( + home: TerminalPage( + session: _session('session-1', 'codex-main'), + agentBaseUri: Uri.parse('http://100.81.30.82:5067'), + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +Session _session(String sessionId, String name) { + return Session( + sessionId: sessionId, + name: name, + status: 'created', + projectId: 'project-1', + workingDirectory: r'C:\repo\codex-main', + createdAtUtc: DateTime.parse('2026-03-30T10:00:00Z'), + updatedAtUtc: DateTime.parse('2026-03-30T10:00:00Z'), + ); +} + +class _MemoryPresetRepository extends PresetRepository { + _MemoryPresetRepository(List presets) + : _presets = List.of(presets); + + final List _presets; + + @override + Future> listPresets() async { + return List.of(_presets); + } +} + +class _FakeAgentApiClient extends AgentApiClient { + _FakeAgentApiClient() : super(Uri.parse('http://100.81.30.82:5067')); + + @override + Future> getSessionHistory( + String sessionId, { + int lineCount = 200, + }) async { + return { + 'sessionId': sessionId, + 'lines': ['one', 'two'], + 'hasMoreAbove': true, + }; + } +} + +class _QueuedTerminalSocketTransportFactory { + final createdTransports = <_FakeTerminalSocketTransport>[]; + + TerminalSocketSessionFactory get factory => + TerminalSocketSessionFactory(transportFactory: create); + + TerminalSocketTransport create(Uri _) { + final transport = _FakeTerminalSocketTransport(autoAttach: true); + createdTransports.add(transport); + return transport; + } +} + +class _FakeTerminalSocketTransport implements TerminalSocketTransport { + _FakeTerminalSocketTransport({this.autoAttach = false}) { + if (autoAttach) { + Future.microtask(() { + emit('{"type":"attached","sessionId":"session-1"}'); + }); + } + } + + final bool autoAttach; + final _incoming = StreamController.broadcast(); + final sentMessages = []; + + @override + Stream get stream => _incoming.stream; + + @override + void send(String message) { + sentMessages.add(message); + } + + @override + Future close() async { + await _incoming.close(); + } + + void emit(String message) { + _incoming.add(message); + } +} diff --git a/apps/mobile_app/test/widget_test.dart b/apps/mobile_app/test/widget_test.dart index ddc0fc0..fc73b56 100644 --- a/apps/mobile_app/test/widget_test.dart +++ b/apps/mobile_app/test/widget_test.dart @@ -275,7 +275,12 @@ void main() { 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.text('Browse mode'), findsOneWidget); + expect(find.byKey(const Key('terminal_key_tray')), findsNothing); + expect(find.byKey(const Key('terminal_mode_button')), findsOneWidget); + expect( + find.byKey(const Key('terminal_keys_toggle_button')), + findsOneWidget, + ); await tester.tap(find.byKey(const Key('terminal_toggle_actions_button'))); await tester.pumpAndSettle(); @@ -300,6 +305,10 @@ void main() { await _openProjectTerminal(tester); + expect(find.byKey(const Key('terminal_key_tray')), findsNothing); + await tester.tap(find.byKey(const Key('terminal_keys_toggle_button'))); + await tester.pumpAndSettle(); + 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); @@ -330,19 +339,29 @@ void main() { await _openProjectTerminal(tester); - expect(find.text('Browse mode'), findsOneWidget); - expect(find.text('Direct input enabled'), findsNothing); + final commandField = tester.widget(find.byType(TextField).last); + expect( + commandField.decoration?.hintText, + 'Buffered mode: type a command and send', + ); - await tester.tap(find.byKey(const Key('terminal_direct_input_toggle'))); + await tester.tap(find.byKey(const Key('terminal_mode_button'))); await tester.pumpAndSettle(); - expect(find.text('Direct input enabled'), findsOneWidget); - expect(find.text('Browse mode'), findsNothing); + final directField = tester.widget(find.byType(TextField).last); + expect( + directField.decoration?.hintText, + 'Direct mode: keys send immediately', + ); - await tester.tap(find.byKey(const Key('terminal_direct_input_toggle'))); + await tester.tap(find.byKey(const Key('terminal_mode_button'))); await tester.pumpAndSettle(); - expect(find.text('Browse mode'), findsOneWidget); + final bufferedField = tester.widget(find.byType(TextField).last); + expect( + bufferedField.decoration?.hintText, + 'Buffered mode: type a command and send', + ); }); testWidgets( @@ -384,6 +403,8 @@ void main() { 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_keys_toggle_button'))); + await tester.pumpAndSettle(); await tester.tap(find.byKey(const Key('terminal_quick_key_ctrl_l'))); await tester.pumpAndSettle(); @@ -520,7 +541,7 @@ void main() { final commandField = find.byType(TextField).last; final editableField = find.byType(EditableText).last; - await tester.tap(find.byKey(const Key('terminal_direct_input_toggle'))); + await tester.tap(find.byKey(const Key('terminal_mode_button'))); await tester.pumpAndSettle(); await tester.tap(commandField); await tester.pumpAndSettle(); @@ -848,7 +869,9 @@ void main() { overrides: [ agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()), projectRepositoryProvider.overrideWithValue(projectRepository), - sessionRepositoryProvider.overrideWithValue(_FakeSessionRepository()), + sessionRepositoryProvider.overrideWithValue( + _FakeSessionRepository(), + ), terminalSocketSessionFactoryProvider.overrideWithValue( TerminalSocketSessionFactory( transportFactory: (_) =>