Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implemented autosave on a 5-second timer #3037

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
12 changes: 11 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 @@ -303,6 +311,8 @@ class _EditorWidgetState extends State<EditorWidget> implements EditorService {
@override
void dispose() {
listener?.cancel();
_autosaveTimer?.cancel();
_autosave();

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;
}
32 changes: 18 additions & 14 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 @@ -272,24 +273,27 @@ class _DartPadMainPageState extends State<DartPadMainPage>
);

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<void> _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);
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 @@ -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<void>.delayed(const Duration(milliseconds: 1));
Expand Down Expand Up @@ -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();
Expand All @@ -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;

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

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)));
});
});
});
}