377 lines
11 KiB
Dart
377 lines
11 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
|
|
import '../../app/app_theme.dart';
|
|
import '../../app/ui_shell.dart';
|
|
import '../../core/network/agent_connection_providers.dart';
|
|
import '../../core/network/agent_error_formatter.dart';
|
|
import '../sessions/session.dart';
|
|
import '../terminal/terminal_page.dart';
|
|
import 'project.dart';
|
|
|
|
class ProjectDetailPage extends ConsumerStatefulWidget {
|
|
const ProjectDetailPage({super.key, required this.project});
|
|
|
|
final Project project;
|
|
|
|
@override
|
|
ConsumerState<ProjectDetailPage> createState() => _ProjectDetailPageState();
|
|
}
|
|
|
|
class _ProjectDetailPageState extends ConsumerState<ProjectDetailPage> {
|
|
late Future<ProjectDetail> _detailFuture;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_detailFuture = _loadDetail();
|
|
}
|
|
|
|
Future<ProjectDetail> _loadDetail() {
|
|
return ref
|
|
.read(projectRepositoryProvider)
|
|
.getProjectDetail(widget.project.projectId);
|
|
}
|
|
|
|
Future<void> _refreshDetail() async {
|
|
setState(() {
|
|
_detailFuture = _loadDetail();
|
|
});
|
|
await _detailFuture;
|
|
}
|
|
|
|
Future<void> _openNewTerminal() async {
|
|
final repository = ref.read(sessionRepositoryProvider);
|
|
try {
|
|
final session = await repository.createSession(
|
|
projectId: widget.project.projectId,
|
|
);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
await Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (context) => TerminalPage(
|
|
session: session,
|
|
agentBaseUri: ref.read(agentBaseUriProvider),
|
|
project: widget.project,
|
|
),
|
|
),
|
|
);
|
|
await _refreshDetail();
|
|
} catch (error) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
formatAgentError(error, fallback: 'Failed to open terminal.'),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _editProject(Project project) async {
|
|
final nameController = TextEditingController(text: project.name);
|
|
final pathController = TextEditingController(
|
|
text: project.workingDirectory,
|
|
);
|
|
|
|
try {
|
|
final shouldSave = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) => AlertDialog(
|
|
title: const Text('Edit project'),
|
|
content: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
TextField(
|
|
controller: nameController,
|
|
decoration: const InputDecoration(labelText: 'Project name'),
|
|
),
|
|
const SizedBox(height: 12),
|
|
TextField(
|
|
controller: pathController,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Working directory',
|
|
),
|
|
),
|
|
],
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
child: const Text('Save'),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
|
|
if (shouldSave != true) {
|
|
return;
|
|
}
|
|
|
|
await ref
|
|
.read(projectRepositoryProvider)
|
|
.updateProject(
|
|
projectId: project.projectId,
|
|
name: nameController.text.trim(),
|
|
workingDirectory: pathController.text.trim(),
|
|
);
|
|
await _refreshDetail();
|
|
} catch (error) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
formatAgentError(error, fallback: 'Failed to update project.'),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _openExistingSession(Session session, Project project) async {
|
|
try {
|
|
await Navigator.of(context).push(
|
|
MaterialPageRoute(
|
|
builder: (context) => TerminalPage(
|
|
session: session,
|
|
agentBaseUri: ref.read(agentBaseUriProvider),
|
|
project: project,
|
|
),
|
|
),
|
|
);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
await _refreshDetail();
|
|
} catch (error) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
formatAgentError(error, fallback: 'Failed to refresh project.'),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
Future<void> _deleteProject(Project project) async {
|
|
final shouldDelete = await showDialog<bool>(
|
|
context: context,
|
|
builder: (context) {
|
|
return AlertDialog(
|
|
title: const Text('Delete project'),
|
|
content: Text(
|
|
'Delete "${project.name}"? This also deletes all sessions and terminal history for this project.',
|
|
),
|
|
actions: [
|
|
TextButton(
|
|
onPressed: () => Navigator.of(context).pop(false),
|
|
child: const Text('Cancel'),
|
|
),
|
|
FilledButton(
|
|
onPressed: () => Navigator.of(context).pop(true),
|
|
child: const Text('Delete'),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
);
|
|
|
|
if (shouldDelete != true) {
|
|
return;
|
|
}
|
|
|
|
try {
|
|
await ref
|
|
.read(projectRepositoryProvider)
|
|
.deleteProject(project.projectId);
|
|
ref.invalidate(projectsProvider);
|
|
ref.invalidate(sessionsProvider);
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
Navigator.of(context).pop();
|
|
} catch (error) {
|
|
if (!mounted) {
|
|
return;
|
|
}
|
|
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
SnackBar(
|
|
content: Text(
|
|
formatAgentError(error, fallback: 'Failed to delete project.'),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isCompact = MediaQuery.sizeOf(context).width < 460;
|
|
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: Text(widget.project.name),
|
|
actions: [
|
|
IconButton(
|
|
onPressed: _refreshDetail,
|
|
tooltip: 'Refresh project',
|
|
icon: const Icon(Icons.refresh),
|
|
),
|
|
],
|
|
),
|
|
floatingActionButton: FloatingActionButton.extended(
|
|
onPressed: _openNewTerminal,
|
|
icon: const Icon(Icons.add),
|
|
label: const Text('Open terminal'),
|
|
),
|
|
body: FutureBuilder<ProjectDetail>(
|
|
future: _detailFuture,
|
|
builder: (context, snapshot) {
|
|
if (snapshot.connectionState != ConnectionState.done) {
|
|
return const Center(child: CircularProgressIndicator());
|
|
}
|
|
|
|
if (snapshot.hasError) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(24),
|
|
child: Text(
|
|
formatAgentError(
|
|
snapshot.error!,
|
|
fallback: 'Failed to load project.',
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
final detail = snapshot.data!;
|
|
final project = detail.project;
|
|
return ListView(
|
|
padding: AppTheme.pagePadding,
|
|
children: [
|
|
AppPanel(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
project.name,
|
|
style: Theme.of(context).textTheme.titleLarge,
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
project.workingDirectory,
|
|
maxLines: isCompact ? 2 : 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
),
|
|
const SizedBox(height: 16),
|
|
if (isCompact)
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
FilledButton.icon(
|
|
onPressed: _openNewTerminal,
|
|
icon: const Icon(Icons.terminal),
|
|
label: const Text('Open terminal'),
|
|
),
|
|
const SizedBox(height: 10),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: OutlinedButton.icon(
|
|
onPressed: () => _editProject(project),
|
|
icon: const Icon(Icons.edit_outlined),
|
|
label: const Text('Edit'),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: OutlinedButton.icon(
|
|
onPressed: () => _deleteProject(project),
|
|
icon: const Icon(Icons.delete_outline),
|
|
label: const Text('Delete'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
)
|
|
else
|
|
Wrap(
|
|
spacing: 12,
|
|
runSpacing: 12,
|
|
children: [
|
|
FilledButton.icon(
|
|
onPressed: _openNewTerminal,
|
|
icon: const Icon(Icons.terminal),
|
|
label: const Text('Open terminal'),
|
|
),
|
|
OutlinedButton.icon(
|
|
onPressed: () => _editProject(project),
|
|
icon: const Icon(Icons.edit_outlined),
|
|
label: const Text('Edit'),
|
|
),
|
|
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}'),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|