Skip to content

Commit

Permalink
Implemented autosave on a 5-second timer (#3037)
Browse files Browse the repository at this point in the history
* 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 c952120.
  • Loading branch information
Levi-Lesches authored Jan 16, 2025
1 parent e50eaf1 commit 4b1ab01
Show file tree
Hide file tree
Showing 9 changed files with 205 additions and 13 deletions.
11 changes: 10 additions & 1 deletion pkgs/dartpad_ui/lib/editor/editor.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -177,10 +178,17 @@ class _EditorWidgetState extends State<EditorWidget> 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;

Expand Down Expand Up @@ -304,6 +312,7 @@ class _EditorWidgetState extends State<EditorWidget> implements EditorService {
@override
void dispose() {
listener?.cancel();
_autosaveTimer?.cancel();

widget.appServices.registerEditorService(null);

Expand Down
2 changes: 1 addition & 1 deletion pkgs/dartpad_ui/lib/execution/frame.dart
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ function dartPrint(message) {
'sender': 'frame',
'type': 'stdout',
'message': message.toString()
}, '*');
}, '*');
}
''');

Expand Down
13 changes: 13 additions & 0 deletions pkgs/dartpad_ui/lib/local_storage.dart
Original file line number Diff line number Diff line change
@@ -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();
}
16 changes: 16 additions & 0 deletions pkgs/dartpad_ui/lib/local_storage/stub.dart
Original file line number Diff line number Diff line change
@@ -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;
}
20 changes: 20 additions & 0 deletions pkgs/dartpad_ui/lib/local_storage/web.dart
Original file line number Diff line number Diff line change
@@ -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;
}
14 changes: 8 additions & 6 deletions pkgs/dartpad_ui/lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -274,19 +275,20 @@ class _DartPadMainPageState extends State<DartPadMainPage>
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);
if (widget.runOnLoad) {
appServices.performCompileAndRun();
}
});

appModel.compilingBusy.addListener(_handleRunStarted);
}

Expand Down
10 changes: 5 additions & 5 deletions pkgs/dartpad_ui/lib/model.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>.delayed(const Duration(milliseconds: 1));
Expand Down Expand Up @@ -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();
Expand All @@ -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;

Expand All @@ -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();
Expand All @@ -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;
}

Expand Down
4 changes: 4 additions & 0 deletions pkgs/dartpad_ui/lib/utils.dart
Original file line number Diff line number Diff line change
Expand Up @@ -184,3 +184,7 @@ enum MessageState {
showing,
closing;
}

extension StringUtils on String {
String? get nullIfEmpty => isEmpty ? null : this;
}
128 changes: 128 additions & 0 deletions pkgs/dartpad_ui/test/autosave_test.dart
Original file line number Diff line number Diff line change
@@ -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)));
});
});
});
}

0 comments on commit 4b1ab01

Please sign in to comment.