refactor: streamline terminal bottom controls

This commit is contained in:
sladro 2026-04-06 12:29:19 +08:00
parent 2dd5113044
commit 12f2b7bb8c
3 changed files with 213 additions and 230 deletions

View File

@ -5,13 +5,15 @@ import 'package:flutter/material.dart';
class RepeatableTerminalKeyButton extends StatefulWidget {
const RepeatableTerminalKeyButton({
super.key,
required this.label,
required this.onPressed,
this.label,
this.icon,
this.enabled = true,
this.repeatable = false,
});
}) : assert(label != null || icon != null);
final String label;
final String? label;
final IconData? icon;
final VoidCallback onPressed;
final bool enabled;
final bool repeatable;
@ -61,11 +63,15 @@ class _RepeatableTerminalKeyButtonState
backgroundColor: const Color(0xFF151A20),
side: const BorderSide(color: Color(0xFF3F3428)),
minimumSize: const Size(0, 34),
padding: const EdgeInsets.symmetric(horizontal: 10),
padding: EdgeInsets.symmetric(
horizontal: widget.label == null ? 8 : 10,
),
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
child: Text(widget.label),
child: widget.icon != null
? Icon(widget.icon, size: 18)
: Text(widget.label!),
),
);
}

View File

@ -92,24 +92,28 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
keyId: 'up',
label: 'Up',
input: '\u001b[A',
icon: Icons.arrow_upward,
repeatable: true,
),
_QuickTerminalKey(
keyId: 'down',
label: 'Down',
input: '\u001b[B',
icon: Icons.arrow_downward,
repeatable: true,
),
_QuickTerminalKey(
keyId: 'left',
label: 'Left',
input: '\u001b[D',
icon: Icons.arrow_back,
repeatable: true,
),
_QuickTerminalKey(
keyId: 'right',
label: 'Right',
input: '\u001b[C',
icon: Icons.arrow_forward,
repeatable: true,
),
];
@ -142,6 +146,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
bool _awaitingAttachReplayFrame = true;
bool _awaitingReconnectRestore = false;
bool _shouldReconnectOnResume = false;
bool _showExpandedControls = false;
_TerminalInputMode _inputMode = _TerminalInputMode.read;
TerminalConnectionState? _lastConnectionState;
@ -558,127 +563,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
);
}
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),
isScrollControlled: true,
builder: (context) {
return SafeArea(
child: AnimatedBuilder(
animation: _pageStateListenable,
builder: (context, _) {
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
child: Column(
key: const Key('terminal_actions_sheet'),
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(
'Terminal actions',
style: Theme.of(context).textTheme.titleMedium,
),
const Spacer(),
StatusPill(
label: _statusLabel,
icon: _statusIcon,
color: _statusColor(context),
),
],
),
const SizedBox(height: 12),
Text(
'Session',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
onPressed: () {
Navigator.of(context).pop();
_jumpToBottom();
},
icon: const Icon(Icons.vertical_align_bottom),
label: const Text('Latest'),
),
OutlinedButton.icon(
onPressed: () {
Navigator.of(context).pop();
unawaited(_coordinator.reconnectNow());
},
icon: const Icon(Icons.refresh),
label: const Text('Reconnect'),
),
if (widget.project != null)
OutlinedButton.icon(
onPressed: () {
Navigator.of(context).pop();
unawaited(_openSiblingTerminal());
},
icon: const Icon(Icons.add_box_outlined),
label: const Text('New terminal'),
),
TextButton.icon(
key: const Key('terminal_diagnostics_button'),
onPressed: () async {
Navigator.of(context).pop();
await _showDiagnostics();
},
icon: const Icon(Icons.bug_report_outlined),
label: const Text('Diagnostics'),
),
],
),
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,
style: Theme.of(context).textTheme.bodySmall,
),
],
),
),
);
},
),
);
},
);
}
Future<void> _openPresetManagementPage() {
return Navigator.of(context).push(
MaterialPageRoute(builder: (context) => const PresetManagementPage()),
@ -688,7 +572,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
@override
Widget build(BuildContext context) {
final width = MediaQuery.sizeOf(context).width;
final isCompact = width < 420;
final isTight = width < 400;
final workingDirectory =
widget.project?.workingDirectory ??
@ -722,67 +605,14 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
],
),
),
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,
),
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),
),
),
),
IconButton(
key: const Key('terminal_actions_button'),
onPressed: _showActionsSheet,
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.tune),
tooltip: 'Show actions',
),
],
);
},
),
],
actions: const [],
),
body: SafeArea(
top: false,
minimum: AppTheme.pagePadding,
child: Column(
children: [
_buildScrollbackSection(context, isCompact),
_buildScrollbackSection(context),
Expanded(
child: Container(
key: const Key('terminal_surface_panel'),
@ -819,14 +649,15 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
),
),
const SizedBox(height: 6),
_buildCommandDeck(context, isCompact),
_buildCommandDeck(context),
],
),
),
);
}
Widget _buildScrollbackSection(BuildContext context, bool isCompact) {
Widget _buildScrollbackSection(BuildContext context) {
final isCompact = MediaQuery.sizeOf(context).width < 420;
return AnimatedBuilder(
animation: _pageStateListenable,
builder: (context, _) {
@ -922,7 +753,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
borderRadius: BorderRadius.zero,
border: Border.all(color: const Color(0xFF2A231B)),
),
child: isCompact
child: MediaQuery.sizeOf(context).width < 420
? _buildCompactHistoryActions(context)
: _buildWideHistoryActions(context),
),
@ -1045,7 +876,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
);
}
Widget _buildCommandDeck(BuildContext context, bool isCompact) {
Widget _buildCommandDeck(BuildContext context) {
return AppPanel(
key: const Key('terminal_command_deck'),
tone: AppPanelTone.emphasis,
@ -1095,7 +926,13 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
],
),
),
_buildQuickKeysSection(),
_buildPinnedQuickKeysRow(),
if (_showExpandedControls) ...[
const SizedBox(height: 8),
_buildExpandedControls(context),
],
const SizedBox(height: 8),
_buildStatusRow(context),
],
);
},
@ -1103,38 +940,167 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
);
}
Widget _buildQuickKeysSection() {
return Column(
Widget _buildPinnedQuickKeysRow() {
return Wrap(
spacing: 8,
runSpacing: 8,
children: [
_buildKeyTrayRow(_editingControlKeys),
const SizedBox(height: 8),
_buildKeyTrayRow(_navigationKeys),
const SizedBox(height: 8),
_buildKeyTrayRow(_symbolTerminalKeys),
_buildMoreControlsButton(),
..._navigationKeys
.where((key) => switch (key.keyId) {
'up' || 'down' || 'left' || 'right' => true,
_ => false,
})
.map(_buildQuickKeyButton),
],
);
}
Widget _buildExpandedControls(BuildContext context) {
final presetsFuture = ref.watch(presetRepositoryProvider).listPresets();
return FutureBuilder<List<PresetCommand>>(
future: presetsFuture,
builder: (context, snapshot) {
final presets = snapshot.data ?? const <PresetCommand>[];
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_buildKeyTrayRow(_editingControlKeys),
const SizedBox(height: 8),
_buildKeyTrayRow(
_navigationKeys.where((key) {
return key.keyId != 'up' &&
key.keyId != 'down' &&
key.keyId != 'left' &&
key.keyId != 'right';
}).toList(growable: false),
),
const SizedBox(height: 8),
_buildKeyTrayRow(_symbolTerminalKeys),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
OutlinedButton.icon(
key: const Key('terminal_latest_inline_button'),
onPressed: _jumpToBottom,
icon: const Icon(Icons.vertical_align_bottom),
label: const Text('Latest'),
),
OutlinedButton.icon(
key: const Key('terminal_reconnect_inline_button'),
onPressed: () => unawaited(_coordinator.reconnectNow()),
icon: const Icon(Icons.refresh),
label: const Text('Reconnect'),
),
if (widget.project != null)
OutlinedButton.icon(
key: const Key('terminal_new_inline_button'),
onPressed: () => unawaited(_openSiblingTerminal()),
icon: const Icon(Icons.add_box_outlined),
label: const Text('New terminal'),
),
TextButton.icon(
key: const Key('terminal_diagnostics_inline_button'),
onPressed: _showDiagnostics,
icon: const Icon(Icons.bug_report_outlined),
label: const Text('Diagnostics'),
),
],
),
const SizedBox(height: 8),
PresetPanel(
presets: presets,
onPresetSelected: (preset) {
unawaited(_sendLine(preset.commandText));
},
onManagePressed: () {
unawaited(_openPresetManagementPage());
},
),
],
);
},
);
}
Widget _buildKeyTrayRow(List<_QuickTerminalKey> keys) {
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),
),
);
})
.map(_buildQuickKeyButton)
.toList(growable: false),
);
}
Widget _buildQuickKeyButton(_QuickTerminalKey quickKey) {
return _buildCommandDeckAction(
RepeatableTerminalKeyButton(
key: Key('terminal_quick_key_${quickKey.keyId}'),
enabled: _canSendInput,
repeatable: quickKey.repeatable,
label: quickKey.label,
icon: quickKey.icon,
onPressed: () => _sendQuickKey(quickKey),
),
);
}
Widget _buildMoreControlsButton() {
return _buildCommandDeckAction(
OutlinedButton.icon(
key: const Key('terminal_more_controls_button'),
onPressed: () {
setState(() {
_showExpandedControls = !_showExpandedControls;
});
},
icon: Icon(_showExpandedControls ? Icons.expand_less : Icons.expand_more),
label: Text(_showExpandedControls ? 'Less' : 'More'),
),
);
}
Widget _buildStatusRow(BuildContext context) {
final mode = controller.isFollowingLiveOutput ? 'Live' : 'Scrollback';
final modeLabel = '$mode | ${controller.liveLines.length} lines';
return Wrap(
key: const Key('terminal_status_summary'),
spacing: 8,
runSpacing: 6,
crossAxisAlignment: WrapCrossAlignment.center,
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),
),
const SizedBox(width: 4),
StatusPill(
label: _statusLabel,
icon: _statusIcon,
color: _statusColor(context),
),
Text(
_coordinator.connectionStatus,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.bodySmall,
),
],
);
}
Widget _buildCommandDeckAction(Widget child) {
return ExcludeFocus(child: child);
}
@ -1172,13 +1138,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
TerminalConnectionState.disconnected => 'Offline',
};
String get _compactStatusLabel => switch (_connectionState) {
TerminalConnectionState.connecting => 'Sync',
TerminalConnectionState.connected => 'On',
TerminalConnectionState.reconnecting => 'Sync',
TerminalConnectionState.disconnected => 'Off',
};
bool get _canSendInput =>
controller.canSendInput && _inputMode == _TerminalInputMode.edit;
@ -1208,12 +1167,14 @@ class _QuickTerminalKey {
required this.keyId,
required this.label,
required this.input,
this.icon,
this.repeatable = false,
});
final String keyId;
final String label;
final String input;
final IconData? icon;
final bool repeatable;
}

