Refine terminal input and session replay behavior
This commit is contained in:
parent
a77bfc8c78
commit
7ac7965e7e
47
.gitignore
vendored
47
.gitignore
vendored
@ -1,29 +1,56 @@
|
||||
# Flutter
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editors
|
||||
.idea/
|
||||
.vscode/
|
||||
*.swp
|
||||
*.swo
|
||||
|
||||
# Flutter / Dart
|
||||
.dart_tool/
|
||||
.flutter-plugins
|
||||
.flutter-plugins-dependencies
|
||||
.packages
|
||||
build/
|
||||
.pub/
|
||||
**/build/
|
||||
|
||||
# iOS
|
||||
# Flutter generated platform files
|
||||
**/ios/Flutter/Generated.xcconfig
|
||||
**/ios/Flutter/flutter_export_environment.sh
|
||||
**/ios/Flutter/ephemeral/
|
||||
**/ios/Flutter/App.framework
|
||||
**/ios/Flutter/Flutter.framework
|
||||
**/ios/Flutter/flutter_assets/
|
||||
**/ios/Runner/GeneratedPluginRegistrant.*
|
||||
|
||||
# iOS / Xcode local artifacts
|
||||
**/ios/Pods/
|
||||
**/ios/.symlinks/
|
||||
**/ios/DerivedData/
|
||||
**/ios/xcuserdata/
|
||||
**/*.pbxuser
|
||||
**/*.mode1v3
|
||||
**/*.mode2v3
|
||||
**/*.perspectivev3
|
||||
**/*.moved-aside
|
||||
**/*.xcuserstate
|
||||
|
||||
# Android
|
||||
# Android local artifacts
|
||||
**/android/.gradle/
|
||||
**/android/local.properties
|
||||
|
||||
# .NET
|
||||
# .NET build outputs
|
||||
**/bin/
|
||||
**/obj/
|
||||
.vs/
|
||||
|
||||
# Local security artifacts
|
||||
# Local project artifacts
|
||||
data/
|
||||
certs/
|
||||
|
||||
# Brainstorm artifacts
|
||||
.superpowers/
|
||||
|
||||
# Local worktrees
|
||||
.worktrees/
|
||||
|
||||
# Local notes
|
||||
docs/ios-build.md
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="?android:colorBackground" />
|
||||
<item android:drawable="@color/launch_background" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Modify this file to customize your launch splash screen -->
|
||||
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<item android:drawable="@android:color/white" />
|
||||
<item android:drawable="@color/launch_background" />
|
||||
|
||||
<!-- You can insert your own image assets here -->
|
||||
<!-- <item>
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="launch_background">#05070A</color>
|
||||
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:colorBackground">@color/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
@ -13,6 +16,7 @@
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
<item name="android:windowBackground">@color/launch_background</item>
|
||||
<item name="android:colorBackground">@color/launch_background</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="launch_background">#05070A</color>
|
||||
|
||||
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
|
||||
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<!-- Show a splash screen on the activity. Automatically removed when
|
||||
the Flutter engine draws its first frame -->
|
||||
<item name="android:windowBackground">@drawable/launch_background</item>
|
||||
<item name="android:colorBackground">@color/launch_background</item>
|
||||
</style>
|
||||
<!-- Theme applied to the Android Window as soon as the process has started.
|
||||
This theme determines the color of the Android Window while your
|
||||
@ -13,6 +16,7 @@
|
||||
|
||||
This Theme is only used starting with V2 of Flutter's Android embedding. -->
|
||||
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
|
||||
<item name="android:windowBackground">?android:colorBackground</item>
|
||||
<item name="android:windowBackground">@color/launch_background</item>
|
||||
<item name="android:colorBackground">@color/launch_background</item>
|
||||
</style>
|
||||
</resources>
|
||||
|
||||
@ -19,7 +19,7 @@
|
||||
<imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
|
||||
</imageView>
|
||||
</subviews>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<color key="backgroundColor" red="0.01960784314" green="0.02745098039" blue="0.03921568627" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
<constraints>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
|
||||
<constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="15F34" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="BYZ-38-t0r">
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="24765" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" colorMatched="YES" initialViewController="BYZ-38-t0r">
|
||||
<device id="retina6_12" orientation="portrait" appearance="light"/>
|
||||
<dependencies>
|
||||
<deployment identifier="iOS"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
|
||||
<plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="24743"/>
|
||||
<capability name="documents saved in the Xcode 8 format" minToolsVersion="8.0"/>
|
||||
</dependencies>
|
||||
<scenes>
|
||||
<!--Flutter View Controller-->
|
||||
@ -14,13 +16,14 @@
|
||||
<viewControllerLayoutGuide type="bottom" id="wfy-db-euE"/>
|
||||
</layoutGuides>
|
||||
<view key="view" contentMode="scaleToFill" id="8bC-Xf-vdC">
|
||||
<rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
|
||||
<rect key="frame" x="0.0" y="0.0" width="393" height="852"/>
|
||||
<autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
|
||||
<color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
|
||||
<color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
|
||||
</view>
|
||||
</viewController>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
<point key="canvasLocation" x="-248" y="8"/>
|
||||
</scene>
|
||||
</scenes>
|
||||
</document>
|
||||
|
||||
@ -7,7 +7,7 @@ import '../../features/sessions/session_repository.dart';
|
||||
import '../../features/sessions/session.dart';
|
||||
|
||||
final agentBaseUriProvider = StateProvider<Uri>((ref) {
|
||||
return Uri.parse('http://10.0.2.2:5067');
|
||||
return Uri.parse('http://100.81.30.82:5067');
|
||||
});
|
||||
|
||||
final agentApiClientProvider = Provider<AgentApiClient>((ref) {
|
||||
|
||||
@ -411,7 +411,7 @@ class _ProjectListPageState extends ConsumerState<ProjectListPage> {
|
||||
TextField(
|
||||
controller: _agentUrlController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'http://10.0.2.2:5067',
|
||||
hintText: 'http://100.81.30.82:5067',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
@ -433,7 +433,7 @@ class _ProjectListPageState extends ConsumerState<ProjectListPage> {
|
||||
child: TextField(
|
||||
controller: _agentUrlController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'http://10.0.2.2:5067',
|
||||
hintText: 'http://100.81.30.82:5067',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
|
||||
@ -202,7 +202,7 @@ class _SessionListPageState extends ConsumerState<SessionListPage> {
|
||||
TextField(
|
||||
controller: _agentUrlController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'http://10.0.2.2:5067',
|
||||
hintText: 'http://100.81.30.82:5067',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
@ -224,7 +224,7 @@ class _SessionListPageState extends ConsumerState<SessionListPage> {
|
||||
child: TextField(
|
||||
controller: _agentUrlController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'http://10.0.2.2:5067',
|
||||
hintText: 'http://100.81.30.82:5067',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
|
||||
@ -0,0 +1,227 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
enum TerminalInputMode { buffered, direct }
|
||||
|
||||
typedef TerminalBufferedSubmit = FutureOr<void> Function(String command);
|
||||
typedef TerminalDirectInputSink = void Function(String input);
|
||||
|
||||
class TerminalInputController extends ChangeNotifier {
|
||||
TerminalInputController({
|
||||
required TerminalBufferedSubmit onBufferedSubmit,
|
||||
required TerminalDirectInputSink onDirectInput,
|
||||
}) : _onBufferedSubmit = onBufferedSubmit,
|
||||
_onDirectInput = onDirectInput {
|
||||
focusNode.addListener(notifyListeners);
|
||||
textController.addListener(_handleTextChanged);
|
||||
_applyEditingValue(_bufferedEditingValue);
|
||||
}
|
||||
|
||||
static const String _directInputSentinel = ' ';
|
||||
static const int _directInputSelectionOffset = 2;
|
||||
|
||||
final FocusNode focusNode = FocusNode();
|
||||
final TextEditingController textController = TextEditingController();
|
||||
final TerminalBufferedSubmit _onBufferedSubmit;
|
||||
final TerminalDirectInputSink _onDirectInput;
|
||||
|
||||
TerminalInputMode _mode = TerminalInputMode.buffered;
|
||||
String _bufferedDraft = '';
|
||||
String? _composingText;
|
||||
bool _isApplyingValue = false;
|
||||
bool _isDisposed = false;
|
||||
|
||||
TerminalInputMode get mode => _mode;
|
||||
|
||||
bool get isDirectInputEnabled => _mode == TerminalInputMode.direct;
|
||||
|
||||
String? get composingText => _composingText;
|
||||
|
||||
String get hintText => isDirectInputEnabled
|
||||
? 'Keyboard sends straight to terminal'
|
||||
: 'Send command or input';
|
||||
|
||||
Future<void> submit() async {
|
||||
if (isDirectInputEnabled) {
|
||||
_composingText = null;
|
||||
_onDirectInput('\r');
|
||||
_restoreDirectEditingValue();
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
final command = _bufferedDraft;
|
||||
if (command.trim().isEmpty) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _onBufferedSubmit(command);
|
||||
_bufferedDraft = '';
|
||||
_applyEditingValue(_bufferedEditingValue);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void toggleMode() {
|
||||
setMode(
|
||||
isDirectInputEnabled
|
||||
? TerminalInputMode.buffered
|
||||
: TerminalInputMode.direct,
|
||||
);
|
||||
}
|
||||
|
||||
void setMode(TerminalInputMode mode) {
|
||||
if (_mode == mode) {
|
||||
if (mode == TerminalInputMode.direct) {
|
||||
_restoreDirectEditingValue();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_mode = mode;
|
||||
_composingText = null;
|
||||
_applyEditingValue(
|
||||
mode == TerminalInputMode.direct
|
||||
? _directEditingValue
|
||||
: _bufferedEditingValue,
|
||||
);
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void requestFocus() {
|
||||
focusNode.requestFocus();
|
||||
}
|
||||
|
||||
void syncDirectSelection() {
|
||||
if (!isDirectInputEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
_restoreDirectEditingValue();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_isDisposed = true;
|
||||
focusNode.removeListener(notifyListeners);
|
||||
textController.removeListener(_handleTextChanged);
|
||||
focusNode.dispose();
|
||||
textController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _handleTextChanged() {
|
||||
if (_isApplyingValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
final value = textController.value;
|
||||
|
||||
if (!isDirectInputEnabled) {
|
||||
_bufferedDraft = value.text;
|
||||
final nextComposing = value.composing.isCollapsed
|
||||
? null
|
||||
: value.composing.textInside(value.text);
|
||||
if (_composingText != nextComposing) {
|
||||
_composingText = nextComposing;
|
||||
notifyListeners();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!value.composing.isCollapsed) {
|
||||
final nextComposing = value.composing.textInside(value.text);
|
||||
if (_composingText != nextComposing) {
|
||||
_composingText = nextComposing;
|
||||
notifyListeners();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var didChange = false;
|
||||
if (_composingText != null) {
|
||||
_composingText = null;
|
||||
didChange = true;
|
||||
}
|
||||
|
||||
final backspaceCount = _detectBackspaceCount(value.text);
|
||||
if (backspaceCount > 0) {
|
||||
for (var index = 0; index < backspaceCount; index += 1) {
|
||||
_onDirectInput('\x7f');
|
||||
}
|
||||
_restoreDirectEditingValue();
|
||||
if (didChange) {
|
||||
notifyListeners();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
final insertedText = _extractInsertedText(value.text);
|
||||
if (insertedText.isNotEmpty) {
|
||||
_onDirectInput(insertedText);
|
||||
_restoreDirectEditingValue();
|
||||
if (didChange) {
|
||||
notifyListeners();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (value.text != _directInputSentinel ||
|
||||
value.selection.baseOffset != _directInputSelectionOffset ||
|
||||
value.selection.extentOffset != _directInputSelectionOffset) {
|
||||
_restoreDirectEditingValue();
|
||||
if (didChange) {
|
||||
notifyListeners();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (didChange) {
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
int _detectBackspaceCount(String text) {
|
||||
if (text.length >= _directInputSentinel.length) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return _directInputSentinel.length - text.length;
|
||||
}
|
||||
|
||||
String _extractInsertedText(String text) {
|
||||
if (text.isEmpty || text == _directInputSentinel) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (text.startsWith(_directInputSentinel)) {
|
||||
return text.substring(_directInputSentinel.length);
|
||||
}
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
void _restoreDirectEditingValue() {
|
||||
_applyEditingValue(_directEditingValue);
|
||||
}
|
||||
|
||||
void _applyEditingValue(TextEditingValue value) {
|
||||
if (_isDisposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
_isApplyingValue = true;
|
||||
textController.value = value;
|
||||
_isApplyingValue = false;
|
||||
}
|
||||
|
||||
TextEditingValue get _bufferedEditingValue => TextEditingValue(
|
||||
text: _bufferedDraft,
|
||||
selection: TextSelection.collapsed(offset: _bufferedDraft.length),
|
||||
);
|
||||
|
||||
TextEditingValue get _directEditingValue => const TextEditingValue(
|
||||
text: _directInputSentinel,
|
||||
selection: TextSelection.collapsed(offset: _directInputSelectionOffset),
|
||||
);
|
||||
}
|
||||
@ -4,14 +4,13 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:xterm/xterm.dart';
|
||||
|
||||
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 '../projects/project.dart';
|
||||
import '../sessions/session.dart';
|
||||
import 'terminal_diagnostic_log.dart';
|
||||
import 'history_window.dart';
|
||||
import 'terminal_input_controller.dart';
|
||||
import 'terminal_interaction_controller.dart';
|
||||
import 'terminal_session_coordinator.dart';
|
||||
import 'terminal_socket_session.dart';
|
||||
@ -49,18 +48,15 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
|
||||
final TerminalInteractionController controller =
|
||||
TerminalInteractionController();
|
||||
final TerminalDiagnosticLog _diagnosticLog = TerminalDiagnosticLog();
|
||||
final FocusNode _terminalFocusNode = FocusNode();
|
||||
final FocusNode _inputFocusNode = FocusNode();
|
||||
final TextEditingController _inputController = TextEditingController();
|
||||
final FocusNode _terminalFocusNode = FocusNode(canRequestFocus: false);
|
||||
final ScrollController _terminalScrollController = ScrollController();
|
||||
late final TerminalSessionCoordinator _coordinator;
|
||||
late final Listenable _controllerAndCoordinator;
|
||||
bool _isDirectInputEnabled = false;
|
||||
late final TerminalInputController _inputController;
|
||||
late final Listenable _pageStateListenable;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_terminalFocusNode.canRequestFocus = false;
|
||||
_coordinator = TerminalSessionCoordinator(
|
||||
controller: controller,
|
||||
apiClient: ref.read(agentApiClientProvider),
|
||||
@ -69,23 +65,27 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
|
||||
baseUri: widget.agentBaseUri,
|
||||
diagnosticLog: _diagnosticLog,
|
||||
onFrame: terminal.write,
|
||||
onHistoryLoaded: (history) {
|
||||
if (history.lines.isNotEmpty) {
|
||||
terminal.write('${history.lines.join('\r\n')}\r\n');
|
||||
}
|
||||
},
|
||||
viewportProvider: () => TerminalViewport(
|
||||
columns: terminal.viewWidth,
|
||||
rows: terminal.viewHeight,
|
||||
),
|
||||
);
|
||||
_controllerAndCoordinator = Listenable.merge([controller, _coordinator]);
|
||||
_inputController = TerminalInputController(
|
||||
onBufferedSubmit: (command) => _sendLine(command),
|
||||
onDirectInput: _sendDirectInput,
|
||||
);
|
||||
_pageStateListenable = Listenable.merge([
|
||||
controller,
|
||||
_coordinator,
|
||||
_inputController,
|
||||
]);
|
||||
terminal.onResize = (width, height, _, _) {
|
||||
_coordinator.handleTerminalResize(width, height);
|
||||
};
|
||||
terminal.onOutput = (data) {
|
||||
_diagnosticLog.add('ui.terminal.key', data);
|
||||
_coordinator.sendInput(data);
|
||||
final normalizedData = _normalizeTerminalDirectInput(data);
|
||||
_diagnosticLog.add('ui.terminal.key', normalizedData);
|
||||
_coordinator.sendInput(normalizedData);
|
||||
};
|
||||
unawaited(_coordinator.start());
|
||||
}
|
||||
@ -93,7 +93,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
|
||||
@override
|
||||
void dispose() {
|
||||
_terminalFocusNode.dispose();
|
||||
_inputFocusNode.dispose();
|
||||
_inputController.dispose();
|
||||
_terminalScrollController.dispose();
|
||||
unawaited(_coordinator.close());
|
||||
@ -153,8 +152,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _sendLine() async {
|
||||
final input = _inputController.text;
|
||||
Future<void> _sendLine(String input) async {
|
||||
if (!_canSendInput || input.trim().isEmpty) {
|
||||
return;
|
||||
}
|
||||
@ -164,7 +162,14 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
|
||||
diagnosticEvent: 'ui.input.send',
|
||||
detail: input,
|
||||
);
|
||||
_inputController.clear();
|
||||
}
|
||||
|
||||
Future<void> _submitInput() async {
|
||||
if (!_canSendInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
await _inputController.submit();
|
||||
}
|
||||
|
||||
void _sendQuickKey(_QuickTerminalKey quickKey) {
|
||||
@ -182,28 +187,44 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
|
||||
}) {
|
||||
_diagnosticLog.add(diagnosticEvent, detail);
|
||||
_coordinator.sendInput(input);
|
||||
if (_isDirectInputEnabled) {
|
||||
_terminalFocusNode.requestFocus();
|
||||
} else {
|
||||
_inputFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
void _sendDirectInput(String input) {
|
||||
_sendTerminalInput(
|
||||
input,
|
||||
diagnosticEvent: 'ui.input.direct',
|
||||
detail: _describeTerminalInput(input),
|
||||
);
|
||||
}
|
||||
|
||||
String _normalizeTerminalDirectInput(String data) {
|
||||
if (!_inputController.isDirectInputEnabled || data.isEmpty) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return data.replaceAll('\r\n', '\r').replaceAll('\n', '\r');
|
||||
}
|
||||
|
||||
void _toggleDirectInput() {
|
||||
final enabled = !_isDirectInputEnabled;
|
||||
setState(() {
|
||||
_isDirectInputEnabled = enabled;
|
||||
_terminalFocusNode.canRequestFocus = enabled;
|
||||
});
|
||||
|
||||
if (enabled) {
|
||||
_terminalFocusNode.requestFocus();
|
||||
} else {
|
||||
_terminalFocusNode.unfocus();
|
||||
_inputFocusNode.requestFocus();
|
||||
setState(_inputController.toggleMode);
|
||||
if (_canSendInput) {
|
||||
_inputController.requestFocus();
|
||||
}
|
||||
}
|
||||
|
||||
void _handleTerminalSurfaceTap() {
|
||||
if (!_canSendInput || !_inputController.isDirectInputEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
_inputController.requestFocus();
|
||||
_inputController.syncDirectSelection();
|
||||
}
|
||||
|
||||
String _describeTerminalInput(String input) {
|
||||
return input.replaceAll('\r', r'\r').replaceAll('\n', r'\n');
|
||||
}
|
||||
|
||||
Future<void> _showDiagnostics() {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
@ -274,7 +295,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: AnimatedBuilder(
|
||||
animation: _controllerAndCoordinator,
|
||||
animation: _pageStateListenable,
|
||||
builder: (context, _) {
|
||||
return SingleChildScrollView(
|
||||
child: Padding(
|
||||
@ -435,35 +456,41 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
|
||||
),
|
||||
body: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 6, 16, 10),
|
||||
child: Column(
|
||||
children: [
|
||||
_buildScrollbackSection(context, isCompact),
|
||||
Expanded(
|
||||
child: Container(
|
||||
key: const Key('terminal_surface_panel'),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [
|
||||
Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
const Color(0xFF090B0E),
|
||||
],
|
||||
child: 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)),
|
||||
),
|
||||
child: TerminalView(
|
||||
terminal,
|
||||
focusNode: _terminalFocusNode,
|
||||
autofocus: false,
|
||||
scrollController: _terminalScrollController,
|
||||
),
|
||||
),
|
||||
borderRadius: BorderRadius.zero,
|
||||
border: Border.all(color: const Color(0xFF332B22)),
|
||||
),
|
||||
child: TerminalView(
|
||||
terminal,
|
||||
focusNode: _terminalFocusNode,
|
||||
autofocus: false,
|
||||
scrollController: _terminalScrollController,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
_buildCommandDeck(context, isCompact),
|
||||
],
|
||||
const SizedBox(height: 6),
|
||||
_buildCommandDeck(context, isCompact),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@ -471,7 +498,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
|
||||
|
||||
Widget _buildScrollbackSection(BuildContext context, bool isCompact) {
|
||||
return AnimatedBuilder(
|
||||
animation: _controllerAndCoordinator,
|
||||
animation: _pageStateListenable,
|
||||
builder: (context, _) {
|
||||
if (controller.isFollowingLiveOutput) {
|
||||
return const SizedBox.shrink();
|
||||
@ -695,7 +722,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
|
||||
padding: const EdgeInsets.fromLTRB(8, 6, 8, 6),
|
||||
borderRadius: BorderRadius.zero,
|
||||
child: AnimatedBuilder(
|
||||
animation: _controllerAndCoordinator,
|
||||
animation: _pageStateListenable,
|
||||
builder: (context, _) {
|
||||
return Column(
|
||||
key: const Key('terminal_action_bar'),
|
||||
@ -716,23 +743,27 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
|
||||
.map((quickKey) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: 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,
|
||||
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,
|
||||
),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
child: Text(quickKey.label),
|
||||
),
|
||||
child: Text(quickKey.label),
|
||||
),
|
||||
);
|
||||
})
|
||||
@ -744,59 +775,63 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _inputController,
|
||||
focusNode: _inputFocusNode,
|
||||
enabled: _canSendInput,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Send command or input',
|
||||
),
|
||||
onSubmitted: (_) => _sendLine(),
|
||||
),
|
||||
_buildInputField(context),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
key: const Key('terminal_send_button'),
|
||||
onPressed: _canSendInput ? _sendLine : null,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(0, 38),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
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',
|
||||
),
|
||||
),
|
||||
child: const Text('Send'),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
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),
|
||||
_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',
|
||||
),
|
||||
icon: Icon(
|
||||
_isDirectInputEnabled
|
||||
? Icons.keyboard_hide
|
||||
: Icons.keyboard,
|
||||
),
|
||||
tooltip: _isDirectInputEnabled
|
||||
? 'Disable direct input'
|
||||
: 'Enable direct input',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
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),
|
||||
_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',
|
||||
),
|
||||
icon: const Icon(Icons.tune),
|
||||
tooltip: 'Show tools',
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -805,66 +840,78 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
|
||||
else
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _inputController,
|
||||
focusNode: _inputFocusNode,
|
||||
enabled: _canSendInput,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'Send command or input',
|
||||
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',
|
||||
),
|
||||
onSubmitted: (_) => _sendLine(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
FilledButton(
|
||||
key: const Key('terminal_send_button'),
|
||||
onPressed: _canSendInput ? _sendLine : null,
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size(0, 38),
|
||||
tapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
_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',
|
||||
),
|
||||
child: const Text('Send'),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
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),
|
||||
_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',
|
||||
),
|
||||
icon: Icon(
|
||||
_isDirectInputEnabled
|
||||
? Icons.keyboard_hide
|
||||
: Icons.keyboard,
|
||||
),
|
||||
tooltip: _isDirectInputEnabled
|
||||
? 'Disable direct input'
|
||||
: 'Enable direct input',
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
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(
|
||||
_isDirectInputEnabled ? 'Direct input enabled' : 'Browse mode',
|
||||
_inputController.isDirectInputEnabled
|
||||
? 'Direct input enabled'
|
||||
: 'Browse mode',
|
||||
key: const Key('terminal_input_mode_label'),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (_inputController.isDirectInputEnabled &&
|
||||
_inputController.composingText != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 2),
|
||||
child: Text(
|
||||
'Composing: ${_inputController.composingText}',
|
||||
key: const Key('terminal_direct_input_composing_label'),
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
@ -872,6 +919,42 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInputField(BuildContext context) {
|
||||
final isDirectInputEnabled = _inputController.isDirectInputEnabled;
|
||||
final baseStyle = Theme.of(context).textTheme.bodyMedium;
|
||||
|
||||
return TextField(
|
||||
controller: _inputController.textController,
|
||||
focusNode: _inputController.focusNode,
|
||||
enabled: _canSendInput,
|
||||
keyboardType: TextInputType.text,
|
||||
textInputAction: TextInputAction.send,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
enableIMEPersonalizedLearning: false,
|
||||
smartDashesType: SmartDashesType.disabled,
|
||||
smartQuotesType: SmartQuotesType.disabled,
|
||||
textCapitalization: TextCapitalization.none,
|
||||
enableInteractiveSelection: !isDirectInputEnabled,
|
||||
showCursor: !isDirectInputEnabled,
|
||||
selectAllOnFocus: false,
|
||||
style: isDirectInputEnabled
|
||||
? baseStyle?.copyWith(color: Colors.transparent)
|
||||
: baseStyle,
|
||||
cursorColor: isDirectInputEnabled
|
||||
? Colors.transparent
|
||||
: Theme.of(context).colorScheme.primary,
|
||||
decoration: InputDecoration(hintText: _inputController.hintText),
|
||||
onTap: _inputController.syncDirectSelection,
|
||||
onEditingComplete: () {},
|
||||
onSubmitted: (_) => _submitInput(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildCommandDeckAction(Widget child) {
|
||||
return ExcludeFocus(child: child);
|
||||
}
|
||||
|
||||
String get _statusLabel => switch (_connectionState) {
|
||||
TerminalConnectionState.connecting => 'Connecting',
|
||||
TerminalConnectionState.connected => 'Connected',
|
||||
|
||||
@ -6,7 +6,7 @@ packages:
|
||||
description:
|
||||
name: _fe_analyzer_shared
|
||||
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "85.0.0"
|
||||
analyzer:
|
||||
@ -14,7 +14,7 @@ packages:
|
||||
description:
|
||||
name: analyzer
|
||||
sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.7.1"
|
||||
args:
|
||||
@ -22,7 +22,7 @@ packages:
|
||||
description:
|
||||
name: args
|
||||
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.7.0"
|
||||
async:
|
||||
@ -30,7 +30,7 @@ packages:
|
||||
description:
|
||||
name: async
|
||||
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.13.1"
|
||||
boolean_selector:
|
||||
@ -38,7 +38,7 @@ packages:
|
||||
description:
|
||||
name: boolean_selector
|
||||
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
build:
|
||||
@ -46,7 +46,7 @@ packages:
|
||||
description:
|
||||
name: build
|
||||
sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
build_config:
|
||||
@ -54,7 +54,7 @@ packages:
|
||||
description:
|
||||
name: build_config
|
||||
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
build_daemon:
|
||||
@ -62,7 +62,7 @@ packages:
|
||||
description:
|
||||
name: build_daemon
|
||||
sha256: bf05f6e12cfea92d3c09308d7bcdab1906cd8a179b023269eed00c071004b957
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.1"
|
||||
build_resolvers:
|
||||
@ -70,7 +70,7 @@ packages:
|
||||
description:
|
||||
name: build_resolvers
|
||||
sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
build_runner:
|
||||
@ -78,7 +78,7 @@ packages:
|
||||
description:
|
||||
name: build_runner
|
||||
sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.4"
|
||||
build_runner_core:
|
||||
@ -86,7 +86,7 @@ packages:
|
||||
description:
|
||||
name: build_runner_core
|
||||
sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.1.2"
|
||||
built_collection:
|
||||
@ -94,7 +94,7 @@ packages:
|
||||
description:
|
||||
name: built_collection
|
||||
sha256: "376e3dd27b51ea877c28d525560790aee2e6fbb5f20e2f85d5081027d94e2100"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
built_value:
|
||||
@ -102,23 +102,23 @@ packages:
|
||||
description:
|
||||
name: built_value
|
||||
sha256: "0730c18c770d05636a8f945c32a4d7d81cb6e0f0148c8db4ad12e7748f7e49af"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "8.12.5"
|
||||
characters:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: characters
|
||||
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
|
||||
url: "https://pub.flutter-io.cn"
|
||||
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
version: "1.4.1"
|
||||
checked_yaml:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: checked_yaml
|
||||
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.4"
|
||||
clock:
|
||||
@ -126,7 +126,7 @@ packages:
|
||||
description:
|
||||
name: clock
|
||||
sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.2"
|
||||
code_builder:
|
||||
@ -134,7 +134,7 @@ packages:
|
||||
description:
|
||||
name: code_builder
|
||||
sha256: "6a6cab2ba4680d6423f34a9b972a4c9a94ebe1b62ecec4e1a1f2cba91fd1319d"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.11.1"
|
||||
collection:
|
||||
@ -142,7 +142,7 @@ packages:
|
||||
description:
|
||||
name: collection
|
||||
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.19.1"
|
||||
convert:
|
||||
@ -150,7 +150,7 @@ packages:
|
||||
description:
|
||||
name: convert
|
||||
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.2"
|
||||
crypto:
|
||||
@ -158,7 +158,7 @@ packages:
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
cupertino_icons:
|
||||
@ -166,7 +166,7 @@ packages:
|
||||
description:
|
||||
name: cupertino_icons
|
||||
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.9"
|
||||
dart_style:
|
||||
@ -174,7 +174,7 @@ packages:
|
||||
description:
|
||||
name: dart_style
|
||||
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.1"
|
||||
dio:
|
||||
@ -182,7 +182,7 @@ packages:
|
||||
description:
|
||||
name: dio
|
||||
sha256: aff32c08f92787a557dd5c0145ac91536481831a01b4648136373cddb0e64f8c
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.9.2"
|
||||
dio_web_adapter:
|
||||
@ -190,7 +190,7 @@ packages:
|
||||
description:
|
||||
name: dio_web_adapter
|
||||
sha256: "2f9e64323a7c3c7ef69567d5c800424a11f8337b8b228bad02524c9fb3c1f340"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.2"
|
||||
equatable:
|
||||
@ -198,7 +198,7 @@ packages:
|
||||
description:
|
||||
name: equatable
|
||||
sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.8"
|
||||
fake_async:
|
||||
@ -206,7 +206,7 @@ packages:
|
||||
description:
|
||||
name: fake_async
|
||||
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.3"
|
||||
file:
|
||||
@ -214,7 +214,7 @@ packages:
|
||||
description:
|
||||
name: file
|
||||
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "7.0.1"
|
||||
fixnum:
|
||||
@ -222,7 +222,7 @@ packages:
|
||||
description:
|
||||
name: fixnum
|
||||
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
flutter:
|
||||
@ -235,7 +235,7 @@ packages:
|
||||
description:
|
||||
name: flutter_lints
|
||||
sha256: "5398f14efa795ffb7a33e9b6a08798b26a180edac4ad7db3f231e40f82ce11e1"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.0.0"
|
||||
flutter_riverpod:
|
||||
@ -243,7 +243,7 @@ packages:
|
||||
description:
|
||||
name: flutter_riverpod
|
||||
sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
flutter_test:
|
||||
@ -261,7 +261,7 @@ packages:
|
||||
description:
|
||||
name: freezed
|
||||
sha256: "59a584c24b3acdc5250bb856d0d3e9c0b798ed14a4af1ddb7dc1c7b41df91c9c"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.5.8"
|
||||
freezed_annotation:
|
||||
@ -269,7 +269,7 @@ packages:
|
||||
description:
|
||||
name: freezed_annotation
|
||||
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.4.4"
|
||||
frontend_server_client:
|
||||
@ -277,7 +277,7 @@ packages:
|
||||
description:
|
||||
name: frontend_server_client
|
||||
sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
glob:
|
||||
@ -285,7 +285,7 @@ packages:
|
||||
description:
|
||||
name: glob
|
||||
sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
go_router:
|
||||
@ -293,7 +293,7 @@ packages:
|
||||
description:
|
||||
name: go_router
|
||||
sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "14.8.1"
|
||||
graphs:
|
||||
@ -301,7 +301,7 @@ packages:
|
||||
description:
|
||||
name: graphs
|
||||
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.3.2"
|
||||
http:
|
||||
@ -309,7 +309,7 @@ packages:
|
||||
description:
|
||||
name: http
|
||||
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.6.0"
|
||||
http_multi_server:
|
||||
@ -317,7 +317,7 @@ packages:
|
||||
description:
|
||||
name: http_multi_server
|
||||
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
http_parser:
|
||||
@ -325,7 +325,7 @@ packages:
|
||||
description:
|
||||
name: http_parser
|
||||
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.2"
|
||||
io:
|
||||
@ -333,7 +333,7 @@ packages:
|
||||
description:
|
||||
name: io
|
||||
sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.5"
|
||||
js:
|
||||
@ -341,7 +341,7 @@ packages:
|
||||
description:
|
||||
name: js
|
||||
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.2"
|
||||
json_annotation:
|
||||
@ -349,7 +349,7 @@ packages:
|
||||
description:
|
||||
name: json_annotation
|
||||
sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.9.0"
|
||||
json_serializable:
|
||||
@ -357,7 +357,7 @@ packages:
|
||||
description:
|
||||
name: json_serializable
|
||||
sha256: c50ef5fc083d5b5e12eef489503ba3bf5ccc899e487d691584699b4bdefeea8c
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "6.9.5"
|
||||
leak_tracker:
|
||||
@ -365,7 +365,7 @@ packages:
|
||||
description:
|
||||
name: leak_tracker
|
||||
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.0.2"
|
||||
leak_tracker_flutter_testing:
|
||||
@ -373,7 +373,7 @@ packages:
|
||||
description:
|
||||
name: leak_tracker_flutter_testing
|
||||
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.10"
|
||||
leak_tracker_testing:
|
||||
@ -381,7 +381,7 @@ packages:
|
||||
description:
|
||||
name: leak_tracker_testing
|
||||
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.2"
|
||||
lints:
|
||||
@ -389,7 +389,7 @@ packages:
|
||||
description:
|
||||
name: lints
|
||||
sha256: c35bb79562d980e9a453fc715854e1ed39e24e7d0297a880ef54e17f9874a9d7
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "5.1.1"
|
||||
logging:
|
||||
@ -397,39 +397,39 @@ packages:
|
||||
description:
|
||||
name: logging
|
||||
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: matcher
|
||||
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
|
||||
url: "https://pub.flutter-io.cn"
|
||||
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.12.17"
|
||||
version: "0.12.19"
|
||||
material_color_utilities:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: material_color_utilities
|
||||
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
|
||||
url: "https://pub.flutter-io.cn"
|
||||
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.1"
|
||||
version: "0.13.0"
|
||||
meta:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: meta
|
||||
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
|
||||
url: "https://pub.flutter-io.cn"
|
||||
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.16.0"
|
||||
version: "1.17.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mime
|
||||
sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
mocktail:
|
||||
@ -437,7 +437,7 @@ packages:
|
||||
description:
|
||||
name: mocktail
|
||||
sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.4"
|
||||
package_config:
|
||||
@ -445,7 +445,7 @@ packages:
|
||||
description:
|
||||
name: package_config
|
||||
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
path:
|
||||
@ -453,7 +453,7 @@ packages:
|
||||
description:
|
||||
name: path
|
||||
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
pool:
|
||||
@ -461,7 +461,7 @@ packages:
|
||||
description:
|
||||
name: pool
|
||||
sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.2"
|
||||
pub_semver:
|
||||
@ -469,7 +469,7 @@ packages:
|
||||
description:
|
||||
name: pub_semver
|
||||
sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
pubspec_parse:
|
||||
@ -477,7 +477,7 @@ packages:
|
||||
description:
|
||||
name: pubspec_parse
|
||||
sha256: "0560ba233314abbed0a48a2956f7f022cce7c3e1e73df540277da7544cad4082"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.5.0"
|
||||
quiver:
|
||||
@ -485,7 +485,7 @@ packages:
|
||||
description:
|
||||
name: quiver
|
||||
sha256: ea0b925899e64ecdfbf9c7becb60d5b50e706ade44a85b2363be2a22d88117d2
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.2.2"
|
||||
riverpod:
|
||||
@ -493,7 +493,7 @@ packages:
|
||||
description:
|
||||
name: riverpod
|
||||
sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.6.1"
|
||||
shelf:
|
||||
@ -501,7 +501,7 @@ packages:
|
||||
description:
|
||||
name: shelf
|
||||
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.2"
|
||||
shelf_web_socket:
|
||||
@ -509,7 +509,7 @@ packages:
|
||||
description:
|
||||
name: shelf_web_socket
|
||||
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.0"
|
||||
sky_engine:
|
||||
@ -522,7 +522,7 @@ packages:
|
||||
description:
|
||||
name: source_gen
|
||||
sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
source_helper:
|
||||
@ -530,7 +530,7 @@ packages:
|
||||
description:
|
||||
name: source_helper
|
||||
sha256: a447acb083d3a5ef17f983dd36201aeea33fedadb3228fa831f2f0c92f0f3aca
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.7"
|
||||
source_span:
|
||||
@ -538,7 +538,7 @@ packages:
|
||||
description:
|
||||
name: source_span
|
||||
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.10.2"
|
||||
stack_trace:
|
||||
@ -546,7 +546,7 @@ packages:
|
||||
description:
|
||||
name: stack_trace
|
||||
sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.12.1"
|
||||
state_notifier:
|
||||
@ -554,7 +554,7 @@ packages:
|
||||
description:
|
||||
name: state_notifier
|
||||
sha256: b8677376aa54f2d7c58280d5a007f9e8774f1968d1fb1c096adcb4792fba29bb
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
stream_channel:
|
||||
@ -562,7 +562,7 @@ packages:
|
||||
description:
|
||||
name: stream_channel
|
||||
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
stream_transform:
|
||||
@ -570,7 +570,7 @@ packages:
|
||||
description:
|
||||
name: stream_transform
|
||||
sha256: ad47125e588cfd37a9a7f86c7d6356dde8dfe89d071d293f80ca9e9273a33871
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
string_scanner:
|
||||
@ -578,7 +578,7 @@ packages:
|
||||
description:
|
||||
name: string_scanner
|
||||
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.1"
|
||||
term_glyph:
|
||||
@ -586,23 +586,23 @@ packages:
|
||||
description:
|
||||
name: term_glyph
|
||||
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.2"
|
||||
test_api:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: test_api
|
||||
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.7.6"
|
||||
version: "0.7.10"
|
||||
timing:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: timing
|
||||
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.2"
|
||||
typed_data:
|
||||
@ -610,7 +610,7 @@ packages:
|
||||
description:
|
||||
name: typed_data
|
||||
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.4.0"
|
||||
vector_math:
|
||||
@ -618,7 +618,7 @@ packages:
|
||||
description:
|
||||
name: vector_math
|
||||
sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.0"
|
||||
vm_service:
|
||||
@ -626,7 +626,7 @@ packages:
|
||||
description:
|
||||
name: vm_service
|
||||
sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "15.0.2"
|
||||
watcher:
|
||||
@ -634,7 +634,7 @@ packages:
|
||||
description:
|
||||
name: watcher
|
||||
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.1"
|
||||
web:
|
||||
@ -642,7 +642,7 @@ packages:
|
||||
description:
|
||||
name: web
|
||||
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
web_socket:
|
||||
@ -650,7 +650,7 @@ packages:
|
||||
description:
|
||||
name: web_socket
|
||||
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
web_socket_channel:
|
||||
@ -658,7 +658,7 @@ packages:
|
||||
description:
|
||||
name: web_socket_channel
|
||||
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.3"
|
||||
xterm:
|
||||
@ -666,7 +666,7 @@ packages:
|
||||
description:
|
||||
name: xterm
|
||||
sha256: "168dfedca77cba33fdb6f52e2cd001e9fde216e398e89335c19b524bb22da3a2"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
yaml:
|
||||
@ -674,7 +674,7 @@ packages:
|
||||
description:
|
||||
name: yaml
|
||||
sha256: b9da305ac7c39faa3f030eccd175340f968459dae4af175130b3fc47e40d76ce
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.1.3"
|
||||
zmodem:
|
||||
@ -682,7 +682,7 @@ packages:
|
||||
description:
|
||||
name: zmodem
|
||||
sha256: "3b7e5b29f3a7d8aee472029b05165a68438eff2f3f7766edf13daba1e297adbf"
|
||||
url: "https://pub.flutter-io.cn"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.6"
|
||||
sdks:
|
||||
|
||||
@ -4,13 +4,13 @@ import 'package:term_remote_ctl/core/network/agent_api_client.dart';
|
||||
import 'package:term_remote_ctl/core/network/agent_connection_providers.dart';
|
||||
|
||||
void main() {
|
||||
test('uses the emulator-local http agent base uri by default', () {
|
||||
test('uses the configured default http agent base uri', () {
|
||||
final container = ProviderContainer();
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final baseUri = container.read(agentBaseUriProvider);
|
||||
|
||||
expect(baseUri.toString(), 'http://10.0.2.2:5067');
|
||||
expect(baseUri.toString(), 'http://100.81.30.82:5067');
|
||||
});
|
||||
|
||||
test('builds the agent client from the configured base uri', () {
|
||||
@ -28,20 +28,23 @@ void main() {
|
||||
expect(client.baseUri.toString(), 'https://host.example:9443');
|
||||
});
|
||||
|
||||
test('session repository provider depends on the configured agent client', () async {
|
||||
final client = _FakeAgentApiClient(Uri.parse('https://host.example:9443'));
|
||||
final container = ProviderContainer(
|
||||
overrides: [
|
||||
agentApiClientProvider.overrideWith((ref) => client),
|
||||
],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
test(
|
||||
'session repository provider depends on the configured agent client',
|
||||
() async {
|
||||
final client = _FakeAgentApiClient(
|
||||
Uri.parse('https://host.example:9443'),
|
||||
);
|
||||
final container = ProviderContainer(
|
||||
overrides: [agentApiClientProvider.overrideWith((ref) => client)],
|
||||
);
|
||||
addTearDown(container.dispose);
|
||||
|
||||
final repository = container.read(sessionRepositoryProvider);
|
||||
await repository.listSessions();
|
||||
final repository = container.read(sessionRepositoryProvider);
|
||||
await repository.listSessions();
|
||||
|
||||
expect(client.listCalls, 1);
|
||||
});
|
||||
expect(client.listCalls, 1);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeAgentApiClient extends AgentApiClient {
|
||||
|
||||
@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_input_controller.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test(
|
||||
'direct mode emits inserted text, backspace, and carriage return',
|
||||
() async {
|
||||
final directInputs = <String>[];
|
||||
final bufferedCommands = <String>[];
|
||||
final controller = TerminalInputController(
|
||||
onBufferedSubmit: bufferedCommands.add,
|
||||
onDirectInput: directInputs.add,
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
controller.setMode(TerminalInputMode.direct);
|
||||
|
||||
controller.textController.value = const TextEditingValue(
|
||||
text: 'ls',
|
||||
selection: TextSelection.collapsed(offset: 2),
|
||||
);
|
||||
expect(directInputs, ['ls']);
|
||||
expect(controller.textController.text, ' ');
|
||||
expect(controller.textController.selection.baseOffset, 2);
|
||||
|
||||
controller.textController.value = const TextEditingValue(
|
||||
text: ' ',
|
||||
selection: TextSelection.collapsed(offset: 1),
|
||||
);
|
||||
expect(directInputs, ['ls', '\x7f']);
|
||||
expect(controller.textController.text, ' ');
|
||||
expect(controller.textController.selection.baseOffset, 2);
|
||||
|
||||
await controller.submit();
|
||||
expect(directInputs, ['ls', '\x7f', '\r']);
|
||||
expect(bufferedCommands, isEmpty);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'switching modes preserves the buffered draft at the end of the field',
|
||||
() {
|
||||
final controller = TerminalInputController(
|
||||
onBufferedSubmit: (_) {},
|
||||
onDirectInput: (_) {},
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
|
||||
controller.textController.value = const TextEditingValue(
|
||||
text: 'git status',
|
||||
selection: TextSelection.collapsed(offset: 10),
|
||||
);
|
||||
|
||||
controller.setMode(TerminalInputMode.direct);
|
||||
expect(controller.textController.text, ' ');
|
||||
expect(controller.textController.selection.baseOffset, 2);
|
||||
|
||||
controller.setMode(TerminalInputMode.buffered);
|
||||
expect(controller.textController.text, 'git status');
|
||||
expect(controller.textController.selection.baseOffset, 10);
|
||||
expect(controller.textController.selection.extentOffset, 10);
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -106,7 +106,7 @@ class _FakeSessionRepository extends SessionRepository {
|
||||
}
|
||||
|
||||
class _FakeAgentApiClient extends AgentApiClient {
|
||||
_FakeAgentApiClient() : super(Uri.parse('http://10.0.2.2:5067'));
|
||||
_FakeAgentApiClient() : super(Uri.parse('http://100.81.30.82:5067'));
|
||||
}
|
||||
|
||||
class _FakeTerminalSocketTransport implements TerminalSocketTransport {
|
||||
|
||||
@ -13,6 +13,7 @@ import 'package:term_remote_ctl/features/projects/project_repository.dart';
|
||||
import 'package:term_remote_ctl/features/sessions/session.dart';
|
||||
import 'package:term_remote_ctl/features/sessions/session_list_page.dart';
|
||||
import 'package:term_remote_ctl/features/sessions/session_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:xterm/xterm.dart';
|
||||
|
||||
@ -36,11 +37,13 @@ void main() {
|
||||
expect(materialApp.theme?.colorScheme.primary, const Color(0xFFC2A574));
|
||||
expect(find.byKey(const Key('project_page_header')), findsOneWidget);
|
||||
expect(find.text('Agent base URL'), findsOneWidget);
|
||||
expect(agentUrlField.controller?.text, 'http://10.0.2.2:5067');
|
||||
expect(agentUrlField.decoration?.hintText, 'http://10.0.2.2:5067');
|
||||
expect(agentUrlField.controller?.text, 'http://100.81.30.82:5067');
|
||||
expect(agentUrlField.decoration?.hintText, 'http://100.81.30.82:5067');
|
||||
expect(find.byKey(const Key('agent_connection_readout')), findsOneWidget);
|
||||
expect(
|
||||
find.text('Project requests use this base origin: http://10.0.2.2:5067.'),
|
||||
find.text(
|
||||
'Project requests use this base origin: http://100.81.30.82:5067.',
|
||||
),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.text('codex-main'), findsOneWidget);
|
||||
@ -354,6 +357,142 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('terminal send keeps the command input focused', (tester) async {
|
||||
final transportFactory = _QueuedTerminalSocketTransportFactory();
|
||||
|
||||
await _pumpApp(
|
||||
tester,
|
||||
projectRepository: _FakeProjectRepository(),
|
||||
sessionRepository: _FakeSessionRepository(),
|
||||
socketFactory: TerminalSocketSessionFactory(
|
||||
transportFactory: transportFactory.create,
|
||||
),
|
||||
);
|
||||
|
||||
await _openProjectTerminal(tester);
|
||||
|
||||
final commandField = find.byType(TextField).last;
|
||||
final editableField = find.byType(EditableText).last;
|
||||
|
||||
await tester.tap(commandField);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
tester.widget<EditableText>(editableField).focusNode.hasFocus,
|
||||
isTrue,
|
||||
);
|
||||
|
||||
await tester.enterText(commandField, 'dir');
|
||||
await tester.tap(find.byKey(const Key('terminal_send_button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
tester.widget<EditableText>(editableField).focusNode.hasFocus,
|
||||
isTrue,
|
||||
);
|
||||
expect(tester.widget<TextField>(commandField).controller?.text, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('terminal keyboard submit keeps the command input focused', (
|
||||
tester,
|
||||
) async {
|
||||
final transportFactory = _QueuedTerminalSocketTransportFactory();
|
||||
|
||||
await _pumpApp(
|
||||
tester,
|
||||
projectRepository: _FakeProjectRepository(),
|
||||
sessionRepository: _FakeSessionRepository(),
|
||||
socketFactory: TerminalSocketSessionFactory(
|
||||
transportFactory: transportFactory.create,
|
||||
),
|
||||
);
|
||||
|
||||
await _openProjectTerminal(tester);
|
||||
|
||||
final commandField = find.byType(TextField).last;
|
||||
final editableField = find.byType(EditableText).last;
|
||||
|
||||
await tester.tap(commandField);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(commandField, 'dir');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.send);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
tester.widget<EditableText>(editableField).focusNode.hasFocus,
|
||||
isTrue,
|
||||
);
|
||||
expect(tester.widget<TextField>(commandField).controller?.text, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('terminal done action keeps the command input focused', (
|
||||
tester,
|
||||
) async {
|
||||
final transportFactory = _QueuedTerminalSocketTransportFactory();
|
||||
|
||||
await _pumpApp(
|
||||
tester,
|
||||
projectRepository: _FakeProjectRepository(),
|
||||
sessionRepository: _FakeSessionRepository(),
|
||||
socketFactory: TerminalSocketSessionFactory(
|
||||
transportFactory: transportFactory.create,
|
||||
),
|
||||
);
|
||||
|
||||
await _openProjectTerminal(tester);
|
||||
|
||||
final commandField = find.byType(TextField).last;
|
||||
final editableField = find.byType(EditableText).last;
|
||||
|
||||
await tester.tap(commandField);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(commandField, 'dir');
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
tester.widget<EditableText>(editableField).focusNode.hasFocus,
|
||||
isTrue,
|
||||
);
|
||||
expect(tester.widget<TextField>(commandField).controller?.text, isEmpty);
|
||||
});
|
||||
|
||||
testWidgets('terminal direct input keyboard action sends carriage return', (
|
||||
tester,
|
||||
) async {
|
||||
final transportFactory = _QueuedTerminalSocketTransportFactory();
|
||||
|
||||
await _pumpApp(
|
||||
tester,
|
||||
projectRepository: _FakeProjectRepository(),
|
||||
sessionRepository: _FakeSessionRepository(),
|
||||
socketFactory: TerminalSocketSessionFactory(
|
||||
transportFactory: transportFactory.create,
|
||||
),
|
||||
);
|
||||
|
||||
await _openProjectTerminal(tester);
|
||||
final commandField = find.byType(TextField).last;
|
||||
final editableField = find.byType(EditableText).last;
|
||||
|
||||
await tester.tap(find.byKey(const Key('terminal_direct_input_toggle')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(commandField);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(commandField, 'pwd');
|
||||
await tester.pumpAndSettle();
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(
|
||||
transportFactory.createdTransports.single.sentMessages.last,
|
||||
contains(r'"input":"\r"'),
|
||||
);
|
||||
expect(
|
||||
tester.widget<EditableText>(editableField).focusNode.hasFocus,
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('terminal page reconnects after the socket closes', (
|
||||
tester,
|
||||
) async {
|
||||
@ -455,6 +594,114 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'terminal attach replay keeps the cursor on the last restored line',
|
||||
(tester) async {
|
||||
final apiClient = _SequencedHistoryAgentApiClient(
|
||||
responses: [
|
||||
<String, dynamic>{
|
||||
'sessionId': 'session-1',
|
||||
'lines': <String>['one', 'two'],
|
||||
'hasMoreAbove': true,
|
||||
},
|
||||
<String, dynamic>{
|
||||
'sessionId': 'session-1',
|
||||
'lines': <String>['zero', 'one', 'two'],
|
||||
'hasMoreAbove': false,
|
||||
},
|
||||
],
|
||||
);
|
||||
final transportFactory = _QueuedTerminalSocketTransportFactory(
|
||||
startupFrames: const [
|
||||
'{"type":"attached","sessionId":"session-1"}',
|
||||
'one\r\ntwo',
|
||||
],
|
||||
);
|
||||
|
||||
await _pumpApp(
|
||||
tester,
|
||||
projectRepository: _FakeProjectRepository(),
|
||||
sessionRepository: _FakeSessionRepository(),
|
||||
apiClient: apiClient,
|
||||
socketFactory: TerminalSocketSessionFactory(
|
||||
transportFactory: transportFactory.create,
|
||||
),
|
||||
);
|
||||
|
||||
await _openProjectTerminal(tester);
|
||||
|
||||
var terminal = tester
|
||||
.widget<TerminalView>(find.byType(TerminalView))
|
||||
.terminal;
|
||||
|
||||
expect(terminal.buffer.cursorY, 1);
|
||||
expect(terminal.buffer.cursorX, 3);
|
||||
expect(terminal.buffer.getText(), contains('one\ntwo'));
|
||||
|
||||
await tester.tap(find.textContaining('Live |'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.ensureVisible(find.text('Load older lines'));
|
||||
await tester.tap(find.text('Load older lines'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
terminal = tester
|
||||
.widget<TerminalView>(find.byType(TerminalView))
|
||||
.terminal;
|
||||
|
||||
expect(terminal.buffer.getText(), contains('one\ntwo'));
|
||||
expect(terminal.buffer.getText(), isNot(contains('zero')));
|
||||
expect(terminal.buffer.cursorY, 1);
|
||||
expect(terminal.buffer.cursorX, 3);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
're-entering an existing session restores the terminal cursor to the last line',
|
||||
(tester) async {
|
||||
final transportFactory = _QueuedTerminalSocketTransportFactory(
|
||||
startupFrames: const [
|
||||
'{"type":"attached","sessionId":"session-1"}',
|
||||
'one\r\ntwo',
|
||||
],
|
||||
);
|
||||
final session = _session('session-1', 'codex-main');
|
||||
|
||||
await _pumpTerminalPage(
|
||||
tester,
|
||||
session: session,
|
||||
socketFactory: TerminalSocketSessionFactory(
|
||||
transportFactory: transportFactory.create,
|
||||
),
|
||||
);
|
||||
|
||||
var terminal = tester
|
||||
.widget<TerminalView>(find.byType(TerminalView))
|
||||
.terminal;
|
||||
expect(terminal.buffer.getText(), contains('one\ntwo'));
|
||||
expect(terminal.buffer.cursorY, 1);
|
||||
expect(terminal.buffer.cursorX, 3);
|
||||
|
||||
await tester.pumpWidget(const SizedBox.shrink());
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
await _pumpTerminalPage(
|
||||
tester,
|
||||
session: session,
|
||||
socketFactory: TerminalSocketSessionFactory(
|
||||
transportFactory: transportFactory.create,
|
||||
),
|
||||
);
|
||||
|
||||
terminal = tester
|
||||
.widget<TerminalView>(find.byType(TerminalView))
|
||||
.terminal;
|
||||
expect(transportFactory.createCount, 2);
|
||||
expect(terminal.buffer.getText(), contains('one\ntwo'));
|
||||
expect(terminal.buffer.cursorY, 1);
|
||||
expect(terminal.buffer.cursorX, 3);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('session list deletes a session after confirmation', (
|
||||
tester,
|
||||
) async {
|
||||
@ -523,6 +770,37 @@ Future<void> _pumpApp(
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> _pumpTerminalPage(
|
||||
WidgetTester tester, {
|
||||
required Session session,
|
||||
AgentApiClient? apiClient,
|
||||
TerminalSocketSessionFactory? socketFactory,
|
||||
}) async {
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
agentApiClientProvider.overrideWithValue(
|
||||
apiClient ?? _FakeAgentApiClient(),
|
||||
),
|
||||
terminalSocketSessionFactoryProvider.overrideWithValue(
|
||||
socketFactory ??
|
||||
TerminalSocketSessionFactory(
|
||||
transportFactory: (_) =>
|
||||
_FakeTerminalSocketTransport(autoAttach: true),
|
||||
),
|
||||
),
|
||||
],
|
||||
child: MaterialApp(
|
||||
home: TerminalPage(
|
||||
session: session,
|
||||
agentBaseUri: Uri.parse('http://100.81.30.82:5067'),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> _openProjectTerminal(WidgetTester tester) async {
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Open terminal'));
|
||||
await tester.pumpAndSettle();
|
||||
@ -679,7 +957,7 @@ class _FailingSessionRepository extends SessionRepository {
|
||||
}
|
||||
|
||||
class _FakeAgentApiClient extends AgentApiClient {
|
||||
_FakeAgentApiClient() : super(Uri.parse('http://10.0.2.2:5067'));
|
||||
_FakeAgentApiClient() : super(Uri.parse('http://100.81.30.82:5067'));
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> getSessionHistory(
|
||||
@ -719,15 +997,25 @@ class _SequencedHistoryAgentApiClient extends _FakeAgentApiClient {
|
||||
}
|
||||
|
||||
class _FakeTerminalSocketTransport implements TerminalSocketTransport {
|
||||
_FakeTerminalSocketTransport({this.autoAttach = false}) {
|
||||
if (autoAttach) {
|
||||
_FakeTerminalSocketTransport({
|
||||
this.autoAttach = false,
|
||||
this.startupFrames = const <String>[],
|
||||
}) {
|
||||
if (autoAttach && startupFrames.isEmpty) {
|
||||
Future<void>.microtask(() {
|
||||
emit('{"type":"attached","sessionId":"session-1"}');
|
||||
});
|
||||
} else if (startupFrames.isNotEmpty) {
|
||||
Future<void>.microtask(() {
|
||||
for (final frame in startupFrames) {
|
||||
emit(frame);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
final bool autoAttach;
|
||||
final List<String> startupFrames;
|
||||
final _incoming = StreamController<dynamic>.broadcast();
|
||||
final sentMessages = <String>[];
|
||||
|
||||
@ -750,11 +1038,19 @@ class _FakeTerminalSocketTransport implements TerminalSocketTransport {
|
||||
}
|
||||
|
||||
class _QueuedTerminalSocketTransportFactory {
|
||||
_QueuedTerminalSocketTransportFactory({
|
||||
this.startupFrames = const <String>[],
|
||||
});
|
||||
|
||||
final List<String> startupFrames;
|
||||
final createdTransports = <_FakeTerminalSocketTransport>[];
|
||||
int createCount = 0;
|
||||
|
||||
TerminalSocketTransport create(Uri _) {
|
||||
final transport = _FakeTerminalSocketTransport(autoAttach: true);
|
||||
final transport = _FakeTerminalSocketTransport(
|
||||
autoAttach: startupFrames.isEmpty,
|
||||
startupFrames: startupFrames,
|
||||
);
|
||||
createdTransports.add(transport);
|
||||
createCount += 1;
|
||||
return transport;
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
namespace TermRemoteCtl.Agent.History;
|
||||
|
||||
public sealed class TerminalReplayBuffer
|
||||
{
|
||||
private readonly int _maxCharacters;
|
||||
private readonly Queue<string> _chunks = new();
|
||||
private int _characterCount;
|
||||
|
||||
public TerminalReplayBuffer(int maxCharacters)
|
||||
{
|
||||
ArgumentOutOfRangeException.ThrowIfNegativeOrZero(maxCharacters);
|
||||
_maxCharacters = maxCharacters;
|
||||
}
|
||||
|
||||
public void Append(string chunk)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(chunk);
|
||||
|
||||
if (chunk.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (chunk.Length >= _maxCharacters)
|
||||
{
|
||||
_chunks.Clear();
|
||||
_chunks.Enqueue(chunk[^_maxCharacters..]);
|
||||
_characterCount = _maxCharacters;
|
||||
return;
|
||||
}
|
||||
|
||||
_chunks.Enqueue(chunk);
|
||||
_characterCount += chunk.Length;
|
||||
TrimToLimit();
|
||||
}
|
||||
|
||||
public string GetSnapshot()
|
||||
{
|
||||
return string.Concat(_chunks);
|
||||
}
|
||||
|
||||
private void TrimToLimit()
|
||||
{
|
||||
while (_characterCount > _maxCharacters && _chunks.Count > 0)
|
||||
{
|
||||
var oldest = _chunks.Dequeue();
|
||||
var overflow = _characterCount - _maxCharacters;
|
||||
if (oldest.Length <= overflow)
|
||||
{
|
||||
_characterCount -= oldest.Length;
|
||||
continue;
|
||||
}
|
||||
|
||||
var trimmed = oldest[overflow..];
|
||||
_chunks.Enqueue(trimmed);
|
||||
_characterCount -= overflow;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -78,6 +78,11 @@ public static class TerminalWebSocketHandler
|
||||
try
|
||||
{
|
||||
await SendJsonAsync(socket, new TerminalAttachResponse(sessionId), sendGate, context.RequestAborted).ConfigureAwait(false);
|
||||
var replay = registry.GetReplaySnapshot(sessionId);
|
||||
if (!string.IsNullOrEmpty(replay))
|
||||
{
|
||||
await SendTextAsync(socket, replay, sendGate, context.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
await ReceiveLoopAsync(context, socket, host, diagnostics, sessionId).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
|
||||
@ -7,8 +7,10 @@ namespace TermRemoteCtl.Agent.Sessions;
|
||||
|
||||
public sealed class SessionRegistry
|
||||
{
|
||||
private const int ReplayCharacterLimit = 262_144;
|
||||
private readonly ConcurrentDictionary<string, SessionRecord> _records = new();
|
||||
private readonly ConcurrentDictionary<string, TerminalRingBuffer> _historyBySession = new();
|
||||
private readonly ConcurrentDictionary<string, TerminalReplayBuffer> _replayBySession = new();
|
||||
private readonly SessionHistoryStore _historyStore;
|
||||
private readonly int _ringBufferLineLimit;
|
||||
|
||||
@ -37,6 +39,7 @@ public sealed class SessionRegistry
|
||||
|
||||
_records[record.SessionId] = record;
|
||||
_historyBySession[record.SessionId] = new TerminalRingBuffer(_ringBufferLineLimit);
|
||||
_replayBySession[record.SessionId] = new TerminalReplayBuffer(ReplayCharacterLimit);
|
||||
return record;
|
||||
}
|
||||
|
||||
@ -92,6 +95,10 @@ public sealed class SessionRegistry
|
||||
sessionId,
|
||||
_ => new TerminalRingBuffer(_ringBufferLineLimit));
|
||||
history.Append(chunk);
|
||||
var replay = _replayBySession.GetOrAdd(
|
||||
sessionId,
|
||||
_ => new TerminalReplayBuffer(ReplayCharacterLimit));
|
||||
replay.Append(chunk);
|
||||
_records[sessionId] = record with { UpdatedAtUtc = DateTimeOffset.UtcNow };
|
||||
await _historyStore.AppendAsync(sessionId, chunk, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
@ -118,6 +125,21 @@ public sealed class SessionRegistry
|
||||
skipCount > 0);
|
||||
}
|
||||
|
||||
public string GetReplaySnapshot(string sessionId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
|
||||
|
||||
if (!_records.ContainsKey(sessionId))
|
||||
{
|
||||
throw new KeyNotFoundException($"Session '{sessionId}' was not found.");
|
||||
}
|
||||
|
||||
var replay = _replayBySession.GetOrAdd(
|
||||
sessionId,
|
||||
_ => new TerminalReplayBuffer(ReplayCharacterLimit));
|
||||
return replay.GetSnapshot();
|
||||
}
|
||||
|
||||
public async Task DeleteAsync(string sessionId, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
|
||||
@ -128,6 +150,7 @@ public sealed class SessionRegistry
|
||||
}
|
||||
|
||||
_historyBySession.TryRemove(sessionId, out _);
|
||||
_replayBySession.TryRemove(sessionId, out _);
|
||||
await _historyStore.DeleteAsync(sessionId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@ -49,6 +49,30 @@ public sealed class TerminalWebSocketHandlerTests
|
||||
TimeSpan.FromSeconds(2));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Attach_Replays_Recent_Output_For_Existing_Session()
|
||||
{
|
||||
await using var fixture = new TerminalApiFixture();
|
||||
var registry = fixture.Services.GetRequiredService<SessionRegistry>();
|
||||
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
|
||||
await registry.AppendOutputAsync(session.SessionId, "prompt> dir\r\nnext> ", CancellationToken.None);
|
||||
|
||||
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\nnext> ", replayFrame);
|
||||
}
|
||||
|
||||
private static async Task<string> ReceiveTextAsync(WebSocket socket, CancellationToken cancellationToken)
|
||||
{
|
||||
var buffer = new byte[4096];
|
||||
|
||||
@ -68,6 +68,21 @@ public class SessionRegistryTests
|
||||
Assert.Equal("dir\r\n", content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task AppendOutputAsync_Stores_Recent_Replay_Output()
|
||||
{
|
||||
using var harness = SessionRegistryHarness.Create();
|
||||
var registry = harness.Registry;
|
||||
var session = registry.Create("Shell", DateTimeOffset.UtcNow);
|
||||
|
||||
await registry.AppendOutputAsync(session.SessionId, "prompt> ", CancellationToken.None);
|
||||
await registry.AppendOutputAsync(session.SessionId, "dir\r\n", CancellationToken.None);
|
||||
|
||||
var replay = registry.GetReplaySnapshot(session.SessionId);
|
||||
|
||||
Assert.Equal("prompt> dir\r\n", replay);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_Removes_Session_Record_And_History_Log()
|
||||
{
|
||||
|
||||
Loading…
Reference in New Issue
Block a user