From d9cf4e72e2a2cc06db48938f095f96796cb62e1e Mon Sep 17 00:00:00 2001 From: sladro Date: Tue, 31 Mar 2026 18:13:06 +0800 Subject: [PATCH] chore: commit all pending project changes --- .codex/environments/environment.toml | 35 + apps/mobile_app/lib/app/app.dart | 4 +- .../lib/core/network/agent_api_client.dart | 4 + .../network/agent_connection_providers.dart | 10 + .../core/network/agent_error_formatter.dart | 18 + .../lib/features/projects/project.dart | 41 + .../projects/project_detail_page.dart | 315 +++++++ .../features/projects/project_list_page.dart | 412 ++++++++++ .../features/projects/project_repository.dart | 47 ++ .../lib/features/sessions/session.dart | 28 +- .../terminal/terminal_diagnostic_log.dart | 42 + .../terminal_interaction_controller.dart | 21 +- .../lib/features/terminal/terminal_page.dart | 769 ++++++++++++------ .../terminal_session_coordinator.dart | 84 +- .../terminal/terminal_socket_session.dart | 43 +- .../core/network/agent_api_client_test.dart | 14 + .../projects/project_repository_test.dart | 83 ++ .../sessions/session_repository_test.dart | 16 +- .../terminal_session_coordinator_test.dart | 294 ++++--- apps/mobile_app/test/project_home_test.dart | 130 +++ ..._session_coordinator_diagnostics_test.dart | 126 +++ apps/mobile_app/test/widget_test.dart | 86 +- .../Api/ProjectEndpoints.cs | 154 ++++ .../src/TermRemoteCtl.Agent/Program.cs | 9 + .../Projects/ProjectRecord.cs | 8 + .../Projects/ProjectRegistry.cs | 89 ++ .../Projects/ProjectStore.cs | 46 ++ .../Realtime/TerminalWebSocketHandler.cs | 12 +- .../Sessions/SessionRecord.cs | 2 + .../Sessions/SessionRegistry.cs | 10 + .../TermRemoteCtl.Agent.csproj.user | 6 + .../Terminal/ConPtySessionFactory.cs | 5 +- .../Terminal/HelperBackedConPtySession.cs | 9 +- .../Terminal/IConPtySession.cs | 2 +- .../Terminal/ITerminalDiagnosticsSink.cs | 6 + .../LoggingTerminalDiagnosticsSink.cs | 22 + .../src/TermRemoteCtl.Agent/appsettings.json | 2 +- .../src/TermRemoteCtl.ConPtyHelper/Program.cs | 13 +- .../ProjectApiTests.cs | 164 ++++ .../Terminal/ConPtySessionFactoryTests.cs | 15 +- .../Terminal/PowerShellSessionHostTests.cs | 26 +- work/windows_agent.stderr.log | 0 work/windows_agent.stdout.log | 127 +++ 43 files changed, 2894 insertions(+), 455 deletions(-) create mode 100644 .codex/environments/environment.toml create mode 100644 apps/mobile_app/lib/core/network/agent_error_formatter.dart create mode 100644 apps/mobile_app/lib/features/projects/project.dart create mode 100644 apps/mobile_app/lib/features/projects/project_detail_page.dart create mode 100644 apps/mobile_app/lib/features/projects/project_list_page.dart create mode 100644 apps/mobile_app/lib/features/projects/project_repository.dart create mode 100644 apps/mobile_app/lib/features/terminal/terminal_diagnostic_log.dart create mode 100644 apps/mobile_app/test/features/projects/project_repository_test.dart create mode 100644 apps/mobile_app/test/project_home_test.dart create mode 100644 apps/mobile_app/test/terminal_session_coordinator_diagnostics_test.dart create mode 100644 apps/windows_agent/src/TermRemoteCtl.Agent/Api/ProjectEndpoints.cs create mode 100644 apps/windows_agent/src/TermRemoteCtl.Agent/Projects/ProjectRecord.cs create mode 100644 apps/windows_agent/src/TermRemoteCtl.Agent/Projects/ProjectRegistry.cs create mode 100644 apps/windows_agent/src/TermRemoteCtl.Agent/Projects/ProjectStore.cs create mode 100644 apps/windows_agent/src/TermRemoteCtl.Agent/TermRemoteCtl.Agent.csproj.user create mode 100644 apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ITerminalDiagnosticsSink.cs create mode 100644 apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/LoggingTerminalDiagnosticsSink.cs create mode 100644 apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/ProjectApiTests.cs create mode 100644 work/windows_agent.stderr.log create mode 100644 work/windows_agent.stdout.log diff --git a/.codex/environments/environment.toml b/.codex/environments/environment.toml new file mode 100644 index 0000000..d51e3c9 --- /dev/null +++ b/.codex/environments/environment.toml @@ -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' +''' diff --git a/apps/mobile_app/lib/app/app.dart b/apps/mobile_app/lib/app/app.dart index 3c4b53f..1aa3fa5 100644 --- a/apps/mobile_app/lib/app/app.dart +++ b/apps/mobile_app/lib/app/app.dart @@ -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(), ); } } diff --git a/apps/mobile_app/lib/core/network/agent_api_client.dart b/apps/mobile_app/lib/core/network/agent_api_client.dart index b0c3f10..fbd5945 100644 --- a/apps/mobile_app/lib/core/network/agent_api_client.dart +++ b/apps/mobile_app/lib/core/network/agent_api_client.dart @@ -66,6 +66,10 @@ class AgentApiClient { return _readJsonMap(response.data, 'project'); } + Future deleteProject(String projectId) async { + await _dio.deleteUri(projectUri(projectId)); + } + Future> createSession({ String? name, String? projectId, diff --git a/apps/mobile_app/lib/core/network/agent_connection_providers.dart b/apps/mobile_app/lib/core/network/agent_connection_providers.dart index dcb62d0..6355217 100644 --- a/apps/mobile_app/lib/core/network/agent_connection_providers.dart +++ b/apps/mobile_app/lib/core/network/agent_connection_providers.dart @@ -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((ref) { return SessionRepository(ref.watch(agentApiClientProvider)); }); +final projectRepositoryProvider = Provider((ref) { + return ProjectRepository(ref.watch(agentApiClientProvider)); +}); + +final projectsProvider = FutureProvider>((ref) { + return ref.watch(projectRepositoryProvider).listProjects(); +}); + final sessionsProvider = FutureProvider>((ref) { return ref.watch(sessionRepositoryProvider).listSessions(); }); diff --git a/apps/mobile_app/lib/core/network/agent_error_formatter.dart b/apps/mobile_app/lib/core/network/agent_error_formatter.dart new file mode 100644 index 0000000..f50050d --- /dev/null +++ b/apps/mobile_app/lib/core/network/agent_error_formatter.dart @@ -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'; +} diff --git a/apps/mobile_app/lib/features/projects/project.dart b/apps/mobile_app/lib/features/projects/project.dart new file mode 100644 index 0000000..9ba6bef --- /dev/null +++ b/apps/mobile_app/lib/features/projects/project.dart @@ -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 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 recentSessions; + + factory ProjectDetail.fromJson(Map json) => ProjectDetail( + project: Project.fromJson(json), + recentSessions: ((json['recentSessions'] as List?) ?? const []) + .map( + (entry) => Session.fromJson(Map.from(entry as Map)), + ) + .toList(growable: false), + ); +} diff --git a/apps/mobile_app/lib/features/projects/project_detail_page.dart b/apps/mobile_app/lib/features/projects/project_detail_page.dart new file mode 100644 index 0000000..c339635 --- /dev/null +++ b/apps/mobile_app/lib/features/projects/project_detail_page.dart @@ -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 createState() => _ProjectDetailPageState(); +} + +class _ProjectDetailPageState extends ConsumerState { + late Future _detailFuture; + + @override + void initState() { + super.initState(); + _detailFuture = _loadDetail(); + } + + Future _loadDetail() { + return ref + .read(projectRepositoryProvider) + .getProjectDetail(widget.project.projectId); + } + + Future _refreshDetail() async { + setState(() { + _detailFuture = _loadDetail(); + }); + await _detailFuture; + } + + Future _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 _editProject(Project project) async { + final nameController = TextEditingController(text: project.name); + final pathController = TextEditingController( + text: project.workingDirectory, + ); + + try { + final shouldSave = await showDialog( + 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 _deleteProject(Project project) async { + final shouldDelete = await showDialog( + 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( + 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}'), + ), + ), + ), + ], + ); + }, + ), + ); + } +} diff --git a/apps/mobile_app/lib/features/projects/project_list_page.dart b/apps/mobile_app/lib/features/projects/project_list_page.dart new file mode 100644 index 0000000..7bbd95d --- /dev/null +++ b/apps/mobile_app/lib/features/projects/project_list_page.dart @@ -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 createState() => _ProjectListPageState(); +} + +class _ProjectListPageState extends ConsumerState { + late final TextEditingController _agentUrlController; + + @override + void initState() { + super.initState(); + _agentUrlController = TextEditingController( + text: ref.read(agentBaseUriProvider).toString(), + ); + } + + @override + void dispose() { + _agentUrlController.dispose(); + super.dispose(); + } + + Future _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 _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 _showProjectEditor({Project? existing}) async { + final nameController = TextEditingController(text: existing?.name ?? ''); + final pathController = TextEditingController( + text: existing?.workingDirectory ?? '', + ); + + try { + final shouldSubmit = await showDialog( + 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 _deleteProject(Project project) async { + final shouldDelete = await showDialog( + 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 _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> 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)), + ], + ), + ); + } +} diff --git a/apps/mobile_app/lib/features/projects/project_repository.dart b/apps/mobile_app/lib/features/projects/project_repository.dart new file mode 100644 index 0000000..cc4cfff --- /dev/null +++ b/apps/mobile_app/lib/features/projects/project_repository.dart @@ -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> listProjects() async { + final projects = await _client.listProjects(); + return projects.map(Project.fromJson).toList(growable: false); + } + + Future createProject({ + required String name, + required String workingDirectory, + }) async { + final project = await _client.createProject( + name: name, + workingDirectory: workingDirectory, + ); + return Project.fromJson(project); + } + + Future 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 getProjectDetail(String projectId) async { + final detail = await _client.getProjectDetail(projectId); + return ProjectDetail.fromJson(detail); + } + + Future deleteProject(String projectId) { + return _client.deleteProject(projectId); + } +} diff --git a/apps/mobile_app/lib/features/sessions/session.dart b/apps/mobile_app/lib/features/sessions/session.dart index 96cc898..e6572b4 100644 --- a/apps/mobile_app/lib/features/sessions/session.dart +++ b/apps/mobile_app/lib/features/sessions/session.dart @@ -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 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); + } } diff --git a/apps/mobile_app/lib/features/terminal/terminal_diagnostic_log.dart b/apps/mobile_app/lib/features/terminal/terminal_diagnostic_log.dart new file mode 100644 index 0000000..d5b3c9a --- /dev/null +++ b/apps/mobile_app/lib/features/terminal/terminal_diagnostic_log.dart @@ -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 _entries = []; + bool _notificationScheduled = false; + + List 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(); + }); + } +} diff --git a/apps/mobile_app/lib/features/terminal/terminal_interaction_controller.dart b/apps/mobile_app/lib/features/terminal/terminal_interaction_controller.dart index 7072603..977ec7f 100644 --- a/apps/mobile_app/lib/features/terminal/terminal_interaction_controller.dart +++ b/apps/mobile_app/lib/features/terminal/terminal_interaction_controller.dart @@ -10,6 +10,8 @@ enum TerminalConnectionState { } class TerminalInteractionController extends ChangeNotifier { + static const int maxTrackedLiveLines = 200; + TerminalInteractionController({ HistoryWindow historyWindow = const HistoryWindow( lines: [], @@ -22,6 +24,7 @@ class TerminalInteractionController extends ChangeNotifier { bool _hasPendingLiveOutput = false; HistoryWindow _historyWindow; final List _liveLines = []; + 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 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(); } diff --git a/apps/mobile_app/lib/features/terminal/terminal_page.dart b/apps/mobile_app/lib/features/terminal/terminal_page.dart index 11015a4..e2f88ed 100644 --- a/apps/mobile_app/lib/features/terminal/terminal_page.dart +++ b/apps/mobile_app/lib/features/terminal/terminal_page.dart @@ -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 createState() => _TerminalPageState(); } class _TerminalPageState extends ConsumerState { + 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 { 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 { 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 _sendLine() async { - final input = _inputController.text; - if (!_canSendInput || input.trim().isEmpty) { + Future _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 _showDiagnostics() { + return showModalBottomSheet( + 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 _showToolsSheet() { + return showModalBottomSheet( + 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 { 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 { 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 { 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 { } 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, diff --git a/apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart b/apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart index c1998c4..e25b5dd 100644 --- a/apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart +++ b/apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart @@ -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 Function() callback, -); -typedef TerminalSessionFactory = TerminalSocketSession Function({ - required Uri baseUri, - required Session session, -}); +typedef ReconnectScheduler = + CancelReconnect Function(Duration delay, Future 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 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'); + } } diff --git a/apps/mobile_app/lib/features/terminal/terminal_socket_session.dart b/apps/mobile_app/lib/features/terminal/terminal_socket_session.dart index 1131e87..b98e90c 100644 --- a/apps/mobile_app/lib/features/terminal/terminal_socket_session.dart +++ b/apps/mobile_app/lib/features/terminal/terminal_socket_session.dart @@ -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((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(); @@ -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 get stream; void send(String message); diff --git a/apps/mobile_app/test/core/network/agent_api_client_test.dart b/apps/mobile_app/test/core/network/agent_api_client_test.dart index 7cbada9..26f9dde 100644 --- a/apps/mobile_app/test/core/network/agent_api_client_test.dart +++ b/apps/mobile_app/test/core/network/agent_api_client_test.dart @@ -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; diff --git a/apps/mobile_app/test/features/projects/project_repository_test.dart b/apps/mobile_app/test/features/projects/project_repository_test.dart new file mode 100644 index 0000000..b1bc8b9 --- /dev/null +++ b/apps/mobile_app/test/features/projects/project_repository_test.dart @@ -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 [ + { + '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()); + 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 { + '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> projects; + final Map? createdProject; + + String? lastCreatedProjectName; + String? lastCreatedProjectWorkingDirectory; + + @override + Future>> listProjects() async { + return projects; + } + + @override + Future> createProject({ + required String name, + required String workingDirectory, + }) async { + lastCreatedProjectName = name; + lastCreatedProjectWorkingDirectory = workingDirectory; + return createdProject ?? + { + 'projectId': 'generated', + 'name': name, + 'workingDirectory': workingDirectory, + 'createdAtUtc': '2026-03-30T10:00:00Z', + 'updatedAtUtc': '2026-03-30T10:00:00Z', + }; + } +} diff --git a/apps/mobile_app/test/features/sessions/session_repository_test.dart b/apps/mobile_app/test/features/sessions/session_repository_test.dart index aa02d53..cceebbc 100644 --- a/apps/mobile_app/test/features/sessions/session_repository_test.dart +++ b/apps/mobile_app/test/features/sessions/session_repository_test.dart @@ -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()); @@ -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> sessions; final Map? createdSession; @@ -67,12 +65,16 @@ class _FakeAgentApiClient extends AgentApiClient { } @override - Future> createSession(String name) async { + Future> createSession({ + String? name, + String? projectId, + String? workingDirectory, + }) async { lastCreatedName = name; return createdSession ?? { 'sessionId': 'generated', - 'name': name, + 'name': name ?? 'generated', 'status': 'idle', }; } diff --git a/apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart b/apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart index 1e8d162..b7245fc 100644 --- a/apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart +++ b/apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart @@ -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.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: [ + { + 'sessionId': 'abc', + 'lines': ['one', 'two'], + 'hasMoreAbove': true, + }, + { + 'sessionId': 'abc', + 'lines': ['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 = []; + 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.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: [ - { - 'sessionId': 'abc', - 'lines': ['one', 'two'], - 'hasMoreAbove': true, - }, - { - 'sessionId': 'abc', - 'lines': ['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 = []; - 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>? responses}) - : _responses = responses ?? - [ - { - 'sessionId': 'abc', - 'lines': ['one', 'two'], - 'hasMoreAbove': true, - }, - ], - super(Uri.parse('https://host:9443')); + : _responses = + responses ?? + [ + { + 'sessionId': 'abc', + 'lines': ['one', 'two'], + 'hasMoreAbove': true, + }, + ], + super(Uri.parse('https://host:9443')); final List> _responses; final requestedLineCounts = []; @@ -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 = >[]; diff --git a/apps/mobile_app/test/project_home_test.dart b/apps/mobile_app/test/project_home_test.dart new file mode 100644 index 0000000..9498d97 --- /dev/null +++ b/apps/mobile_app/test/project_home_test.dart @@ -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 _projects; + + @override + Future> listProjects() async => List.of(_projects); +} + +class _FakeSessionRepository extends SessionRepository { + _FakeSessionRepository() : super(_FakeAgentApiClient()); + + String? lastCreatedProjectId; + + @override + Future 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> listSessions() async => const []; +} + +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.microtask(() { + emit('{"type":"attached","sessionId":"session-1"}'); + }); + } + } + + final bool autoAttach; + final _incoming = StreamController.broadcast(); + + @override + Stream get stream => _incoming.stream; + + @override + void send(String message) {} + + @override + Future close() async { + await _incoming.close(); + } + + void emit(String message) { + _incoming.add(message); + } +} diff --git a/apps/mobile_app/test/terminal_session_coordinator_diagnostics_test.dart b/apps/mobile_app/test/terminal_session_coordinator_diagnostics_test.dart new file mode 100644 index 0000000..d910a9f --- /dev/null +++ b/apps/mobile_app/test/terminal_session_coordinator_diagnostics_test.dart @@ -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( + (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((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> getSessionHistory( + String sessionId, { + int lineCount = 200, + }) async { + return { + 'sessionId': sessionId, + 'lines': const [], + 'hasMoreAbove': false, + }; + } +} + +class _RecordingTerminalSocketSession extends TerminalSocketSession { + _RecordingTerminalSocketSession() + : super( + sessionId: 'abc', + socketClient: AgentSocketClient(Uri.parse('https://host.example:9443')), + ); + + final List sentInputs = []; + + @override + Future 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 dispose() async {} +} diff --git a/apps/mobile_app/test/widget_test.dart b/apps/mobile_app/test/widget_test.dart index e81e2ff..10c2b96 100644 --- a/apps/mobile_app/test/widget_test.dart +++ b/apps/mobile_app/test/widget_test.dart @@ -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 projects) + : _projects = List.of(projects), + super(_FakeAgentApiClient()); + final List _projects; + final List deletedProjectIds = []; @override Future> listProjects() async => List.of(_projects); @@ -342,6 +410,12 @@ class _FakeProjectRepository extends ProjectRepository { recentSessions: const [], ); } + + @override + Future deleteProject(String projectId) async { + deletedProjectIds.add(projectId); + _projects.removeWhere((project) => project.projectId == projectId); + } } class _FakeSessionRepository extends SessionRepository { diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Api/ProjectEndpoints.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Api/ProjectEndpoints.cs new file mode 100644 index 0000000..b46a9e1 --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Api/ProjectEndpoints.cs @@ -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 ReadProjectRequestAsync( + HttpRequest httpRequest, + CancellationToken cancellationToken) + { + try + { + return await httpRequest.ReadFromJsonAsync(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 RecentSessions); + + private sealed record ProjectSessionSummary( + string SessionId, + string Name, + string Status, + DateTimeOffset CreatedAtUtc, + DateTimeOffset UpdatedAtUtc); +} diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Program.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Program.cs index 892dd32..158aa15 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Program.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Program.cs @@ -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(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(serviceProvider => +{ + var options = serviceProvider.GetRequiredService>().Value; + return new ProjectStore(options.DataRoot); +}); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(serviceProvider => { var options = serviceProvider.GetRequiredService>().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(); diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Projects/ProjectRecord.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Projects/ProjectRecord.cs new file mode 100644 index 0000000..ad4bdee --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Projects/ProjectRecord.cs @@ -0,0 +1,8 @@ +namespace TermRemoteCtl.Agent.Projects; + +public sealed record ProjectRecord( + string ProjectId, + string Name, + string WorkingDirectory, + DateTimeOffset CreatedAtUtc, + DateTimeOffset UpdatedAtUtc); diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Projects/ProjectRegistry.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Projects/ProjectRegistry.cs new file mode 100644 index 0000000..aee0242 --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Projects/ProjectRegistry.cs @@ -0,0 +1,89 @@ +using System.Collections.Concurrent; + +namespace TermRemoteCtl.Agent.Projects; + +public sealed class ProjectRegistry +{ + private readonly ConcurrentDictionary _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 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()); + } +} diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Projects/ProjectStore.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Projects/ProjectStore.cs new file mode 100644 index 0000000..e9fb07c --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Projects/ProjectStore.cs @@ -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 Load() + { + lock (_gate) + { + if (!File.Exists(_filePath)) + { + return []; + } + + using var stream = new FileStream(_filePath, FileMode.Open, FileAccess.Read, FileShare.Read); + return JsonSerializer.Deserialize>(stream, JsonOptions) ?? []; + } + } + + public void Save(IReadOnlyCollection projects) + { + ArgumentNullException.ThrowIfNull(projects); + + lock (_gate) + { + using var stream = new FileStream(_filePath, FileMode.Create, FileAccess.Write, FileShare.None); + JsonSerializer.Serialize(stream, projects, JsonOptions); + } + } +} diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs index db2275e..ed3598a 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Realtime/TerminalWebSocketHandler.cs @@ -43,6 +43,7 @@ public static class TerminalWebSocketHandler } var host = context.RequestServices.GetRequiredService(); + var diagnostics = context.RequestServices.GetRequiredService(); var options = context.RequestServices.GetRequiredService>().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, diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRecord.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRecord.cs index 6d88517..7635528 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRecord.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRecord.cs @@ -4,6 +4,8 @@ public sealed record SessionRecord( string SessionId, string Name, string Status, + string? ProjectId, + string? WorkingDirectory, DateTimeOffset CreatedAtUtc, DateTimeOffset UpdatedAtUtc); diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs index 98db386..42f2dbc 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Sessions/SessionRegistry.cs @@ -59,6 +59,16 @@ public sealed class SessionRegistry .ToArray(); } + public IReadOnlyList 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); diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/TermRemoteCtl.Agent.csproj.user b/apps/windows_agent/src/TermRemoteCtl.Agent/TermRemoteCtl.Agent.csproj.user new file mode 100644 index 0000000..9ff5820 --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/TermRemoteCtl.Agent.csproj.user @@ -0,0 +1,6 @@ + + + + https + + \ No newline at end of file diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ConPtySessionFactory.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ConPtySessionFactory.cs index 07fef32..f888bec 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ConPtySessionFactory.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ConPtySessionFactory.cs @@ -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); } } diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/HelperBackedConPtySession.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/HelperBackedConPtySession.cs index 483fe9d..4dd6fd7 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/HelperBackedConPtySession.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/HelperBackedConPtySession.cs @@ -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? 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."); diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/IConPtySession.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/IConPtySession.cs index 3f679a5..b8c7fbe 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/IConPtySession.cs +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/IConPtySession.cs @@ -13,5 +13,5 @@ internal interface IConPtySession : IAsyncDisposable internal interface IConPtySessionFactory { - IConPtySession Create(string sessionId); + IConPtySession Create(string sessionId, string? workingDirectory = null); } diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ITerminalDiagnosticsSink.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ITerminalDiagnosticsSink.cs new file mode 100644 index 0000000..c97c7c9 --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/ITerminalDiagnosticsSink.cs @@ -0,0 +1,6 @@ +namespace TermRemoteCtl.Agent.Terminal; + +public interface ITerminalDiagnosticsSink +{ + void Record(string eventName, string sessionId, string detail); +} diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/LoggingTerminalDiagnosticsSink.cs b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/LoggingTerminalDiagnosticsSink.cs new file mode 100644 index 0000000..297b84c --- /dev/null +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/Terminal/LoggingTerminalDiagnosticsSink.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Logging; + +namespace TermRemoteCtl.Agent.Terminal; + +internal sealed class LoggingTerminalDiagnosticsSink : ITerminalDiagnosticsSink +{ + private readonly ILogger _logger; + + public LoggingTerminalDiagnosticsSink(ILogger logger) + { + _logger = logger; + } + + public void Record(string eventName, string sessionId, string detail) + { + _logger.LogInformation( + "Terminal diagnostic {EventName} session={SessionId} detail={Detail}", + eventName, + sessionId, + detail); + } +} diff --git a/apps/windows_agent/src/TermRemoteCtl.Agent/appsettings.json b/apps/windows_agent/src/TermRemoteCtl.Agent/appsettings.json index 26dfcb8..44f0042 100644 --- a/apps/windows_agent/src/TermRemoteCtl.Agent/appsettings.json +++ b/apps/windows_agent/src/TermRemoteCtl.Agent/appsettings.json @@ -1,7 +1,7 @@ { "Agent": { "DataRoot": "C:\\ProgramData\\TermRemoteCtl", - "BindAddress": "localhost", + "BindAddress": "0.0.0.0", "HttpsPort": 0, "HttpPort": 5067, "WebSocketFrameFlushMilliseconds": 33, diff --git a/apps/windows_agent/src/TermRemoteCtl.ConPtyHelper/Program.cs b/apps/windows_agent/src/TermRemoteCtl.ConPtyHelper/Program.cs index 379f700..994d180 100644 --- a/apps/windows_agent/src/TermRemoteCtl.ConPtyHelper/Program.cs +++ b/apps/windows_agent/src/TermRemoteCtl.ConPtyHelper/Program.cs @@ -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)) { diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/ProjectApiTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/ProjectApiTests.cs new file mode 100644 index 0000000..3937fb7 --- /dev/null +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.IntegrationTests/ProjectApiTests.cs @@ -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(JsonOptions); + Assert.NotNull(created); + Assert.Equal("TermRemoteCtl", created!.Name); + Assert.Equal(fixture.ValidProjectPath, created.WorkingDirectory); + + var listed = await client.GetFromJsonAsync>("/api/projects", JsonOptions); + Assert.NotNull(listed); + Assert.Contains(listed!, project => project.ProjectId == created.ProjectId); + + var registry = fixture.Services.GetRequiredService(); + registry.Create( + "TermRemoteCtl", + DateTimeOffset.UtcNow, + projectId: created.ProjectId, + workingDirectory: created.WorkingDirectory); + + var detail = await client.GetFromJsonAsync( + $"/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(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(JsonOptions); + Assert.NotNull(project); + + var createSessionResponse = await client.PostAsJsonAsync( + "/api/sessions", + new { projectId = project!.ProjectId }); + createSessionResponse.EnsureSuccessStatusCode(); + var session = await createSessionResponse.Content.ReadFromJsonAsync(JsonOptions); + Assert.NotNull(session); + + var deleteResponse = await client.DeleteAsync($"/api/projects/{project.ProjectId}"); + deleteResponse.EnsureSuccessStatusCode(); + + var listedProjects = await client.GetFromJsonAsync>("/api/projects", JsonOptions); + Assert.NotNull(listedProjects); + Assert.DoesNotContain(listedProjects!, item => item.ProjectId == project.ProjectId); + + var listedSessions = await client.GetFromJsonAsync>("/api/sessions", JsonOptions); + Assert.NotNull(listedSessions); + Assert.DoesNotContain(listedSessions!, item => item.SessionId == session!.SessionId); + } + + private sealed class AgentFixture : WebApplicationFactory + { + 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 + { + ["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 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); +} diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/ConPtySessionFactoryTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/ConPtySessionFactoryTests.cs index 690e953..11a616f 100644 --- a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/ConPtySessionFactoryTests.cs +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/ConPtySessionFactoryTests.cs @@ -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(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() diff --git a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/PowerShellSessionHostTests.cs b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/PowerShellSessionHostTests.cs index 58bda49..4858eba 100644 --- a/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/PowerShellSessionHostTests.cs +++ b/apps/windows_agent/tests/TermRemoteCtl.Agent.Tests/Terminal/PowerShellSessionHostTests.cs @@ -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; } } diff --git a/work/windows_agent.stderr.log b/work/windows_agent.stderr.log new file mode 100644 index 0000000..e69de29 diff --git a/work/windows_agent.stdout.log b/work/windows_agent.stdout.log new file mode 100644 index 0000000..87ad378 --- /dev/null +++ b/work/windows_agent.stdout.log @@ -0,0 +1,127 @@ +正在生成... +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