diff --git a/playground/frontend/lib/l10n/app_en.arb b/playground/frontend/lib/l10n/app_en.arb index 6f3be2e97ad2f..fdb655364c586 100644 --- a/playground/frontend/lib/l10n/app_en.arb +++ b/playground/frontend/lib/l10n/app_en.arb @@ -19,6 +19,18 @@ "@run": { "description": "Title for the run button" }, + "cancel": "Cancel", + "@cancel": { + "description": "Title for the cancel button" + }, + "cancelExecution": "Cancel Execution", + "@cancelExecution": { + "description": "Title for the cancel execution notification" + }, + "unknownExample": "Unknown Example", + "@unknownExample": { + "description": "Unknown example text part" + }, "log": "Log", "@log": { "description": "Title for the log section" diff --git a/playground/frontend/lib/modules/editor/components/run_button.dart b/playground/frontend/lib/modules/editor/components/run_button.dart index e96298a96969a..8421ee8bed543 100644 --- a/playground/frontend/lib/modules/editor/components/run_button.dart +++ b/playground/frontend/lib/modules/editor/components/run_button.dart @@ -17,6 +17,7 @@ */ import 'package:flutter/material.dart'; +import 'package:playground/config/theme.dart'; import 'package:playground/constants/sizes.dart'; import 'package:playground/modules/shortcuts/components/shortcut_tooltip.dart'; import 'package:playground/modules/shortcuts/constants/global_shortcuts.dart'; @@ -30,9 +31,14 @@ const kSecondsFractions = 1; class RunButton extends StatelessWidget { final bool isRunning; final VoidCallback runCode; + final VoidCallback cancelRun; - const RunButton({Key? key, required this.isRunning, required this.runCode}) - : super(key: key); + const RunButton({ + Key? key, + required this.isRunning, + required this.runCode, + required this.cancelRun, + }) : super(key: key); @override Widget build(BuildContext context) { @@ -44,7 +50,7 @@ class RunButton extends StatelessWidget { width: kIconSizeSm, height: kIconSizeSm, child: CircularProgressIndicator( - color: Theme.of(context).primaryColor, + color: ThemeColors.of(context).primaryBackgroundTextColor, ), ) : const Icon(Icons.play_arrow), @@ -53,14 +59,16 @@ class RunButton extends StatelessWidget { builder: (context, AsyncSnapshot state) { final seconds = (state.data ?? 0) / kMsToSec; final runText = AppLocalizations.of(context)!.run; + final cancelText = AppLocalizations.of(context)!.cancel; + final buttonText = isRunning ? cancelText : runText; if (seconds > 0) { return Text( - '$runText (${seconds.toStringAsFixed(kSecondsFractions)} s)', + '$buttonText (${seconds.toStringAsFixed(kSecondsFractions)} s)', ); } - return Text(runText); + return Text(buttonText); }), - onPressed: !isRunning ? runCode : null, + onPressed: !isRunning ? runCode : cancelRun, ), ); } diff --git a/playground/frontend/lib/modules/editor/repository/code_repository/code_client/code_client.dart b/playground/frontend/lib/modules/editor/repository/code_repository/code_client/code_client.dart index 3d02fe6eb4caf..3a1868ec1b7cf 100644 --- a/playground/frontend/lib/modules/editor/repository/code_repository/code_client/code_client.dart +++ b/playground/frontend/lib/modules/editor/repository/code_repository/code_client/code_client.dart @@ -24,6 +24,8 @@ import 'package:playground/modules/editor/repository/code_repository/run_code_re abstract class CodeClient { Future runCode(RunCodeRequestWrapper request); + Future cancelExecution(String pipelineUuid); + Future checkStatus( String pipelineUuid, RunCodeRequestWrapper request, diff --git a/playground/frontend/lib/modules/editor/repository/code_repository/code_client/grpc_code_client.dart b/playground/frontend/lib/modules/editor/repository/code_repository/code_client/grpc_code_client.dart index 954aee49ca912..9f5047d3de973 100644 --- a/playground/frontend/lib/modules/editor/repository/code_repository/code_client/grpc_code_client.dart +++ b/playground/frontend/lib/modules/editor/repository/code_repository/code_client/grpc_code_client.dart @@ -50,6 +50,12 @@ class GrpcCodeClient implements CodeClient { .then((response) => RunCodeResponse(response.pipelineUuid))); } + @override + Future cancelExecution(String pipelineUuid) { + return _runSafely(() => _defaultClient + .cancel(grpc.CancelRequest(pipelineUuid: pipelineUuid))); + } + @override Future checkStatus( String pipelineUuid, diff --git a/playground/frontend/lib/modules/editor/repository/code_repository/code_repository.dart b/playground/frontend/lib/modules/editor/repository/code_repository/code_repository.dart index 15c4f405beb00..d55c22c2c9172 100644 --- a/playground/frontend/lib/modules/editor/repository/code_repository/code_repository.dart +++ b/playground/frontend/lib/modules/editor/repository/code_repository/code_repository.dart @@ -62,6 +62,10 @@ class CodeRepository { } } + Future cancelExecution(String pipelineUuid) { + return _client.cancelExecution(pipelineUuid); + } + Stream _checkPipelineExecution( String pipelineUuid, RunCodeRequestWrapper request, { @@ -88,6 +92,7 @@ class CodeRepository { } } on RunCodeError catch (error) { yield RunCodeResult( + pipelineUuid: prevResult?.pipelineUuid, status: RunCodeStatus.unknownError, errorMessage: error.message ?? kUnknownErrorText, output: error.message ?? kUnknownErrorText, @@ -110,12 +115,14 @@ class CodeRepository { request, ); return RunCodeResult( + pipelineUuid: pipelineUuid, status: status, output: compileOutput.output, log: prevLog, ); case RunCodeStatus.timeout: return RunCodeResult( + pipelineUuid: pipelineUuid, status: status, errorMessage: kTimeoutErrorText, output: kTimeoutErrorText, @@ -123,12 +130,14 @@ class CodeRepository { case RunCodeStatus.runError: final output = await _client.getRunErrorOutput(pipelineUuid, request); return RunCodeResult( + pipelineUuid: pipelineUuid, status: status, output: output.output, log: prevLog, ); case RunCodeStatus.unknownError: return RunCodeResult( + pipelineUuid: pipelineUuid, status: status, errorMessage: kUnknownErrorText, output: kUnknownErrorText, @@ -143,12 +152,16 @@ class CodeRepository { final output = responses[0]; final log = responses[1]; return RunCodeResult( + pipelineUuid: pipelineUuid, status: status, output: prevOutput + output.output, log: prevLog + log.output, ); default: - return RunCodeResult(status: status); + return RunCodeResult( + pipelineUuid: pipelineUuid, + status: status, + ); } } } diff --git a/playground/frontend/lib/modules/editor/repository/code_repository/run_code_result.dart b/playground/frontend/lib/modules/editor/repository/code_repository/run_code_result.dart index 76a3839fde810..16e0c33b24ffe 100644 --- a/playground/frontend/lib/modules/editor/repository/code_repository/run_code_result.dart +++ b/playground/frontend/lib/modules/editor/repository/code_repository/run_code_result.dart @@ -16,6 +16,8 @@ * limitations under the License. */ +import 'package:flutter/material.dart'; + enum RunCodeStatus { unspecified, preparation, @@ -38,12 +40,14 @@ const kFinishedStatuses = [ class RunCodeResult { final RunCodeStatus status; + final String? pipelineUuid; final String? output; final String? log; final String? errorMessage; RunCodeResult({ required this.status, + this.pipelineUuid, this.output, this.log, this.errorMessage, @@ -58,6 +62,7 @@ class RunCodeResult { identical(this, other) || other is RunCodeResult && runtimeType == other.runtimeType && + pipelineUuid == other.pipelineUuid && status == other.status && output == other.output && log == other.log && @@ -65,10 +70,10 @@ class RunCodeResult { @override int get hashCode => - status.hashCode ^ output.hashCode ^ log.hashCode ^ errorMessage.hashCode; + hashValues(pipelineUuid, status, output, log, errorMessage); @override String toString() { - return 'RunCodeResult{status: $status, output: $output, log: $log, errorMessage: $errorMessage}'; + return 'RunCodeResult{pipelineId: $pipelineUuid, status: $status, output: $output, log: $log, errorMessage: $errorMessage}'; } } diff --git a/playground/frontend/lib/pages/playground/components/close_listener.dart b/playground/frontend/lib/pages/playground/components/close_listener.dart new file mode 100644 index 0000000000000..47e9781a4cc3d --- /dev/null +++ b/playground/frontend/lib/pages/playground/components/close_listener.dart @@ -0,0 +1,49 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; +import 'package:playground/pages/playground/states/playground_state.dart'; +import 'dart:html' as html; + +import 'package:provider/provider.dart'; + +class CloseListener extends StatefulWidget { + final Widget child; + + const CloseListener({Key? key, required this.child}) : super(key: key); + + @override + State createState() => _CloseListenerState(); +} + +class _CloseListenerState extends State { + @override + void initState() { + WidgetsBinding.instance?.addPostFrameCallback((timeStamp) { + html.window.onBeforeUnload.listen((event) async { + Provider.of(context, listen: false).cancelRun(); + }); + }); + super.initState(); + } + + @override + Widget build(BuildContext context) { + return widget.child; + } +} diff --git a/playground/frontend/lib/pages/playground/components/close_listener_nonweb.dart b/playground/frontend/lib/pages/playground/components/close_listener_nonweb.dart new file mode 100644 index 0000000000000..4ec38570fa206 --- /dev/null +++ b/playground/frontend/lib/pages/playground/components/close_listener_nonweb.dart @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import 'package:flutter/material.dart'; + +class CloseListener extends StatelessWidget { + final Widget child; + const CloseListener({Key? key, required this.child}) : super(key: key); + + @override + Widget build(BuildContext context) { + return child; + } +} diff --git a/playground/frontend/lib/pages/playground/components/editor_textarea_wrapper.dart b/playground/frontend/lib/pages/playground/components/editor_textarea_wrapper.dart index 65ddcd3e51599..8626d1c53587f 100644 --- a/playground/frontend/lib/pages/playground/components/editor_textarea_wrapper.dart +++ b/playground/frontend/lib/pages/playground/components/editor_textarea_wrapper.dart @@ -29,8 +29,6 @@ import 'package:playground/modules/sdk/models/sdk.dart'; import 'package:playground/pages/playground/states/playground_state.dart'; import 'package:provider/provider.dart'; -const kUnknownExamplePrefix = 'Unknown Example'; - class CodeTextAreaWrapper extends StatelessWidget { const CodeTextAreaWrapper({Key? key}) : super(key: key); @@ -67,13 +65,22 @@ class CodeTextAreaWrapper extends StatelessWidget { height: kRunButtonHeight, child: RunButton( isRunning: state.isCodeRunning, + cancelRun: () { + state.cancelRun().catchError( + (_) => NotificationManager.showError( + context, + AppLocalizations.of(context)!.runCode, + AppLocalizations.of(context)!.cancelExecution, + ), + ); + }, runCode: () { final stopwatch = Stopwatch()..start(); state.runCode( onFinish: () { AnalyticsService.get(context).trackRunTimeEvent( state.selectedExample?.path ?? - '$kUnknownExamplePrefix, sdk ${state.sdk.displayName}', + '${AppLocalizations.of(context)!.unknownExample}, sdk ${state.sdk.displayName}', stopwatch.elapsedMilliseconds, ); }, @@ -122,5 +129,5 @@ class EditorKeyObject { resetKey == other.resetKey; @override - int get hashCode => sdk.hashCode ^ example.hashCode ^ resetKey.hashCode; + int get hashCode => hashValues(sdk, example, resetKey); } diff --git a/playground/frontend/lib/pages/playground/playground_page.dart b/playground/frontend/lib/pages/playground/playground_page.dart index 5d76fe73c1f95..09b4522f66890 100644 --- a/playground/frontend/lib/pages/playground/playground_page.dart +++ b/playground/frontend/lib/pages/playground/playground_page.dart @@ -27,6 +27,8 @@ import 'package:playground/modules/examples/example_selector.dart'; import 'package:playground/modules/sdk/components/sdk_selector.dart'; import 'package:playground/modules/shortcuts/components/shortcuts_manager.dart'; import 'package:playground/modules/shortcuts/constants/global_shortcuts.dart'; +import 'package:playground/pages/playground/components/close_listener_nonweb.dart' + if (dart.library.html) 'package:playground/pages/playground/components/close_listener.dart'; import 'package:playground/pages/playground/components/more_actions.dart'; import 'package:playground/pages/playground/components/playground_page_body.dart'; import 'package:playground/pages/playground/components/playground_page_footer.dart'; @@ -39,49 +41,51 @@ class PlaygroundPage extends StatelessWidget { @override Widget build(BuildContext context) { - return ShortcutsManager( - shortcuts: globalShortcuts, - child: Scaffold( - appBar: AppBar( - automaticallyImplyLeading: false, - title: Consumer( - builder: (context, state, child) { - return Wrap( - crossAxisAlignment: WrapCrossAlignment.center, - spacing: kXlSpacing, - children: [ - const Logo(), - Consumer( - builder: (context, state, child) { - return ExampleSelector( - changeSelectorVisibility: - state.changeSelectorVisibility, - isSelectorOpened: state.isSelectorOpened, - ); - }, - ), - SDKSelector( - sdk: state.sdk, - setSdk: (newSdk) { - AnalyticsService.get(context) - .trackSelectSdk(state.sdk, newSdk); - state.setSdk(newSdk); - }, - setExample: state.setExample, - ), - const NewExampleAction(), - ResetAction(reset: state.reset), - ], - ); - }, + return CloseListener( + child: ShortcutsManager( + shortcuts: globalShortcuts, + child: Scaffold( + appBar: AppBar( + automaticallyImplyLeading: false, + title: Consumer( + builder: (context, state, child) { + return Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + spacing: kXlSpacing, + children: [ + const Logo(), + Consumer( + builder: (context, state, child) { + return ExampleSelector( + changeSelectorVisibility: + state.changeSelectorVisibility, + isSelectorOpened: state.isSelectorOpened, + ); + }, + ), + SDKSelector( + sdk: state.sdk, + setSdk: (newSdk) { + AnalyticsService.get(context) + .trackSelectSdk(state.sdk, newSdk); + state.setSdk(newSdk); + }, + setExample: state.setExample, + ), + const NewExampleAction(), + ResetAction(reset: state.reset), + ], + ); + }, + ), + actions: const [ToggleThemeButton(), MoreActions()], + ), + body: Column( + children: const [ + Expanded(child: PlaygroundPageBody()), + PlaygroundPageFooter(), + ], ), - actions: const [ToggleThemeButton(), MoreActions()], - ), - body: Column( - children: const [ - Expanded(child: PlaygroundPageBody()), - PlaygroundPageFooter(), - ], ), ), ); diff --git a/playground/frontend/lib/pages/playground/states/playground_state.dart b/playground/frontend/lib/pages/playground/states/playground_state.dart index a481a5186ab27..e57422bb24cd9 100644 --- a/playground/frontend/lib/pages/playground/states/playground_state.dart +++ b/playground/frontend/lib/pages/playground/states/playground_state.dart @@ -31,6 +31,7 @@ const kTitleLength = 15; const kExecutionTimeUpdate = 100; const kPrecompiledDelay = Duration(seconds: 1); const kTitle = 'Catalog'; +const kExecutionCancelledText = '\nPipeline cancelled'; const kPipelineOptionsParseError = 'Failed to parse pipeline options, please check the format (example: --key1 value1 --key2 value2), only alphanumeric and ",*,/,-,:,;,\',. symbols are allowed'; @@ -40,6 +41,7 @@ class PlaygroundState with ChangeNotifier { ExampleModel? _selectedExample; String _source = ''; RunCodeResult? _result; + StreamSubscription? _runSubscription; String _pipelineOptions = ''; DateTime? resetKey; StreamController? _executionTime; @@ -140,7 +142,7 @@ class PlaygroundState with ChangeNotifier { sdk: sdk, pipelineOptions: parsedPipelineOptions, ); - _codeRepository?.runCode(request).listen((event) { + _runSubscription = _codeRepository?.runCode(request).listen((event) { _result = event; if (event.isFinished && onFinish != null) { onFinish(); @@ -152,6 +154,21 @@ class PlaygroundState with ChangeNotifier { } } + Future cancelRun() async { + _runSubscription?.cancel(); + final pipelineUuid = result?.pipelineUuid ?? ''; + if (pipelineUuid.isNotEmpty) { + await _codeRepository?.cancelExecution(pipelineUuid); + } + _result = RunCodeResult( + status: RunCodeStatus.finished, + output: _result?.output, + log: (_result?.log ?? '') + kExecutionCancelledText, + ); + _executionTime?.close(); + notifyListeners(); + } + bool get _arePipelineOptionsChanges { return pipelineOptions != (_selectedExample?.pipelineOptions ?? ''); } diff --git a/playground/frontend/test/modules/editor/repository/code_repository/code_repository_test.dart b/playground/frontend/test/modules/editor/repository/code_repository/code_repository_test.dart index 3ab75a125ad80..f28951862a43a 100644 --- a/playground/frontend/test/modules/editor/repository/code_repository/code_repository_test.dart +++ b/playground/frontend/test/modules/editor/repository/code_repository/code_repository_test.dart @@ -90,6 +90,7 @@ void main() { log: kProcessingStartedText, ), RunCodeResult( + pipelineUuid: kPipelineUuid, status: RunCodeStatus.finished, output: kRunOutput, log: kProcessingStartedText + kLogOutput, @@ -132,6 +133,7 @@ void main() { log: kProcessingStartedText, ), RunCodeResult( + pipelineUuid: kPipelineUuid, status: RunCodeStatus.compileError, output: kCompileOutput, log: kProcessingStartedText, @@ -176,6 +178,7 @@ void main() { log: kProcessingStartedText, ), RunCodeResult( + pipelineUuid: kPipelineUuid, status: RunCodeStatus.runError, output: kRunErrorOutput, log: kProcessingStartedText, @@ -223,16 +226,19 @@ void main() { log: kProcessingStartedText, ), RunCodeResult( + pipelineUuid: kPipelineUuid, status: RunCodeStatus.executing, output: kRunOutput, log: kProcessingStartedText + kLogOutput, ), RunCodeResult( + pipelineUuid: kPipelineUuid, status: RunCodeStatus.executing, output: kRunOutput * 2, log: kProcessingStartedText + kLogOutput * 2, ), RunCodeResult( + pipelineUuid: kPipelineUuid, status: RunCodeStatus.finished, output: kRunOutput * 3, log: kProcessingStartedText + kLogOutput * 3, diff --git a/playground/frontend/test/modules/editor/repository/code_repository/code_repository_test.mocks.dart b/playground/frontend/test/modules/editor/repository/code_repository/code_repository_test.mocks.dart index bf93e8a46b0bf..1fe6d6b7864a3 100644 --- a/playground/frontend/test/modules/editor/repository/code_repository/code_repository_test.mocks.dart +++ b/playground/frontend/test/modules/editor/repository/code_repository/code_repository_test.mocks.dart @@ -64,6 +64,11 @@ class MockCodeClient extends _i1.Mock implements _i5.CodeClient { Future<_i2.RunCodeResponse>.value(_FakeRunCodeResponse_0())) as _i6.Future<_i2.RunCodeResponse>); @override + _i6.Future cancelExecution(String? pipelineUuid) => + (super.noSuchMethod(Invocation.method(#cancelExecution, [pipelineUuid]), + returnValue: Future.value(), + returnValueForMissingStub: Future.value()) as _i6.Future); + @override _i6.Future<_i3.CheckStatusResponse> checkStatus( String? pipelineUuid, _i7.RunCodeRequestWrapper? request) => (super.noSuchMethod(