diff --git a/pkgs/dartpad_ui/lib/editor/editor.dart b/pkgs/dartpad_ui/lib/editor/editor.dart index 1278cd2ae..09042cf24 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; @@ -303,6 +311,8 @@ class _EditorWidgetState extends State implements EditorService { @override void dispose() { listener?.cancel(); + _autosaveTimer?.cancel(); + _autosave(); 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 807c9853c..81675002d 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'; @@ -272,24 +273,27 @@ class _DartPadMainPageState extends State ); appServices.populateVersions(); - appServices - .performInitialLoad( - gistId: widget.gistId, - sampleId: widget.builtinSampleId, - flutterSampleId: widget.flutterSampleId, - channel: widget.initialChannel, - fallbackSnippet: Samples.defaultSnippet()) - .then((value) { - // Start listening for inject code messages. - handleEmbedMessage(appServices, runOnInject: widget.runOnLoad); - if (widget.runOnLoad) { - appServices.performCompileAndRun(); - } - }); + _initAppServices(); appModel.compilingBusy.addListener(_handleRunStarted); } + Future _initAppServices() async { + await appServices.performInitialLoad( + gistId: widget.gistId, + sampleId: widget.builtinSampleId, + flutterSampleId: widget.flutterSampleId, + channel: widget.initialChannel, + getFallback: () => + LocalStorage.instance.getUserCode() ?? Samples.defaultSnippet(), + ); + // Start listening for inject code messages. + handleEmbedMessage(appServices, runOnInject: widget.runOnLoad); + if (widget.runOnLoad) { + appServices.performCompileAndRun(); + } + } + @override void dispose() { appModel.compilingBusy.removeListener(_handleRunStarted); diff --git a/pkgs/dartpad_ui/lib/model.dart b/pkgs/dartpad_ui/lib/model.dart index d3a09a93f..6d2175cda 100644 --- a/pkgs/dartpad_ui/lib/model.dart +++ b/pkgs/dartpad_ui/lib/model.dart @@ -205,7 +205,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)); @@ -239,7 +239,7 @@ class AppServices { appModel.appendLineToConsole('Error loading sample: $e'); - appModel.sourceCodeController.text = fallbackSnippet; + appModel.sourceCodeController.text = getFallback(); appModel.appReady.value = true; } finally { loader.dispose(); @@ -263,7 +263,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; @@ -283,7 +283,7 @@ class AppServices { appModel.appendLineToConsole('Error loading gist: $e'); - appModel.sourceCodeController.text = fallbackSnippet; + appModel.sourceCodeController.text = getFallback(); appModel.appReady.value = true; } finally { gistLoader.dispose(); @@ -293,7 +293,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))); + }); + }); + }); +}