diff --git a/packages/flutter_tools/lib/src/commands/widget_preview.dart b/packages/flutter_tools/lib/src/commands/widget_preview.dart index 059fd01eb4..bd70afd4be 100644 --- a/packages/flutter_tools/lib/src/commands/widget_preview.dart +++ b/packages/flutter_tools/lib/src/commands/widget_preview.dart @@ -26,6 +26,7 @@ import '../project.dart'; import '../runner/flutter_command.dart'; import '../widget_preview/preview_code_generator.dart'; import '../widget_preview/preview_detector.dart'; +import '../widget_preview/preview_manifest.dart'; import '../windows/build_windows.dart'; import 'create_base.dart'; import 'daemon.dart'; @@ -177,25 +178,35 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C final OperatingSystemUtils os; + late final FlutterProject rootProject = getRootProject(); + late final PreviewDetector _previewDetector = PreviewDetector( logger: logger, fs: fs, onChangeDetected: onChangeDetected, + onPubspecChangeDetected: onPubspecChangeDetected, ); late final PreviewCodeGenerator _previewCodeGenerator; + late final PreviewManifest _previewManifest; /// The currently running instance of the widget preview scaffold. AppInstance? _widgetPreviewApp; @override Future runCommand() async { - final FlutterProject rootProject = getRootProject(); final Directory widgetPreviewScaffold = rootProject.widgetPreviewScaffold; + _previewManifest = PreviewManifest( + logger: logger, + rootProject: rootProject, + fs: fs, + cache: cache, + ); // Check to see if a preview scaffold has already been generated. If not, // generate one. - final bool generateScaffoldProject = !widgetPreviewScaffold.existsSync(); + final bool generateScaffoldProject = _previewManifest.shouldGenerateProject(); + // TODO(bkonyi): can this be moved? widgetPreviewScaffold.createSync(); if (generateScaffoldProject) { @@ -218,6 +229,7 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C overwrite: true, generateMetadata: false, ); + _previewManifest.generate(); // WARNING: this access of widgetPreviewScaffoldProject needs to happen // after we generate the scaffold project as invoking the getter triggers @@ -232,13 +244,21 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C fs: fs, ); - // TODO(matanlurey): Remove this comment once flutter_gen is removed. - // - // Tracking removal: https://github.com/flutter/flutter/issues/102983. - // - // Populate the pubspec after the initial build to avoid blowing away the package_config.json - // which may have manual changes for flutter_gen support. - await _populatePreviewPubspec(rootProject: rootProject); + if (generateScaffoldProject || _previewManifest.shouldRegeneratePubspec()) { + if (!generateScaffoldProject) { + logger.printStatus( + 'Detected changes in pubspec.yaml. Regenerating pubspec.yaml for the ' + 'widget preview scaffold.', + ); + } + // TODO(matanlurey): Remove this comment once flutter_gen is removed. + // + // Tracking removal: https://github.com/flutter/flutter/issues/102983. + // + // Populate the pubspec after the initial build to avoid blowing away the package_config.json + // which may have manual changes for flutter_gen support. + await _populatePreviewPubspec(rootProject: rootProject); + } final PreviewMapping initialPreviews = await _previewDetector.initialize(rootProject.directory); _previewCodeGenerator.populatePreviewsInGeneratedPreviewScaffold(initialPreviews); @@ -266,6 +286,12 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C _widgetPreviewApp?.restart(); } + void onPubspecChangeDetected() { + // TODO(bkonyi): trigger hot reload or restart? + logger.printStatus('Changes to pubspec.yaml detected.'); + _populatePreviewPubspec(rootProject: rootProject); + } + /// Builds the application binary for the widget preview scaffold the first /// time the widget preview command is run. /// @@ -566,6 +592,7 @@ final class WidgetPreviewStartCommand extends WidgetPreviewSubCommandBase with C ); maybeAddFlutterGenToPackageConfig(rootProject: rootProject); + _previewManifest.updatePubspecHash(); } /// Manually adds an entry for package:flutter_gen to the preview scaffold's diff --git a/packages/flutter_tools/lib/src/widget_preview/preview_detector.dart b/packages/flutter_tools/lib/src/widget_preview/preview_detector.dart index db0df75e73..0f238836ba 100644 --- a/packages/flutter_tools/lib/src/widget_preview/preview_detector.dart +++ b/packages/flutter_tools/lib/src/widget_preview/preview_detector.dart @@ -40,6 +40,8 @@ extension on Annotation { /// Convenience getters for examining [String] paths. extension on String { bool get isDartFile => endsWith('.dart'); + bool get isPubspec => endsWith('pubspec.yaml'); + bool get doesContainDartTool => contains('.dart_tool'); bool get isGeneratedPreviewFile => endsWith(PreviewCodeGenerator.generatedPreviewFilePath); } @@ -49,11 +51,18 @@ extension on ParsedUnitResult { } class PreviewDetector { - PreviewDetector({required this.fs, required this.logger, required this.onChangeDetected}); + PreviewDetector({ + required this.fs, + required this.logger, + required this.onChangeDetected, + required this.onPubspecChangeDetected, + }); final FileSystem fs; final Logger logger; final void Function(PreviewMapping) onChangeDetected; + final void Function() onPubspecChangeDetected; + StreamSubscription? _fileWatcher; late final PreviewMapping _pathToPreviews; @@ -64,9 +73,14 @@ class PreviewDetector { _pathToPreviews = findPreviewFunctions(projectRoot); final Watcher watcher = Watcher(projectRoot.path); - // TODO(bkonyi): watch for changes to pubspec.yaml _fileWatcher = watcher.events.listen((WatchEvent event) async { final String eventPath = event.path; + // If the pubspec has changed, new dependencies or assets could have been added, requiring + // the preview scaffold's pubspec to be updated. + if (eventPath.isPubspec && !eventPath.doesContainDartTool) { + onPubspecChangeDetected(); + return; + } // Only trigger a reload when changes to Dart sources are detected. We // ignore the generated preview file to avoid getting stuck in a loop. if (!eventPath.isDartFile || eventPath.isGeneratedPreviewFile) { diff --git a/packages/flutter_tools/lib/src/widget_preview/preview_manifest.dart b/packages/flutter_tools/lib/src/widget_preview/preview_manifest.dart new file mode 100644 index 0000000000..727a72cfd4 --- /dev/null +++ b/packages/flutter_tools/lib/src/widget_preview/preview_manifest.dart @@ -0,0 +1,137 @@ +// Copyright 2014 The Flutter Authors. 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:crypto/crypto.dart'; +import 'package:meta/meta.dart'; + +import '../base/file_system.dart'; +import '../base/logger.dart'; +import '../base/version.dart'; +import '../cache.dart'; +import '../convert.dart'; +import '../project.dart'; + +typedef PreviewManifestContents = Map; + +class PreviewManifest { + PreviewManifest({ + required this.logger, + required this.rootProject, + required this.fs, + required this.cache, + }); + + static const String previewManifestPath = 'preview_manifest.json'; + static final Version previewManifestVersion = Version(0, 0, 1); + static const String kManifestVersion = 'version'; + static const String kSdkVersion = 'sdk-version'; + static const String kPubspecHash = 'pubspec-hash'; + + final Logger logger; + final FlutterProject rootProject; + final FileSystem fs; + final Cache cache; + + Directory get widgetPreviewScaffold => rootProject.widgetPreviewScaffold; + String get _manifestPath => fs.path.join(widgetPreviewScaffold.path, previewManifestPath); + File get _manifest => fs.file(_manifestPath); + + PreviewManifestContents? _tryLoadManifest() { + final File manifest = fs.file(_manifestPath); + if (!manifest.existsSync()) { + return null; + } + return json.decode(manifest.readAsStringSync()) as PreviewManifestContents; + } + + void generate() { + logger.printStatus('Creating the Widget Preview Scaffold manifest at ${_manifest.path}'); + assert(!_manifest.existsSync()); + _manifest.createSync(recursive: true); + final PreviewManifestContents manifestContents = { + kManifestVersion: previewManifestVersion.toString(), + kSdkVersion: cache.dartSdkVersion, + kPubspecHash: _calculatePubspecHash(), + }; + _updateManifest(manifestContents); + } + + void _updateManifest(PreviewManifestContents contents) { + _manifest.writeAsStringSync(json.encode(contents)); + } + + String _calculatePubspecHash() { + return md5.convert(rootProject.manifest.toYaml().toString().codeUnits).toString(); + } + + bool shouldGenerateProject() { + if (!widgetPreviewScaffold.existsSync()) { + return true; + } + final PreviewManifestContents? manifest = _tryLoadManifest(); + // If the manifest doesn't exist or the SDK version isn't present, the widget preview scaffold + // should be regenerated and rebuilt. + if (manifest == null || + !manifest.containsKey(kManifestVersion) || + !manifest.containsKey(kSdkVersion)) { + logger.printWarning( + 'Invalid Widget Preview Scaffold manifest at ${_manifest.path}. Regenerating Widget ' + 'Preview Scaffold.', + ); + return true; + } + final Version? manifestVersion = Version.parse(manifest[kManifestVersion]! as String); + // If the manifest version in the scaffold doesn't match the current preview manifest spec, + // we should regenerate it. + // TODO(bkonyi): is this actually what we want to do, or do we want to just update the manifest? + if (manifestVersion == null || manifestVersion != previewManifestVersion) { + logger.printStatus( + 'The existing Widget Preview Scaffold manifest version ($manifestVersion) ' + 'is older than the currently supported version ($previewManifestVersion). Regenerating ' + 'Widget Preview Scaffold.', + ); + return true; + } + // If the SDK version of the widget preview scaffold doesn't match the current SDK version + // the widget preview scaffold should also be regenerated to pick up any new functionality and + // avoid possible binary compatibility issues. + final bool sdkVersionMismatch = manifest[kSdkVersion] != cache.dartSdkVersion; + if (sdkVersionMismatch) { + logger.printStatus( + 'The existing Widget Preview Scaffold was generated with Dart SDK ' + 'version ${manifest[kSdkVersion]}, which does not match the current Dart SDK version ' + '(${cache.dartSdkVersion}). Regenerating Widget Preview Scaffold.', + ); + } + return sdkVersionMismatch; + } + + bool shouldRegeneratePubspec() { + final PreviewManifestContents manifest = _tryLoadManifest()!; + if (!manifest.containsKey(kPubspecHash)) { + logger.printWarning( + 'The Widget Preview Scaffold manifest does not include the last known state of the root ' + "project's pubspec.yaml.", + ); + return true; + } + return manifest[kPubspecHash] != _calculatePubspecHash(); + } + + void updatePubspecHash() { + final PreviewManifestContents manifest = _tryLoadManifest()!; + manifest[kPubspecHash] = _calculatePubspecHash(); + _updateManifest(manifest); + } + + @visibleForTesting + PreviewManifest copyWith({Cache? cache}) { + return PreviewManifest( + logger: logger, + rootProject: rootProject, + fs: fs, + cache: cache ?? this.cache, + ); + } +} diff --git a/packages/flutter_tools/test/general.shard/widget_preview/preview_detector_test.dart b/packages/flutter_tools/test/general.shard/widget_preview/preview_detector_test.dart index 5e249c1ceb..cf0b73e9f5 100644 --- a/packages/flutter_tools/test/general.shard/widget_preview/preview_detector_test.dart +++ b/packages/flutter_tools/test/general.shard/widget_preview/preview_detector_test.dart @@ -18,6 +18,12 @@ Directory createBasicProjectStructure(FileSystem fs) { return fs.systemTempDirectory.createTempSync('root'); } +void populatePubspec(Directory projectRoot, String contents) { + projectRoot.childFile('pubspec.yaml') + ..createSync(recursive: true) + ..writeAsStringSync(contents); +} + PreviewPath addPreviewContainingFile(Directory projectRoot, List path) { final File file = projectRoot.childDirectory('lib').childFile(path.join(const LocalPlatform().pathSeparator)) @@ -44,11 +50,16 @@ void main() { late PreviewDetector previewDetector; late Directory projectRoot; void Function(PreviewMapping)? onChangeDetected; + void Function()? onPubspecChangeDetected; void onChangeDetectedRoot(PreviewMapping mapping) { onChangeDetected!(mapping); } + void onPubspecChangeDetectedRoot() { + onPubspecChangeDetected!(); + } + setUp(() { fs = LocalFileSystem.test(signals: Signals.test()); projectRoot = createBasicProjectStructure(fs); @@ -57,6 +68,7 @@ void main() { logger: logger, fs: fs, onChangeDetected: onChangeDetectedRoot, + onPubspecChangeDetected: onPubspecChangeDetectedRoot, ); }); @@ -117,6 +129,23 @@ void main() { addNonPreviewContainingFile(projectRoot, ['baz.dart']); await completer.future; }); + + testUsingContext('can detect changes in the pubspec.yaml', () async { + // Create an initial pubspec. + populatePubspec(projectRoot, 'abc'); + + final Completer completer = Completer(); + onPubspecChangeDetected = () { + completer.complete(); + }; + // Initialize the file watcher. + final PreviewMapping initialPreviews = await previewDetector.initialize(projectRoot); + expect(initialPreviews, isEmpty); + + // Change the contents of the pubspec and verify the callback is invoked. + populatePubspec(projectRoot, 'foo'); + await completer.future; + }); }); } diff --git a/packages/flutter_tools/test/general.shard/widget_preview/preview_manifest_test.dart b/packages/flutter_tools/test/general.shard/widget_preview/preview_manifest_test.dart new file mode 100644 index 0000000000..5343e3d6a9 --- /dev/null +++ b/packages/flutter_tools/test/general.shard/widget_preview/preview_manifest_test.dart @@ -0,0 +1,119 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:convert'; + +import 'package:file/memory.dart'; +import 'package:flutter_tools/src/base/file_system.dart'; +import 'package:flutter_tools/src/base/logger.dart'; +import 'package:flutter_tools/src/base/platform.dart'; +import 'package:flutter_tools/src/cache.dart'; +import 'package:flutter_tools/src/flutter_manifest.dart'; +import 'package:flutter_tools/src/project.dart'; +import 'package:flutter_tools/src/widget_preview/preview_manifest.dart'; +import 'package:test/test.dart'; + +import '../../src/common.dart'; +import '../../src/context.dart'; + +void main() { + group('$PreviewManifest', () { + late FlutterProject rootProject; + late PreviewManifest previewManifest; + late Logger logger; + + // The version really doesn't matter, just the format. + const String kFakeSDKVersion = '2.1.0-dev.8.0.flutter-4312ae32'; + + setUp(() { + final MemoryFileSystem fs = MemoryFileSystem.test(); + final FlutterManifest manifest = FlutterManifest.empty(logger: BufferLogger.test()); + final Directory projectDir = fs.currentDirectory.childDirectory('project')..createSync(); + projectDir.childDirectory('lib/src').createSync(recursive: true); + rootProject = FlutterProject(projectDir, manifest, manifest); + logger = BufferLogger.test(); + previewManifest = PreviewManifest( + logger: logger, + rootProject: rootProject, + fs: fs, + cache: Cache.test( + processManager: FakeProcessManager.any(), + fileSystem: fs, + platform: FakePlatform(version: kFakeSDKVersion), + ), + ); + }); + + testUsingContext('generates a valid manifest', () async { + previewManifest.generate(); + final PreviewManifestContents manifest = + json.decode( + rootProject.widgetPreviewScaffold + .childFile(PreviewManifest.previewManifestPath) + .readAsStringSync(), + ) + as PreviewManifestContents; + + expect(manifest.containsKey(PreviewManifest.kPubspecHash), true); + expect(manifest.containsKey(PreviewManifest.kManifestVersion), true); + expect(manifest.containsKey(PreviewManifest.kSdkVersion), true); + }); + + testUsingContext('identifies widget preview scaffold project needs to be generated', () { + // The widget preview scaffold directory doesn't exist, so we should know that we need to + // generate the project. + expect(previewManifest.shouldGenerateProject(), true); + + // Populate the manifest. For this test, this has the side effect of creating the widget + // preview scaffold project directory as well. + previewManifest.generate(); + + // The widget preview scaffold project directory should exist as well as the newly generated + // preview manifest. + expect(previewManifest.shouldGenerateProject(), false); + + // Simulate changing the SDK version and verify that we should regenerate the project. + final PreviewManifest modified = previewManifest.copyWith( + cache: Cache.test( + processManager: FakeProcessManager.any(), + platform: FakePlatform(version: '${kFakeSDKVersion}foo'), + ), + ); + + const String sdkMismatchMessage = + 'The existing Widget Preview Scaffold was generated with Dart SDK ' + 'version 2.1.0 (build 2.1.0-dev.8.0 4312ae32), which does not match the current Dart ' + 'SDK version (2.1.0 (build 2.1.0-dev.8.0 4312ae32foo)). Regenerating Widget Preview ' + 'Scaffold.\n'; + expect(modified.shouldGenerateProject(), true); + expect((modified.logger as BufferLogger).statusText, contains(sdkMismatchMessage)); + }); + + testUsingContext('identifies root project pubspec has changed', () { + // The widget preview scaffold directory doesn't exist, so we should know that we need to + // generate the project. + expect(previewManifest.shouldGenerateProject(), true); + + // Populate the manifest. For this test, this has the side effect of creating the widget + // preview scaffold project directory as well. + previewManifest.generate(); + + // The widget preview scaffold project directory should exist as well as the newly generated + // preview manifest. + expect(previewManifest.shouldGenerateProject(), false); + + // Simulate changing the root project's pubspec.yaml and verify that we should regenerate + // the widget preview scaffold's pubspec.yaml. + rootProject.replacePubspec( + rootProject.manifest.copyWith(logger: logger, models: [Uri(host: 'Random')]), + ); + expect(previewManifest.shouldRegeneratePubspec(), true); + + // Update the manifest to include the hash for the updated pubspec.yaml and verify that we + // no longer need to regenerate the pubspec. + previewManifest.updatePubspecHash(); + expect(previewManifest.shouldRegeneratePubspec(), false); + }); + }); +}