chore: commit all pending project changes
This commit is contained in:
parent
4ed8525209
commit
d9cf4e72e2
35
.codex/environments/environment.toml
Normal file
35
.codex/environments/environment.toml
Normal file
@ -0,0 +1,35 @@
|
||||
# THIS IS AUTOGENERATED. DO NOT EDIT MANUALLY
|
||||
version = 1
|
||||
name = "TermRemoteCtl"
|
||||
|
||||
[setup]
|
||||
script = ""
|
||||
|
||||
[[actions]]
|
||||
name = "运行"
|
||||
icon = "run"
|
||||
command = '''
|
||||
chcp 65001
|
||||
[Console]::InputEncoding = [System.Text.UTF8Encoding]::new($false)
|
||||
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
||||
$OutputEncoding = [System.Text.UTF8Encoding]::new($false)
|
||||
$env:PYTHONUTF8='1'
|
||||
$env:PYTHONIOENCODING='utf-8'
|
||||
Set-Location 'D:\App\Flutter\TermRemoteCtl\apps\mobile_app'
|
||||
C:\tools\flutter\bin\flutter.bat build apk --debug
|
||||
'''
|
||||
|
||||
[[actions]]
|
||||
name = "后端启动"
|
||||
icon = "tool"
|
||||
command = '''
|
||||
Get-Process dotnet -ErrorAction SilentlyContinue | Stop-Process -Force -ErrorAction SilentlyContinue
|
||||
$proj='D:\App\Flutter\TermRemoteCtl\apps\windows_agent\src\TermRemoteCtl.Agent\TermRemoteCtl.Agent.csproj'
|
||||
$work='D:\App\Flutter\TermRemoteCtl\work'
|
||||
New-Item -ItemType Directory -Force $work | Out-Null
|
||||
$stdout=Join-Path $work 'windows_agent.stdout.log'
|
||||
$stderr=Join-Path $work 'windows_agent.stderr.log'
|
||||
$proc=Start-Process dotnet -ArgumentList @('run','--project',$proj) -WorkingDirectory 'D:\App\Flutter\TermRemoteCtl' -RedirectStandardOutput $stdout -RedirectStandardError $stderr -PassThru
|
||||
Start-Sleep -Seconds 6
|
||||
Invoke-WebRequest -UseBasicParsing 'http://127.0.0.1:5067/health'
|
||||
'''
|
||||
@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../features/sessions/session_list_page.dart';
|
||||
import '../features/projects/project_list_page.dart';
|
||||
|
||||
class TermRemoteCtlApp extends StatelessWidget {
|
||||
const TermRemoteCtlApp({super.key});
|
||||
@ -10,7 +10,7 @@ class TermRemoteCtlApp extends StatelessWidget {
|
||||
return MaterialApp(
|
||||
title: 'TermRemoteCtl',
|
||||
theme: ThemeData(colorSchemeSeed: Colors.blue),
|
||||
home: const SessionListPage(),
|
||||
home: const ProjectListPage(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -66,6 +66,10 @@ class AgentApiClient {
|
||||
return _readJsonMap(response.data, 'project');
|
||||
}
|
||||
|
||||
Future<void> deleteProject(String projectId) async {
|
||||
await _dio.deleteUri(projectUri(projectId));
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> createSession({
|
||||
String? name,
|
||||
String? projectId,
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import 'agent_api_client.dart';
|
||||
import '../../features/projects/project.dart';
|
||||
import '../../features/projects/project_repository.dart';
|
||||
import '../../features/sessions/session_repository.dart';
|
||||
import '../../features/sessions/session.dart';
|
||||
|
||||
@ -16,6 +18,14 @@ final sessionRepositoryProvider = Provider<SessionRepository>((ref) {
|
||||
return SessionRepository(ref.watch(agentApiClientProvider));
|
||||
});
|
||||
|
||||
final projectRepositoryProvider = Provider<ProjectRepository>((ref) {
|
||||
return ProjectRepository(ref.watch(agentApiClientProvider));
|
||||
});
|
||||
|
||||
final projectsProvider = FutureProvider<List<Project>>((ref) {
|
||||
return ref.watch(projectRepositoryProvider).listProjects();
|
||||
});
|
||||
|
||||
final sessionsProvider = FutureProvider<List<Session>>((ref) {
|
||||
return ref.watch(sessionRepositoryProvider).listSessions();
|
||||
});
|
||||
|
||||
18
apps/mobile_app/lib/core/network/agent_error_formatter.dart
Normal file
18
apps/mobile_app/lib/core/network/agent_error_formatter.dart
Normal file
@ -0,0 +1,18 @@
|
||||
import 'package:dio/dio.dart';
|
||||
|
||||
String formatAgentError(Object error, {String fallback = 'Request failed.'}) {
|
||||
if (error case DioException(response: final response?)) {
|
||||
final data = response.data;
|
||||
if (data is Map) {
|
||||
final code = data['error'];
|
||||
if (code == 'invalid_working_directory') {
|
||||
return 'Working directory does not exist. Update the project path and try again.';
|
||||
}
|
||||
if (code == 'project_not_found') {
|
||||
return 'Project could not be found. Refresh the list and try again.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return '$fallback $error';
|
||||
}
|
||||
41
apps/mobile_app/lib/features/projects/project.dart
Normal file
41
apps/mobile_app/lib/features/projects/project.dart
Normal file
@ -0,0 +1,41 @@
|
||||
import '../sessions/session.dart';
|
||||
|
||||
class Project {
|
||||
Project({
|
||||
required this.projectId,
|
||||
required this.name,
|
||||
required this.workingDirectory,
|
||||
required this.createdAtUtc,
|
||||
required this.updatedAtUtc,
|
||||
});
|
||||
|
||||
final String projectId;
|
||||
final String name;
|
||||
final String workingDirectory;
|
||||
final DateTime createdAtUtc;
|
||||
final DateTime updatedAtUtc;
|
||||
|
||||
factory Project.fromJson(Map<String, dynamic> json) => Project(
|
||||
projectId: json['projectId'] as String,
|
||||
name: json['name'] as String,
|
||||
workingDirectory: json['workingDirectory'] as String,
|
||||
createdAtUtc: DateTime.parse(json['createdAtUtc'] as String),
|
||||
updatedAtUtc: DateTime.parse(json['updatedAtUtc'] as String),
|
||||
);
|
||||
}
|
||||
|
||||
class ProjectDetail {
|
||||
ProjectDetail({required this.project, required this.recentSessions});
|
||||
|
||||
final Project project;
|
||||
final List<Session> recentSessions;
|
||||
|
||||
factory ProjectDetail.fromJson(Map<String, dynamic> json) => ProjectDetail(
|
||||
project: Project.fromJson(json),
|
||||
recentSessions: ((json['recentSessions'] as List?) ?? const <dynamic>[])
|
||||
.map(
|
||||
(entry) => Session.fromJson(Map<String, dynamic>.from(entry as Map)),
|
||||
)
|
||||
.toList(growable: false),
|
||||
);
|
||||
}
|
||||
315
apps/mobile_app/lib/features/projects/project_detail_page.dart
Normal file
315
apps/mobile_app/lib/features/projects/project_detail_page.dart
Normal file
@ -0,0 +1,315 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/network/agent_connection_providers.dart';
|
||||
import '../../core/network/agent_error_formatter.dart';
|
||||
import '../sessions/session.dart';
|
||||
import '../terminal/terminal_page.dart';
|
||||
import 'project.dart';
|
||||
|
||||
class ProjectDetailPage extends ConsumerStatefulWidget {
|
||||
const ProjectDetailPage({super.key, required this.project});
|
||||
|
||||
final Project project;
|
||||
|
||||
@override
|
||||
ConsumerState<ProjectDetailPage> createState() => _ProjectDetailPageState();
|
||||
}
|
||||
|
||||
class _ProjectDetailPageState extends ConsumerState<ProjectDetailPage> {
|
||||
late Future<ProjectDetail> _detailFuture;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_detailFuture = _loadDetail();
|
||||
}
|
||||
|
||||
Future<ProjectDetail> _loadDetail() {
|
||||
return ref
|
||||
.read(projectRepositoryProvider)
|
||||
.getProjectDetail(widget.project.projectId);
|
||||
}
|
||||
|
||||
Future<void> _refreshDetail() async {
|
||||
setState(() {
|
||||
_detailFuture = _loadDetail();
|
||||
});
|
||||
await _detailFuture;
|
||||
}
|
||||
|
||||
Future<void> _openNewTerminal() async {
|
||||
final repository = ref.read(sessionRepositoryProvider);
|
||||
try {
|
||||
final session = await repository.createSession(
|
||||
projectId: widget.project.projectId,
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => TerminalPage(
|
||||
session: session,
|
||||
agentBaseUri: ref.read(agentBaseUriProvider),
|
||||
project: widget.project,
|
||||
),
|
||||
),
|
||||
);
|
||||
await _refreshDetail();
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
formatAgentError(error, fallback: 'Failed to open terminal.'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _editProject(Project project) async {
|
||||
final nameController = TextEditingController(text: project.name);
|
||||
final pathController = TextEditingController(
|
||||
text: project.workingDirectory,
|
||||
);
|
||||
|
||||
try {
|
||||
final shouldSave = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Edit project'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameController,
|
||||
decoration: const InputDecoration(labelText: 'Project name'),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: pathController,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Working directory',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
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;
|
||||
}
|
||||
|
||||
await ref
|
||||
.read(projectRepositoryProvider)
|
||||
.updateProject(
|
||||
projectId: project.projectId,
|
||||
name: nameController.text.trim(),
|
||||
workingDirectory: pathController.text.trim(),
|
||||
);
|
||||
await _refreshDetail();
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
formatAgentError(error, fallback: 'Failed to update project.'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _openExistingSession(Session session, Project project) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => TerminalPage(
|
||||
session: session,
|
||||
agentBaseUri: ref.read(agentBaseUriProvider),
|
||||
project: project,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteProject(Project project) async {
|
||||
final shouldDelete = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Delete project'),
|
||||
content: Text(
|
||||
'Delete "${project.name}"? This also deletes all sessions and terminal history for this project.',
|
||||
),
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
await ref
|
||||
.read(projectRepositoryProvider)
|
||||
.deleteProject(project.projectId);
|
||||
ref.invalidate(projectsProvider);
|
||||
ref.invalidate(sessionsProvider);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
Navigator.of(context).pop();
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
formatAgentError(error, fallback: 'Failed to delete project.'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.project.name),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _refreshDetail,
|
||||
tooltip: 'Refresh project',
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton.extended(
|
||||
onPressed: _openNewTerminal,
|
||||
icon: const Icon(Icons.add),
|
||||
label: const Text('Open terminal'),
|
||||
),
|
||||
body: FutureBuilder<ProjectDetail>(
|
||||
future: _detailFuture,
|
||||
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 project: ${snapshot.error}'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final detail = snapshot.data!;
|
||||
final project = detail.project;
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
Card(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
project.name,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(project.workingDirectory),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
FilledButton.icon(
|
||||
onPressed: _openNewTerminal,
|
||||
icon: const Icon(Icons.terminal),
|
||||
label: const Text('Open terminal'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _editProject(project),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
label: const Text('Edit'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _deleteProject(project),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Recent sessions',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (detail.recentSessions.isEmpty)
|
||||
const Card(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text('No recent sessions for this project yet.'),
|
||||
),
|
||||
)
|
||||
else
|
||||
...detail.recentSessions.map(
|
||||
(session) => Card(
|
||||
child: ListTile(
|
||||
onTap: () => _openExistingSession(session, project),
|
||||
leading: const Icon(Icons.terminal),
|
||||
title: Text(session.name),
|
||||
subtitle: Text('Status: ${session.status}'),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
412
apps/mobile_app/lib/features/projects/project_list_page.dart
Normal file
412
apps/mobile_app/lib/features/projects/project_list_page.dart
Normal file
@ -0,0 +1,412 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
|
||||
import '../../core/network/agent_connection_providers.dart';
|
||||
import '../../core/network/agent_error_formatter.dart';
|
||||
import '../sessions/session_list_page.dart';
|
||||
import '../terminal/terminal_page.dart';
|
||||
import 'project.dart';
|
||||
import 'project_detail_page.dart';
|
||||
|
||||
class ProjectListPage extends ConsumerStatefulWidget {
|
||||
const ProjectListPage({super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ProjectListPage> createState() => _ProjectListPageState();
|
||||
}
|
||||
|
||||
class _ProjectListPageState extends ConsumerState<ProjectListPage> {
|
||||
late final TextEditingController _agentUrlController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_agentUrlController = TextEditingController(
|
||||
text: ref.read(agentBaseUriProvider).toString(),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_agentUrlController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _reloadProjects() async {
|
||||
ref.invalidate(projectsProvider);
|
||||
try {
|
||||
await ref.read(projectsProvider.future);
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
_showMessage('Failed to load projects: $error');
|
||||
}
|
||||
}
|
||||
|
||||
void _showMessage(String message) {
|
||||
ScaffoldMessenger.of(
|
||||
context,
|
||||
).showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
Future<void> _applyAgentUrl() async {
|
||||
final parsedUri = Uri.tryParse(_agentUrlController.text.trim());
|
||||
if (parsedUri == null ||
|
||||
parsedUri.scheme.isEmpty ||
|
||||
parsedUri.host.isEmpty) {
|
||||
_showMessage('Enter a valid agent base URL.');
|
||||
return;
|
||||
}
|
||||
|
||||
final current = ref.read(agentBaseUriProvider);
|
||||
if (current == parsedUri) {
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(agentBaseUriProvider.notifier).state = parsedUri;
|
||||
ref.invalidate(sessionsProvider);
|
||||
await _reloadProjects();
|
||||
}
|
||||
|
||||
Future<void> _showProjectEditor({Project? existing}) async {
|
||||
final nameController = TextEditingController(text: existing?.name ?? '');
|
||||
final pathController = TextEditingController(
|
||||
text: existing?.workingDirectory ?? '',
|
||||
);
|
||||
|
||||
try {
|
||||
final shouldSubmit = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: Text(existing == null ? 'New project' : 'Edit project'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: nameController,
|
||||
autofocus: true,
|
||||
textInputAction: TextInputAction.next,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Project name',
|
||||
hintText: 'TermRemoteCtl',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: pathController,
|
||||
textInputAction: TextInputAction.done,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Working directory',
|
||||
hintText: r'C:\repo\termremotectl',
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.of(context).pop(true),
|
||||
child: Text(existing == null ? 'Create' : 'Save'),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
if (shouldSubmit != true) {
|
||||
return;
|
||||
}
|
||||
|
||||
final name = nameController.text.trim();
|
||||
final workingDirectory = pathController.text.trim();
|
||||
if (name.isEmpty || workingDirectory.isEmpty) {
|
||||
_showMessage('Project name and working directory are required.');
|
||||
return;
|
||||
}
|
||||
|
||||
final repository = ref.read(projectRepositoryProvider);
|
||||
if (existing == null) {
|
||||
await repository.createProject(
|
||||
name: name,
|
||||
workingDirectory: workingDirectory,
|
||||
);
|
||||
} else {
|
||||
await repository.updateProject(
|
||||
projectId: existing.projectId,
|
||||
name: name,
|
||||
workingDirectory: workingDirectory,
|
||||
);
|
||||
}
|
||||
await _reloadProjects();
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
_showMessage('Failed to save project: $error');
|
||||
}
|
||||
}
|
||||
|
||||
void _openProject(Project project) {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ProjectDetailPage(project: project),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _deleteProject(Project project) async {
|
||||
final shouldDelete = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return AlertDialog(
|
||||
title: const Text('Delete project'),
|
||||
content: Text(
|
||||
'Delete "${project.name}"? This also deletes all sessions and terminal history for this project.',
|
||||
),
|
||||
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;
|
||||
}
|
||||
|
||||
try {
|
||||
await ref
|
||||
.read(projectRepositoryProvider)
|
||||
.deleteProject(project.projectId);
|
||||
ref.invalidate(sessionsProvider);
|
||||
await _reloadProjects();
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
_showMessage('Failed to delete project: $error');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _openTerminal(Project project) async {
|
||||
final repository = ref.read(sessionRepositoryProvider);
|
||||
try {
|
||||
final session = await repository.createSession(
|
||||
projectId: project.projectId,
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => TerminalPage(
|
||||
session: session,
|
||||
agentBaseUri: ref.read(agentBaseUriProvider),
|
||||
project: project,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
_showMessage(
|
||||
formatAgentError(error, fallback: 'Failed to open terminal.'),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildAgentConfigCard(Uri baseUri) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.fromLTRB(16, 16, 16, 8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Agent base URL',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: TextField(
|
||||
controller: _agentUrlController,
|
||||
decoration: const InputDecoration(
|
||||
hintText: 'http://10.0.2.2:5067',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
keyboardType: TextInputType.url,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _applyAgentUrl(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
FilledButton(
|
||||
onPressed: _applyAgentUrl,
|
||||
child: const Text('Use'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Project requests use this base origin: ${baseUri.toString()}.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildProjectsBody(AsyncValue<List<Project>> projectsAsync) {
|
||||
return projectsAsync.when(
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, _) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: _reloadProjects,
|
||||
child: ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
const SizedBox(height: 96),
|
||||
const Icon(Icons.folder_off_outlined, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Could not load projects',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text('$error', textAlign: TextAlign.center),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
data: (projects) {
|
||||
return RefreshIndicator(
|
||||
onRefresh: _reloadProjects,
|
||||
child: projects.isEmpty
|
||||
? ListView(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(24),
|
||||
children: [
|
||||
const SizedBox(height: 56),
|
||||
Icon(
|
||||
Icons.folder_open,
|
||||
size: 56,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'No projects yet',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Add a project to launch new terminals in a known working directory.',
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
)
|
||||
: ListView.separated(
|
||||
physics: const AlwaysScrollableScrollPhysics(),
|
||||
padding: const EdgeInsets.all(16),
|
||||
itemCount: projects.length,
|
||||
separatorBuilder: (_, _) => const SizedBox(height: 12),
|
||||
itemBuilder: (context, index) {
|
||||
final project = projects[index];
|
||||
return Card(
|
||||
child: ListTile(
|
||||
onTap: () => _openProject(project),
|
||||
leading: const Icon(Icons.folder_copy_outlined),
|
||||
title: Text(project.name),
|
||||
subtitle: Text(project.workingDirectory),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
key: Key(
|
||||
'project_delete_button_${project.projectId}',
|
||||
),
|
||||
tooltip: 'Delete project',
|
||||
onPressed: () => _deleteProject(project),
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: () => _openTerminal(project),
|
||||
child: const Text('Open terminal'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final baseUri = ref.watch(agentBaseUriProvider);
|
||||
final projectsAsync = ref.watch(projectsProvider);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: const Text('Projects'),
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _reloadProjects,
|
||||
tooltip: 'Refresh projects',
|
||||
icon: const Icon(Icons.refresh),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => const SessionListPage(),
|
||||
),
|
||||
);
|
||||
},
|
||||
tooltip: 'All sessions',
|
||||
icon: const Icon(Icons.terminal),
|
||||
),
|
||||
],
|
||||
),
|
||||
floatingActionButton: FloatingActionButton(
|
||||
onPressed: _showProjectEditor,
|
||||
tooltip: 'Create project',
|
||||
child: const Icon(Icons.add),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
_buildAgentConfigCard(baseUri),
|
||||
Expanded(child: _buildProjectsBody(projectsAsync)),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,47 @@
|
||||
import 'package:term_remote_ctl/core/network/agent_api_client.dart';
|
||||
|
||||
import 'project.dart';
|
||||
|
||||
class ProjectRepository {
|
||||
ProjectRepository(this._client);
|
||||
|
||||
final AgentApiClient _client;
|
||||
|
||||
Future<List<Project>> listProjects() async {
|
||||
final projects = await _client.listProjects();
|
||||
return projects.map(Project.fromJson).toList(growable: false);
|
||||
}
|
||||
|
||||
Future<Project> createProject({
|
||||
required String name,
|
||||
required String workingDirectory,
|
||||
}) async {
|
||||
final project = await _client.createProject(
|
||||
name: name,
|
||||
workingDirectory: workingDirectory,
|
||||
);
|
||||
return Project.fromJson(project);
|
||||
}
|
||||
|
||||
Future<Project> updateProject({
|
||||
required String projectId,
|
||||
required String name,
|
||||
required String workingDirectory,
|
||||
}) async {
|
||||
final project = await _client.updateProject(
|
||||
projectId: projectId,
|
||||
name: name,
|
||||
workingDirectory: workingDirectory,
|
||||
);
|
||||
return Project.fromJson(project);
|
||||
}
|
||||
|
||||
Future<ProjectDetail> getProjectDetail(String projectId) async {
|
||||
final detail = await _client.getProjectDetail(projectId);
|
||||
return ProjectDetail.fromJson(detail);
|
||||
}
|
||||
|
||||
Future<void> deleteProject(String projectId) {
|
||||
return _client.deleteProject(projectId);
|
||||
}
|
||||
}
|
||||
@ -3,15 +3,35 @@ class Session {
|
||||
required this.sessionId,
|
||||
required this.name,
|
||||
required this.status,
|
||||
this.projectId,
|
||||
this.workingDirectory,
|
||||
this.createdAtUtc,
|
||||
this.updatedAtUtc,
|
||||
});
|
||||
|
||||
final String sessionId;
|
||||
final String name;
|
||||
final String status;
|
||||
final String? projectId;
|
||||
final String? workingDirectory;
|
||||
final DateTime? createdAtUtc;
|
||||
final DateTime? updatedAtUtc;
|
||||
|
||||
factory Session.fromJson(Map<String, dynamic> json) => Session(
|
||||
sessionId: json['sessionId'] as String,
|
||||
name: json['name'] as String,
|
||||
status: json['status'] as String,
|
||||
);
|
||||
sessionId: json['sessionId'] as String,
|
||||
name: json['name'] as String,
|
||||
status: json['status'] as String,
|
||||
projectId: json['projectId'] as String?,
|
||||
workingDirectory: json['workingDirectory'] as String?,
|
||||
createdAtUtc: _tryParseDateTime(json['createdAtUtc']),
|
||||
updatedAtUtc: _tryParseDateTime(json['updatedAtUtc']),
|
||||
);
|
||||
|
||||
static DateTime? _tryParseDateTime(dynamic value) {
|
||||
if (value is! String || value.isEmpty) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return DateTime.tryParse(value);
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/scheduler.dart';
|
||||
|
||||
class TerminalDiagnosticLog extends ChangeNotifier {
|
||||
TerminalDiagnosticLog({this.maxEntries = 40});
|
||||
|
||||
final int maxEntries;
|
||||
final List<String> _entries = <String>[];
|
||||
bool _notificationScheduled = false;
|
||||
|
||||
List<String> get entries => List.unmodifiable(_entries);
|
||||
|
||||
void add(String event, [String? detail]) {
|
||||
final message = detail == null || detail.isEmpty
|
||||
? event
|
||||
: '$event | $detail';
|
||||
_entries.insert(0, message);
|
||||
if (_entries.length > maxEntries) {
|
||||
_entries.removeLast();
|
||||
}
|
||||
_notifySafely();
|
||||
}
|
||||
|
||||
void _notifySafely() {
|
||||
final schedulerPhase = SchedulerBinding.instance.schedulerPhase;
|
||||
if (schedulerPhase == SchedulerPhase.idle ||
|
||||
schedulerPhase == SchedulerPhase.postFrameCallbacks) {
|
||||
notifyListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
if (_notificationScheduled) {
|
||||
return;
|
||||
}
|
||||
|
||||
_notificationScheduled = true;
|
||||
SchedulerBinding.instance.addPostFrameCallback((_) {
|
||||
_notificationScheduled = false;
|
||||
notifyListeners();
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -10,6 +10,8 @@ enum TerminalConnectionState {
|
||||
}
|
||||
|
||||
class TerminalInteractionController extends ChangeNotifier {
|
||||
static const int maxTrackedLiveLines = 200;
|
||||
|
||||
TerminalInteractionController({
|
||||
HistoryWindow historyWindow = const HistoryWindow(
|
||||
lines: <String>[],
|
||||
@ -22,6 +24,7 @@ class TerminalInteractionController extends ChangeNotifier {
|
||||
bool _hasPendingLiveOutput = false;
|
||||
HistoryWindow _historyWindow;
|
||||
final List<String> _liveLines = <String>[];
|
||||
int _liveOutputCount = 0;
|
||||
|
||||
TerminalConnectionState get connectionState => _connectionState;
|
||||
|
||||
@ -29,12 +32,15 @@ class TerminalInteractionController extends ChangeNotifier {
|
||||
|
||||
bool get hasPendingLiveOutput => _hasPendingLiveOutput;
|
||||
|
||||
bool get canSendInput => _connectionState == TerminalConnectionState.connected;
|
||||
bool get canSendInput =>
|
||||
_connectionState == TerminalConnectionState.connected;
|
||||
|
||||
HistoryWindow get historyWindow => _historyWindow;
|
||||
|
||||
List<String> get liveLines => List.unmodifiable(_liveLines);
|
||||
|
||||
int get liveOutputCount => _liveOutputCount;
|
||||
|
||||
void markConnecting() {
|
||||
_connectionState = TerminalConnectionState.connecting;
|
||||
notifyListeners();
|
||||
@ -75,6 +81,10 @@ class TerminalInteractionController extends ChangeNotifier {
|
||||
|
||||
void applyFrame(String chunk) {
|
||||
_liveLines.add(chunk);
|
||||
_liveOutputCount += 1;
|
||||
while (_liveLines.length > maxTrackedLiveLines) {
|
||||
_liveLines.removeAt(0);
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@ -82,7 +92,14 @@ class TerminalInteractionController extends ChangeNotifier {
|
||||
_historyWindow = historyWindow;
|
||||
_liveLines
|
||||
..clear()
|
||||
..addAll(historyWindow.lines);
|
||||
..addAll(
|
||||
historyWindow.lines.length <= maxTrackedLiveLines
|
||||
? historyWindow.lines
|
||||
: historyWindow.lines.sublist(
|
||||
historyWindow.lines.length - maxTrackedLiveLines,
|
||||
),
|
||||
);
|
||||
_liveOutputCount = historyWindow.lines.length;
|
||||
_hasPendingLiveOutput = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
@ -5,7 +5,10 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:xterm/xterm.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_interaction_controller.dart';
|
||||
import 'terminal_session_coordinator.dart';
|
||||
@ -16,21 +19,36 @@ class TerminalPage extends ConsumerStatefulWidget {
|
||||
super.key,
|
||||
required this.session,
|
||||
required this.agentBaseUri,
|
||||
this.project,
|
||||
});
|
||||
|
||||
final Session session;
|
||||
final Uri agentBaseUri;
|
||||
final Project? project;
|
||||
|
||||
@override
|
||||
ConsumerState<TerminalPage> createState() => _TerminalPageState();
|
||||
}
|
||||
|
||||
class _TerminalPageState extends ConsumerState<TerminalPage> {
|
||||
static const List<_QuickTerminalKey> _quickTerminalKeys = [
|
||||
_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'),
|
||||
];
|
||||
|
||||
final Terminal terminal = Terminal(maxLines: 1000);
|
||||
final TerminalInteractionController controller =
|
||||
TerminalInteractionController();
|
||||
final TerminalDiagnosticLog _diagnosticLog = TerminalDiagnosticLog();
|
||||
final FocusNode _terminalFocusNode = FocusNode();
|
||||
final TextEditingController _inputController = TextEditingController();
|
||||
final ScrollController _terminalScrollController = ScrollController();
|
||||
late final TerminalSessionCoordinator _coordinator;
|
||||
late final Listenable _controllerAndCoordinator;
|
||||
|
||||
@ -43,6 +61,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
|
||||
session: widget.session,
|
||||
sessionFactory: ref.read(terminalSocketSessionFactoryProvider).create,
|
||||
baseUri: widget.agentBaseUri,
|
||||
diagnosticLog: _diagnosticLog,
|
||||
onFrame: terminal.write,
|
||||
onHistoryLoaded: (history) {
|
||||
if (history.lines.isNotEmpty) {
|
||||
@ -58,34 +77,261 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
|
||||
terminal.onResize = (width, height, _, _) {
|
||||
_coordinator.handleTerminalResize(width, height);
|
||||
};
|
||||
terminal.onOutput = _coordinator.sendInput;
|
||||
terminal.onOutput = (data) {
|
||||
_diagnosticLog.add('ui.terminal.key', data);
|
||||
_coordinator.sendInput(data);
|
||||
};
|
||||
unawaited(_coordinator.start());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_terminalFocusNode.dispose();
|
||||
_inputController.dispose();
|
||||
_terminalScrollController.dispose();
|
||||
unawaited(_coordinator.close());
|
||||
controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _sendLine() async {
|
||||
final input = _inputController.text;
|
||||
if (!_canSendInput || input.trim().isEmpty) {
|
||||
Future<void> _openSiblingTerminal() async {
|
||||
final project = widget.project;
|
||||
if (project == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
_coordinator.sendInput('$input\r');
|
||||
_inputController.clear();
|
||||
final repository = ref.read(sessionRepositoryProvider);
|
||||
try {
|
||||
final session = await repository.createSession(
|
||||
projectId: project.projectId,
|
||||
);
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Navigator.of(context).push(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => TerminalPage(
|
||||
session: session,
|
||||
agentBaseUri: widget.agentBaseUri,
|
||||
project: project,
|
||||
),
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
formatAgentError(error, fallback: 'Failed to open terminal.'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void _jumpToBottom() {
|
||||
controller.jumpToLive();
|
||||
if (!_terminalScrollController.hasClients) {
|
||||
return;
|
||||
}
|
||||
|
||||
_terminalScrollController.animateTo(
|
||||
_terminalScrollController.position.maxScrollExtent,
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOut,
|
||||
);
|
||||
}
|
||||
|
||||
void _sendQuickKey(_QuickTerminalKey quickKey) {
|
||||
_sendTerminalInput(
|
||||
quickKey.input,
|
||||
diagnosticEvent: 'ui.input.quick',
|
||||
detail: quickKey.label,
|
||||
);
|
||||
}
|
||||
|
||||
void _sendTerminalInput(
|
||||
String input, {
|
||||
required String diagnosticEvent,
|
||||
required String detail,
|
||||
}) {
|
||||
_diagnosticLog.add(diagnosticEvent, detail);
|
||||
_coordinator.sendInput(input);
|
||||
_terminalFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
Future<void> _showDiagnostics() {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: AnimatedBuilder(
|
||||
animation: _diagnosticLog,
|
||||
builder: (context, _) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Diagnostics',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 280),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: _diagnosticLog.entries.isEmpty
|
||||
? Text(
|
||||
'No diagnostics yet.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
)
|
||||
: ListView.separated(
|
||||
key: const Key('terminal_diagnostics_list'),
|
||||
shrinkWrap: true,
|
||||
itemCount: _diagnosticLog.entries.length,
|
||||
itemBuilder: (context, index) {
|
||||
return Text(
|
||||
_diagnosticLog.entries[index],
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall,
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) =>
|
||||
const SizedBox(height: 6),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showToolsSheet() {
|
||||
return showModalBottomSheet<void>(
|
||||
context: context,
|
||||
builder: (context) {
|
||||
return SafeArea(
|
||||
child: AnimatedBuilder(
|
||||
animation: _controllerAndCoordinator,
|
||||
builder: (context, _) {
|
||||
return SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||
child: Column(
|
||||
key: const Key('terminal_tools_sheet'),
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Terminal tools',
|
||||
style: Theme.of(context).textTheme.titleMedium,
|
||||
),
|
||||
const Spacer(),
|
||||
_StatusChip(
|
||||
label: _statusLabel,
|
||||
icon: _statusIcon,
|
||||
color: _statusColor(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
_jumpToBottom();
|
||||
},
|
||||
icon: const Icon(Icons.vertical_align_bottom),
|
||||
label: const Text('Latest'),
|
||||
),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
unawaited(_coordinator.reconnectNow());
|
||||
},
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Reconnect'),
|
||||
),
|
||||
if (widget.project != null)
|
||||
OutlinedButton.icon(
|
||||
onPressed: () {
|
||||
Navigator.of(context).pop();
|
||||
unawaited(_openSiblingTerminal());
|
||||
},
|
||||
icon: const Icon(Icons.add_box_outlined),
|
||||
label: const Text('New terminal'),
|
||||
),
|
||||
TextButton.icon(
|
||||
key: const Key('terminal_diagnostics_button'),
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
await _showDiagnostics();
|
||||
},
|
||||
icon: const Icon(Icons.bug_report_outlined),
|
||||
label: const Text('Diagnostics'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_coordinator.connectionStatus,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(widget.session.name),
|
||||
title: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(widget.session.name),
|
||||
Text(
|
||||
widget.project?.workingDirectory ??
|
||||
widget.session.workingDirectory ??
|
||||
'',
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
AnimatedBuilder(
|
||||
animation: controller,
|
||||
@ -110,7 +356,9 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
|
||||
horizontal: 8,
|
||||
vertical: 6,
|
||||
),
|
||||
child: Text('$mode | ${controller.liveLines.length} lines'),
|
||||
child: Text(
|
||||
'$mode | ${controller.liveLines.length} lines',
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@ -137,166 +385,190 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.fromLTRB(12, 6, 12, 0),
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Recent scrollback',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${controller.historyWindow.lines.length} lines loaded',
|
||||
style: Theme.of(context).textTheme.labelMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 72),
|
||||
child: DecoratedBox(
|
||||
return Flexible(
|
||||
fit: FlexFit.loose,
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.fromLTRB(12, 0, 12, 0),
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: ListView.separated(
|
||||
key: const Key('terminal_scrollback_list'),
|
||||
shrinkWrap: true,
|
||||
itemCount: controller.historyWindow.lines.length,
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 6,
|
||||
),
|
||||
child: Text(
|
||||
controller.historyWindow.lines[index],
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => Divider(
|
||||
height: 1,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.surfaceContainerHigh,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
key: const Key('terminal_scrollback_actions'),
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
controller.historyWindow.hasMoreAbove
|
||||
? 'Recent history is loaded. Older lines are not loaded yet.'
|
||||
: 'All loaded history is visible.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Text(
|
||||
'Recent scrollback',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
'${controller.historyWindow.lines.length} lines loaded',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.labelMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (controller.historyWindow.hasMoreAbove) ...[
|
||||
const SizedBox(width: 8),
|
||||
TextButton.icon(
|
||||
onPressed: _coordinator.isLoadingOlderHistory
|
||||
? null
|
||||
: _coordinator.loadOlderHistory,
|
||||
icon: _coordinator.isLoadingOlderHistory
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
const SizedBox(height: 6),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 72),
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: ListView.separated(
|
||||
key: const Key('terminal_scrollback_list'),
|
||||
shrinkWrap: true,
|
||||
itemCount:
|
||||
controller.historyWindow.lines.length,
|
||||
itemBuilder: (context, index) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 6,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.unfold_less_double),
|
||||
label: Text(
|
||||
_coordinator.isLoadingOlderHistory
|
||||
? 'Loading older lines...'
|
||||
: 'Load older lines',
|
||||
child: Text(
|
||||
controller.historyWindow.lines[index],
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall,
|
||||
),
|
||||
);
|
||||
},
|
||||
separatorBuilder: (context, index) => Divider(
|
||||
height: 1,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
key: const Key('terminal_scrollback_actions'),
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
controller.historyWindow.hasMoreAbove
|
||||
? 'Recent history is loaded. Older lines are not loaded yet.'
|
||||
: 'All loaded history is visible.',
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
if (controller
|
||||
.historyWindow
|
||||
.hasMoreAbove) ...[
|
||||
const SizedBox(width: 8),
|
||||
TextButton.icon(
|
||||
onPressed:
|
||||
_coordinator.isLoadingOlderHistory
|
||||
? null
|
||||
: _coordinator.loadOlderHistory,
|
||||
icon: _coordinator.isLoadingOlderHistory
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: const Icon(
|
||||
Icons.unfold_less_double,
|
||||
),
|
||||
label: Text(
|
||||
_coordinator.isLoadingOlderHistory
|
||||
? 'Loading older lines...'
|
||||
: 'Load older lines',
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: controller,
|
||||
builder: (context, _) {
|
||||
if (controller.isFollowingLiveOutput) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.fromLTRB(12, 4, 12, 0),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.pause_circle_outline,
|
||||
size: 18,
|
||||
color: Theme.of(context).colorScheme.onSecondaryContainer,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Browsing history. Live output is still arriving.',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Theme.of(context)
|
||||
.colorScheme
|
||||
.onSecondaryContainer,
|
||||
Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.fromLTRB(12, 4, 12, 0),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 12,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.secondaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.pause_circle_outline,
|
||||
size: 18,
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
if (controller.hasPendingLiveOutput)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: controller.jumpToLive,
|
||||
child: const Text('New output available'),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Browsing history. Live output is still arriving.',
|
||||
style: Theme.of(context).textTheme.bodySmall
|
||||
?.copyWith(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).colorScheme.onSecondaryContainer,
|
||||
),
|
||||
),
|
||||
if (controller.hasPendingLiveOutput)
|
||||
Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: TextButton(
|
||||
onPressed: controller.jumpToLive,
|
||||
child: const Text(
|
||||
'New output available',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
@ -306,91 +578,76 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
|
||||
terminal,
|
||||
focusNode: _terminalFocusNode,
|
||||
autofocus: true,
|
||||
scrollController: _terminalScrollController,
|
||||
),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: _controllerAndCoordinator,
|
||||
builder: (context, _) {
|
||||
return Material(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Container(
|
||||
key: const Key('terminal_controls_panel'),
|
||||
padding: const EdgeInsets.all(12),
|
||||
Material(
|
||||
color: Theme.of(context).colorScheme.surfaceContainerHighest,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(12, 8, 12, 12),
|
||||
child: AnimatedBuilder(
|
||||
animation: _controllerAndCoordinator,
|
||||
builder: (context, _) {
|
||||
return Container(
|
||||
key: const Key('terminal_action_bar'),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10,
|
||||
vertical: 8,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
border: Border.all(
|
||||
color: Theme.of(context).colorScheme.outlineVariant,
|
||||
),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Terminal controls',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
_StatusChip(
|
||||
label: _statusLabel,
|
||||
icon: _statusIcon,
|
||||
color: _statusColor(context),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_StatusChip(
|
||||
label: controller.historyWindow.lines.isEmpty
|
||||
? 'No history'
|
||||
: 'History ready',
|
||||
icon: controller.historyWindow.hasMoreAbove
|
||||
? Icons.history_toggle_off
|
||||
: Icons.history,
|
||||
color: Theme.of(context).colorScheme.secondary,
|
||||
),
|
||||
const Spacer(),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => unawaited(_coordinator.reconnectNow()),
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Reconnect'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextField(
|
||||
controller: _inputController,
|
||||
enabled: _canSendInput,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Send input',
|
||||
hintText: 'dir',
|
||||
border: OutlineInputBorder(),
|
||||
),
|
||||
onSubmitted: (_) => _sendLine(),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Row(
|
||||
children: [
|
||||
FilledButton(
|
||||
onPressed: _canSendInput ? _sendLine : null,
|
||||
child: const Text('Send'),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
_coordinator.connectionStatus,
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Theme.of(
|
||||
context,
|
||||
).shadowColor.withValues(alpha: 0.06),
|
||||
blurRadius: 12,
|
||||
offset: const Offset(0, 6),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
child: Row(
|
||||
children: _quickTerminalKeys
|
||||
.map((quickKey) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: OutlinedButton(
|
||||
key: Key(
|
||||
'terminal_quick_key_${quickKey.keyId}',
|
||||
),
|
||||
onPressed: _canSendInput
|
||||
? () => _sendQuickKey(quickKey)
|
||||
: null,
|
||||
child: Text(quickKey.label),
|
||||
),
|
||||
);
|
||||
})
|
||||
.toList(growable: false),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
IconButton.filledTonal(
|
||||
key: const Key('terminal_toggle_actions_button'),
|
||||
onPressed: _showToolsSheet,
|
||||
icon: const Icon(Icons.tune),
|
||||
tooltip: 'Show tools',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
@ -398,35 +655,47 @@ class _TerminalPageState extends ConsumerState<TerminalPage> {
|
||||
}
|
||||
|
||||
String get _statusLabel => switch (_connectionState) {
|
||||
TerminalConnectionState.connecting => 'Connecting',
|
||||
TerminalConnectionState.connected => 'Connected',
|
||||
TerminalConnectionState.reconnecting => 'Reconnecting',
|
||||
TerminalConnectionState.disconnected => 'Offline',
|
||||
};
|
||||
TerminalConnectionState.connecting => 'Connecting',
|
||||
TerminalConnectionState.connected => 'Connected',
|
||||
TerminalConnectionState.reconnecting => 'Reconnecting',
|
||||
TerminalConnectionState.disconnected => 'Offline',
|
||||
};
|
||||
|
||||
bool get _canSendInput => controller.canSendInput;
|
||||
|
||||
IconData get _statusIcon => switch (_connectionState) {
|
||||
TerminalConnectionState.connecting => Icons.sync,
|
||||
TerminalConnectionState.connected => Icons.check_circle,
|
||||
TerminalConnectionState.reconnecting => Icons.refresh,
|
||||
TerminalConnectionState.disconnected => Icons.portable_wifi_off,
|
||||
};
|
||||
TerminalConnectionState.connecting => Icons.sync,
|
||||
TerminalConnectionState.connected => Icons.check_circle,
|
||||
TerminalConnectionState.reconnecting => Icons.refresh,
|
||||
TerminalConnectionState.disconnected => Icons.portable_wifi_off,
|
||||
};
|
||||
|
||||
Color _statusColor(BuildContext context) => switch (_connectionState) {
|
||||
TerminalConnectionState.connecting =>
|
||||
Theme.of(context).colorScheme.tertiary,
|
||||
TerminalConnectionState.connected =>
|
||||
Theme.of(context).colorScheme.primary,
|
||||
TerminalConnectionState.reconnecting =>
|
||||
Theme.of(context).colorScheme.secondary,
|
||||
TerminalConnectionState.disconnected =>
|
||||
Theme.of(context).colorScheme.error,
|
||||
};
|
||||
TerminalConnectionState.connecting => Theme.of(
|
||||
context,
|
||||
).colorScheme.tertiary,
|
||||
TerminalConnectionState.connected => Theme.of(context).colorScheme.primary,
|
||||
TerminalConnectionState.reconnecting => Theme.of(
|
||||
context,
|
||||
).colorScheme.secondary,
|
||||
TerminalConnectionState.disconnected => Theme.of(context).colorScheme.error,
|
||||
};
|
||||
|
||||
TerminalConnectionState get _connectionState => controller.connectionState;
|
||||
}
|
||||
|
||||
class _QuickTerminalKey {
|
||||
const _QuickTerminalKey({
|
||||
required this.keyId,
|
||||
required this.label,
|
||||
required this.input,
|
||||
});
|
||||
|
||||
final String keyId;
|
||||
final String label;
|
||||
final String input;
|
||||
}
|
||||
|
||||
class _StatusChip extends StatelessWidget {
|
||||
const _StatusChip({
|
||||
required this.label,
|
||||
|
||||
@ -4,25 +4,22 @@ import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../core/network/agent_api_client.dart';
|
||||
import '../sessions/session.dart';
|
||||
import 'terminal_diagnostic_log.dart';
|
||||
import 'history_window.dart';
|
||||
import 'terminal_interaction_controller.dart';
|
||||
import 'terminal_socket_session.dart';
|
||||
|
||||
typedef CancelReconnect = void Function();
|
||||
typedef ReconnectScheduler = CancelReconnect Function(
|
||||
Duration delay,
|
||||
Future<void> Function() callback,
|
||||
);
|
||||
typedef TerminalSessionFactory = TerminalSocketSession Function({
|
||||
required Uri baseUri,
|
||||
required Session session,
|
||||
});
|
||||
typedef ReconnectScheduler =
|
||||
CancelReconnect Function(Duration delay, Future<void> Function() callback);
|
||||
typedef TerminalSessionFactory =
|
||||
TerminalSocketSession Function({
|
||||
required Uri baseUri,
|
||||
required Session session,
|
||||
});
|
||||
|
||||
class TerminalViewport {
|
||||
const TerminalViewport({
|
||||
required this.columns,
|
||||
required this.rows,
|
||||
});
|
||||
const TerminalViewport({required this.columns, required this.rows});
|
||||
|
||||
final int columns;
|
||||
final int rows;
|
||||
@ -38,9 +35,10 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
required this.viewportProvider,
|
||||
Uri? baseUri,
|
||||
this.onHistoryLoaded,
|
||||
this.diagnosticLog,
|
||||
ReconnectScheduler? reconnectScheduler,
|
||||
}) : baseUri = baseUri ?? _defaultBaseUri,
|
||||
_reconnectScheduler = reconnectScheduler ?? _defaultReconnectScheduler;
|
||||
}) : baseUri = baseUri ?? _defaultBaseUri,
|
||||
_reconnectScheduler = reconnectScheduler ?? _defaultReconnectScheduler;
|
||||
|
||||
static final Uri _defaultBaseUri = Uri(
|
||||
scheme: 'https',
|
||||
@ -48,7 +46,7 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
port: 9443,
|
||||
);
|
||||
static const Duration reconnectDelay = Duration(seconds: 1);
|
||||
static const int initialHistoryLineCount = 1000;
|
||||
static const int initialHistoryLineCount = 200;
|
||||
|
||||
final TerminalInteractionController controller;
|
||||
final AgentApiClient apiClient;
|
||||
@ -58,6 +56,7 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
final TerminalViewport Function() viewportProvider;
|
||||
final Uri baseUri;
|
||||
final void Function(HistoryWindow history)? onHistoryLoaded;
|
||||
final TerminalDiagnosticLog? diagnosticLog;
|
||||
final ReconnectScheduler _reconnectScheduler;
|
||||
|
||||
TerminalSocketSession? _socketSession;
|
||||
@ -81,15 +80,17 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
if (isReconnect) {
|
||||
controller.markReconnecting();
|
||||
_connectionStatus = 'Reconnecting to ${session.name}...';
|
||||
diagnosticLog?.add('socket.reconnect.start', session.sessionId);
|
||||
} else {
|
||||
controller.markConnecting();
|
||||
_connectionStatus = 'Connecting...';
|
||||
diagnosticLog?.add('socket.connect.start', session.sessionId);
|
||||
}
|
||||
notifyListeners();
|
||||
|
||||
_disposeActiveSessionInBackground();
|
||||
|
||||
if (!isReconnect && controller.liveLines.isEmpty) {
|
||||
if (!isReconnect && controller.liveOutputCount == 0) {
|
||||
await _loadHistory();
|
||||
}
|
||||
|
||||
@ -97,10 +98,7 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
return;
|
||||
}
|
||||
|
||||
final socketSession = sessionFactory(
|
||||
baseUri: baseUri,
|
||||
session: session,
|
||||
);
|
||||
final socketSession = sessionFactory(baseUri: baseUri, session: session);
|
||||
_socketSession = socketSession;
|
||||
|
||||
try {
|
||||
@ -121,8 +119,13 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
|
||||
final viewport = viewportProvider();
|
||||
socketSession.sendResize(viewport.columns, viewport.rows);
|
||||
diagnosticLog?.add(
|
||||
'socket.resize.send',
|
||||
'${viewport.columns}x${viewport.rows}',
|
||||
);
|
||||
controller.markConnected();
|
||||
_connectionStatus = 'Attached to ${session.name}';
|
||||
diagnosticLog?.add('socket.attach.ack', session.sessionId);
|
||||
notifyListeners();
|
||||
} catch (error) {
|
||||
if (_isDisposed || !identical(_socketSession, socketSession)) {
|
||||
@ -131,17 +134,42 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
|
||||
controller.markDisconnected();
|
||||
_connectionStatus = 'Live connection unavailable: $error';
|
||||
diagnosticLog?.add('socket.connect.error', '$error');
|
||||
notifyListeners();
|
||||
_scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
void handleTerminalResize(int columns, int rows) {
|
||||
diagnosticLog?.add('ui.terminal.resize', '${columns}x${rows}');
|
||||
_socketSession?.sendResize(columns, rows);
|
||||
}
|
||||
|
||||
void sendInput(String input) {
|
||||
_socketSession?.sendInput(input);
|
||||
final socketSession = _socketSession;
|
||||
if (socketSession == null) {
|
||||
diagnosticLog?.add(
|
||||
'socket.input.skip',
|
||||
'reason=no-session input=${_formatInputForDiagnostics(input)}',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final result = socketSession.sendInput(input);
|
||||
switch (result) {
|
||||
case TerminalSocketDispatchResult.sent:
|
||||
diagnosticLog?.add(
|
||||
'socket.input.tx',
|
||||
_formatInputForDiagnostics(input),
|
||||
);
|
||||
case TerminalSocketDispatchResult.noTransport:
|
||||
diagnosticLog?.add(
|
||||
'socket.input.skip',
|
||||
'reason=no-transport input=${_formatInputForDiagnostics(input)}',
|
||||
);
|
||||
case TerminalSocketDispatchResult.emptyInput:
|
||||
diagnosticLog?.add('socket.input.skip', 'reason=empty-input');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadOlderHistory() async {
|
||||
@ -151,6 +179,7 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
|
||||
_isLoadingOlderHistory = true;
|
||||
_historyLineCount += initialHistoryLineCount;
|
||||
diagnosticLog?.add('history.load.older', 'lineCount=$_historyLineCount');
|
||||
notifyListeners();
|
||||
|
||||
try {
|
||||
@ -173,6 +202,7 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
}
|
||||
|
||||
void _handleFrame(String chunk) {
|
||||
diagnosticLog?.add('socket.frame.rx', chunk);
|
||||
controller.registerIncomingFrame();
|
||||
controller.applyFrame(chunk);
|
||||
onFrame(chunk);
|
||||
@ -192,14 +222,18 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
);
|
||||
controller.loadHistory(history);
|
||||
onHistoryLoaded?.call(history);
|
||||
} catch (_) {
|
||||
}
|
||||
diagnosticLog?.add(
|
||||
'history.loaded',
|
||||
'${history.lines.length} lines, more=${history.hasMoreAbove}',
|
||||
);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
void _scheduleReconnect() {
|
||||
_cancelPendingReconnect();
|
||||
controller.markReconnecting();
|
||||
_connectionStatus = 'Connection lost. Reconnecting...';
|
||||
diagnosticLog?.add('socket.disconnect', session.sessionId);
|
||||
notifyListeners();
|
||||
_cancelReconnect = _reconnectScheduler(reconnectDelay, () async {
|
||||
if (_isDisposed) {
|
||||
@ -240,4 +274,8 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
||||
});
|
||||
return timer.cancel;
|
||||
}
|
||||
|
||||
static String _formatInputForDiagnostics(String input) {
|
||||
return input.replaceAll('\r', r'\r').replaceAll('\n', r'\n');
|
||||
}
|
||||
}
|
||||
|
||||
@ -8,18 +8,21 @@ import '../../core/network/agent_api_client.dart';
|
||||
import '../../core/network/agent_socket_client.dart';
|
||||
import '../sessions/session.dart';
|
||||
|
||||
typedef TerminalSocketTransportFactory = TerminalSocketTransport Function(Uri uri);
|
||||
typedef TerminalSocketTransportFactory =
|
||||
TerminalSocketTransport Function(Uri uri);
|
||||
|
||||
final terminalSocketSessionFactoryProvider =
|
||||
Provider<TerminalSocketSessionFactory>((ref) {
|
||||
return TerminalSocketSessionFactory(
|
||||
transportFactory: WebSocketTerminalSocketTransport.connect,
|
||||
);
|
||||
});
|
||||
return TerminalSocketSessionFactory(
|
||||
transportFactory: WebSocketTerminalSocketTransport.connect,
|
||||
);
|
||||
});
|
||||
|
||||
class TerminalSocketSessionFactory {
|
||||
TerminalSocketSessionFactory({TerminalSocketTransportFactory? transportFactory})
|
||||
: _transportFactory = transportFactory ?? WebSocketTerminalSocketTransport.connect;
|
||||
TerminalSocketSessionFactory({
|
||||
TerminalSocketTransportFactory? transportFactory,
|
||||
}) : _transportFactory =
|
||||
transportFactory ?? WebSocketTerminalSocketTransport.connect;
|
||||
|
||||
final TerminalSocketTransportFactory _transportFactory;
|
||||
|
||||
@ -40,7 +43,8 @@ class TerminalSocketSession {
|
||||
required this.sessionId,
|
||||
required this.socketClient,
|
||||
TerminalSocketTransportFactory? transportFactory,
|
||||
}) : _transportFactory = transportFactory ?? WebSocketTerminalSocketTransport.connect;
|
||||
}) : _transportFactory =
|
||||
transportFactory ?? WebSocketTerminalSocketTransport.connect;
|
||||
|
||||
final String sessionId;
|
||||
final AgentSocketClient socketClient;
|
||||
@ -58,7 +62,9 @@ class TerminalSocketSession {
|
||||
await dispose();
|
||||
}
|
||||
|
||||
final transport = _transportFactory(socketClient.buildTerminalSocketUri(sessionId));
|
||||
final transport = _transportFactory(
|
||||
socketClient.buildTerminalSocketUri(sessionId),
|
||||
);
|
||||
_transport = transport;
|
||||
|
||||
final attachedCompleter = Completer<void>();
|
||||
@ -105,13 +111,18 @@ class TerminalSocketSession {
|
||||
}
|
||||
}
|
||||
|
||||
void sendInput(String input) {
|
||||
TerminalSocketDispatchResult sendInput(String input) {
|
||||
final transport = _transport;
|
||||
if (transport == null || input.isEmpty) {
|
||||
return;
|
||||
if (input.isEmpty) {
|
||||
return TerminalSocketDispatchResult.emptyInput;
|
||||
}
|
||||
|
||||
if (transport == null) {
|
||||
return TerminalSocketDispatchResult.noTransport;
|
||||
}
|
||||
|
||||
transport.send(jsonEncode(socketClient.buildInputMessage(input)));
|
||||
return TerminalSocketDispatchResult.sent;
|
||||
}
|
||||
|
||||
void sendResize(int columns, int rows) {
|
||||
@ -132,8 +143,7 @@ class TerminalSocketSession {
|
||||
await subscription?.cancel();
|
||||
try {
|
||||
await transport?.close();
|
||||
} catch (_) {
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
bool _handleAttachedAck(String frame) {
|
||||
@ -142,13 +152,14 @@ class TerminalSocketSession {
|
||||
if (decoded is Map && decoded['type'] == 'attached') {
|
||||
return true;
|
||||
}
|
||||
} catch (_) {
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
enum TerminalSocketDispatchResult { sent, noTransport, emptyInput }
|
||||
|
||||
abstract class TerminalSocketTransport {
|
||||
Stream<dynamic> get stream;
|
||||
void send(String message);
|
||||
|
||||
@ -89,6 +89,20 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
test('deletes a project by id', () async {
|
||||
final adapter = _FakeHttpClientAdapter();
|
||||
final dio = Dio()..httpClientAdapter = adapter;
|
||||
final client = AgentApiClient(Uri.parse('https://host:9443'), dio: dio);
|
||||
|
||||
await client.deleteProject('abc');
|
||||
|
||||
expect(adapter.lastOptions?.method, 'DELETE');
|
||||
expect(
|
||||
adapter.lastOptions?.uri.toString(),
|
||||
'https://host:9443/api/projects/abc',
|
||||
);
|
||||
});
|
||||
|
||||
test('posts pairing redeem payload to the redeem endpoint', () async {
|
||||
final adapter = _FakeHttpClientAdapter();
|
||||
final dio = Dio()..httpClientAdapter = adapter;
|
||||
|
||||
@ -0,0 +1,83 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:term_remote_ctl/core/network/agent_api_client.dart';
|
||||
import 'package:term_remote_ctl/features/projects/project.dart';
|
||||
import 'package:term_remote_ctl/features/projects/project_repository.dart';
|
||||
|
||||
void main() {
|
||||
test('lists projects from the agent and maps them to models', () async {
|
||||
final client = _FakeAgentApiClient(
|
||||
projects: const [
|
||||
<String, dynamic>{
|
||||
'projectId': 'abc',
|
||||
'name': 'TermRemoteCtl',
|
||||
'workingDirectory': r'C:\repo\termremotectl',
|
||||
'createdAtUtc': '2026-03-30T10:00:00Z',
|
||||
'updatedAtUtc': '2026-03-30T10:00:00Z',
|
||||
},
|
||||
],
|
||||
);
|
||||
final repository = ProjectRepository(client);
|
||||
|
||||
final projects = await repository.listProjects();
|
||||
|
||||
expect(projects, hasLength(1));
|
||||
expect(projects.single, isA<Project>());
|
||||
expect(projects.single.projectId, 'abc');
|
||||
expect(projects.single.workingDirectory, r'C:\repo\termremotectl');
|
||||
});
|
||||
|
||||
test('creates a project through the agent and maps the response', () async {
|
||||
final client = _FakeAgentApiClient(
|
||||
createdProject: const <String, dynamic>{
|
||||
'projectId': 'xyz',
|
||||
'name': 'TRC',
|
||||
'workingDirectory': r'D:\workspace\trc',
|
||||
'createdAtUtc': '2026-03-30T10:00:00Z',
|
||||
'updatedAtUtc': '2026-03-30T10:00:00Z',
|
||||
},
|
||||
);
|
||||
final repository = ProjectRepository(client);
|
||||
|
||||
final project = await repository.createProject(
|
||||
name: 'TRC',
|
||||
workingDirectory: r'D:\workspace\trc',
|
||||
);
|
||||
|
||||
expect(client.lastCreatedProjectName, 'TRC');
|
||||
expect(client.lastCreatedProjectWorkingDirectory, r'D:\workspace\trc');
|
||||
expect(project.projectId, 'xyz');
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeAgentApiClient extends AgentApiClient {
|
||||
_FakeAgentApiClient({this.projects = const [], this.createdProject})
|
||||
: super(Uri.parse('https://host:9443'));
|
||||
|
||||
final List<Map<String, dynamic>> projects;
|
||||
final Map<String, dynamic>? createdProject;
|
||||
|
||||
String? lastCreatedProjectName;
|
||||
String? lastCreatedProjectWorkingDirectory;
|
||||
|
||||
@override
|
||||
Future<List<Map<String, dynamic>>> listProjects() async {
|
||||
return projects;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> createProject({
|
||||
required String name,
|
||||
required String workingDirectory,
|
||||
}) async {
|
||||
lastCreatedProjectName = name;
|
||||
lastCreatedProjectWorkingDirectory = workingDirectory;
|
||||
return createdProject ??
|
||||
<String, dynamic>{
|
||||
'projectId': 'generated',
|
||||
'name': name,
|
||||
'workingDirectory': workingDirectory,
|
||||
'createdAtUtc': '2026-03-30T10:00:00Z',
|
||||
'updatedAtUtc': '2026-03-30T10:00:00Z',
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -39,7 +39,7 @@ void main() {
|
||||
);
|
||||
final repository = SessionRepository(client);
|
||||
|
||||
final session = await repository.createSession('new-session');
|
||||
final session = await repository.createSession(name: 'new-session');
|
||||
|
||||
expect(client.lastCreatedName, 'new-session');
|
||||
expect(session, isA<Session>());
|
||||
@ -49,10 +49,8 @@ void main() {
|
||||
}
|
||||
|
||||
class _FakeAgentApiClient extends AgentApiClient {
|
||||
_FakeAgentApiClient({
|
||||
this.sessions = const [],
|
||||
this.createdSession,
|
||||
}) : super(Uri.parse('https://host:9443'));
|
||||
_FakeAgentApiClient({this.sessions = const [], this.createdSession})
|
||||
: super(Uri.parse('https://host:9443'));
|
||||
|
||||
final List<Map<String, dynamic>> sessions;
|
||||
final Map<String, dynamic>? createdSession;
|
||||
@ -67,12 +65,16 @@ class _FakeAgentApiClient extends AgentApiClient {
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> createSession(String name) async {
|
||||
Future<Map<String, dynamic>> createSession({
|
||||
String? name,
|
||||
String? projectId,
|
||||
String? workingDirectory,
|
||||
}) async {
|
||||
lastCreatedName = name;
|
||||
return createdSession ??
|
||||
<String, dynamic>{
|
||||
'sessionId': 'generated',
|
||||
'name': name,
|
||||
'name': name ?? 'generated',
|
||||
'status': 'idle',
|
||||
};
|
||||
}
|
||||
|
||||
@ -9,138 +9,179 @@ import 'package:term_remote_ctl/features/terminal/terminal_session_coordinator.d
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_socket_session.dart';
|
||||
|
||||
void main() {
|
||||
test('start stays connecting until attach completes and then sends resize', () async {
|
||||
test(
|
||||
'start stays connecting until attach completes and then sends resize',
|
||||
() async {
|
||||
final controller = TerminalInteractionController();
|
||||
final apiClient = _FakeAgentApiClient();
|
||||
final sessionFactory = _FakeTerminalSessionFactory(autoConnect: false);
|
||||
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: 132, rows: 40),
|
||||
);
|
||||
|
||||
final startFuture = coordinator.start();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
|
||||
expect(controller.connectionState, TerminalConnectionState.connecting);
|
||||
expect(sessionFactory.createdSessions.single.resizeCalls, isEmpty);
|
||||
|
||||
sessionFactory.createdSessions.single.completeConnect();
|
||||
await startFuture;
|
||||
|
||||
expect(controller.connectionState, TerminalConnectionState.connected);
|
||||
expect(sessionFactory.createdSessions.single.resizeCalls, const [
|
||||
[132, 40],
|
||||
]);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'disconnected session schedules reconnect and reconnects when triggered',
|
||||
() 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();
|
||||
expect(sessionFactory.createdSessions, hasLength(1));
|
||||
|
||||
sessionFactory.createdSessions.single.disconnect();
|
||||
|
||||
expect(controller.connectionState, TerminalConnectionState.reconnecting);
|
||||
expect(sessionFactory.createdSessions, hasLength(1));
|
||||
expect(reconnectScheduler.pendingCallback, isNotNull);
|
||||
|
||||
await reconnectScheduler.runPending();
|
||||
|
||||
expect(sessionFactory.createdSessions, hasLength(2));
|
||||
expect(controller.connectionState, TerminalConnectionState.connected);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'loadOlderHistory increases the requested history window size',
|
||||
() async {
|
||||
final controller = TerminalInteractionController();
|
||||
final apiClient = _FakeAgentApiClient(
|
||||
responses: [
|
||||
<String, dynamic>{
|
||||
'sessionId': 'abc',
|
||||
'lines': <String>['one', 'two'],
|
||||
'hasMoreAbove': true,
|
||||
},
|
||||
<String, dynamic>{
|
||||
'sessionId': 'abc',
|
||||
'lines': <String>['zero', 'one', 'two'],
|
||||
'hasMoreAbove': false,
|
||||
},
|
||||
],
|
||||
);
|
||||
final sessionFactory = _FakeTerminalSessionFactory();
|
||||
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),
|
||||
);
|
||||
|
||||
await coordinator.start();
|
||||
await coordinator.loadOlderHistory();
|
||||
|
||||
expect(apiClient.requestedLineCounts, [200, 400]);
|
||||
expect(controller.historyWindow.lines, ['zero', 'one', 'two']);
|
||||
expect(controller.historyWindow.hasMoreAbove, isFalse);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'incoming frames while browsing history flag pending live output',
|
||||
() async {
|
||||
final controller = TerminalInteractionController();
|
||||
final apiClient = _FakeAgentApiClient();
|
||||
final sessionFactory = _FakeTerminalSessionFactory();
|
||||
final session = Session(
|
||||
sessionId: 'abc',
|
||||
name: 'codex-main',
|
||||
status: 'idle',
|
||||
);
|
||||
final receivedFrames = <String>[];
|
||||
final coordinator = TerminalSessionCoordinator(
|
||||
controller: controller,
|
||||
apiClient: apiClient,
|
||||
session: session,
|
||||
sessionFactory: sessionFactory.create,
|
||||
onFrame: receivedFrames.add,
|
||||
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
|
||||
);
|
||||
|
||||
await coordinator.start();
|
||||
controller.enterScrollback();
|
||||
|
||||
sessionFactory.createdSessions.single.emitFrame('next-line');
|
||||
|
||||
expect(receivedFrames, ['next-line']);
|
||||
expect(controller.hasPendingLiveOutput, isTrue);
|
||||
expect(controller.liveLines, contains('next-line'));
|
||||
},
|
||||
);
|
||||
|
||||
test('incoming frames keep only a bounded recent live cache', () {
|
||||
final controller = TerminalInteractionController();
|
||||
final apiClient = _FakeAgentApiClient();
|
||||
final sessionFactory = _FakeTerminalSessionFactory(autoConnect: false);
|
||||
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: 132, rows: 40),
|
||||
);
|
||||
|
||||
final startFuture = coordinator.start();
|
||||
await Future<void>.delayed(Duration.zero);
|
||||
for (var index = 0; index < 300; index += 1) {
|
||||
controller.applyFrame('line-$index');
|
||||
}
|
||||
|
||||
expect(controller.connectionState, TerminalConnectionState.connecting);
|
||||
expect(sessionFactory.createdSessions.single.resizeCalls, isEmpty);
|
||||
|
||||
sessionFactory.createdSessions.single.completeConnect();
|
||||
await startFuture;
|
||||
|
||||
expect(controller.connectionState, TerminalConnectionState.connected);
|
||||
expect(sessionFactory.createdSessions.single.resizeCalls, const [
|
||||
[132, 40],
|
||||
]);
|
||||
});
|
||||
|
||||
test('disconnected session schedules reconnect and reconnects when triggered', () 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();
|
||||
expect(sessionFactory.createdSessions, hasLength(1));
|
||||
|
||||
sessionFactory.createdSessions.single.disconnect();
|
||||
|
||||
expect(controller.connectionState, TerminalConnectionState.reconnecting);
|
||||
expect(sessionFactory.createdSessions, hasLength(1));
|
||||
expect(reconnectScheduler.pendingCallback, isNotNull);
|
||||
|
||||
await reconnectScheduler.runPending();
|
||||
|
||||
expect(sessionFactory.createdSessions, hasLength(2));
|
||||
expect(controller.connectionState, TerminalConnectionState.connected);
|
||||
});
|
||||
|
||||
test('loadOlderHistory increases the requested history window size', () async {
|
||||
final controller = TerminalInteractionController();
|
||||
final apiClient = _FakeAgentApiClient(
|
||||
responses: [
|
||||
<String, dynamic>{
|
||||
'sessionId': 'abc',
|
||||
'lines': <String>['one', 'two'],
|
||||
'hasMoreAbove': true,
|
||||
},
|
||||
<String, dynamic>{
|
||||
'sessionId': 'abc',
|
||||
'lines': <String>['zero', 'one', 'two'],
|
||||
'hasMoreAbove': false,
|
||||
},
|
||||
],
|
||||
);
|
||||
final sessionFactory = _FakeTerminalSessionFactory();
|
||||
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),
|
||||
);
|
||||
|
||||
await coordinator.start();
|
||||
await coordinator.loadOlderHistory();
|
||||
|
||||
expect(apiClient.requestedLineCounts, [1000, 2000]);
|
||||
expect(controller.historyWindow.lines, ['zero', 'one', 'two']);
|
||||
expect(controller.historyWindow.hasMoreAbove, isFalse);
|
||||
});
|
||||
|
||||
test('incoming frames while browsing history flag pending live output', () async {
|
||||
final controller = TerminalInteractionController();
|
||||
final apiClient = _FakeAgentApiClient();
|
||||
final sessionFactory = _FakeTerminalSessionFactory();
|
||||
final session = Session(sessionId: 'abc', name: 'codex-main', status: 'idle');
|
||||
final receivedFrames = <String>[];
|
||||
final coordinator = TerminalSessionCoordinator(
|
||||
controller: controller,
|
||||
apiClient: apiClient,
|
||||
session: session,
|
||||
sessionFactory: sessionFactory.create,
|
||||
onFrame: receivedFrames.add,
|
||||
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
|
||||
);
|
||||
|
||||
await coordinator.start();
|
||||
controller.enterScrollback();
|
||||
|
||||
sessionFactory.createdSessions.single.emitFrame('next-line');
|
||||
|
||||
expect(receivedFrames, ['next-line']);
|
||||
expect(controller.hasPendingLiveOutput, isTrue);
|
||||
expect(controller.liveLines, contains('next-line'));
|
||||
expect(controller.liveLines.length, lessThanOrEqualTo(200));
|
||||
expect(controller.liveLines.last, 'line-299');
|
||||
expect(controller.liveLines, isNot(contains('line-0')));
|
||||
});
|
||||
}
|
||||
|
||||
class _FakeAgentApiClient extends AgentApiClient {
|
||||
_FakeAgentApiClient({List<Map<String, dynamic>>? responses})
|
||||
: _responses = responses ??
|
||||
[
|
||||
<String, dynamic>{
|
||||
'sessionId': 'abc',
|
||||
'lines': <String>['one', 'two'],
|
||||
'hasMoreAbove': true,
|
||||
},
|
||||
],
|
||||
super(Uri.parse('https://host:9443'));
|
||||
: _responses =
|
||||
responses ??
|
||||
[
|
||||
<String, dynamic>{
|
||||
'sessionId': 'abc',
|
||||
'lines': <String>['one', 'two'],
|
||||
'hasMoreAbove': true,
|
||||
},
|
||||
],
|
||||
super(Uri.parse('https://host:9443'));
|
||||
|
||||
final List<Map<String, dynamic>> _responses;
|
||||
final requestedLineCounts = <int>[];
|
||||
@ -179,10 +220,7 @@ class _FakeTerminalSessionFactory {
|
||||
|
||||
class _FakeTerminalSocketSession extends TerminalSocketSession {
|
||||
_FakeTerminalSocketSession({required this.autoConnect})
|
||||
: super(
|
||||
sessionId: 'abc',
|
||||
socketClient: _FakeAgentSocketClient(),
|
||||
);
|
||||
: super(sessionId: 'abc', socketClient: _FakeAgentSocketClient());
|
||||
|
||||
final bool autoConnect;
|
||||
final resizeCalls = <List<int>>[];
|
||||
|
||||
130
apps/mobile_app/test/project_home_test.dart
Normal file
130
apps/mobile_app/test/project_home_test.dart
Normal file
@ -0,0 +1,130 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:term_remote_ctl/app/app.dart';
|
||||
import 'package:term_remote_ctl/core/network/agent_api_client.dart';
|
||||
import 'package:term_remote_ctl/core/network/agent_connection_providers.dart';
|
||||
import 'package:term_remote_ctl/features/projects/project.dart';
|
||||
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_repository.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_socket_session.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'home renders projects and opens a new terminal from a project card',
|
||||
(tester) async {
|
||||
final projectRepository = _FakeProjectRepository();
|
||||
final sessionRepository = _FakeSessionRepository();
|
||||
final socketFactory = TerminalSocketSessionFactory(
|
||||
transportFactory: (_) => _FakeTerminalSocketTransport(autoAttach: true),
|
||||
);
|
||||
|
||||
await tester.pumpWidget(
|
||||
ProviderScope(
|
||||
overrides: [
|
||||
agentApiClientProvider.overrideWithValue(_FakeAgentApiClient()),
|
||||
projectRepositoryProvider.overrideWithValue(projectRepository),
|
||||
sessionRepositoryProvider.overrideWithValue(sessionRepository),
|
||||
terminalSocketSessionFactoryProvider.overrideWithValue(
|
||||
socketFactory,
|
||||
),
|
||||
],
|
||||
child: const TermRemoteCtlApp(),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.text('Projects'), findsOneWidget);
|
||||
expect(find.text('TermRemoteCtl'), findsOneWidget);
|
||||
expect(find.text(r'C:\repo\termremotectl'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Open terminal'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(sessionRepository.lastCreatedProjectId, 'project-1');
|
||||
expect(find.text('TermRemoteCtl'), findsOneWidget);
|
||||
expect(find.text(r'C:\repo\termremotectl'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class _FakeProjectRepository extends ProjectRepository {
|
||||
_FakeProjectRepository()
|
||||
: _projects = [
|
||||
Project(
|
||||
projectId: 'project-1',
|
||||
name: 'TermRemoteCtl',
|
||||
workingDirectory: r'C:\repo\termremotectl',
|
||||
createdAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
|
||||
updatedAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
|
||||
),
|
||||
],
|
||||
super(_FakeAgentApiClient());
|
||||
|
||||
final List<Project> _projects;
|
||||
|
||||
@override
|
||||
Future<List<Project>> listProjects() async => List<Project>.of(_projects);
|
||||
}
|
||||
|
||||
class _FakeSessionRepository extends SessionRepository {
|
||||
_FakeSessionRepository() : super(_FakeAgentApiClient());
|
||||
|
||||
String? lastCreatedProjectId;
|
||||
|
||||
@override
|
||||
Future<Session> createSession({
|
||||
String? name,
|
||||
String? projectId,
|
||||
String? workingDirectory,
|
||||
}) async {
|
||||
lastCreatedProjectId = projectId;
|
||||
return Session(
|
||||
sessionId: 'session-1',
|
||||
name: 'TermRemoteCtl',
|
||||
status: 'created',
|
||||
projectId: projectId,
|
||||
workingDirectory: workingDirectory ?? r'C:\repo\termremotectl',
|
||||
createdAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
|
||||
updatedAtUtc: DateTime.parse('2026-03-30T10:00:00Z'),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<List<Session>> listSessions() async => const <Session>[];
|
||||
}
|
||||
|
||||
class _FakeAgentApiClient extends AgentApiClient {
|
||||
_FakeAgentApiClient() : super(Uri.parse('http://10.0.2.2:5067'));
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
@override
|
||||
Stream<dynamic> get stream => _incoming.stream;
|
||||
|
||||
@override
|
||||
void send(String message) {}
|
||||
|
||||
@override
|
||||
Future<void> close() async {
|
||||
await _incoming.close();
|
||||
}
|
||||
|
||||
void emit(String message) {
|
||||
_incoming.add(message);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,126 @@
|
||||
import 'dart:async';
|
||||
|
||||
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_socket_client.dart';
|
||||
import 'package:term_remote_ctl/features/sessions/session.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/history_window.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_diagnostic_log.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_interaction_controller.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_session_coordinator.dart';
|
||||
import 'package:term_remote_ctl/features/terminal/terminal_socket_session.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
test(
|
||||
'sendInput records socket.input.tx when the socket session forwards input',
|
||||
() async {
|
||||
final diagnosticLog = TerminalDiagnosticLog();
|
||||
final socketSession = _RecordingTerminalSocketSession();
|
||||
final coordinator = TerminalSessionCoordinator(
|
||||
controller: TerminalInteractionController(),
|
||||
apiClient: _FakeAgentApiClient(),
|
||||
session: _session,
|
||||
sessionFactory: ({required baseUri, required session}) => socketSession,
|
||||
baseUri: Uri.parse('https://host.example:9443'),
|
||||
diagnosticLog: diagnosticLog,
|
||||
onFrame: (_) {},
|
||||
viewportProvider: () => const TerminalViewport(columns: 120, rows: 30),
|
||||
);
|
||||
|
||||
addTearDown(coordinator.close);
|
||||
|
||||
await coordinator.start();
|
||||
coordinator.sendInput('dir\r');
|
||||
|
||||
expect(socketSession.sentInputs, ['dir\r']);
|
||||
expect(
|
||||
diagnosticLog.entries,
|
||||
contains(
|
||||
predicate<String>(
|
||||
(entry) => entry.contains('socket.input.tx | dir\\r'),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'sendInput records socket.input.skip when no socket session is active',
|
||||
() {
|
||||
final diagnosticLog = TerminalDiagnosticLog();
|
||||
final coordinator = TerminalSessionCoordinator(
|
||||
controller: TerminalInteractionController(),
|
||||
apiClient: _FakeAgentApiClient(),
|
||||
session: _session,
|
||||
sessionFactory: ({required baseUri, required session}) {
|
||||
throw UnimplementedError('sessionFactory should not be called');
|
||||
},
|
||||
baseUri: Uri.parse('https://host.example:9443'),
|
||||
diagnosticLog: diagnosticLog,
|
||||
onFrame: (_) {},
|
||||
viewportProvider: () => const TerminalViewport(columns: 120, rows: 30),
|
||||
);
|
||||
|
||||
coordinator.sendInput('dir\r');
|
||||
|
||||
expect(
|
||||
diagnosticLog.entries,
|
||||
contains(
|
||||
predicate<String>((entry) => entry.contains('socket.input.skip')),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
final Session _session = Session(
|
||||
sessionId: 'abc',
|
||||
name: 'codex-main',
|
||||
status: 'idle',
|
||||
);
|
||||
|
||||
class _FakeAgentApiClient extends AgentApiClient {
|
||||
_FakeAgentApiClient() : super(Uri.parse('https://host.example:9443'));
|
||||
|
||||
@override
|
||||
Future<Map<String, dynamic>> getSessionHistory(
|
||||
String sessionId, {
|
||||
int lineCount = 200,
|
||||
}) async {
|
||||
return <String, dynamic>{
|
||||
'sessionId': sessionId,
|
||||
'lines': const <String>[],
|
||||
'hasMoreAbove': false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
class _RecordingTerminalSocketSession extends TerminalSocketSession {
|
||||
_RecordingTerminalSocketSession()
|
||||
: super(
|
||||
sessionId: 'abc',
|
||||
socketClient: AgentSocketClient(Uri.parse('https://host.example:9443')),
|
||||
);
|
||||
|
||||
final List<String> sentInputs = <String>[];
|
||||
|
||||
@override
|
||||
Future<void> connect({
|
||||
required void Function(String frame) onFrame,
|
||||
void Function()? onDisconnected,
|
||||
}) async {}
|
||||
|
||||
@override
|
||||
TerminalSocketDispatchResult sendInput(String input) {
|
||||
sentInputs.add(input);
|
||||
return TerminalSocketDispatchResult.sent;
|
||||
}
|
||||
|
||||
@override
|
||||
void sendResize(int columns, int rows) {}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {}
|
||||
}
|
||||
@ -62,6 +62,36 @@ void main() {
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets('project list deletes a project after confirmation', (
|
||||
tester,
|
||||
) async {
|
||||
final projectRepository = _FakeProjectRepository.withProjects([
|
||||
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 _pumpApp(
|
||||
tester,
|
||||
projectRepository: projectRepository,
|
||||
sessionRepository: _FakeSessionRepository(),
|
||||
);
|
||||
|
||||
expect(find.text('codex-main'), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const Key('project_delete_button_project-1')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Delete'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(projectRepository.deletedProjectIds, ['project-1']);
|
||||
expect(find.text('codex-main'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'terminal page keeps tools hidden until the user opens the tools sheet',
|
||||
(tester) async {
|
||||
@ -78,8 +108,9 @@ void main() {
|
||||
find.byKey(const Key('terminal_toggle_actions_button')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byKey(const Key('terminal_input_bar')), findsOneWidget);
|
||||
expect(find.byKey(const Key('terminal_send_button')), findsOneWidget);
|
||||
expect(find.byKey(const Key('terminal_action_bar')), findsOneWidget);
|
||||
expect(find.byKey(const Key('terminal_send_button')), findsNothing);
|
||||
expect(find.byKey(const Key('terminal_input_bar')), findsNothing);
|
||||
|
||||
await tester.tap(find.byKey(const Key('terminal_toggle_actions_button')));
|
||||
await tester.pumpAndSettle();
|
||||
@ -90,6 +121,39 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('terminal tools expose quick terminal keys', (tester) async {
|
||||
final transportFactory = _QueuedTerminalSocketTransportFactory();
|
||||
|
||||
await _pumpApp(
|
||||
tester,
|
||||
projectRepository: _FakeProjectRepository(),
|
||||
sessionRepository: _FakeSessionRepository(),
|
||||
socketFactory: TerminalSocketSessionFactory(
|
||||
transportFactory: transportFactory.create,
|
||||
),
|
||||
);
|
||||
|
||||
await _openProjectTerminal(tester);
|
||||
|
||||
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);
|
||||
expect(find.byKey(const Key('terminal_quick_key_ctrl_d')), findsOneWidget);
|
||||
expect(find.byKey(const Key('terminal_quick_key_ctrl_l')), findsOneWidget);
|
||||
expect(find.byKey(const Key('terminal_quick_key_up')), findsOneWidget);
|
||||
expect(find.byKey(const Key('terminal_quick_key_down')), findsOneWidget);
|
||||
expect(find.byKey(const Key('terminal_quick_key_left')), findsOneWidget);
|
||||
expect(find.byKey(const Key('terminal_quick_key_right')), findsOneWidget);
|
||||
|
||||
await tester.tap(find.byKey(const Key('terminal_quick_key_esc')));
|
||||
await tester.pump();
|
||||
|
||||
expect(
|
||||
transportFactory.createdTransports.single.sentMessages.last,
|
||||
contains('"input":"\\u001b"'),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'project launch surfaces a friendly message when the working directory is invalid',
|
||||
(tester) async {
|
||||
@ -126,8 +190,7 @@ void main() {
|
||||
);
|
||||
|
||||
await _openProjectTerminal(tester);
|
||||
await tester.enterText(find.byType(TextField).last, 'dir');
|
||||
await tester.tap(find.widgetWithText(FilledButton, 'Send'));
|
||||
await tester.tap(find.byKey(const Key('terminal_quick_key_ctrl_l')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
transportFactory.createdTransports.single.emit('command-output');
|
||||
@ -137,8 +200,8 @@ void main() {
|
||||
await tester.tap(find.byKey(const Key('terminal_diagnostics_button')));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(find.textContaining('ui.input.send | dir'), findsOneWidget);
|
||||
expect(find.textContaining(r'socket.input.tx | dir\r'), findsOneWidget);
|
||||
expect(find.textContaining('ui.input.quick | Ctrl+L'), findsOneWidget);
|
||||
expect(find.textContaining('socket.input.tx | '), findsOneWidget);
|
||||
expect(
|
||||
find.textContaining('socket.frame.rx | command-output'),
|
||||
findsOneWidget,
|
||||
@ -330,7 +393,12 @@ class _FakeProjectRepository extends ProjectRepository {
|
||||
],
|
||||
super(_FakeAgentApiClient());
|
||||
|
||||
_FakeProjectRepository.withProjects(List<Project> projects)
|
||||
: _projects = List<Project>.of(projects),
|
||||
super(_FakeAgentApiClient());
|
||||
|
||||
final List<Project> _projects;
|
||||
final List<String> deletedProjectIds = <String>[];
|
||||
|
||||
@override
|
||||
Future<List<Project>> listProjects() async => List<Project>.of(_projects);
|
||||
@ -342,6 +410,12 @@ class _FakeProjectRepository extends ProjectRepository {
|
||||
recentSessions: const <Session>[],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> deleteProject(String projectId) async {
|
||||
deletedProjectIds.add(projectId);
|
||||
_projects.removeWhere((project) => project.projectId == projectId);
|
||||
}
|
||||
}
|
||||
|
||||
class _FakeSessionRepository extends SessionRepository {
|
||||
|
||||
@ -0,0 +1,154 @@
|
||||
using System.Text.Json;
|
||||
using TermRemoteCtl.Agent.Projects;
|
||||
using TermRemoteCtl.Agent.Security;
|
||||
using TermRemoteCtl.Agent.Sessions;
|
||||
using TermRemoteCtl.Agent.Terminal;
|
||||
|
||||
namespace TermRemoteCtl.Agent.Api;
|
||||
|
||||
public static class ProjectEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapProjectEndpoints(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
var group = endpoints.MapGroup("/api/projects");
|
||||
|
||||
group.MapGet(string.Empty, (ProjectRegistry registry) => Results.Ok(registry.List()));
|
||||
group.MapGet("/{projectId}", (
|
||||
string projectId,
|
||||
ProjectRegistry projects,
|
||||
SessionRegistry sessions) =>
|
||||
{
|
||||
if (!projects.TryGet(projectId, out var project) || project is null)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
return Results.Ok(new ProjectDetailResponse(
|
||||
project.ProjectId,
|
||||
project.Name,
|
||||
project.WorkingDirectory,
|
||||
project.CreatedAtUtc,
|
||||
project.UpdatedAtUtc,
|
||||
sessions.ListRecentForProject(projectId, 10)
|
||||
.Select(session => new ProjectSessionSummary(
|
||||
session.SessionId,
|
||||
session.Name,
|
||||
session.Status,
|
||||
session.CreatedAtUtc,
|
||||
session.UpdatedAtUtc))
|
||||
.ToArray()));
|
||||
});
|
||||
group.MapDelete("/{projectId}", async (
|
||||
string projectId,
|
||||
ProjectRegistry projects,
|
||||
SessionRegistry sessions,
|
||||
ISessionHost host,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
if (!projects.TryGet(projectId, out _) )
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
|
||||
foreach (var session in sessions.ListForProject(projectId))
|
||||
{
|
||||
await host.StopAsync(session.SessionId, cancellationToken).ConfigureAwait(false);
|
||||
await sessions.DeleteAsync(session.SessionId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
projects.Delete(projectId);
|
||||
return Results.NoContent();
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
});
|
||||
group.MapPost(string.Empty, async (
|
||||
HttpRequest httpRequest,
|
||||
ProjectRegistry registry,
|
||||
IClock clock,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var request = await ReadProjectRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (request is null || string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.WorkingDirectory))
|
||||
{
|
||||
return Results.BadRequest(new { error = "invalid_request" });
|
||||
}
|
||||
|
||||
if (!Directory.Exists(request.WorkingDirectory))
|
||||
{
|
||||
return Results.BadRequest(new { error = "invalid_working_directory" });
|
||||
}
|
||||
|
||||
var record = registry.Create(request.Name, request.WorkingDirectory, clock.UtcNow);
|
||||
return Results.Ok(record);
|
||||
});
|
||||
group.MapPut("/{projectId}", async (
|
||||
string projectId,
|
||||
HttpRequest httpRequest,
|
||||
ProjectRegistry registry,
|
||||
IClock clock,
|
||||
CancellationToken cancellationToken) =>
|
||||
{
|
||||
var request = await ReadProjectRequestAsync(httpRequest, cancellationToken).ConfigureAwait(false);
|
||||
if (request is null || string.IsNullOrWhiteSpace(request.Name) || string.IsNullOrWhiteSpace(request.WorkingDirectory))
|
||||
{
|
||||
return Results.BadRequest(new { error = "invalid_request" });
|
||||
}
|
||||
|
||||
if (!Directory.Exists(request.WorkingDirectory))
|
||||
{
|
||||
return Results.BadRequest(new { error = "invalid_working_directory" });
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return Results.Ok(registry.Update(projectId, request.Name, request.WorkingDirectory, clock.UtcNow));
|
||||
}
|
||||
catch (KeyNotFoundException)
|
||||
{
|
||||
return Results.NotFound();
|
||||
}
|
||||
});
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static async Task<ProjectRequest?> ReadProjectRequestAsync(
|
||||
HttpRequest httpRequest,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await httpRequest.ReadFromJsonAsync<ProjectRequest>(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (BadHttpRequestException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record ProjectRequest(string Name, string WorkingDirectory);
|
||||
|
||||
private sealed record ProjectDetailResponse(
|
||||
string ProjectId,
|
||||
string Name,
|
||||
string WorkingDirectory,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
DateTimeOffset UpdatedAtUtc,
|
||||
IReadOnlyList<ProjectSessionSummary> RecentSessions);
|
||||
|
||||
private sealed record ProjectSessionSummary(
|
||||
string SessionId,
|
||||
string Name,
|
||||
string Status,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
}
|
||||
@ -2,6 +2,7 @@ using Microsoft.Extensions.Options;
|
||||
using TermRemoteCtl.Agent.Api;
|
||||
using TermRemoteCtl.Agent.Configuration;
|
||||
using TermRemoteCtl.Agent.History;
|
||||
using TermRemoteCtl.Agent.Projects;
|
||||
using TermRemoteCtl.Agent.Realtime;
|
||||
using TermRemoteCtl.Agent.Security;
|
||||
using TermRemoteCtl.Agent.Sessions;
|
||||
@ -14,9 +15,16 @@ builder.Services.AddSingleton<IClock, SystemClock>();
|
||||
builder.Services.AddSingleton<PairingService>();
|
||||
builder.Services.AddSingleton<TrustedDeviceStore>();
|
||||
builder.Services.AddSingleton<AuditLog>();
|
||||
builder.Services.AddSingleton(serviceProvider =>
|
||||
{
|
||||
var options = serviceProvider.GetRequiredService<IOptions<AgentOptions>>().Value;
|
||||
return new ProjectStore(options.DataRoot);
|
||||
});
|
||||
builder.Services.AddSingleton<ProjectRegistry>();
|
||||
builder.Services.AddSingleton<SessionRegistry>();
|
||||
builder.Services.AddSingleton<IConPtySessionFactory, ConPtySessionFactory>();
|
||||
builder.Services.AddSingleton<ISessionHost, PowerShellSessionHost>();
|
||||
builder.Services.AddSingleton<ITerminalDiagnosticsSink, LoggingTerminalDiagnosticsSink>();
|
||||
builder.Services.AddSingleton(serviceProvider =>
|
||||
{
|
||||
var options = serviceProvider.GetRequiredService<IOptions<AgentOptions>>().Value;
|
||||
@ -35,6 +43,7 @@ Directory.CreateDirectory(agentOptions.DataRoot);
|
||||
|
||||
app.MapGet("/health", () => Results.Json(new { status = "ok" }));
|
||||
app.MapPairingEndpoints();
|
||||
app.MapProjectEndpoints();
|
||||
app.MapSessionEndpoints();
|
||||
app.MapTerminalSocket();
|
||||
|
||||
|
||||
@ -0,0 +1,8 @@
|
||||
namespace TermRemoteCtl.Agent.Projects;
|
||||
|
||||
public sealed record ProjectRecord(
|
||||
string ProjectId,
|
||||
string Name,
|
||||
string WorkingDirectory,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
@ -0,0 +1,89 @@
|
||||
using System.Collections.Concurrent;
|
||||
|
||||
namespace TermRemoteCtl.Agent.Projects;
|
||||
|
||||
public sealed class ProjectRegistry
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ProjectRecord> _records = new(StringComparer.Ordinal);
|
||||
private readonly ProjectStore _store;
|
||||
|
||||
public ProjectRegistry(ProjectStore store)
|
||||
{
|
||||
_store = store;
|
||||
foreach (var project in store.Load())
|
||||
{
|
||||
_records[project.ProjectId] = project;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<ProjectRecord> List()
|
||||
{
|
||||
return _records.Values
|
||||
.OrderBy(record => record.Name, StringComparer.OrdinalIgnoreCase)
|
||||
.ThenBy(record => record.WorkingDirectory, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public bool TryGet(string projectId, out ProjectRecord? record)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(projectId);
|
||||
return _records.TryGetValue(projectId, out record);
|
||||
}
|
||||
|
||||
public ProjectRecord Create(string name, string workingDirectory, DateTimeOffset now)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory);
|
||||
|
||||
var record = new ProjectRecord(
|
||||
Guid.NewGuid().ToString("N"),
|
||||
name.Trim(),
|
||||
workingDirectory.Trim(),
|
||||
now,
|
||||
now);
|
||||
|
||||
_records[record.ProjectId] = record;
|
||||
Persist();
|
||||
return record;
|
||||
}
|
||||
|
||||
public ProjectRecord Update(string projectId, string name, string workingDirectory, DateTimeOffset now)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(projectId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(name);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(workingDirectory);
|
||||
|
||||
if (!_records.TryGetValue(projectId, out var existing))
|
||||
{
|
||||
throw new KeyNotFoundException($"Project '{projectId}' was not found.");
|
||||
}
|
||||
|
||||
var updated = existing with
|
||||
{
|
||||
Name = name.Trim(),
|
||||
WorkingDirectory = workingDirectory.Trim(),
|
||||
UpdatedAtUtc = now,
|
||||
};
|
||||
|
||||
_records[projectId] = updated;
|
||||
Persist();
|
||||
return updated;
|
||||
}
|
||||
|
||||
public void Delete(string projectId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(projectId);
|
||||
|
||||
if (!_records.TryRemove(projectId, out _))
|
||||
{
|
||||
throw new KeyNotFoundException($"Project '{projectId}' was not found.");
|
||||
}
|
||||
|
||||
Persist();
|
||||
}
|
||||
|
||||
private void Persist()
|
||||
{
|
||||
_store.Save(_records.Values.OrderBy(record => record.Name, StringComparer.OrdinalIgnoreCase).ToArray());
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
using System.Text.Json;
|
||||
|
||||
namespace TermRemoteCtl.Agent.Projects;
|
||||
|
||||
public sealed class ProjectStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
WriteIndented = true,
|
||||
};
|
||||
|
||||
private readonly string _filePath;
|
||||
private readonly object _gate = new();
|
||||
|
||||
public ProjectStore(string rootPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(rootPath);
|
||||
Directory.CreateDirectory(rootPath);
|
||||
_filePath = Path.Combine(rootPath, "projects.json");
|
||||
}
|
||||
|
||||
public IReadOnlyList<ProjectRecord> Load()
|
||||
{
|
||||
lock (_gate)
|
||||
{
|
||||
if (!File.Exists(_filePath))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
using var stream = new FileStream(_filePath, FileMode.Open, FileAccess.Read, FileShare.Read);
|
||||
return JsonSerializer.Deserialize<List<ProjectRecord>>(stream, JsonOptions) ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
public void Save(IReadOnlyCollection<ProjectRecord> projects)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(projects);
|
||||
|
||||
lock (_gate)
|
||||
{
|
||||
using var stream = new FileStream(_filePath, FileMode.Create, FileAccess.Write, FileShare.None);
|
||||
JsonSerializer.Serialize(stream, projects, JsonOptions);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -43,6 +43,7 @@ public static class TerminalWebSocketHandler
|
||||
}
|
||||
|
||||
var host = context.RequestServices.GetRequiredService<ISessionHost>();
|
||||
var diagnostics = context.RequestServices.GetRequiredService<ITerminalDiagnosticsSink>();
|
||||
var options = context.RequestServices.GetRequiredService<IOptions<AgentOptions>>().Value;
|
||||
using var socket = await context.WebSockets.AcceptWebSocketAsync().ConfigureAwait(false);
|
||||
try
|
||||
@ -77,7 +78,7 @@ public static class TerminalWebSocketHandler
|
||||
try
|
||||
{
|
||||
await SendJsonAsync(socket, new TerminalAttachResponse(sessionId), sendGate, context.RequestAborted).ConfigureAwait(false);
|
||||
await ReceiveLoopAsync(context, socket, host, sessionId).ConfigureAwait(false);
|
||||
await ReceiveLoopAsync(context, socket, host, diagnostics, sessionId).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
@ -89,6 +90,7 @@ public static class TerminalWebSocketHandler
|
||||
HttpContext context,
|
||||
WebSocket socket,
|
||||
ISessionHost host,
|
||||
ITerminalDiagnosticsSink diagnostics,
|
||||
string sessionId)
|
||||
{
|
||||
var buffer = new byte[4096];
|
||||
@ -118,6 +120,7 @@ public static class TerminalWebSocketHandler
|
||||
await HandleClientMessageAsync(
|
||||
Encoding.UTF8.GetString(message.ToArray()),
|
||||
host,
|
||||
diagnostics,
|
||||
sessionId,
|
||||
context.RequestAborted).ConfigureAwait(false);
|
||||
}
|
||||
@ -126,6 +129,7 @@ public static class TerminalWebSocketHandler
|
||||
private static async Task HandleClientMessageAsync(
|
||||
string payload,
|
||||
ISessionHost host,
|
||||
ITerminalDiagnosticsSink diagnostics,
|
||||
string sessionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
@ -154,6 +158,7 @@ public static class TerminalWebSocketHandler
|
||||
{
|
||||
if (!string.IsNullOrEmpty(message.Input))
|
||||
{
|
||||
diagnostics.Record("backend.input.received", sessionId, SanitizeDiagnosticText(message.Input));
|
||||
await host.WriteInputAsync(sessionId, message.Input, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
@ -166,6 +171,11 @@ public static class TerminalWebSocketHandler
|
||||
}
|
||||
}
|
||||
|
||||
private static string SanitizeDiagnosticText(string input)
|
||||
{
|
||||
return input.Replace("\r", "\\r", StringComparison.Ordinal).Replace("\n", "\\n", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static async Task SendJsonAsync(
|
||||
WebSocket socket,
|
||||
TerminalAttachResponse response,
|
||||
|
||||
@ -4,6 +4,8 @@ public sealed record SessionRecord(
|
||||
string SessionId,
|
||||
string Name,
|
||||
string Status,
|
||||
string? ProjectId,
|
||||
string? WorkingDirectory,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
|
||||
@ -59,6 +59,16 @@ public sealed class SessionRegistry
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public IReadOnlyList<SessionRecord> ListForProject(string projectId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(projectId);
|
||||
|
||||
return _records.Values
|
||||
.Where(record => string.Equals(record.ProjectId, projectId, StringComparison.Ordinal))
|
||||
.OrderByDescending(record => record.UpdatedAtUtc)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
public bool TryGet(string sessionId, out SessionRecord? record)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(sessionId);
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project ToolsVersion="Current" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
<PropertyGroup>
|
||||
<ActiveDebugProfile>https</ActiveDebugProfile>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@ -5,13 +5,14 @@ namespace TermRemoteCtl.Agent.Terminal;
|
||||
[SupportedOSPlatform("windows")]
|
||||
internal sealed class ConPtySessionFactory : IConPtySessionFactory
|
||||
{
|
||||
public IConPtySession Create(string sessionId)
|
||||
public IConPtySession Create(string sessionId, string? workingDirectory = null)
|
||||
{
|
||||
ConPtyInterop.EnsureSupported();
|
||||
return new HelperBackedConPtySession(
|
||||
sessionId,
|
||||
ConPtyInterop.ResolveShellPath(),
|
||||
ConPtyInterop.ResolveShellArguments(),
|
||||
HelperPathResolver.ResolveHelperExePath());
|
||||
HelperPathResolver.ResolveHelperExePath(),
|
||||
workingDirectory);
|
||||
}
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@ internal sealed class HelperBackedConPtySession : IConPtySession
|
||||
private readonly string _shellPath;
|
||||
private readonly string _shellArguments;
|
||||
private readonly string _helperExePath;
|
||||
private readonly string? _workingDirectory;
|
||||
private readonly string _commandPipeName = $"termremotectl-cmd-{Guid.NewGuid():N}";
|
||||
private readonly string _outputPipeName = $"termremotectl-out-{Guid.NewGuid():N}";
|
||||
private Process? _helperProcess;
|
||||
@ -24,12 +25,13 @@ internal sealed class HelperBackedConPtySession : IConPtySession
|
||||
private bool _started;
|
||||
private bool _disposed;
|
||||
|
||||
public HelperBackedConPtySession(string sessionId, string shellPath, string shellArguments, string helperExePath)
|
||||
public HelperBackedConPtySession(string sessionId, string shellPath, string shellArguments, string helperExePath, string? workingDirectory = null)
|
||||
{
|
||||
_sessionId = sessionId;
|
||||
_shellPath = shellPath;
|
||||
_shellArguments = shellArguments;
|
||||
_helperExePath = helperExePath;
|
||||
_workingDirectory = string.IsNullOrWhiteSpace(workingDirectory) ? null : workingDirectory;
|
||||
}
|
||||
|
||||
public event EventHandler<TerminalOutputEventArgs>? OutputReceived;
|
||||
@ -62,6 +64,11 @@ internal sealed class HelperBackedConPtySession : IConPtySession
|
||||
startInfo.ArgumentList.Add("120");
|
||||
startInfo.ArgumentList.Add("--rows");
|
||||
startInfo.ArgumentList.Add("30");
|
||||
if (_workingDirectory is not null)
|
||||
{
|
||||
startInfo.ArgumentList.Add("--working-directory");
|
||||
startInfo.ArgumentList.Add(_workingDirectory);
|
||||
}
|
||||
|
||||
_helperProcess = Process.Start(startInfo) ?? throw new InvalidOperationException("Failed to start ConPTY helper process.");
|
||||
|
||||
|
||||
@ -13,5 +13,5 @@ internal interface IConPtySession : IAsyncDisposable
|
||||
|
||||
internal interface IConPtySessionFactory
|
||||
{
|
||||
IConPtySession Create(string sessionId);
|
||||
IConPtySession Create(string sessionId, string? workingDirectory = null);
|
||||
}
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
namespace TermRemoteCtl.Agent.Terminal;
|
||||
|
||||
public interface ITerminalDiagnosticsSink
|
||||
{
|
||||
void Record(string eventName, string sessionId, string detail);
|
||||
}
|
||||
@ -0,0 +1,22 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace TermRemoteCtl.Agent.Terminal;
|
||||
|
||||
internal sealed class LoggingTerminalDiagnosticsSink : ITerminalDiagnosticsSink
|
||||
{
|
||||
private readonly ILogger<LoggingTerminalDiagnosticsSink> _logger;
|
||||
|
||||
public LoggingTerminalDiagnosticsSink(ILogger<LoggingTerminalDiagnosticsSink> logger)
|
||||
{
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
public void Record(string eventName, string sessionId, string detail)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Terminal diagnostic {EventName} session={SessionId} detail={Detail}",
|
||||
eventName,
|
||||
sessionId,
|
||||
detail);
|
||||
}
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
{
|
||||
"Agent": {
|
||||
"DataRoot": "C:\\ProgramData\\TermRemoteCtl",
|
||||
"BindAddress": "localhost",
|
||||
"BindAddress": "0.0.0.0",
|
||||
"HttpsPort": 0,
|
||||
"HttpPort": 5067,
|
||||
"WebSocketFrameFlushMilliseconds": 33,
|
||||
|
||||
@ -30,7 +30,12 @@ internal static class Program
|
||||
await using var writer = new StreamWriter(outputPipe, new UTF8Encoding(false), leaveOpen: true) { AutoFlush = true };
|
||||
using var reader = new StreamReader(commandPipe, new UTF8Encoding(false), detectEncodingFromByteOrderMarks: false, leaveOpen: true);
|
||||
|
||||
var session = ConPtyRuntime.Start(options.ShellPath, options.ShellArguments, options.Columns, options.Rows);
|
||||
var session = ConPtyRuntime.Start(
|
||||
options.ShellPath,
|
||||
options.ShellArguments,
|
||||
options.WorkingDirectory,
|
||||
options.Columns,
|
||||
options.Rows);
|
||||
|
||||
try
|
||||
{
|
||||
@ -119,6 +124,7 @@ internal static class Program
|
||||
public required string OutputPipeName { get; init; }
|
||||
public required string ShellPath { get; init; }
|
||||
public required string ShellArguments { get; init; }
|
||||
public string? WorkingDirectory { get; init; }
|
||||
public int Columns { get; init; } = 120;
|
||||
public int Rows { get; init; } = 30;
|
||||
|
||||
@ -136,6 +142,7 @@ internal static class Program
|
||||
OutputPipeName = values["--output-pipe"],
|
||||
ShellPath = values["--shell-path"],
|
||||
ShellArguments = values.TryGetValue("--shell-args", out var shellArgs) ? shellArgs : "",
|
||||
WorkingDirectory = values.TryGetValue("--working-directory", out var workingDirectory) ? workingDirectory : null,
|
||||
Columns = values.TryGetValue("--columns", out var columns) ? int.Parse(columns) : 120,
|
||||
Rows = values.TryGetValue("--rows", out var rows) ? int.Parse(rows) : 30
|
||||
};
|
||||
@ -175,7 +182,7 @@ internal static class Program
|
||||
}
|
||||
}
|
||||
|
||||
public static ConPtyRuntime Start(string shellPath, string shellArguments, int columns, int rows)
|
||||
public static ConPtyRuntime Start(string shellPath, string shellArguments, string? workingDirectory, int columns, int rows)
|
||||
{
|
||||
var inputPipe = CreatePipePair();
|
||||
var outputPipe = CreatePipePair();
|
||||
@ -216,7 +223,7 @@ internal static class Program
|
||||
false,
|
||||
EXTENDED_STARTUPINFO_PRESENT,
|
||||
IntPtr.Zero,
|
||||
null,
|
||||
string.IsNullOrWhiteSpace(workingDirectory) ? null : workingDirectory,
|
||||
ref startupInfo,
|
||||
out var processInfo))
|
||||
{
|
||||
|
||||
@ -0,0 +1,164 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using Microsoft.AspNetCore.Hosting;
|
||||
using Microsoft.AspNetCore.Mvc.Testing;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using TermRemoteCtl.Agent.Sessions;
|
||||
|
||||
namespace TermRemoteCtl.Agent.IntegrationTests;
|
||||
|
||||
public sealed class ProjectApiTests
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
[Fact]
|
||||
public async Task Create_List_Update_And_Detail_Project_Flow_Works()
|
||||
{
|
||||
await using var fixture = new AgentFixture();
|
||||
using var client = fixture.CreateClient();
|
||||
|
||||
var createResponse = await client.PostAsJsonAsync(
|
||||
"/api/projects",
|
||||
new { name = "TermRemoteCtl", workingDirectory = fixture.ValidProjectPath });
|
||||
createResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var created = await createResponse.Content.ReadFromJsonAsync<ProjectResponse>(JsonOptions);
|
||||
Assert.NotNull(created);
|
||||
Assert.Equal("TermRemoteCtl", created!.Name);
|
||||
Assert.Equal(fixture.ValidProjectPath, created.WorkingDirectory);
|
||||
|
||||
var listed = await client.GetFromJsonAsync<List<ProjectResponse>>("/api/projects", JsonOptions);
|
||||
Assert.NotNull(listed);
|
||||
Assert.Contains(listed!, project => project.ProjectId == created.ProjectId);
|
||||
|
||||
var registry = fixture.Services.GetRequiredService<SessionRegistry>();
|
||||
registry.Create(
|
||||
"TermRemoteCtl",
|
||||
DateTimeOffset.UtcNow,
|
||||
projectId: created.ProjectId,
|
||||
workingDirectory: created.WorkingDirectory);
|
||||
|
||||
var detail = await client.GetFromJsonAsync<ProjectDetailResponse>(
|
||||
$"/api/projects/{created.ProjectId}",
|
||||
JsonOptions);
|
||||
Assert.NotNull(detail);
|
||||
Assert.Equal(created.ProjectId, detail!.ProjectId);
|
||||
Assert.Single(detail.RecentSessions);
|
||||
|
||||
var updateResponse = await client.PutAsJsonAsync(
|
||||
$"/api/projects/{created.ProjectId}",
|
||||
new { name = "TRC", workingDirectory = fixture.UpdatedProjectPath });
|
||||
updateResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var updated = await updateResponse.Content.ReadFromJsonAsync<ProjectResponse>(JsonOptions);
|
||||
Assert.NotNull(updated);
|
||||
Assert.Equal("TRC", updated!.Name);
|
||||
Assert.Equal(fixture.UpdatedProjectPath, updated.WorkingDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_Project_Returns_BadRequest_For_Invalid_WorkingDirectory()
|
||||
{
|
||||
await using var fixture = new AgentFixture();
|
||||
using var client = fixture.CreateClient();
|
||||
|
||||
var response = await client.PostAsJsonAsync(
|
||||
"/api/projects",
|
||||
new { name = "Broken", workingDirectory = "Z:\\path\\does-not-exist" });
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Delete_Project_Cascades_Associated_Sessions()
|
||||
{
|
||||
await using var fixture = new AgentFixture();
|
||||
using var client = fixture.CreateClient();
|
||||
|
||||
var createProjectResponse = await client.PostAsJsonAsync(
|
||||
"/api/projects",
|
||||
new { name = "TermRemoteCtl", workingDirectory = fixture.ValidProjectPath });
|
||||
createProjectResponse.EnsureSuccessStatusCode();
|
||||
var project = await createProjectResponse.Content.ReadFromJsonAsync<ProjectResponse>(JsonOptions);
|
||||
Assert.NotNull(project);
|
||||
|
||||
var createSessionResponse = await client.PostAsJsonAsync(
|
||||
"/api/sessions",
|
||||
new { projectId = project!.ProjectId });
|
||||
createSessionResponse.EnsureSuccessStatusCode();
|
||||
var session = await createSessionResponse.Content.ReadFromJsonAsync<SessionResponse>(JsonOptions);
|
||||
Assert.NotNull(session);
|
||||
|
||||
var deleteResponse = await client.DeleteAsync($"/api/projects/{project.ProjectId}");
|
||||
deleteResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var listedProjects = await client.GetFromJsonAsync<List<ProjectResponse>>("/api/projects", JsonOptions);
|
||||
Assert.NotNull(listedProjects);
|
||||
Assert.DoesNotContain(listedProjects!, item => item.ProjectId == project.ProjectId);
|
||||
|
||||
var listedSessions = await client.GetFromJsonAsync<List<SessionResponse>>("/api/sessions", JsonOptions);
|
||||
Assert.NotNull(listedSessions);
|
||||
Assert.DoesNotContain(listedSessions!, item => item.SessionId == session!.SessionId);
|
||||
}
|
||||
|
||||
private sealed class AgentFixture : WebApplicationFactory<Program>
|
||||
{
|
||||
private readonly string _dataRoot = Path.Combine(Path.GetTempPath(), "TermRemoteCtl.Tests", Guid.NewGuid().ToString("N"));
|
||||
|
||||
public string ValidProjectPath => Path.Combine(_dataRoot, "project-a");
|
||||
|
||||
public string UpdatedProjectPath => Path.Combine(_dataRoot, "project-b");
|
||||
|
||||
protected override void ConfigureWebHost(IWebHostBuilder builder)
|
||||
{
|
||||
Directory.CreateDirectory(ValidProjectPath);
|
||||
Directory.CreateDirectory(UpdatedProjectPath);
|
||||
builder.UseEnvironment("Development");
|
||||
builder.ConfigureAppConfiguration((_, configBuilder) =>
|
||||
{
|
||||
configBuilder.AddInMemoryCollection(new Dictionary<string, string?>
|
||||
{
|
||||
["Agent:DataRoot"] = _dataRoot,
|
||||
["Agent:BindAddress"] = "127.0.0.1",
|
||||
["Agent:HttpsPort"] = "9443",
|
||||
["Agent:WebSocketFrameFlushMilliseconds"] = "33",
|
||||
["Agent:RingBufferLineLimit"] = "4000"
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
public new async ValueTask DisposeAsync()
|
||||
{
|
||||
await base.DisposeAsync();
|
||||
if (Directory.Exists(_dataRoot))
|
||||
{
|
||||
Directory.Delete(_dataRoot, true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record ProjectResponse(
|
||||
string ProjectId,
|
||||
string Name,
|
||||
string WorkingDirectory,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
|
||||
private sealed record ProjectDetailResponse(
|
||||
string ProjectId,
|
||||
string Name,
|
||||
string WorkingDirectory,
|
||||
IReadOnlyList<SessionSummaryResponse> RecentSessions);
|
||||
|
||||
private sealed record SessionSummaryResponse(string SessionId, string Name, string Status);
|
||||
|
||||
private sealed record SessionResponse(
|
||||
string SessionId,
|
||||
string Name,
|
||||
string Status,
|
||||
string? ProjectId,
|
||||
DateTimeOffset CreatedAtUtc,
|
||||
DateTimeOffset UpdatedAtUtc);
|
||||
}
|
||||
@ -18,7 +18,8 @@ public class ConPtySessionFactoryTests
|
||||
|
||||
using var harness = HostHarness.Create();
|
||||
await using var host = harness.Host;
|
||||
await host.StartAsync("smoke", CancellationToken.None);
|
||||
var session = harness.Registry.Create("smoke", DateTimeOffset.UtcNow);
|
||||
await host.StartAsync(session.SessionId, CancellationToken.None);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@ -32,6 +33,7 @@ public class ConPtySessionFactoryTests
|
||||
var output = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
using var harness = HostHarness.Create();
|
||||
await using var host = harness.Host;
|
||||
var session = harness.Registry.Create("smoke", DateTimeOffset.UtcNow);
|
||||
host.OutputReceived += (_, args) =>
|
||||
{
|
||||
if (args.Chunk.Contains("smoke", StringComparison.OrdinalIgnoreCase))
|
||||
@ -40,9 +42,9 @@ public class ConPtySessionFactoryTests
|
||||
}
|
||||
};
|
||||
|
||||
await host.StartAsync("smoke", CancellationToken.None);
|
||||
await host.StartAsync(session.SessionId, CancellationToken.None);
|
||||
await Task.Delay(1000);
|
||||
await host.WriteInputAsync("smoke", "Write-Output smoke\r\n", CancellationToken.None);
|
||||
await host.WriteInputAsync(session.SessionId, "Write-Output smoke\r\n", CancellationToken.None);
|
||||
|
||||
var completed = await Task.WhenAny(output.Task, Task.Delay(TimeSpan.FromSeconds(20)));
|
||||
Assert.True(ReferenceEquals(output.Task, completed), "Timed out waiting for shell output.");
|
||||
@ -51,14 +53,17 @@ public class ConPtySessionFactoryTests
|
||||
|
||||
private sealed class HostHarness : IDisposable
|
||||
{
|
||||
private HostHarness(string dataRoot, PowerShellSessionHost host)
|
||||
private HostHarness(string dataRoot, SessionRegistry registry, PowerShellSessionHost host)
|
||||
{
|
||||
DataRoot = dataRoot;
|
||||
Registry = registry;
|
||||
Host = host;
|
||||
}
|
||||
|
||||
public string DataRoot { get; }
|
||||
|
||||
public SessionRegistry Registry { get; }
|
||||
|
||||
public PowerShellSessionHost Host { get; }
|
||||
|
||||
public static HostHarness Create()
|
||||
@ -74,7 +79,7 @@ public class ConPtySessionFactoryTests
|
||||
var registry = new SessionRegistry(new SessionHistoryStore(dataRoot), options);
|
||||
var host = new PowerShellSessionHost(new ConPtySessionFactory(), registry);
|
||||
|
||||
return new HostHarness(dataRoot, host);
|
||||
return new HostHarness(dataRoot, registry, host);
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
|
||||
@ -15,12 +15,30 @@ public class PowerShellSessionHostTests
|
||||
using var harness = HostHarness.Create(factory);
|
||||
await using var host = harness.Host;
|
||||
|
||||
await host.StartAsync("alpha", CancellationToken.None);
|
||||
await host.ResizeAsync("alpha", 120, 40, CancellationToken.None);
|
||||
var session = harness.Registry.Create("alpha", DateTimeOffset.UtcNow);
|
||||
|
||||
await host.StartAsync(session.SessionId, CancellationToken.None);
|
||||
await host.ResizeAsync(session.SessionId, 120, 40, CancellationToken.None);
|
||||
|
||||
Assert.Equal((120, 40), factory.Session.ResizeCalls.Single());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task StartAsync_Forwards_WorkingDirectory_To_SessionFactory()
|
||||
{
|
||||
var factory = new FakeConPtySessionFactory();
|
||||
using var harness = HostHarness.Create(factory);
|
||||
await using var host = harness.Host;
|
||||
var session = harness.Registry.Create(
|
||||
"alpha",
|
||||
DateTimeOffset.UtcNow,
|
||||
workingDirectory: @"C:\repo\termremotectl");
|
||||
|
||||
await host.StartAsync(session.SessionId, CancellationToken.None);
|
||||
|
||||
Assert.Equal(@"C:\repo\termremotectl", factory.LastWorkingDirectory);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Session_Output_Is_Captured_In_Registry_History()
|
||||
{
|
||||
@ -81,10 +99,12 @@ public class PowerShellSessionHostTests
|
||||
private sealed class FakeConPtySessionFactory : IConPtySessionFactory
|
||||
{
|
||||
public FakeConPtySession Session { get; } = new();
|
||||
public string? LastWorkingDirectory { get; private set; }
|
||||
|
||||
public IConPtySession Create(string sessionId)
|
||||
public IConPtySession Create(string sessionId, string? workingDirectory = null)
|
||||
{
|
||||
Session.SessionId = sessionId;
|
||||
LastWorkingDirectory = workingDirectory;
|
||||
return Session;
|
||||
}
|
||||
}
|
||||
|
||||
0
work/windows_agent.stderr.log
Normal file
0
work/windows_agent.stderr.log
Normal file
127
work/windows_agent.stdout.log
Normal file
127
work/windows_agent.stdout.log
Normal file
@ -0,0 +1,127 @@
|
||||
姝e湪鐢熸垚...
|
||||
warn: Microsoft.AspNetCore.Server.Kestrel[0]
|
||||
Overriding address(es) 'http://localhost:5067'. Binding to endpoints defined via IConfiguration and/or UseKestrel() instead.
|
||||
info: Microsoft.Hosting.Lifetime[14]
|
||||
Now listening on: http://0.0.0.0:5067
|
||||
info: Microsoft.Hosting.Lifetime[0]
|
||||
Application started. Press Ctrl+C to shut down.
|
||||
info: Microsoft.Hosting.Lifetime[0]
|
||||
Hosting environment: Development
|
||||
info: Microsoft.Hosting.Lifetime[0]
|
||||
Content root path: D:\App\Flutter\TermRemoteCtl\apps\windows_agent\src\TermRemoteCtl.Agent
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=l
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=s
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=ls
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=\r
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=d
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=i
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=r
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=dir
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=r
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=r
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=\r
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=\r
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=l
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=s
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=ls
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=\r
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=ls\r
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=dir\r
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=pwd\r
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=c
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=o
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=d
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=e
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=x
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=codex
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=\r
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=这个项目是干什么的\r
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=有哪些具体功能?\r
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=6
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=/
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=e
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=x
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=i
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=t
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=exit
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=t
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=t
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=t
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=
|
||||
info: TermRemoteCtl.Agent.Terminal.LoggingTerminalDiagnosticsSink[0]
|
||||
Terminal diagnostic backend.input.received session=023dbc621ca24fe0871f6bd38a701aea detail=\r
|
||||
Loading…
Reference in New Issue
Block a user