Compare commits
3 Commits
4fcbb07fdc
...
cb6def34b8
| Author | SHA1 | Date | |
|---|---|---|---|
| cb6def34b8 | |||
| c96d52142b | |||
| 2215def240 |
@ -1,7 +1,17 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
class AgentApiClient {
|
||||
AgentApiClient(this.baseUri, {Dio? dio}) : _dio = dio ?? Dio();
|
||||
AgentApiClient(this.baseUri, {Dio? dio}) : _dio = dio ?? Dio() {
|
||||
_dio.options = _dio.options.copyWith(
|
||||
connectTimeout: _dio.options.connectTimeout ?? _defaultConnectTimeout,
|
||||
sendTimeout: _dio.options.sendTimeout ?? _defaultSendTimeout,
|
||||
receiveTimeout: _dio.options.receiveTimeout ?? _defaultReceiveTimeout,
|
||||
);
|
||||
}
|
||||
|
||||
static const Duration _defaultConnectTimeout = Duration(seconds: 8);
|
||||
static const Duration _defaultSendTimeout = Duration(seconds: 10);
|
||||
static const Duration _defaultReceiveTimeout = Duration(seconds: 20);
|
||||
|
||||
final Uri baseUri;
|
||||
final Dio _dio;
|
||||
|
||||
@ -14,5 +14,47 @@ String formatAgentError(Object error, {String fallback = 'Request failed.'}) {
|
||||
}
|
||||
}
|
||||
|
||||
if (error case DioException dioError) {
|
||||
final origin = _describeAgentOrigin(dioError.requestOptions);
|
||||
switch (dioError.type) {
|
||||
case DioExceptionType.connectionTimeout:
|
||||
case DioExceptionType.connectionError:
|
||||
return 'Cannot reach the agent${origin == null ? '' : ' at $origin'}. '
|
||||
'Check that the desktop agent is running and that the agent URL is correct, then try again.';
|
||||
case DioExceptionType.badCertificate:
|
||||
return 'The certificate presented by the agent${origin == null ? '' : ' at $origin'} '
|
||||
'is not trusted on this device. Trust the certificate or use HTTP for local testing.';
|
||||
case DioExceptionType.receiveTimeout:
|
||||
return 'The agent${origin == null ? '' : ' at $origin'} took too long to respond. '
|
||||
'Check that it is still running and try again.';
|
||||
case DioExceptionType.sendTimeout:
|
||||
return 'Sending the request to the agent${origin == null ? '' : ' at $origin'} took too long. '
|
||||
'Check the connection and try again.';
|
||||
case DioExceptionType.badResponse:
|
||||
case DioExceptionType.cancel:
|
||||
case DioExceptionType.unknown:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return '$fallback $error';
|
||||
}
|
||||
|
||||
String? _describeAgentOrigin(RequestOptions requestOptions) {
|
||||
final uri = requestOptions.uri;
|
||||
if (uri.hasScheme && uri.host.isNotEmpty) {
|
||||
return uri.origin;
|
||||
}
|
||||
|
||||
final baseUrl = requestOptions.baseUrl;
|
||||
if (baseUrl.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final parsed = Uri.tryParse(baseUrl);
|
||||
if (parsed == null || !parsed.hasScheme || parsed.host.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parsed.origin;
|
||||
}
|
||||
|
||||
@ -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<String, dynamic> json) {
|
||||
return PresetCommand(
|
||||
id: json['id']?.toString() ?? '',
|
||||
label: json['label']?.toString() ?? '',
|
||||
commandText: json['commandText']?.toString() ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
Map<String, dynamic> toJson() {
|
||||
return <String, dynamic>{
|
||||
'id': id,
|
||||
'label': label,
|
||||
'commandText': commandText,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
233
apps/mobile_app/lib/features/presets/preset_management_page.dart
Normal file
233
apps/mobile_app/lib/features/presets/preset_management_page.dart
Normal file
@ -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<PresetManagementPage> createState() =>
|
||||
_PresetManagementPageState();
|
||||
}
|
||||
|
||||
class _PresetManagementPageState extends ConsumerState<PresetManagementPage> {
|
||||
late Future<List<PresetCommand>> _presetsFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_presetsFuture = _loadPresets();
|
||||
}
|
||||
|
||||
Future<List<PresetCommand>> _loadPresets() {
|
||||
return ref.read(presetRepositoryProvider).listPresets();
|
||||
}
|
||||
|
||||
Future<void> _refreshPresets() async {
|
||||
setState(() {
|
||||
_presetsFuture = _loadPresets();
|
||||
});
|
||||
await _presetsFuture;
|
||||
}
|
||||
|
||||
Future<void> _openEditor({PresetCommand? existing}) async {
|
||||
final labelController = TextEditingController(text: existing?.label ?? '');
|
||||
final commandController = TextEditingController(
|
||||
text: existing?.commandText ?? '',
|
||||
);
|
||||
|
||||
try {
|
||||
final shouldSave = await showDialog<bool>(
|
||||
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<void> _deletePreset(PresetCommand preset) async {
|
||||
final shouldDelete = await showDialog<bool>(
|
||||
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<List<PresetCommand>>(
|
||||
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 <PresetCommand>[];
|
||||
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),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<PresetCommand> presets;
|
||||
final ValueChanged<PresetCommand> 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'preset_repository.dart';
|
||||
|
||||
final presetRepositoryProvider = Provider<PresetRepository>((ref) {
|
||||
return PresetRepository();
|
||||
});
|
||||
@ -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<SharedPreferences> Function()? sharedPreferencesLoader,
|
||||
}) : _sharedPreferencesLoader =
|
||||
sharedPreferencesLoader ?? SharedPreferences.getInstance;
|
||||
|
||||
List<PresetCommand> listPresets() {
|
||||
return const [];
|
||||
static const String storageKey = 'preset_commands_v1';
|
||||
|
||||
final Future<SharedPreferences> Function() _sharedPreferencesLoader;
|
||||
|
||||
Future<List<PresetCommand>> listPresets() async {
|
||||
final sharedPreferences = await _sharedPreferencesLoader();
|
||||
final storedItems =
|
||||
sharedPreferences.getStringList(storageKey) ?? const <String>[];
|
||||
return storedItems
|
||||
.map((item) => jsonDecode(item))
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(PresetCommand.fromJson)
|
||||
.where((preset) => preset.id.isNotEmpty)
|
||||
.toList(growable: false);
|
||||
}
|
||||
|
||||
Future<PresetCommand> savePreset(PresetCommand preset) async {
|
||||
final presets = List<PresetCommand>.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<void> deletePreset(String presetId) async {
|
||||
final presets = await listPresets();
|
||||
await _persistPresets(
|
||||
presets.where((preset) => preset.id != presetId).toList(growable: false),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _persistPresets(List<PresetCommand> presets) async {
|
||||
final sharedPreferences = await _sharedPreferencesLoader();
|
||||
await sharedPreferences.setStringList(
|
||||
storageKey,
|
||||
presets.map((preset) => jsonEncode(preset.toJson())).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -257,7 +257,12 @@ class _ProjectDetailPageState extends ConsumerState<ProjectDetailPage> {
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text('Failed to load project: ${snapshot.error}'),
|
||||
child: Text(
|
||||
formatAgentError(
|
||||
snapshot.error!,
|
||||
fallback: 'Failed to load project.',
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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<RepeatableTerminalKeyButton> createState() =>
|
||||
_RepeatableTerminalKeyButtonState();
|
||||
}
|
||||
|
||||
class _RepeatableTerminalKeyButtonState
|
||||
extends State<RepeatableTerminalKeyButton> {
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<void> submit() async {
|
||||
if (isDirectInputEnabled) {
|
||||
|
||||
@ -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<TerminalPage>
|
||||
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,7 +105,10 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
String? _pendingHistorySeed;
|
||||
bool _receivedSocketFrame = false;
|
||||
bool _historySeeded = false;
|
||||
bool _awaitingAttachReplayFrame = true;
|
||||
bool _isKeyTrayVisible = false;
|
||||
bool _shouldReconnectOnResume = false;
|
||||
TerminalConnectionState? _lastConnectionState;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@ -246,6 +293,12 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
}
|
||||
}
|
||||
|
||||
void _toggleKeyTray() {
|
||||
setState(() {
|
||||
_isKeyTrayVisible = !_isKeyTrayVisible;
|
||||
});
|
||||
}
|
||||
|
||||
void _handleTerminalSurfaceTap() {
|
||||
if (!_canSendInput || !_inputController.isDirectInputEnabled) {
|
||||
return;
|
||||
@ -259,7 +312,23 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
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) {
|
||||
if (_awaitingAttachReplayFrame) {
|
||||
_awaitingAttachReplayFrame = false;
|
||||
if (_shouldSuppressAttachReplay(frame)) {
|
||||
_receivedSocketFrame = true;
|
||||
_cancelHistorySeedTimer();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
_receivedSocketFrame = true;
|
||||
_cancelHistorySeedTimer();
|
||||
terminal.write(frame);
|
||||
@ -276,6 +345,18 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
}
|
||||
|
||||
void _handlePageStateChanged() {
|
||||
final connectionState = _connectionState;
|
||||
if (_lastConnectionState != connectionState) {
|
||||
if (connectionState == TerminalConnectionState.connecting ||
|
||||
connectionState == TerminalConnectionState.reconnecting) {
|
||||
_awaitingAttachReplayFrame = true;
|
||||
if (connectionState == TerminalConnectionState.reconnecting) {
|
||||
_resetTerminalForReplay();
|
||||
}
|
||||
}
|
||||
_lastConnectionState = connectionState;
|
||||
}
|
||||
|
||||
_scheduleHistorySeedIfNeeded();
|
||||
}
|
||||
|
||||
@ -321,7 +402,46 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
_historySeedTimer = null;
|
||||
}
|
||||
|
||||
bool get _terminalHasVisibleContent => terminal.buffer.getText().trim().isNotEmpty;
|
||||
bool get _terminalHasVisibleContent =>
|
||||
terminal.buffer.getText().trim().isNotEmpty;
|
||||
|
||||
void _resetTerminalForReplay() {
|
||||
if (!_terminalHasVisibleContent) {
|
||||
return;
|
||||
}
|
||||
|
||||
terminal.buffer.clear();
|
||||
terminal.buffer.setCursor(0, 0);
|
||||
terminal.notifyListeners();
|
||||
}
|
||||
|
||||
bool _shouldSuppressAttachReplay(String frame) {
|
||||
final normalizedFrame = _normalizeTerminalText(frame);
|
||||
if (normalizedFrame.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!_historySeeded && !_terminalHasVisibleContent) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final normalizedTerminalText = _normalizeTerminalText(
|
||||
terminal.buffer.getText(),
|
||||
);
|
||||
if (normalizedTerminalText.isNotEmpty &&
|
||||
normalizedTerminalText.endsWith(normalizedFrame)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
final pendingHistorySeed = _pendingHistorySeed;
|
||||
return _historySeeded &&
|
||||
pendingHistorySeed != null &&
|
||||
_normalizeTerminalText(pendingHistorySeed) == normalizedFrame;
|
||||
}
|
||||
|
||||
static String _normalizeTerminalText(String text) {
|
||||
return text.replaceAll('\r\n', '\n').replaceAll('\r', '\n');
|
||||
}
|
||||
|
||||
Future<void> _showDiagnostics() {
|
||||
return showModalBottomSheet<void>(
|
||||
@ -474,6 +594,49 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showPresetsSheet() async {
|
||||
final presets = await ref.read(presetRepositoryProvider).listPresets();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: const Color(0xFF13191F),
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: Column(
|
||||
key: const Key('terminal_presets_sheet'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
PresetPanel(
|
||||
presets: presets,
|
||||
onPresetSelected: (preset) {
|
||||
Navigator.of(context).pop();
|
||||
unawaited(_sendLine(preset.commandText));
|
||||
},
|
||||
onManagePressed: () {
|
||||
Navigator.of(context).pop();
|
||||
unawaited(_openPresetManagementPage());
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openPresetManagementPage() {
|
||||
return Navigator.of(context).push(
|
||||
MaterialPageRoute(builder: (context) => const PresetManagementPage()),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final width = MediaQuery.sizeOf(context).width;
|
||||
@ -484,124 +647,129 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
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 +999,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
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 +1009,81 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
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 +1122,139 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
||||
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<ShortcutActivator, VoidCallback> _shortcutBindings() {
|
||||
if (!_inputController.isDirectInputEnabled || !_canSendInput) {
|
||||
return const <ShortcutActivator, VoidCallback>{};
|
||||
}
|
||||
|
||||
return <ShortcutActivator, VoidCallback>{
|
||||
const SingleActivator(LogicalKeyboardKey.escape): () {
|
||||
_sendQuickKey(_quickKey('esc'));
|
||||
},
|
||||
const SingleActivator(LogicalKeyboardKey.tab): () {
|
||||
_sendQuickKey(_quickKey('tab'));
|
||||
},
|
||||
const SingleActivator(LogicalKeyboardKey.arrowUp): () {
|
||||
_sendQuickKey(_quickKey('up'));
|
||||
},
|
||||
const SingleActivator(LogicalKeyboardKey.arrowDown): () {
|
||||
_sendQuickKey(_quickKey('down'));
|
||||
},
|
||||
const SingleActivator(LogicalKeyboardKey.arrowLeft): () {
|
||||
_sendQuickKey(_quickKey('left'));
|
||||
},
|
||||
const SingleActivator(LogicalKeyboardKey.arrowRight): () {
|
||||
_sendQuickKey(_quickKey('right'));
|
||||
},
|
||||
const SingleActivator(LogicalKeyboardKey.keyC, control: true): () {
|
||||
_sendQuickKey(_quickKey('ctrl_c'));
|
||||
},
|
||||
const SingleActivator(LogicalKeyboardKey.keyD, control: true): () {
|
||||
_sendQuickKey(_quickKey('ctrl_d'));
|
||||
},
|
||||
const SingleActivator(LogicalKeyboardKey.keyL, control: true): () {
|
||||
_sendQuickKey(_quickKey('ctrl_l'));
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
String get _statusLabel => switch (_connectionState) {
|
||||
TerminalConnectionState.connecting => 'Connecting',
|
||||
TerminalConnectionState.connected => 'Connected',
|
||||
@ -1109,9 +1297,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;
|
||||
}
|
||||
|
||||
@ -47,6 +47,7 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
);
|
||||
static const Duration reconnectDelay = Duration(seconds: 1);
|
||||
static const int initialHistoryLineCount = 200;
|
||||
static const int pendingInputCharacterLimit = 2048;
|
||||
|
||||
final TerminalInteractionController controller;
|
||||
final AgentApiClient apiClient;
|
||||
@ -64,7 +65,11 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
int _historyLineCount = initialHistoryLineCount;
|
||||
bool _isLoadingOlderHistory = false;
|
||||
bool _isDisposed = false;
|
||||
bool _connectionAttemptInProgress = false;
|
||||
bool _reconnectPending = false;
|
||||
String _connectionStatus = 'Connecting...';
|
||||
final List<String> _pendingInputs = <String>[];
|
||||
int _pendingInputCharacterCount = 0;
|
||||
|
||||
bool get isLoadingOlderHistory => _isLoadingOlderHistory;
|
||||
|
||||
@ -72,6 +77,7 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
|
||||
Future<void> start({bool isReconnect = false}) async {
|
||||
_cancelPendingReconnect();
|
||||
_reconnectPending = false;
|
||||
|
||||
if (_isDisposed) {
|
||||
return;
|
||||
@ -100,6 +106,7 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
|
||||
final socketSession = sessionFactory(baseUri: baseUri, session: session);
|
||||
_socketSession = socketSession;
|
||||
_connectionAttemptInProgress = true;
|
||||
|
||||
try {
|
||||
await socketSession.connect(
|
||||
@ -123,6 +130,7 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
'socket.resize.send',
|
||||
'${viewport.columns}x${viewport.rows}',
|
||||
);
|
||||
_flushPendingInputs(socketSession);
|
||||
controller.markConnected();
|
||||
_connectionStatus = 'Attached to ${session.name}';
|
||||
diagnosticLog?.add('socket.attach.ack', session.sessionId);
|
||||
@ -137,6 +145,8 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
diagnosticLog?.add('socket.connect.error', '$error');
|
||||
notifyListeners();
|
||||
_scheduleReconnect();
|
||||
} finally {
|
||||
_connectionAttemptInProgress = false;
|
||||
}
|
||||
}
|
||||
|
||||
@ -148,6 +158,15 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
void sendInput(String input) {
|
||||
final socketSession = _socketSession;
|
||||
if (socketSession == null) {
|
||||
if (_shouldBufferInput(input)) {
|
||||
_bufferInput(input);
|
||||
diagnosticLog?.add(
|
||||
'socket.input.buffer',
|
||||
'reason=no-session input=${_formatInputForDiagnostics(input)}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
diagnosticLog?.add(
|
||||
'socket.input.skip',
|
||||
'reason=no-session input=${_formatInputForDiagnostics(input)}',
|
||||
@ -163,10 +182,18 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
_formatInputForDiagnostics(input),
|
||||
);
|
||||
case TerminalSocketDispatchResult.noTransport:
|
||||
diagnosticLog?.add(
|
||||
'socket.input.skip',
|
||||
'reason=no-transport input=${_formatInputForDiagnostics(input)}',
|
||||
);
|
||||
if (_shouldBufferInput(input)) {
|
||||
_bufferInput(input);
|
||||
diagnosticLog?.add(
|
||||
'socket.input.buffer',
|
||||
'reason=no-transport input=${_formatInputForDiagnostics(input)}',
|
||||
);
|
||||
} else {
|
||||
diagnosticLog?.add(
|
||||
'socket.input.skip',
|
||||
'reason=no-transport input=${_formatInputForDiagnostics(input)}',
|
||||
);
|
||||
}
|
||||
case TerminalSocketDispatchResult.emptyInput:
|
||||
diagnosticLog?.add('socket.input.skip', 'reason=empty-input');
|
||||
}
|
||||
@ -212,6 +239,8 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
Future<void> close() async {
|
||||
_isDisposed = true;
|
||||
_cancelPendingReconnect();
|
||||
_pendingInputs.clear();
|
||||
_pendingInputCharacterCount = 0;
|
||||
await _closeActiveSession();
|
||||
}
|
||||
|
||||
@ -245,6 +274,7 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
|
||||
void _scheduleReconnect() {
|
||||
_cancelPendingReconnect();
|
||||
_reconnectPending = true;
|
||||
controller.markReconnecting();
|
||||
_connectionStatus = 'Connection lost. Reconnecting...';
|
||||
diagnosticLog?.add('socket.disconnect', session.sessionId);
|
||||
@ -261,6 +291,7 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
void _cancelPendingReconnect() {
|
||||
_cancelReconnect?.call();
|
||||
_cancelReconnect = null;
|
||||
_reconnectPending = false;
|
||||
}
|
||||
|
||||
void _disposeActiveSessionInBackground() {
|
||||
@ -292,4 +323,53 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
static String _formatInputForDiagnostics(String input) {
|
||||
return input.replaceAll('\r', r'\r').replaceAll('\n', r'\n');
|
||||
}
|
||||
|
||||
bool _shouldBufferInput(String input) {
|
||||
if (_isDisposed || input.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return _connectionAttemptInProgress || _reconnectPending;
|
||||
}
|
||||
|
||||
void _bufferInput(String input) {
|
||||
if (input.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
_pendingInputs.add(input);
|
||||
_pendingInputCharacterCount += input.length;
|
||||
while (_pendingInputCharacterCount > pendingInputCharacterLimit &&
|
||||
_pendingInputs.isNotEmpty) {
|
||||
final removed = _pendingInputs.removeAt(0);
|
||||
_pendingInputCharacterCount -= removed.length;
|
||||
}
|
||||
}
|
||||
|
||||
void _flushPendingInputs(TerminalSocketSession socketSession) {
|
||||
if (_pendingInputs.isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
final pendingInputs = List<String>.of(_pendingInputs);
|
||||
_pendingInputs.clear();
|
||||
_pendingInputCharacterCount = 0;
|
||||
|
||||
for (var index = 0; index < pendingInputs.length; index += 1) {
|
||||
final input = pendingInputs[index];
|
||||
final result = socketSession.sendInput(input);
|
||||
if (result == TerminalSocketDispatchResult.sent) {
|
||||
diagnosticLog?.add('socket.input.flush', _formatInputForDiagnostics(input));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (result == TerminalSocketDispatchResult.noTransport) {
|
||||
for (final remainingInput in pendingInputs.skip(index)) {
|
||||
_bufferInput(remainingInput);
|
||||
}
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -85,11 +85,20 @@ class TerminalSocketSession {
|
||||
onFrame(message);
|
||||
},
|
||||
onError: (error, stackTrace) {
|
||||
final wasAttached = _isAttached;
|
||||
_handleTransportClosed(transport);
|
||||
if (!attachedCompleter.isCompleted) {
|
||||
attachedCompleter.completeError(error, stackTrace);
|
||||
return;
|
||||
}
|
||||
|
||||
if (wasAttached) {
|
||||
onDisconnected?.call();
|
||||
}
|
||||
},
|
||||
onDone: () {
|
||||
final wasAttached = _isAttached;
|
||||
_handleTransportClosed(transport);
|
||||
if (!attachedCompleter.isCompleted) {
|
||||
attachedCompleter.completeError(
|
||||
StateError('Terminal socket closed before attach acknowledgement.'),
|
||||
@ -97,7 +106,9 @@ class TerminalSocketSession {
|
||||
return;
|
||||
}
|
||||
|
||||
onDisconnected?.call();
|
||||
if (wasAttached) {
|
||||
onDisconnected?.call();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@ -121,8 +132,13 @@ class TerminalSocketSession {
|
||||
return TerminalSocketDispatchResult.noTransport;
|
||||
}
|
||||
|
||||
transport.send(jsonEncode(socketClient.buildInputMessage(input)));
|
||||
return TerminalSocketDispatchResult.sent;
|
||||
try {
|
||||
transport.send(jsonEncode(socketClient.buildInputMessage(input)));
|
||||
return TerminalSocketDispatchResult.sent;
|
||||
} catch (_) {
|
||||
_handleTransportClosed(transport);
|
||||
return TerminalSocketDispatchResult.noTransport;
|
||||
}
|
||||
}
|
||||
|
||||
void sendResize(int columns, int rows) {
|
||||
@ -131,15 +147,17 @@ class TerminalSocketSession {
|
||||
return;
|
||||
}
|
||||
|
||||
transport.send(jsonEncode(socketClient.buildResizeMessage(columns, rows)));
|
||||
try {
|
||||
transport.send(jsonEncode(socketClient.buildResizeMessage(columns, rows)));
|
||||
} catch (_) {
|
||||
_handleTransportClosed(transport);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
final subscription = _subscription;
|
||||
final transport = _transport;
|
||||
_subscription = null;
|
||||
_transport = null;
|
||||
_isAttached = false;
|
||||
_handleTransportClosed(transport);
|
||||
await subscription?.cancel();
|
||||
try {
|
||||
await transport?.close();
|
||||
@ -156,6 +174,21 @@ class TerminalSocketSession {
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
void _handleTransportClosed(TerminalSocketTransport? transport) {
|
||||
if (transport == null) {
|
||||
_subscription = null;
|
||||
_transport = null;
|
||||
_isAttached = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (identical(_transport, transport)) {
|
||||
_subscription = null;
|
||||
_transport = null;
|
||||
_isAttached = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum TerminalSocketDispatchResult { sent, noTransport, emptyInput }
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -142,6 +142,18 @@ void main() {
|
||||
expect(history['sessionId'], 'abc');
|
||||
expect(history['hasMoreAbove'], isTrue);
|
||||
});
|
||||
|
||||
test('applies explicit default request timeouts', () async {
|
||||
final adapter = _FakeHttpClientAdapter(jsonEncode(const <dynamic>[]));
|
||||
final dio = Dio()..httpClientAdapter = adapter;
|
||||
final client = AgentApiClient(Uri.parse('https://host:9443'), dio: dio);
|
||||
|
||||
await client.listSessions();
|
||||
|
||||
expect(adapter.lastOptions?.connectTimeout, const Duration(seconds: 8));
|
||||
expect(adapter.lastOptions?.sendTimeout, const Duration(seconds: 10));
|
||||
expect(adapter.lastOptions?.receiveTimeout, const Duration(seconds: 20));
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeHttpClientAdapter implements HttpClientAdapter {
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:term_remote_ctl/core/network/agent_error_formatter.dart';
|
||||
|
||||
void main() {
|
||||
test(
|
||||
'formats connection timeout with zero duration as a reachability hint',
|
||||
() {
|
||||
final error = DioException.connectionTimeout(
|
||||
timeout: Duration.zero,
|
||||
requestOptions: RequestOptions(
|
||||
path: '/api/projects/project-1',
|
||||
baseUrl: 'http://100.81.30.82:5067',
|
||||
),
|
||||
error: const SocketException(
|
||||
'Operation timed out',
|
||||
osError: OSError('Operation timed out', 60),
|
||||
port: 5067,
|
||||
),
|
||||
);
|
||||
|
||||
final message = formatAgentError(
|
||||
error,
|
||||
fallback: 'Failed to load project.',
|
||||
);
|
||||
|
||||
expect(message, contains('Cannot reach the agent'));
|
||||
expect(message, contains('http://100.81.30.82:5067'));
|
||||
expect(message, isNot(contains('0:00:00')));
|
||||
},
|
||||
);
|
||||
|
||||
test('formats certificate failures into trust guidance', () {
|
||||
final error = DioException.badCertificate(
|
||||
requestOptions: RequestOptions(
|
||||
path: '/api/projects',
|
||||
baseUrl: 'https://host.example:9443',
|
||||
),
|
||||
);
|
||||
|
||||
final message = formatAgentError(error, fallback: 'Failed to load project.');
|
||||
|
||||
expect(message, contains('certificate'));
|
||||
expect(message, contains('https://host.example:9443'));
|
||||
});
|
||||
}
|
||||
@ -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(<String, Object>{});
|
||||
});
|
||||
|
||||
test('listPresets restores persisted presets from local storage', () async {
|
||||
SharedPreferences.setMockInitialValues(<String, Object>{
|
||||
'preset_commands_v1': <String>[
|
||||
'{"id":"preset-1","label":"ssh prod","commandText":"ssh admin@prod"}',
|
||||
],
|
||||
});
|
||||
|
||||
final repository = PresetRepository();
|
||||
final presets = await repository.listPresets();
|
||||
|
||||
expect(presets, [
|
||||
isA<PresetCommand>()
|
||||
.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(<String, Object>{
|
||||
'preset_commands_v1': <String>[
|
||||
'{"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(<String, Object>{
|
||||
'preset_commands_v1': <String>[
|
||||
'{"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']);
|
||||
});
|
||||
}
|
||||
@ -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<void> _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 <PresetCommand>[]),
|
||||
),
|
||||
],
|
||||
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<PresetCommand> presets)
|
||||
: _presets = List<PresetCommand>.of(presets);
|
||||
|
||||
final List<PresetCommand> _presets;
|
||||
|
||||
@override
|
||||
Future<List<PresetCommand>> listPresets() async {
|
||||
return List<PresetCommand>.of(_presets);
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeAgentApiClient extends AgentApiClient {
|
||||
_FakeAgentApiClient() : super(Uri.parse('http://100.81.30.82:5067'));
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> getSessionHistory(
|
||||
String sessionId, {
|
||||
int lineCount = 200,
|
||||
}) async {
|
||||
return <String, dynamic>{
|
||||
'sessionId': sessionId,
|
||||
'lines': <String>['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<void>.microtask(() {
|
||||
emit('{"type":"attached","sessionId":"session-1"}');
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final bool autoAttach;
|
||||
final _incoming = StreamController<dynamic>.broadcast();
|
||||
final sentMessages = <String>[];
|
||||
|
||||
@override
|
||||
Stream<dynamic> get stream => _incoming.stream;
|
||||
|
||||
@override
|
||||
void send(String message) {
|
||||
sentMessages.add(message);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _incoming.close();
|
||||
}
|
||||
|
||||
void emit(String message) {
|
||||
_incoming.add(message);
|
||||
}
|
||||
}
|
||||
@ -83,6 +83,43 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'input typed while reconnect is pending is flushed after the next attach',
|
||||
() async {
|
||||
final controller = TerminalInteractionController();
|
||||
final apiClient = _FakeAgentApiClient();
|
||||
final sessionFactory = _FakeTerminalSessionFactory();
|
||||
final reconnectScheduler = _FakeReconnectScheduler();
|
||||
final session = Session(
|
||||
sessionId: 'abc',
|
||||
name: 'codex-main',
|
||||
status: 'idle',
|
||||
);
|
||||
final coordinator = TerminalSessionCoordinator(
|
||||
controller: controller,
|
||||
apiClient: apiClient,
|
||||
session: session,
|
||||
sessionFactory: sessionFactory.create,
|
||||
onFrame: (_) {},
|
||||
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
|
||||
reconnectScheduler: reconnectScheduler.schedule,
|
||||
);
|
||||
|
||||
await coordinator.start();
|
||||
final firstSession = sessionFactory.createdSessions.single;
|
||||
|
||||
firstSession.disconnect();
|
||||
coordinator.sendInput('dir\r');
|
||||
|
||||
expect(firstSession.sentInputs, isEmpty);
|
||||
|
||||
await reconnectScheduler.runPending();
|
||||
|
||||
expect(sessionFactory.createdSessions, hasLength(2));
|
||||
expect(sessionFactory.createdSessions.last.sentInputs, ['dir\r']);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'loadOlderHistory increases the requested history window size',
|
||||
() async {
|
||||
@ -251,10 +288,12 @@ class _FakeTerminalSocketSession extends TerminalSocketSession {
|
||||
|
||||
final bool autoConnect;
|
||||
final resizeCalls = <List<int>>[];
|
||||
final sentInputs = <String>[];
|
||||
int disposeCount = 0;
|
||||
Completer<void> _connectCompleter = Completer<void>();
|
||||
void Function(String frame)? _onFrame;
|
||||
void Function()? _onDisconnected;
|
||||
bool _isDisconnected = false;
|
||||
|
||||
@override
|
||||
Future<void> connect({
|
||||
@ -274,6 +313,16 @@ class _FakeTerminalSocketSession extends TerminalSocketSession {
|
||||
resizeCalls.add([columns, rows]);
|
||||
}
|
||||
|
||||
@override
|
||||
TerminalSocketDispatchResult sendInput(String input) {
|
||||
if (_isDisconnected || !_connectCompleter.isCompleted) {
|
||||
return TerminalSocketDispatchResult.noTransport;
|
||||
}
|
||||
|
||||
sentInputs.add(input);
|
||||
return TerminalSocketDispatchResult.sent;
|
||||
}
|
||||
|
||||
void completeConnect() {
|
||||
if (!_connectCompleter.isCompleted) {
|
||||
_connectCompleter.complete();
|
||||
@ -285,6 +334,7 @@ class _FakeTerminalSocketSession extends TerminalSocketSession {
|
||||
}
|
||||
|
||||
void disconnect() {
|
||||
_isDisconnected = true;
|
||||
_onDisconnected?.call();
|
||||
}
|
||||
|
||||
|
||||
@ -123,22 +123,49 @@ void main() {
|
||||
|
||||
expect(disconnectCount, 1);
|
||||
});
|
||||
|
||||
test('sendInput reports noTransport after the socket closes', () async {
|
||||
final transport = _FakeTerminalSocketTransport();
|
||||
final session = TerminalSocketSession(
|
||||
sessionId: 'session-123',
|
||||
socketClient: AgentSocketClient(Uri.parse('https://host:9443')),
|
||||
transportFactory: (_) => transport,
|
||||
);
|
||||
|
||||
final connectFuture = session.connect(onFrame: (_) {});
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
transport.emit('{"type":"attached","sessionId":"session-123"}');
|
||||
await connectFuture;
|
||||
|
||||
await transport.close();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(
|
||||
session.sendInput('dir\r'),
|
||||
TerminalSocketDispatchResult.noTransport,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeTerminalSocketTransport implements TerminalSocketTransport {
|
||||
final _incoming = StreamController<dynamic>.broadcast();
|
||||
final sentMessages = <String>[];
|
||||
var _isClosed = false;
|
||||
|
||||
@override
|
||||
Stream<dynamic> get stream => _incoming.stream;
|
||||
|
||||
@override
|
||||
void send(String message) {
|
||||
if (_isClosed) {
|
||||
throw StateError('transport is closed');
|
||||
}
|
||||
sentMessages.add(message);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
_isClosed = true;
|
||||
await _incoming.close();
|
||||
}
|
||||
|
||||
|
||||
@ -1,3 +1,5 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
@ -275,7 +277,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 +307,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 +341,29 @@ void main() {
|
||||
|
||||
await _openProjectTerminal(tester);
|
||||
|
||||
expect(find.text('Browse mode'), findsOneWidget);
|
||||
expect(find.text('Direct input enabled'), findsNothing);
|
||||
final commandField = tester.widget<TextField>(find.byType(TextField).last);
|
||||
expect(
|
||||
commandField.decoration?.hintText,
|
||||
'Buffered mode: type a command and send',
|
||||
);
|
||||
|
||||
await tester.tap(find.byKey(const Key('terminal_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<TextField>(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<TextField>(find.byType(TextField).last);
|
||||
expect(
|
||||
bufferedField.decoration?.hintText,
|
||||
'Buffered mode: type a command and send',
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
@ -384,6 +405,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 +543,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();
|
||||
@ -728,6 +751,37 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'terminal ignores attach replay when seeded history already restored the same output',
|
||||
(tester) async {
|
||||
final transportFactory = _QueuedTerminalSocketTransportFactory(
|
||||
connectionStartupFrames: [
|
||||
const [
|
||||
_StartupFrame('{"type":"attached","sessionId":"session-1"}'),
|
||||
_StartupFrame('one\r\ntwo', delay: Duration(milliseconds: 200)),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
await _pumpTerminalPage(
|
||||
tester,
|
||||
session: _session('session-1', 'codex-main'),
|
||||
apiClient: _FakeAgentApiClient(),
|
||||
socketFactory: TerminalSocketSessionFactory(
|
||||
transportFactory: transportFactory.create,
|
||||
),
|
||||
);
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
final terminal = tester
|
||||
.widget<TerminalView>(find.byType(TerminalView))
|
||||
.terminal;
|
||||
|
||||
expect(_countOccurrences(terminal.buffer.getText(), 'one\ntwo'), 1);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'terminal attach replay keeps the cursor on the last restored line',
|
||||
(tester) async {
|
||||
@ -789,6 +843,48 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'terminal reconnect replay does not append duplicate visible output',
|
||||
(tester) async {
|
||||
final transportFactory = _QueuedTerminalSocketTransportFactory(
|
||||
connectionStartupFrames: [
|
||||
const [
|
||||
_StartupFrame('{"type":"attached","sessionId":"session-1"}'),
|
||||
_StartupFrame('one\r\ntwo'),
|
||||
],
|
||||
const [
|
||||
_StartupFrame('{"type":"attached","sessionId":"session-1"}'),
|
||||
_StartupFrame('one\r\ntwo'),
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
await _pumpTerminalPage(
|
||||
tester,
|
||||
session: _session('session-1', 'codex-main'),
|
||||
socketFactory: TerminalSocketSessionFactory(
|
||||
transportFactory: transportFactory.create,
|
||||
),
|
||||
);
|
||||
|
||||
var terminal = tester
|
||||
.widget<TerminalView>(find.byType(TerminalView))
|
||||
.terminal;
|
||||
expect(_countOccurrences(terminal.buffer.getText(), 'one\ntwo'), 1);
|
||||
|
||||
await transportFactory.createdTransports.first.close();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(seconds: 2));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
terminal = tester
|
||||
.widget<TerminalView>(find.byType(TerminalView))
|
||||
.terminal;
|
||||
expect(transportFactory.createCount, 2);
|
||||
expect(_countOccurrences(terminal.buffer.getText(), 'one\ntwo'), 1);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
're-entering an existing session restores the terminal cursor to the last line',
|
||||
(tester) async {
|
||||
@ -848,7 +944,9 @@ void main() {
|
||||
overrides: [
|
||||
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
|
||||
projectRepositoryProvider.overrideWithValue(projectRepository),
|
||||
sessionRepositoryProvider.overrideWithValue(_FakeSessionRepository()),
|
||||
sessionRepositoryProvider.overrideWithValue(
|
||||
_FakeSessionRepository(),
|
||||
),
|
||||
terminalSocketSessionFactoryProvider.overrideWithValue(
|
||||
TerminalSocketSessionFactory(
|
||||
transportFactory: (_) =>
|
||||
@ -875,6 +973,39 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'project detail formats agent connection errors instead of dumping Dio text',
|
||||
(tester) async {
|
||||
final project = Project(
|
||||
projectId: 'project-1',
|
||||
name: 'codex-main',
|
||||
workingDirectory: r'C:\repo\codex-main',
|
||||
createdAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
|
||||
updatedAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
|
||||
projectRepositoryProvider.overrideWithValue(
|
||||
_FailingProjectRepository(),
|
||||
),
|
||||
sessionRepositoryProvider.overrideWithValue(
|
||||
_FakeSessionRepository(),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(home: ProjectDetailPage(project: project)),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('Cannot reach the agent'), findsOneWidget);
|
||||
expect(find.textContaining('0:00:00'), findsNothing);
|
||||
expect(find.textContaining('DioException'), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('session list deletes a session after confirmation', (
|
||||
tester,
|
||||
) async {
|
||||
@ -1113,6 +1244,24 @@ Session _session(String sessionId, String name) {
|
||||
);
|
||||
}
|
||||
|
||||
int _countOccurrences(String source, String pattern) {
|
||||
if (pattern.isEmpty) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
var count = 0;
|
||||
var start = 0;
|
||||
while (true) {
|
||||
final index = source.indexOf(pattern, start);
|
||||
if (index < 0) {
|
||||
return count;
|
||||
}
|
||||
|
||||
count += 1;
|
||||
start = index + pattern.length;
|
||||
}
|
||||
}
|
||||
|
||||
class _FailingSessionRepository extends SessionRepository {
|
||||
_FailingSessionRepository() : super(_FakeAgentApiClient());
|
||||
|
||||
@ -1137,6 +1286,26 @@ class _FailingSessionRepository extends SessionRepository {
|
||||
}
|
||||
}
|
||||
|
||||
class _FailingProjectRepository extends ProjectRepository {
|
||||
_FailingProjectRepository() : super(_FakeAgentApiClient());
|
||||
|
||||
@override
|
||||
Future<ProjectDetail> getProjectDetail(String projectId) async {
|
||||
throw DioException.connectionTimeout(
|
||||
timeout: Duration.zero,
|
||||
requestOptions: RequestOptions(
|
||||
path: '/api/projects/$projectId',
|
||||
baseUrl: 'http://100.81.30.82:5067',
|
||||
),
|
||||
error: SocketException(
|
||||
'Operation timed out',
|
||||
osError: OSError('Operation timed out', 60),
|
||||
port: 5067,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeAgentApiClient extends AgentApiClient {
|
||||
_FakeAgentApiClient() : super(Uri.parse('http://100.81.30.82:5067'));
|
||||
|
||||
@ -1181,24 +1350,27 @@ class _FakeTerminalSocketTransport implements TerminalSocketTransport {
|
||||
_FakeTerminalSocketTransport({
|
||||
this.autoAttach = false,
|
||||
this.startupFrames = const <String>[],
|
||||
this.scheduledStartupFrames = const <_StartupFrame>[],
|
||||
}) {
|
||||
if (autoAttach && startupFrames.isEmpty) {
|
||||
final framesToEmit = scheduledStartupFrames.isNotEmpty
|
||||
? scheduledStartupFrames
|
||||
: startupFrames.map(_StartupFrame.new).toList(growable: false);
|
||||
|
||||
if (autoAttach && framesToEmit.isEmpty) {
|
||||
Future<void>.microtask(() {
|
||||
emit('{"type":"attached","sessionId":"session-1"}');
|
||||
});
|
||||
} else if (startupFrames.isNotEmpty) {
|
||||
Future<void>.microtask(() {
|
||||
for (final frame in startupFrames) {
|
||||
emit(frame);
|
||||
}
|
||||
});
|
||||
} else if (framesToEmit.isNotEmpty) {
|
||||
_scheduleFrames(framesToEmit);
|
||||
}
|
||||
}
|
||||
|
||||
final bool autoAttach;
|
||||
final List<String> startupFrames;
|
||||
final List<_StartupFrame> scheduledStartupFrames;
|
||||
final _incoming = StreamController<dynamic>.broadcast();
|
||||
final sentMessages = <String>[];
|
||||
final List<Timer> _frameTimers = <Timer>[];
|
||||
|
||||
@override
|
||||
Stream<dynamic> get stream => _incoming.stream;
|
||||
@ -1210,30 +1382,69 @@ class _FakeTerminalSocketTransport implements TerminalSocketTransport {
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
for (final timer in _frameTimers) {
|
||||
timer.cancel();
|
||||
}
|
||||
_frameTimers.clear();
|
||||
await _incoming.close();
|
||||
}
|
||||
|
||||
void emit(String message) {
|
||||
_incoming.add(message);
|
||||
}
|
||||
|
||||
void _scheduleFrames(List<_StartupFrame> frames) {
|
||||
var cumulativeDelay = Duration.zero;
|
||||
for (final frame in frames) {
|
||||
cumulativeDelay += frame.delay;
|
||||
if (cumulativeDelay == Duration.zero) {
|
||||
Future<void>.microtask(() {
|
||||
if (!_incoming.isClosed) {
|
||||
emit(frame.message);
|
||||
}
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
final timer = Timer(cumulativeDelay, () {
|
||||
if (!_incoming.isClosed) {
|
||||
emit(frame.message);
|
||||
}
|
||||
});
|
||||
_frameTimers.add(timer);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _QueuedTerminalSocketTransportFactory {
|
||||
_QueuedTerminalSocketTransportFactory({
|
||||
this.startupFrames = const <String>[],
|
||||
this.connectionStartupFrames = const <List<_StartupFrame>>[],
|
||||
});
|
||||
|
||||
final List<String> startupFrames;
|
||||
final List<List<_StartupFrame>> connectionStartupFrames;
|
||||
final createdTransports = <_FakeTerminalSocketTransport>[];
|
||||
int createCount = 0;
|
||||
|
||||
TerminalSocketTransport create(Uri _) {
|
||||
final scheduledFrames = createCount < connectionStartupFrames.length
|
||||
? connectionStartupFrames[createCount]
|
||||
: const <_StartupFrame>[];
|
||||
final transport = _FakeTerminalSocketTransport(
|
||||
autoAttach: startupFrames.isEmpty,
|
||||
autoAttach: startupFrames.isEmpty && scheduledFrames.isEmpty,
|
||||
startupFrames: startupFrames,
|
||||
scheduledStartupFrames: scheduledFrames,
|
||||
);
|
||||
createdTransports.add(transport);
|
||||
createCount += 1;
|
||||
return transport;
|
||||
}
|
||||
}
|
||||
|
||||
class _StartupFrame {
|
||||
const _StartupFrame(this.message, {this.delay = Duration.zero});
|
||||
|
||||
final String message;
|
||||
final Duration delay;
|
||||
}
|
||||
|
||||
@ -73,12 +73,11 @@ public static class TerminalWebSocketHandler
|
||||
}
|
||||
}
|
||||
|
||||
host.OutputReceived += HandleOutput;
|
||||
|
||||
try
|
||||
{
|
||||
await SendJsonAsync(socket, new TerminalAttachResponse(sessionId), sendGate, context.RequestAborted).ConfigureAwait(false);
|
||||
var replay = registry.GetReplaySnapshot(sessionId);
|
||||
host.OutputReceived += HandleOutput;
|
||||
await SendJsonAsync(socket, new TerminalAttachResponse(sessionId), sendGate, context.RequestAborted).ConfigureAwait(false);
|
||||
if (!string.IsNullOrEmpty(replay))
|
||||
{
|
||||
await SendTextAsync(socket, replay, sendGate, context.RequestAborted).ConfigureAwait(false);
|
||||
|
||||
@ -73,6 +73,37 @@ public sealed class TerminalWebSocketHandlerTests
|
||||
Assert.Equal("prompt> dir\r\nnext> ", replayFrame);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Attach_Does_Not_Duplicate_Output_Produced_During_Replay_Boundary()
|
||||
{
|
||||
await using var fixture = new TerminalApiFixture();
|
||||
var registry = fixture.Services.GetRequiredService<SessionRegistry>();
|
||||
fixture.TerminalHost.Registry = registry;
|
||||
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
|
||||
await registry.AppendOutputAsync(session.SessionId, "prompt> dir\r\n", CancellationToken.None);
|
||||
fixture.TerminalHost.EmitOutputOnNextSubscription(session.SessionId, "next> ");
|
||||
|
||||
using WebSocket socket = await fixture.Server.CreateWebSocketClient().ConnectAsync(
|
||||
new Uri($"ws://localhost/ws/terminal?sessionId={session.SessionId}"),
|
||||
CancellationToken.None);
|
||||
|
||||
var attachedFrame = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
var attachedPayload = JsonSerializer.Deserialize<TerminalAttachResponse>(
|
||||
attachedFrame,
|
||||
new JsonSerializerOptions(JsonSerializerDefaults.Web));
|
||||
|
||||
Assert.NotNull(attachedPayload);
|
||||
Assert.Equal("attached", attachedPayload!.Type);
|
||||
|
||||
var replayFrame = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
Assert.Equal("prompt> dir\r\n", replayFrame);
|
||||
|
||||
var liveFrame = await ReceiveTextAsync(socket, CancellationToken.None);
|
||||
Assert.Equal("next> ", liveFrame);
|
||||
|
||||
await AssertNoAdditionalTextFrameAsync(socket, TimeSpan.FromMilliseconds(200));
|
||||
}
|
||||
|
||||
private static async Task<string> ReceiveTextAsync(WebSocket socket, CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = new byte[4096];
|
||||
@ -110,12 +141,24 @@ public sealed class TerminalWebSocketHandlerTests
|
||||
throw new TimeoutException("Condition was not met.");
|
||||
}
|
||||
|
||||
private static async Task AssertNoAdditionalTextFrameAsync(WebSocket socket, TimeSpan timeout)
|
||||
{
|
||||
using var cts = new CancellationTokenSource(timeout);
|
||||
await Assert.ThrowsAnyAsync<OperationCanceledException>(
|
||||
async () => await ReceiveTextAsync(socket, cts.Token));
|
||||
}
|
||||
|
||||
private sealed class TerminalApiFixture : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly string _dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Tests", Guid.NewGuid().ToString("N"));
|
||||
private readonly TestTerminalSessionHost _terminalHost = new();
|
||||
private readonly TestTerminalSessionHost _terminalHost;
|
||||
private readonly RecordingTerminalDiagnosticsSink _diagnostics = new();
|
||||
|
||||
public TerminalApiFixture()
|
||||
{
|
||||
_terminalHost = new TestTerminalSessionHost();
|
||||
}
|
||||
|
||||
public TestTerminalSessionHost TerminalHost => _terminalHost;
|
||||
|
||||
public RecordingTerminalDiagnosticsSink Diagnostics => _diagnostics;
|
||||
@ -137,7 +180,8 @@ public sealed class TerminalWebSocketHandlerTests
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
services.RemoveAll<ISessionHost>();
|
||||
services.AddSingleton<ISessionHost>(_terminalHost);
|
||||
services.AddSingleton(_terminalHost);
|
||||
services.AddSingleton<ISessionHost>(sp => sp.GetRequiredService<TestTerminalSessionHost>());
|
||||
services.AddSingleton<ITerminalDiagnosticsSink>(_diagnostics);
|
||||
});
|
||||
}
|
||||
@ -167,8 +211,38 @@ public sealed class TerminalWebSocketHandlerTests
|
||||
private sealed class TestTerminalSessionHost : ISessionHost
|
||||
{
|
||||
private readonly List<(string Kind, string SessionId, string Value)> _inputs = new();
|
||||
private EventHandler<TerminalOutputEventArgs>? _outputReceived;
|
||||
private readonly object _gate = new();
|
||||
private (string SessionId, string Chunk)? _pendingSubscriptionEmission;
|
||||
|
||||
public event EventHandler<TerminalOutputEventArgs>? OutputReceived;
|
||||
public event EventHandler<TerminalOutputEventArgs>? OutputReceived
|
||||
{
|
||||
add
|
||||
{
|
||||
(string SessionId, string Chunk)? emission;
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
_outputReceived += value;
|
||||
emission = _pendingSubscriptionEmission;
|
||||
_pendingSubscriptionEmission = null;
|
||||
}
|
||||
|
||||
if (emission is { } pending)
|
||||
{
|
||||
EmitOutput(pending.SessionId, pending.Chunk);
|
||||
}
|
||||
}
|
||||
remove
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_outputReceived -= value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public SessionRegistry? Registry { get; set; }
|
||||
|
||||
public IReadOnlyList<(string Kind, string SessionId, string Value)> Inputs => _inputs;
|
||||
|
||||
@ -194,9 +268,18 @@ public sealed class TerminalWebSocketHandlerTests
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public void EmitOutputOnNextSubscription(string sessionId, string chunk)
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
_pendingSubscriptionEmission = (sessionId, chunk);
|
||||
}
|
||||
}
|
||||
|
||||
public void EmitOutput(string sessionId, string chunk)
|
||||
{
|
||||
OutputReceived?.Invoke(this, new TerminalOutputEventArgs(sessionId, chunk));
|
||||
Registry?.AppendOutputAsync(sessionId, chunk, CancellationToken.None).GetAwaiter().GetResult();
|
||||
_outputReceived?.Invoke(this, new TerminalOutputEventArgs(sessionId, chunk));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user