View File

@ -144,7 +144,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_actions_button')), findsOneWidget);
expect(find.byKey(const Key('terminal_more_controls_button')), findsOneWidget);
});
testWidgets('project list deletes a project after confirmation', (
@ -348,7 +348,7 @@ void main() {
});
testWidgets(
'terminal page shows mode controls and inline quick keys in the command deck',
'terminal page keeps the action entry in the command deck instead of the app bar',
(tester) async {
await _pumpApp(
tester,
@ -364,14 +364,18 @@ void main() {
expect(find.byType(TextField), findsNothing);
expect(find.byKey(const Key('terminal_mode_read_button')), findsOneWidget);
expect(find.byKey(const Key('terminal_mode_edit_button')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_ctrl_c')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_up')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_enter')), findsOneWidget);
expect(find.byKey(const Key('terminal_actions_button')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_down')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_left')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_right')), findsOneWidget);
expect(find.byKey(const Key('terminal_more_controls_button')), findsOneWidget);
expect(find.byKey(const Key('terminal_actions_button')), findsNothing);
expect(find.byKey(const Key('terminal_quick_key_ctrl_c')), findsNothing);
expect(find.byKey(const Key('terminal_quick_key_enter')), findsNothing);
},
);
testWidgets('terminal command deck exposes expanded quick terminal keys', (tester) async {
testWidgets('terminal more controls button toggles expanded quick terminal keys', (tester) async {
final transportFactory = _QueuedTerminalSocketTransportFactory();
await _pumpApp(
@ -385,6 +389,16 @@ void main() {
await _openProjectTerminal(tester);
expect(find.byKey(const Key('terminal_quick_key_up')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_down')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_left')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_right')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_esc')), findsNothing);
expect(find.byKey(const Key('terminal_quick_key_ctrl_c')), findsNothing);
await tester.tap(find.byKey(const Key('terminal_more_controls_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);
@ -396,10 +410,7 @@ void main() {
expect(find.byKey(const Key('terminal_quick_key_end')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_page_up')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_page_down')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_up')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_down')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_left')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_right')), findsOneWidget);
expect(find.byKey(const Key('terminal_quick_key_enter')), findsOneWidget);
await tester.tap(find.byKey(const Key('terminal_mode_edit_button')));
await tester.pumpAndSettle();
@ -427,6 +438,10 @@ void main() {
expect(find.byKey(const Key('terminal_mode_button')), findsOneWidget);
expect(find.text('Read'), findsOneWidget);
expect(find.text('Edit'), findsOneWidget);
expect(find.byIcon(Icons.arrow_upward), findsWidgets);
expect(find.byIcon(Icons.arrow_downward), findsWidgets);
expect(find.byIcon(Icons.arrow_back), findsWidgets);
expect(find.byIcon(Icons.arrow_forward), findsWidgets);
});
testWidgets('terminal quick keys stay disabled until edit mode is selected', (
@ -445,6 +460,8 @@ void main() {
await _openProjectTerminal(tester);
await tester.tap(find.byKey(const Key('terminal_more_controls_button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('terminal_quick_key_ctrl_l')));
await tester.pump();
@ -503,14 +520,14 @@ void main() {
await _openProjectTerminal(tester);
await tester.tap(find.byKey(const Key('terminal_mode_edit_button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('terminal_more_controls_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_actions_button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('terminal_diagnostics_button')));
await tester.tap(find.byKey(const Key('terminal_diagnostics_inline_button')));
await tester.pumpAndSettle();
expect(find.textContaining('ui.input.quick | Ctrl+L'), findsOneWidget);
@ -537,6 +554,8 @@ void main() {
await _openProjectTerminal(tester);
await tester.tap(find.byKey(const Key('terminal_mode_edit_button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('terminal_more_controls_button')));
await tester.pumpAndSettle();
await tester.tap(find.byKey(const Key('terminal_quick_key_enter')));
await tester.pumpAndSettle();
@ -568,9 +587,6 @@ void main() {
await transportFactory.createdTransports.first.close();
await tester.pump();
await tester.tap(find.byKey(const Key('terminal_actions_button')));
await tester.pumpAndSettle();
expect(find.text('Connection lost. Reconnecting...'), findsOneWidget);
await tester.pump(const Duration(seconds: 2));
@ -659,9 +675,9 @@ void main() {
await _openProjectTerminal(tester);
expect(sessionRepository.createCount, 1);
await tester.tap(find.byKey(const Key('terminal_actions_button')));
await tester.tap(find.byKey(const Key('terminal_more_controls_button')));
await tester.pumpAndSettle();
await tester.tap(find.text('New terminal'));
await tester.tap(find.byKey(const Key('terminal_new_inline_button')));
await tester.pumpAndSettle();
expect(sessionRepository.createCount, 2);