From 4b1ab01bc1716d5c87768c03587bf8b1a55cf26d Mon Sep 17 00:00:00 2001 From: Levi Lesches Date: Thu, 16 Jan 2025 12:20:36 -0500 Subject: [PATCH] Implemented autosave on a 5-second timer (#3037) * Implemented autosave on a 5-second timer * Documented * Removed double quotes * Use appModel instead of codeMirror * Moved autosave functionality to LocalStorage singleton * Added unit tests * Fixed lints * Dart format * Use callback to get fallback snippet * Dart format * Test for more cases * Removed local_storage conditional import * Formatted * Reverted async function to .then * Got rid of _autosave() on dispose() * Revert "Removed local_storage conditional import" This reverts commit c952120fc602139b96925efd8cbf1dc86d9b44bf. --- pkgs/dartpad_ui/lib/editor/editor.dart | 11 +- pkgs/dartpad_ui/lib/execution/frame.dart | 2 +- pkgs/dartpad_ui/lib/local_storage.dart | 13 ++ pkgs/dartpad_ui/lib/local_storage/stub.dart | 16 +++ pkgs/dartpad_ui/lib/local_storage/web.dart | 20 +++ pkgs/dartpad_ui/lib/main.dart | 14 ++- pkgs/dartpad_ui/lib/model.dart | 10 +- pkgs/dartpad_ui/lib/utils.dart | 4 + pkgs/dartpad_ui/test/autosave_test.dart | 128 ++++++++++++++++++++ 9 files changed, 205 insertions(+), 13 deletions(-) create mode 100644 pkgs/dartpad_ui/lib/local_storage.dart create mode 100644 pkgs/dartpad_ui/lib/local_storage/stub.dart create mode 100644 pkgs/dartpad_ui/lib/local_storage/web.dart create mode 100644 pkgs/dartpad_ui/test/autosave_test.dart diff --git a/pkgs/dartpad_ui/lib/editor/editor.dart b/pkgs/dartpad_ui/lib/editor/editor.dart index 1e4ec9ae1..69fa423c5 100644 --- a/pkgs/dartpad_ui/lib/editor/editor.dart +++ b/pkgs/dartpad_ui/lib/editor/editor.dart @@ -12,6 +12,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:web/web.dart' as web; +import '../local_storage.dart'; import '../model.dart'; import 'codemirror.dart'; @@ -177,10 +178,17 @@ class _EditorWidgetState extends State implements EditorService { @override void initState() { super.initState(); - + _autosaveTimer = Timer.periodic(const Duration(seconds: 5), _autosave); widget.appModel.appReady.addListener(_updateEditableStatus); } + Timer? _autosaveTimer; + void _autosave([Timer? timer]) { + final content = widget.appModel.sourceCodeController.text; + if (content.isEmpty) return; + LocalStorage.instance.saveUserCode(content); + } + void _platformViewCreated(int id, {required bool darkMode}) { codeMirror = codeMirrorInstance; @@ -304,6 +312,7 @@ class _EditorWidgetState extends State implements EditorService { @override void dispose() { listener?.cancel(); + _autosaveTimer?.cancel(); widget.appServices.registerEditorService(null); diff --git a/pkgs/dartpad_ui/lib/execution/frame.dart b/pkgs/dartpad_ui/lib/execution/frame.dart index 71b30573c..b535bbfa8 100644 --- a/pkgs/dartpad_ui/lib/execution/frame.dart +++ b/pkgs/dartpad_ui/lib/execution/frame.dart @@ -62,7 +62,7 @@ function dartPrint(message) { 'sender': 'frame', 'type': 'stdout', 'message': message.toString() - }, '*'); + }, '*'); } '''); diff --git a/pkgs/dartpad_ui/lib/local_storage.dart b/pkgs/dartpad_ui/lib/local_storage.dart new file mode 100644 index 000000000..3fc58e03f --- /dev/null +++ b/pkgs/dartpad_ui/lib/local_storage.dart @@ -0,0 +1,13 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'local_storage/stub.dart' + if (dart.library.js_util) 'local_storage/web.dart'; + +abstract class LocalStorage { + static LocalStorage instance = LocalStorageImpl(); + + void saveUserCode(String code); + String? getUserCode(); +} diff --git a/pkgs/dartpad_ui/lib/local_storage/stub.dart b/pkgs/dartpad_ui/lib/local_storage/stub.dart new file mode 100644 index 000000000..72416d6c0 --- /dev/null +++ b/pkgs/dartpad_ui/lib/local_storage/stub.dart @@ -0,0 +1,16 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import '../local_storage.dart'; +import '../utils.dart'; + +class LocalStorageImpl extends LocalStorage { + String? _code; + + @override + void saveUserCode(String code) => _code = code; + + @override + String? getUserCode() => _code?.nullIfEmpty; +} diff --git a/pkgs/dartpad_ui/lib/local_storage/web.dart b/pkgs/dartpad_ui/lib/local_storage/web.dart new file mode 100644 index 000000000..e10088ba4 --- /dev/null +++ b/pkgs/dartpad_ui/lib/local_storage/web.dart @@ -0,0 +1,20 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:web/web.dart' as web; + +import '../local_storage.dart'; +import '../utils.dart'; + +const _userInputKey = 'user_'; + +class LocalStorageImpl extends LocalStorage { + @override + void saveUserCode(String code) => + web.window.localStorage.setItem(_userInputKey, code); + + @override + String? getUserCode() => + web.window.localStorage.getItem(_userInputKey)?.nullIfEmpty; +} diff --git a/pkgs/dartpad_ui/lib/main.dart b/pkgs/dartpad_ui/lib/main.dart index b126fd32e..5af0db161 100644 --- a/pkgs/dartpad_ui/lib/main.dart +++ b/pkgs/dartpad_ui/lib/main.dart @@ -23,6 +23,7 @@ import 'embed.dart'; import 'execution/execution.dart'; import 'extensions.dart'; import 'keys.dart' as keys; +import 'local_storage.dart'; import 'model.dart'; import 'problems.dart'; import 'samples.g.dart'; @@ -274,11 +275,13 @@ class _DartPadMainPageState extends State appServices.populateVersions(); appServices .performInitialLoad( - gistId: widget.gistId, - sampleId: widget.builtinSampleId, - flutterSampleId: widget.flutterSampleId, - channel: widget.initialChannel, - fallbackSnippet: Samples.defaultSnippet()) + gistId: widget.gistId, + sampleId: widget.builtinSampleId, + flutterSampleId: widget.flutterSampleId, + channel: widget.initialChannel, + getFallback: () => + LocalStorage.instance.getUserCode() ?? Samples.defaultSnippet(), + ) .then((value) { // Start listening for inject code messages. handleEmbedMessage(appServices, runOnInject: widget.runOnLoad); @@ -286,7 +289,6 @@ class _DartPadMainPageState extends State appServices.performCompileAndRun(); } }); - appModel.compilingBusy.addListener(_handleRunStarted); } diff --git a/pkgs/dartpad_ui/lib/model.dart b/pkgs/dartpad_ui/lib/model.dart index 21eec8511..423232a36 100644 --- a/pkgs/dartpad_ui/lib/model.dart +++ b/pkgs/dartpad_ui/lib/model.dart @@ -207,7 +207,7 @@ class AppServices { String? sampleId, String? flutterSampleId, String? channel, - required String fallbackSnippet, + required String Function() getFallback, }) async { // Delay a bit for codemirror to initialize. await Future.delayed(const Duration(milliseconds: 1)); @@ -241,7 +241,7 @@ class AppServices { appModel.appendLineToConsole('Error loading sample: $e'); - appModel.sourceCodeController.text = fallbackSnippet; + appModel.sourceCodeController.text = getFallback(); appModel.appReady.value = true; } finally { loader.dispose(); @@ -265,7 +265,7 @@ class AppServices { final source = gist.mainDartSource; if (source == null) { appModel.editorStatus.showToast('main.dart not found'); - appModel.sourceCodeController.text = fallbackSnippet; + appModel.sourceCodeController.text = getFallback(); } else { appModel.sourceCodeController.text = source; @@ -285,7 +285,7 @@ class AppServices { appModel.appendLineToConsole('Error loading gist: $e'); - appModel.sourceCodeController.text = fallbackSnippet; + appModel.sourceCodeController.text = getFallback(); appModel.appReady.value = true; } finally { gistLoader.dispose(); @@ -295,7 +295,7 @@ class AppServices { } // Neither gistId nor flutterSampleId were passed in. - appModel.sourceCodeController.text = fallbackSnippet; + appModel.sourceCodeController.text = getFallback(); appModel.appReady.value = true; } diff --git a/pkgs/dartpad_ui/lib/utils.dart b/pkgs/dartpad_ui/lib/utils.dart index 2243d6be6..c008f888e 100644 --- a/pkgs/dartpad_ui/lib/utils.dart +++ b/pkgs/dartpad_ui/lib/utils.dart @@ -184,3 +184,7 @@ enum MessageState { showing, closing; } + +extension StringUtils on String { + String? get nullIfEmpty => isEmpty ? null : this; +} diff --git a/pkgs/dartpad_ui/test/autosave_test.dart b/pkgs/dartpad_ui/test/autosave_test.dart new file mode 100644 index 000000000..28d23c450 --- /dev/null +++ b/pkgs/dartpad_ui/test/autosave_test.dart @@ -0,0 +1,128 @@ +// Copyright (c) 2024, the Dart project authors. Please see the AUTHORS file +// for details. All rights reserved. Use of this source code is governed by a +// BSD-style license that can be found in the LICENSE file. + +import 'package:dartpad_ui/local_storage.dart'; +import 'package:dartpad_ui/model.dart'; +import 'package:dartpad_ui/samples.g.dart'; +import 'package:dartpad_ui/utils.dart'; + +import 'package:test/test.dart'; + +String getFallback() => + LocalStorage.instance.getUserCode() ?? Samples.defaultSnippet(); + +Never throwingFallback() => + throw StateError('DartPad tried to load the fallback'); + +void main() { + const channel = Channel.stable; + group('Autosave:', () { + test('empty content is treated as null', () { + expect(''.nullIfEmpty, isNull); + + LocalStorage.instance.saveUserCode('non-empty'); + expect(LocalStorage.instance.getUserCode(), isNotNull); + + LocalStorage.instance.saveUserCode(''); + expect(LocalStorage.instance.getUserCode(), isNull); + }); + + test('null content means sample snippet is shown', () async { + final model = AppModel(); + final services = AppServices(model, channel); + LocalStorage.instance.saveUserCode(''); + expect(LocalStorage.instance.getUserCode(), isNull); + + await services.performInitialLoad( + getFallback: getFallback, + ); + expect(model.sourceCodeController.text, equals(Samples.defaultSnippet())); + }); + + group('non-null content is shown with', () { + const sample = 'Hello, World!'; + setUp(() => LocalStorage.instance.saveUserCode(sample)); + + test('only fallback', () async { + final model = AppModel(); + final services = AppServices(model, channel); + expect(LocalStorage.instance.getUserCode(), equals(sample)); + + await services.performInitialLoad( + getFallback: getFallback, + ); + expect(model.sourceCodeController.text, equals(sample)); + }); + + test('invalid sample ID', () async { + final model = AppModel(); + final services = AppServices(model, channel); + expect(LocalStorage.instance.getUserCode(), equals(sample)); + + await services.performInitialLoad( + getFallback: getFallback, + sampleId: 'This is hopefully not a valid sample ID', + ); + expect(model.sourceCodeController.text, equals(sample)); + }); + + test('invalid Flutter sample ID', () async { + final model = AppModel(); + final services = AppServices(model, channel); + expect(LocalStorage.instance.getUserCode(), equals(sample)); + + await services.performInitialLoad( + getFallback: getFallback, + flutterSampleId: 'This is hopefully not a valid sample ID', + ); + expect(model.sourceCodeController.text, equals(sample)); + }); + + test('invalid Gist ID', () async { + final model = AppModel(); + final services = AppServices(model, channel); + expect(LocalStorage.instance.getUserCode(), equals(sample)); + + const gistId = 'This is hopefully not a valid Gist ID'; + await services.performInitialLoad( + getFallback: getFallback, + gistId: gistId, + ); + expect(model.sourceCodeController.text, equals(sample)); + }); + }); + + group('content is not shown with', () { + const sample = 'Hello, World!'; + setUp(() => LocalStorage.instance.saveUserCode(sample)); + // Not testing flutterSampleId to avoid breaking when the Flutter docs change + + test('Gist', () async { + final model = AppModel(); + final services = AppServices(model, channel); + expect(LocalStorage.instance.getUserCode(), equals(sample)); + + // From gists_tests.dart + const gistId = 'd3bd83918d21b6d5f778bdc69c3d36d6'; + await services.performInitialLoad( + getFallback: throwingFallback, + gistId: gistId, + ); + expect(model.sourceCodeController.text, isNot(equals(sample))); + }); + + test('sample', () async { + final model = AppModel(); + final services = AppServices(model, channel); + expect(LocalStorage.instance.getUserCode(), equals(sample)); + + await services.performInitialLoad( + getFallback: throwingFallback, + sampleId: 'dart', + ); + expect(model.sourceCodeController.text, isNot(equals(sample))); + }); + }); + }); +}