diff --git a/src/client/gui/lib/grpc_client.dart b/src/client/gui/lib/grpc_client.dart index 603ac751ed..9f5e96d9be 100644 --- a/src/client/gui/lib/grpc_client.dart +++ b/src/client/gui/lib/grpc_client.dart @@ -1,6 +1,6 @@ +import 'dart:async'; import 'dart:io'; -import 'package:async/async.dart'; import 'package:fpdart/fpdart.dart'; import 'package:grpc/grpc.dart'; import 'package:protobuf/protobuf.dart' hide RpcClient; @@ -100,7 +100,7 @@ class GrpcClient { .start(Stream.value(request)) .doOnData(checkForUpdate) .doOnEach(logGrpc(request)) - .firstOrNull; + .lastOrNull; } Future stop(Iterable names) { @@ -111,7 +111,7 @@ class GrpcClient { return _client .stop(Stream.value(request)) .doOnEach(logGrpc(request)) - .firstOrNull; + .lastOrNull; } Future suspend(Iterable names) { @@ -122,7 +122,7 @@ class GrpcClient { return _client .suspend(Stream.value(request)) .doOnEach(logGrpc(request)) - .firstOrNull; + .lastOrNull; } Future restart(Iterable names) { @@ -134,7 +134,7 @@ class GrpcClient { .restart(Stream.value(request)) .doOnData(checkForUpdate) .doOnEach(logGrpc(request)) - .firstOrNull; + .lastOrNull; } Future delete(Iterable names) { @@ -147,7 +147,7 @@ class GrpcClient { return _client .delet(Stream.value(request)) .doOnEach(logGrpc(request)) - .firstOrNull; + .lastOrNull; } Future recover(Iterable names) { @@ -158,7 +158,7 @@ class GrpcClient { return _client .recover(Stream.value(request)) .doOnEach(logGrpc(request)) - .firstOrNull; + .lastOrNull; } Future purge(Iterable names) { @@ -172,7 +172,7 @@ class GrpcClient { return _client .delet(Stream.value(request)) .doOnEach(logGrpc(request)) - .firstOrNull; + .lastOrNull; } Future> info([Iterable names = const []]) { @@ -193,7 +193,7 @@ class GrpcClient { return _client .mount(Stream.value(request)) .doOnEach(logGrpc(request)) - .firstOrNull; + .lastOrNull; } Future umount(String name, [String? path]) { @@ -204,7 +204,7 @@ class GrpcClient { return _client .umount(Stream.value(request)) .doOnEach(logGrpc(request)) - .firstOrNull; + .lastOrNull; } Future find({bool images = true, bool blueprints = true}) { @@ -254,7 +254,7 @@ class GrpcClient { return _client .set(Stream.value(request)) .doOnEach(logGrpc(request)) - .firstOrNull; + .lastOrNull; } Future sshInfo(String name) { @@ -263,7 +263,7 @@ class GrpcClient { return _client .ssh_info(Stream.value(request)) .doOnEach(logGrpc(request)) - .first + .last .then((reply) => reply.sshInfo[name]); } @@ -299,3 +299,17 @@ class CustomChannelCredentials extends ChannelCredentials { return ctx; } } + +extension on Stream { + Future get lastOrNull { + final completer = Completer.sync(); + T? result; + listen( + (event) => result = event, + onError: completer.completeError, + onDone: () => completer.complete(result), + cancelOnError: true, + ); + return completer.future; + } +} diff --git a/src/client/gui/lib/providers.dart b/src/client/gui/lib/providers.dart index dccc9c7536..0cfc0fb721 100644 --- a/src/client/gui/lib/providers.dart +++ b/src/client/gui/lib/providers.dart @@ -68,16 +68,28 @@ final daemonAvailableProvider = Provider((ref) { if (message.contains('failed to obtain exit status for remote process')) { return true; } - if (message.contains('Connection is being forcefully terminated')) { - return true; - } } return false; }); +class AllVmInfosNotifier extends Notifier> { + @override + List build() { + return ref.watch(vmInfosStreamProvider).valueOrNull ?? const []; + } + + Future update() async { + state = await ref.read(grpcClientProvider).info(); + } +} + +final allVmInfosProvider = + NotifierProvider>( + AllVmInfosNotifier.new); + final vmInfosProvider = Provider((ref) { - final vmInfos = ref.watch(vmInfosStreamProvider).valueOrNull ?? const []; - final existingVms = vmInfos + final existingVms = ref + .watch(allVmInfosProvider) .where((info) => info.instanceStatus.status != Status.DELETED) .toBuiltList(); final existingVmNames = existingVms.map((i) => i.name).toSet(); @@ -115,8 +127,8 @@ final vmNamesProvider = Provider((ref) { }); final deletedVmsProvider = Provider((ref) { - final vmInfos = ref.watch(vmInfosStreamProvider).valueOrNull ?? const []; - return vmInfos + return ref + .watch(allVmInfosProvider) .where((info) => info.instanceStatus.status == Status.DELETED) .map((info) => info.name) .toBuiltSet(); diff --git a/src/client/gui/lib/vm_details/terminal.dart b/src/client/gui/lib/vm_details/terminal.dart index c194ed1b9b..2aa5a4ef6e 100644 --- a/src/client/gui/lib/vm_details/terminal.dart +++ b/src/client/gui/lib/vm_details/terminal.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:isolate'; import 'dart:math'; @@ -14,6 +15,7 @@ import '../logger.dart'; import '../notifications.dart'; import '../platform/platform.dart'; import '../providers.dart'; +import '../vm_action.dart'; final runningShellsProvider = StateProvider.autoDispose.family((_, __) { @@ -182,6 +184,7 @@ class _VmTerminalState extends ConsumerState { final scrollController = ScrollController(); final focusNode = FocusNode(); var fontSize = defaultFontSize; + late final terminalIdentifier = (vmName: widget.name, shellId: widget.id); @override void initState() { @@ -202,13 +205,35 @@ class _VmTerminalState extends ConsumerState { if (widget.isCurrent) focusNode.requestFocus(); } + Future startVmIfNeeded(final bool vmRunning) async { + if (vmRunning) return; + final name = widget.name; + final action = VmAction.start; + final operation = ref.read(grpcClientProvider).start([name]); + ref.read(notificationsProvider.notifier).addOperation( + operation, + loading: '${action.continuousTense} $name', + onSuccess: (_) => '${action.pastTense} $name', + onError: (error) { + return 'Failed to ${action.name.toLowerCase()} $name: $error'; + }, + ); + await operation; + await ref.read(allVmInfosProvider.notifier).update(); + } + + void openShell() { + ref.read(terminalProvider(terminalIdentifier).notifier).start(); + } + @override Widget build(BuildContext context) { - final terminalIdentifier = (vmName: widget.name, shellId: widget.id); final terminal = ref.watch(terminalProvider(terminalIdentifier)); - final vmRunning = ref.watch(vmInfoProvider(widget.name).select((info) { - return info.instanceStatus.status == Status.RUNNING; + final vmStatus = ref.watch(vmInfoProvider(widget.name).select((info) { + return info.instanceStatus.status; })); + final vmRunning = vmStatus == Status.RUNNING; + final canStartVm = [Status.STOPPED, Status.SUSPENDED].contains(vmStatus); final buttonStyle = ButtonStyle( foregroundColor: WidgetStateColor.resolveWith((states) { @@ -234,10 +259,8 @@ class _VmTerminalState extends ConsumerState { const SizedBox(height: 12), OutlinedButton( style: buttonStyle, - onPressed: vmRunning - ? () => ref - .read(terminalProvider(terminalIdentifier).notifier) - .start() + onPressed: canStartVm || vmRunning + ? () => startVmIfNeeded(vmRunning).then((_) => openShell()) : null, child: const Text('Open shell'), ), diff --git a/src/client/gui/lib/vm_details/terminal_tabs.dart b/src/client/gui/lib/vm_details/terminal_tabs.dart index 591cb2eb51..0ee51676de 100644 --- a/src/client/gui/lib/vm_details/terminal_tabs.dart +++ b/src/client/gui/lib/vm_details/terminal_tabs.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import '../confirmation_dialog.dart'; import 'terminal.dart'; typedef ShellIds = ({ @@ -144,7 +145,31 @@ class TerminalTabs extends ConsumerWidget { title: 'Shell ${shellId.id}', selected: index == currentIndex, onTap: () => ref.read(notifier).setCurrent(index), - onClose: () => ref.read(notifier).remove(index), + onClose: () { + final terminalKey = (vmName: name, shellId: shellId); + final terminal = ref.read(terminalProvider(terminalKey)); + if (terminal == null) { + ref.read(notifier).remove(index); + return; + } + showDialog( + context: context, + barrierDismissible: false, + builder: (context) { + return ConfirmationDialog( + title: 'Are you sure you want to close this terminal?', + body: Text('Its current state will be lost.'), + actionText: 'Yes', + onAction: () { + Navigator.pop(context); + ref.read(notifier).remove(index); + }, + inactionText: 'No', + onInaction: () => Navigator.pop(context), + ); + }, + ); + }, ), );