diff --git a/packages/devtools_app/lib/devtools_app.dart b/packages/devtools_app/lib/devtools_app.dart index 4fb9d7141cb..c7c37fbeec7 100644 --- a/packages/devtools_app/lib/devtools_app.dart +++ b/packages/devtools_app/lib/devtools_app.dart @@ -3,6 +3,7 @@ // found in the LICENSE file. export 'src/app.dart'; +export 'src/extensions/extension_service.dart'; export 'src/framework/app_bar.dart'; export 'src/framework/home_screen.dart'; export 'src/framework/notifications_view.dart'; diff --git a/packages/devtools_app/lib/src/app.dart b/packages/devtools_app/lib/src/app.dart index bf5692ce082..edb3b00eb32 100644 --- a/packages/devtools_app/lib/src/app.dart +++ b/packages/devtools_app/lib/src/app.dart @@ -96,6 +96,9 @@ class DevToolsAppState extends State with AutoDisposeMixin { List get _originalScreens => widget.originalScreens.map((s) => s.screen).toList(); + /// TODO(kenz): use [extensionService.visibleExtensions] instead of + /// [extensionService.availableExtensions] and verify tabs are added / removed + /// propertly based on the enabled state of extensions. Iterable get _extensionScreens => extensionService.availableExtensions.value.map( (e) => DevToolsScreen(ExtensionScreen(e)).screen, diff --git a/packages/devtools_app/lib/src/extensions/extension_service.dart b/packages/devtools_app/lib/src/extensions/extension_service.dart index 6c6f77a19ec..7e3c0d006b0 100644 --- a/packages/devtools_app/lib/src/extensions/extension_service.dart +++ b/packages/devtools_app/lib/src/extensions/extension_service.dart @@ -11,15 +11,40 @@ import '../shared/primitives/auto_dispose.dart'; class ExtensionService extends DisposableController with AutoDisposeControllerMixin { + /// All the DevTools extensions that are available for the connected + /// application, regardless of whether they have been enabled or disabled + /// by the user. ValueListenable> get availableExtensions => _availableExtensions; final _availableExtensions = ValueNotifier>([]); + /// DevTools extensions that are visible in their own DevTools screen (i.e. + /// extensions that have not been manually disabled by the user). + ValueListenable> get visibleExtensions => + _visibleExtensions; + final _visibleExtensions = ValueNotifier>([]); + + /// Returns the [ValueListenable] that stores the [ExtensionEnabledState] for + /// the DevTools Extension with [extensionName]. + ValueListenable enabledStateListenable( + String extensionName, + ) { + return _extensionEnabledStates.putIfAbsent( + extensionName.toLowerCase(), + () => ValueNotifier( + ExtensionEnabledState.none, + ), + ); + } + + final _extensionEnabledStates = + >{}; + Future initialize() async { - await maybeRefreshExtensions(); + await _maybeRefreshExtensions(); addAutoDisposeListener( serviceManager.connectedState, - maybeRefreshExtensions, + _maybeRefreshExtensions, ); // TODO(kenz): we should also refresh the available extensions on some event @@ -27,17 +52,63 @@ class ExtensionService extends DisposableController // .dart_tool/package_config.json file for changes. } - Future maybeRefreshExtensions() async { + Future _maybeRefreshExtensions() async { + final appRootPath = await _connectedAppRootPath(); + if (appRootPath == null) return; + + _availableExtensions.value = + await server.refreshAvailableExtensions(appRootPath) + ..sort(); + await _refreshExtensionEnabledStates(); + } + + Future _refreshExtensionEnabledStates() async { + final appRootPath = await _connectedAppRootPath(); + if (appRootPath == null) return; + + final visible = []; + for (final extension in _availableExtensions.value) { + final stateFromOptionsFile = await server.extensionEnabledState( + rootPath: appRootPath, + extensionName: extension.name, + ); + final stateNotifier = _extensionEnabledStates.putIfAbsent( + extension.name, + () => ValueNotifier(stateFromOptionsFile), + ); + stateNotifier.value = stateFromOptionsFile; + if (stateFromOptionsFile != ExtensionEnabledState.disabled) { + visible.add(extension); + } + } + // [_visibleExtensions] should be set last so that all extension states in + // [_extensionEnabledStates] are updated by the time we notify listeners of + // [visibleExtensions]. It is not necessary to sort [visible] because + // [_availableExtensions] is already sorted. + _visibleExtensions.value = visible; + } + + /// Sets the enabled state for [extension]. + Future setExtensionEnabledState( + DevToolsExtensionConfig extension, { + required bool enable, + }) async { final appRootPath = await _connectedAppRootPath(); if (appRootPath != null) { - _availableExtensions.value = - await server.refreshAvailableExtensions(appRootPath); + await server.extensionEnabledState( + rootPath: appRootPath, + extensionName: extension.name, + enable: enable, + ); + await _refreshExtensionEnabledStates(); } } } +// TODO(kenz): consider caching this for the duration of the VM service +// connection. Future _connectedAppRootPath() async { - var fileUri = await serviceManager.rootLibraryForSelectedIsolate(); + var fileUri = await serviceManager.rootLibraryForMainIsolate(); if (fileUri == null) return null; // TODO(kenz): for robustness, consider sending the root library uri to the diff --git a/packages/devtools_app/lib/src/extensions/extension_settings.dart b/packages/devtools_app/lib/src/extensions/extension_settings.dart new file mode 100644 index 00000000000..f6048253865 --- /dev/null +++ b/packages/devtools_app/lib/src/extensions/extension_settings.dart @@ -0,0 +1,205 @@ +// Copyright 2023 The Chromium 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:async'; + +import 'package:devtools_shared/devtools_extensions.dart'; +import 'package:flutter/material.dart'; + +import '../shared/analytics/analytics.dart' as ga; +import '../shared/analytics/constants.dart' as gac; +import '../shared/common_widgets.dart'; +import '../shared/dialogs.dart'; +import '../shared/globals.dart'; +import '../shared/theme.dart'; +import '../shared/utils.dart'; + +/// A [ScaffoldAction] that, when clicked, will open a dialog menu for +/// managing DevTools extension states. +class ExtensionSettingsAction extends ScaffoldAction { + ExtensionSettingsAction({super.key, Color? color}) + : super( + icon: Icons.extension_outlined, + tooltip: 'DevTools Extensions', + color: color, + onPressed: (context) { + unawaited( + showDialog( + context: context, + builder: (context) => const ExtensionSettingsDialog(), + ), + ); + }, + ); +} + +class ExtensionSettingsDialog extends StatelessWidget { + const ExtensionSettingsDialog({super.key}); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final availableExtensions = extensionService.availableExtensions.value; + // This dialog needs a fixed height because it contains a scrollable list. + final dialogHeight = scaleByFontFactor(300.0); + return DevToolsDialog( + title: const DialogTitleText('DevTools Extensions'), + content: SizedBox( + width: defaultDialogWidth, + height: dialogHeight, + child: Column( + children: [ + const Text( + 'Extensions are provided by the pub packages used in your ' + 'application. When activated, the tools provided by these ' + 'extensions will be available in a separate DevTools tab.', + ), + const SizedBox(height: defaultSpacing), + Expanded( + child: availableExtensions.isEmpty + ? Center( + child: Text( + 'No extensions available.', + style: theme.textTheme.bodyLarge!.copyWith( + color: theme.colorScheme.subtleTextColor, + ), + ), + ) + : _ExtensionsList(extensions: availableExtensions), + ), + ], + ), + ), + actions: const [ + DialogCloseButton(), + ], + ); + } +} + +class _ExtensionsList extends StatefulWidget { + const _ExtensionsList({required this.extensions}); + + final List extensions; + + @override + State<_ExtensionsList> createState() => __ExtensionsListState(); +} + +class __ExtensionsListState extends State<_ExtensionsList> { + late ScrollController scrollController; + + @override + void initState() { + super.initState(); + scrollController = ScrollController(); + } + + @override + void dispose() { + scrollController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scrollbar( + controller: scrollController, + thumbVisibility: true, + child: ListView.builder( + controller: scrollController, + itemCount: widget.extensions.length, + itemBuilder: (context, index) => ExtensionSetting( + extension: widget.extensions[index], + ), + ), + ); + } +} + +@visibleForTesting +class ExtensionSetting extends StatelessWidget { + const ExtensionSetting({super.key, required this.extension}); + + final DevToolsExtensionConfig extension; + + @override + Widget build(BuildContext context) { + final buttonStates = [ + ( + title: 'Enabled', + isSelected: (ExtensionEnabledState state) => + state == ExtensionEnabledState.enabled, + onPressed: () { + ga.select( + gac.extensionSettingsId, + gac.extensionEnable(extension.name.toLowerCase()), + ); + unawaited( + extensionService.setExtensionEnabledState( + extension, + enable: true, + ), + ); + }, + ), + ( + title: 'Disabled', + isSelected: (ExtensionEnabledState state) => + state == ExtensionEnabledState.disabled, + onPressed: () { + ga.select( + gac.extensionSettingsId, + gac.extensionDisable(extension.name.toLowerCase()), + ); + unawaited( + extensionService.setExtensionEnabledState( + extension, + enable: false, + ), + ); + }, + ), + ]; + final theme = Theme.of(context); + final extensionName = extension.name.toLowerCase(); + return ValueListenableBuilder( + valueListenable: extensionService.enabledStateListenable(extensionName), + builder: (context, enabledState, _) { + return Padding( + padding: const EdgeInsets.only(bottom: denseSpacing), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'package:$extensionName', + overflow: TextOverflow.ellipsis, + style: theme.fixedFontStyle, + ), + DevToolsToggleButtonGroup( + fillColor: theme.colorScheme.primary, + selectedColor: theme.colorScheme.onPrimary, + onPressed: (index) => buttonStates[index].onPressed(), + selectedStates: buttonStates + .map((option) => option.isSelected(enabledState)) + .toList(), + children: buttonStates + .map( + (option) => Padding( + padding: const EdgeInsets.symmetric( + vertical: densePadding, + horizontal: denseSpacing, + ), + child: Text(option.title), + ), + ) + .toList(), + ), + ], + ), + ); + }, + ); + } +} diff --git a/packages/devtools_app/lib/src/framework/scaffold.dart b/packages/devtools_app/lib/src/framework/scaffold.dart index 202563f66c6..8321a5b4504 100644 --- a/packages/devtools_app/lib/src/framework/scaffold.dart +++ b/packages/devtools_app/lib/src/framework/scaffold.dart @@ -6,6 +6,7 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../app.dart'; +import '../extensions/extension_settings.dart'; import '../screens/debugger/debugger_screen.dart'; import '../shared/analytics/prompt.dart'; import '../shared/banner_messages.dart'; @@ -13,6 +14,7 @@ import '../shared/common_widgets.dart'; import '../shared/config_specific/drag_and_drop/drag_and_drop.dart'; import '../shared/config_specific/import_export/import_export.dart'; import '../shared/console/widgets/console_pane.dart'; +import '../shared/feature_flags.dart'; import '../shared/framework_controller.dart'; import '../shared/globals.dart'; import '../shared/primitives/auto_dispose.dart'; @@ -62,6 +64,8 @@ class DevToolsScaffold extends StatefulWidget { }) => [ OpenSettingsAction(color: color), + if (FeatureFlags.devToolsExtensions) + ExtensionSettingsAction(color: color), ReportFeedbackButton(color: color), if (!isEmbedded) ImportToolbarAction(color: color), OpenAboutAction(color: color), diff --git a/packages/devtools_app/lib/src/service/service_manager.dart b/packages/devtools_app/lib/src/service/service_manager.dart index d2cf83b94ea..ad914b9cffe 100644 --- a/packages/devtools_app/lib/src/service/service_manager.dart +++ b/packages/devtools_app/lib/src/service/service_manager.dart @@ -540,18 +540,18 @@ class ServiceConnectionManager { return libraryUriAvailableNow(uri); } - Future rootLibraryForSelectedIsolate() async { + Future rootLibraryForMainIsolate() async { if (!connectedState.value.connected) return null; - final selectedIsolateRef = isolateManager.mainIsolate.value; - if (selectedIsolateRef == null) return null; + final mainIsolateRef = isolateManager.mainIsolate.value; + if (mainIsolateRef == null) return null; - final isolateState = isolateManager.isolateState(selectedIsolateRef); + final isolateState = isolateManager.isolateState(mainIsolateRef); await isolateState.waitForIsolateLoad(); final rootLib = isolateState.rootInfo!.library; if (rootLib == null) return null; - final selectedIsolateRefId = selectedIsolateRef.id!; + final selectedIsolateRefId = mainIsolateRef.id!; await resolvedUriManager.fetchFileUris(selectedIsolateRefId, [rootLib]); return resolvedUriManager.lookupFileUri( selectedIsolateRefId, diff --git a/packages/devtools_app/lib/src/shared/analytics/constants.dart b/packages/devtools_app/lib/src/shared/analytics/constants.dart index cc11bb6f88a..0678ab9ef26 100644 --- a/packages/devtools_app/lib/src/shared/analytics/constants.dart +++ b/packages/devtools_app/lib/src/shared/analytics/constants.dart @@ -43,7 +43,10 @@ const discordLink = 'discord'; // Extension screens UX actions. const extensionScreenId = 'devtoolsExtension'; +const extensionSettingsId = 'devtoolsExtensionSettings'; String extensionFeedback(String name) => 'extensionFeedback-$name'; +String extensionEnable(String name) => 'extensionEnable-$name'; +String extensionDisable(String name) => 'extensionDisable-$name'; // Inspector UX actions: const refresh = 'refresh'; diff --git a/packages/devtools_app/lib/src/shared/common_widgets.dart b/packages/devtools_app/lib/src/shared/common_widgets.dart index 18ad07d8b4f..ef3663565ad 100644 --- a/packages/devtools_app/lib/src/shared/common_widgets.dart +++ b/packages/devtools_app/lib/src/shared/common_widgets.dart @@ -998,6 +998,8 @@ class DevToolsToggleButtonGroup extends StatelessWidget { required this.children, required this.selectedStates, required this.onPressed, + this.fillColor, + this.selectedColor, }) : super(key: key); final List children; @@ -1006,6 +1008,10 @@ class DevToolsToggleButtonGroup extends StatelessWidget { final void Function(int)? onPressed; + final Color? fillColor; + + final Color? selectedColor; + @override Widget build(BuildContext context) { final theme = Theme.of(context); @@ -1013,6 +1019,8 @@ class DevToolsToggleButtonGroup extends StatelessWidget { height: defaultButtonHeight, child: ToggleButtons( borderRadius: defaultBorderRadius, + fillColor: fillColor, + selectedColor: selectedColor, textStyle: theme.textTheme.bodyMedium, constraints: BoxConstraints( minWidth: defaultButtonHeight, diff --git a/packages/devtools_app/lib/src/shared/config_specific/server/_server_stub.dart b/packages/devtools_app/lib/src/shared/config_specific/server/_server_stub.dart index 4676d4934cd..09670684b4f 100644 --- a/packages/devtools_app/lib/src/shared/config_specific/server/_server_stub.dart +++ b/packages/devtools_app/lib/src/shared/config_specific/server/_server_stub.dart @@ -7,6 +7,7 @@ import 'dart:async'; import 'package:devtools_shared/devtools_extensions.dart'; +import 'package:flutter/foundation.dart'; import '../../primitives/utils.dart'; @@ -80,9 +81,54 @@ Future requestTestAppSizeFile(String path) async { Future> refreshAvailableExtensions( String rootPath, ) async { - return []; + return kDebugMode ? _debugExtensions : []; +} + +Future extensionEnabledState({ + required String rootPath, + required String extensionName, + bool? enable, +}) async { + if (enable != null) { + _stubEnabledStates[extensionName] = + enable ? ExtensionEnabledState.enabled : ExtensionEnabledState.disabled; + } + return _stubEnabledStates.putIfAbsent( + extensionName, + () => ExtensionEnabledState.none, + ); } void logWarning() { throw Exception(unsupportedMessage); } + +/// Stubbed activation states so we can develop DevTools extensions without a +/// server connection on Desktop. +final _stubEnabledStates = {}; + +/// Stubbed extensions so we can develop DevTools Extensions without a server +/// connection on Desktop. +final List _debugExtensions = [ + DevToolsExtensionConfig.parse({ + DevToolsExtensionConfig.nameKey: 'foo', + DevToolsExtensionConfig.issueTrackerKey: 'www.google.com', + DevToolsExtensionConfig.versionKey: '1.0.0', + DevToolsExtensionConfig.pathKey: '/path/to/foo', + }), + DevToolsExtensionConfig.parse({ + DevToolsExtensionConfig.nameKey: 'bar', + DevToolsExtensionConfig.issueTrackerKey: 'www.google.com', + DevToolsExtensionConfig.versionKey: '2.0.0', + DevToolsExtensionConfig.materialIconCodePointKey: 0xe638, + DevToolsExtensionConfig.pathKey: '/path/to/bar', + }), + DevToolsExtensionConfig.parse({ + DevToolsExtensionConfig.nameKey: 'provider', + DevToolsExtensionConfig.issueTrackerKey: + 'https://github.com/rrousselGit/provider/issues', + DevToolsExtensionConfig.versionKey: '3.0.0', + DevToolsExtensionConfig.materialIconCodePointKey: 0xe50a, + DevToolsExtensionConfig.pathKey: '/path/to/provider', + }), +]; diff --git a/packages/devtools_app/lib/src/shared/config_specific/server/_server_web.dart b/packages/devtools_app/lib/src/shared/config_specific/server/_server_web.dart index d1bebe841d1..48bc50d3389 100644 --- a/packages/devtools_app/lib/src/shared/config_specific/server/_server_web.dart +++ b/packages/devtools_app/lib/src/shared/config_specific/server/_server_web.dart @@ -369,6 +369,39 @@ Future> refreshAvailableExtensions( return []; } +/// Makes a request to the server to look up the enabled state for a +/// DevTools extension, and optionally to set the enabled state (when [enable] +/// is non-null). +/// +/// If [enable] is specified, the server will first set the enabled state +/// to the value set forth by [enable] and then return the value that is saved +/// to disk. +Future extensionEnabledState({ + required String rootPath, + required String extensionName, + bool? enable, +}) async { + if (isDevToolsServerAvailable) { + final uri = Uri( + path: ExtensionsApi.apiExtensionEnabledState, + queryParameters: { + ExtensionsApi.extensionRootPathPropertyName: rootPath, + ExtensionsApi.extensionNamePropertyName: extensionName, + if (enable != null) + ExtensionsApi.enabledStatePropertyName: enable.toString(), + }, + ); + final resp = await request(uri.toString()); + if (resp?.status == HttpStatus.ok) { + final parsedResult = json.decode(resp!.responseText!); + return ExtensionEnabledState.from(parsedResult); + } else { + logWarning(resp, ExtensionsApi.apiExtensionEnabledState); + } + } + return ExtensionEnabledState.error; +} + void logWarning(HttpRequest? response, String apiType, [String? respText]) { _log.warning( 'HttpRequest $apiType failed status = ${response?.status}' diff --git a/packages/devtools_app/test/extensions/extension_screen_test.dart b/packages/devtools_app/test/extensions/extension_screen_test.dart index cd4da457a4f..e7c618dac6b 100644 --- a/packages/devtools_app/test/extensions/extension_screen_test.dart +++ b/packages/devtools_app/test/extensions/extension_screen_test.dart @@ -5,11 +5,12 @@ import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app/src/extensions/embedded/view.dart'; import 'package:devtools_app/src/extensions/extension_screen.dart'; -import 'package:devtools_shared/src/extensions/extension_model.dart'; import 'package:devtools_test/devtools_test.dart'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../test_infra/test_data/extensions.dart'; + void main() { const windowSize = Size(2000.0, 2000.0); group('Extension screen', () { @@ -21,7 +22,6 @@ void main() { setGlobal(IdeTheme, IdeTheme()); setGlobal(PreferencesController, PreferencesController()); setGlobal(ServiceConnectionManager, ServiceConnectionManager()); - fooScreen = ExtensionScreen(fooExtension); barScreen = ExtensionScreen(barExtension); providerScreen = ExtensionScreen(providerExtension); @@ -81,27 +81,3 @@ void main() { ); }); } - -final fooExtension = DevToolsExtensionConfig.parse({ - DevToolsExtensionConfig.nameKey: 'Foo', - DevToolsExtensionConfig.issueTrackerKey: 'www.google.com', - DevToolsExtensionConfig.versionKey: '1.0.0', - DevToolsExtensionConfig.pathKey: '/path/to/foo', -}); - -final barExtension = DevToolsExtensionConfig.parse({ - DevToolsExtensionConfig.nameKey: 'bar', - DevToolsExtensionConfig.issueTrackerKey: 'www.google.com', - DevToolsExtensionConfig.versionKey: '2.0.0', - DevToolsExtensionConfig.materialIconCodePointKey: 0xe638, - DevToolsExtensionConfig.pathKey: '/path/to/bar', -}); - -final providerExtension = DevToolsExtensionConfig.parse({ - DevToolsExtensionConfig.nameKey: 'provider', - DevToolsExtensionConfig.issueTrackerKey: - 'https://github.com/rrousselGit/provider/issues', - DevToolsExtensionConfig.versionKey: '3.0.0', - DevToolsExtensionConfig.materialIconCodePointKey: 0xe50a, - DevToolsExtensionConfig.pathKey: '/path/to/provider', -}); diff --git a/packages/devtools_app/test/extensions/extension_settings_test.dart b/packages/devtools_app/test/extensions/extension_settings_test.dart new file mode 100644 index 00000000000..cdd67d0d310 --- /dev/null +++ b/packages/devtools_app/test/extensions/extension_settings_test.dart @@ -0,0 +1,213 @@ +// Copyright 2023 The Chromium 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:devtools_app/devtools_app.dart'; +import 'package:devtools_app/src/extensions/extension_settings.dart'; +import 'package:devtools_shared/devtools_extensions.dart'; +import 'package:devtools_test/devtools_test.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +import '../test_infra/matchers/matchers.dart'; +import '../test_infra/test_data/extensions.dart'; + +void main() { + late ExtensionSettingsDialog dialog; + + final stubEnabledStates = >{}; + + Future setUpExtensionService( + List extensions, + // ignore: avoid-redundant-async, false positive + ) async { + final mockExtensionService = MockExtensionService(); + setGlobal(ExtensionService, mockExtensionService); + when(mockExtensionService.availableExtensions) + .thenReturn(ImmediateValueNotifier(extensions)); + + stubEnabledStates.clear(); + for (final e in extensions) { + stubEnabledStates[e.name.toLowerCase()] = + ValueNotifier(ExtensionEnabledState.none); + when(mockExtensionService.enabledStateListenable(e.name)) + .thenReturn(stubEnabledStates[e.name.toLowerCase()]!); + when(mockExtensionService.enabledStateListenable(e.name.toLowerCase())) + .thenReturn(stubEnabledStates[e.name.toLowerCase()]!); + when(mockExtensionService.setExtensionEnabledState(e, enable: true)) + .thenAnswer((_) async { + stubEnabledStates[e.name.toLowerCase()]!.value = + ExtensionEnabledState.enabled; + }); + when(mockExtensionService.setExtensionEnabledState(e, enable: false)) + .thenAnswer((_) async { + stubEnabledStates[e.name.toLowerCase()]!.value = + ExtensionEnabledState.disabled; + }); + } + } + + group('$ExtensionSettingsDialog', () { + setUp(() async { + dialog = const ExtensionSettingsDialog(); + await setUpExtensionService(testExtensions); + setGlobal(IdeTheme, IdeTheme()); + }); + + testWidgets( + 'builds dialog with no available extensions', + (WidgetTester tester) async { + await setUpExtensionService([]); + await tester.pumpWidget(wrap(dialog)); + expect(find.text('DevTools Extensions'), findsOneWidget); + expect( + find.textContaining('Extensions are provided by the pub packages'), + findsOneWidget, + ); + expect(find.text('No extensions available.'), findsOneWidget); + expect(find.byType(ListView), findsNothing); + expect(find.byType(ExtensionSetting), findsNothing); + }, + ); + + testWidgets( + 'builds dialog with available extensions', + (WidgetTester tester) async { + await tester.pumpWidget(wrap(dialog)); + expect(find.text('DevTools Extensions'), findsOneWidget); + expect( + find.textContaining('Extensions are provided by the pub packages'), + findsOneWidget, + ); + expect(find.text('No extensions available.'), findsNothing); + expect(find.byType(ListView), findsOneWidget); + expect(find.byType(ExtensionSetting), findsNWidgets(3)); + await expectLater( + find.byWidget(dialog), + matchesDevToolsGolden( + '../test_infra/goldens/extensions/settings_state_none.png', + ), + ); + }, + ); + + testWidgets( + 'pressing toggle buttons makes calls to the $ExtensionService', + (WidgetTester tester) async { + await tester.pumpWidget(wrap(dialog)); + + expect( + extensionService.enabledStateListenable(barExtension.name).value, + ExtensionEnabledState.none, + ); + expect( + extensionService.enabledStateListenable(fooExtension.name).value, + ExtensionEnabledState.none, + ); + expect( + extensionService.enabledStateListenable(providerExtension.name).value, + ExtensionEnabledState.none, + ); + + final barSetting = tester + .widgetList(find.byType(ExtensionSetting)) + .where( + (setting) => setting.extension.name.caseInsensitiveEquals('bar'), + ) + .first; + final fooSetting = tester + .widgetList(find.byType(ExtensionSetting)) + .where( + (setting) => setting.extension.name.caseInsensitiveEquals('foo'), + ) + .first; + final providerSetting = tester + .widgetList(find.byType(ExtensionSetting)) + .where( + (setting) => + setting.extension.name.caseInsensitiveEquals('provider'), + ) + .first; + + // Enable the 'bar' extension. + await tester.tap( + find.descendant( + of: find.byWidget(barSetting), + matching: find.text('Enabled'), + ), + ); + expect( + extensionService.enabledStateListenable(barExtension.name).value, + ExtensionEnabledState.enabled, + ); + + // Enable the 'foo' extension. + await tester.tap( + find.descendant( + of: find.byWidget(fooSetting), + matching: find.text('Enabled'), + ), + ); + expect( + extensionService.enabledStateListenable(fooExtension.name).value, + ExtensionEnabledState.enabled, + ); + + // Disable the 'provider' extension. + await tester.tap( + find.descendant( + of: find.byWidget(providerSetting), + matching: find.text('Disabled'), + ), + ); + expect( + extensionService.enabledStateListenable(providerExtension.name).value, + ExtensionEnabledState.disabled, + ); + + await tester.pumpWidget(wrap(dialog)); + await expectLater( + find.byWidget(dialog), + matchesDevToolsGolden( + '../test_infra/goldens/extensions/settings_state_modified.png', + ), + ); + }, + ); + + testWidgets( + 'toggle buttons update for changes to value notifiers', + (WidgetTester tester) async { + await tester.pumpWidget(wrap(dialog)); + await expectLater( + find.byWidget(dialog), + matchesDevToolsGolden( + '../test_infra/goldens/extensions/settings_state_none.png', + ), + ); + + await extensionService.setExtensionEnabledState( + barExtension, + enable: true, + ); + await extensionService.setExtensionEnabledState( + fooExtension, + enable: true, + ); + await extensionService.setExtensionEnabledState( + providerExtension, + enable: false, + ); + + await tester.pumpWidget(wrap(dialog)); + await expectLater( + find.byWidget(dialog), + matchesDevToolsGolden( + '../test_infra/goldens/extensions/settings_state_modified.png', + ), + ); + }, + ); + }); +} diff --git a/packages/devtools_app/test/test_infra/goldens/extensions/settings_state_modified.png b/packages/devtools_app/test/test_infra/goldens/extensions/settings_state_modified.png new file mode 100644 index 00000000000..d2e5f802696 Binary files /dev/null and b/packages/devtools_app/test/test_infra/goldens/extensions/settings_state_modified.png differ diff --git a/packages/devtools_app/test/test_infra/goldens/extensions/settings_state_none.png b/packages/devtools_app/test/test_infra/goldens/extensions/settings_state_none.png new file mode 100644 index 00000000000..488c9c2b8d5 Binary files /dev/null and b/packages/devtools_app/test/test_infra/goldens/extensions/settings_state_none.png differ diff --git a/packages/devtools_app/test/test_infra/test_data/extensions.dart b/packages/devtools_app/test/test_infra/test_data/extensions.dart new file mode 100644 index 00000000000..0cceaab06a3 --- /dev/null +++ b/packages/devtools_app/test/test_infra/test_data/extensions.dart @@ -0,0 +1,31 @@ +// Copyright 2023 The Chromium 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:devtools_shared/src/extensions/extension_model.dart'; + +final testExtensions = [fooExtension, barExtension, providerExtension]; + +final fooExtension = DevToolsExtensionConfig.parse({ + DevToolsExtensionConfig.nameKey: 'Foo', + DevToolsExtensionConfig.issueTrackerKey: 'www.google.com', + DevToolsExtensionConfig.versionKey: '1.0.0', + DevToolsExtensionConfig.pathKey: '/path/to/foo', +}); + +final barExtension = DevToolsExtensionConfig.parse({ + DevToolsExtensionConfig.nameKey: 'bar', + DevToolsExtensionConfig.issueTrackerKey: 'www.google.com', + DevToolsExtensionConfig.versionKey: '2.0.0', + DevToolsExtensionConfig.materialIconCodePointKey: 0xe638, + DevToolsExtensionConfig.pathKey: '/path/to/bar', +}); + +final providerExtension = DevToolsExtensionConfig.parse({ + DevToolsExtensionConfig.nameKey: 'provider', + DevToolsExtensionConfig.issueTrackerKey: + 'https://github.com/rrousselGit/provider/issues', + DevToolsExtensionConfig.versionKey: '3.0.0', + DevToolsExtensionConfig.materialIconCodePointKey: 0xe50a, + DevToolsExtensionConfig.pathKey: '/path/to/provider', +}); diff --git a/packages/devtools_shared/lib/src/extensions/extension_model.dart b/packages/devtools_shared/lib/src/extensions/extension_model.dart index 511a2f8389f..db932dc3ff6 100644 --- a/packages/devtools_shared/lib/src/extensions/extension_model.dart +++ b/packages/devtools_shared/lib/src/extensions/extension_model.dart @@ -6,7 +6,7 @@ import 'package:collection/collection.dart'; /// Describes an extension that can be dynamically loaded into a custom screen /// in DevTools. -class DevToolsExtensionConfig { +class DevToolsExtensionConfig implements Comparable { DevToolsExtensionConfig._({ required this.name, required this.path, @@ -110,6 +110,17 @@ class DevToolsExtensionConfig { versionKey: version, materialIconCodePointKey: materialIconCodePoint, }; + + @override + // ignore: avoid-dynamic, avoids invalid_override error + int compareTo(other) { + final otherConfig = other as DevToolsExtensionConfig; + final compare = name.compareTo(otherConfig.name); + if (compare == 0) { + return path.compareTo(otherConfig.path); + } + return compare; + } } /// Describes the enablement state of a DevTools extension. diff --git a/packages/devtools_test/lib/src/mocks/generated.dart b/packages/devtools_test/lib/src/mocks/generated.dart index 0c3e78a897d..f525ad0c463 100644 --- a/packages/devtools_test/lib/src/mocks/generated.dart +++ b/packages/devtools_test/lib/src/mocks/generated.dart @@ -13,6 +13,7 @@ import 'package:vm_service/vm_service.dart'; DebuggerController, EnhanceTracingController, ErrorBadgeManager, + ExtensionService, FrameAnalysis, FramePhase, HeapSnapshotGraph,