diff --git a/flutter-candidate.txt b/flutter-candidate.txt index 281bf477ad8..9d7a05456ff 100644 --- a/flutter-candidate.txt +++ b/flutter-candidate.txt @@ -1 +1 @@ -dd671fae53d37eb15e0f8fc94cd52c2f2ff147ee +63903561033d7fc2f33b67239f2002d0ee529b48 diff --git a/packages/devtools_app/lib/src/shared/analytics/constants/_property_editor_sidebar_constants.dart b/packages/devtools_app/lib/src/shared/analytics/constants/_property_editor_sidebar_constants.dart index 3931f10c442..0dbe04f998f 100644 --- a/packages/devtools_app/lib/src/shared/analytics/constants/_property_editor_sidebar_constants.dart +++ b/packages/devtools_app/lib/src/shared/analytics/constants/_property_editor_sidebar_constants.dart @@ -23,6 +23,10 @@ extension PropertyEditorSidebar on Never { required String argType, }) => 'applyEditRequest-$argType-$argName'; + /// Analytics event for a "Wrap with" refactor request. + static String applyWrapWithRefactorRequest({required String refactorName}) => + 'wrapWithRefactor-$refactorName'; + /// Analytics event on completion of an edit. static String applyEditComplete({ required String argName, diff --git a/packages/devtools_app/lib/src/shared/ui/common_widgets.dart b/packages/devtools_app/lib/src/shared/ui/common_widgets.dart index f043571983f..09d54a2a50c 100644 --- a/packages/devtools_app/lib/src/shared/ui/common_widgets.dart +++ b/packages/devtools_app/lib/src/shared/ui/common_widgets.dart @@ -612,12 +612,14 @@ class ToolbarAction extends StatelessWidget { super.key, this.size, this.style, + this.buttonStyle, this.color, this.gaScreen, this.gaSelection, }) : assert((gaScreen == null) == (gaSelection == null)); final TextStyle? style; + final ButtonStyle? buttonStyle; final IconData icon; final Color? color; final String? tooltip; @@ -632,6 +634,7 @@ class ToolbarAction extends StatelessWidget { onPressed: onPressed, tooltip: tooltip, style: style, + buttonStyle: buttonStyle, gaScreen: gaScreen, gaSelection: gaSelection, child: Icon( @@ -650,11 +653,13 @@ class SmallAction extends StatelessWidget { this.tooltip, super.key, this.style, + this.buttonStyle, this.gaScreen, this.gaSelection, }) : assert((gaScreen == null) == (gaSelection == null)); final TextStyle? style; + final ButtonStyle? buttonStyle; final Widget child; final String? tooltip; final VoidCallback? onPressed; @@ -664,11 +669,13 @@ class SmallAction extends StatelessWidget { @override Widget build(BuildContext context) { final button = TextButton( - style: TextButton.styleFrom( - padding: EdgeInsets.zero, - tapTargetSize: MaterialTapTargetSize.shrinkWrap, - textStyle: style, - ), + style: + buttonStyle ?? + TextButton.styleFrom( + padding: EdgeInsets.zero, + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + textStyle: style, + ), onPressed: () { if (gaScreen != null && gaSelection != null) { ga.select(gaScreen!, gaSelection!); @@ -2126,6 +2133,7 @@ class ContextMenuButton extends StatelessWidget { this.gaItem, this.buttonWidth = defaultWidth, this.icon = Icons.more_vert, + this.style, double? iconSize, }) : iconSize = iconSize ?? tableIconSize; @@ -2138,6 +2146,7 @@ class ContextMenuButton extends StatelessWidget { final List menuChildren; final IconData icon; final double iconSize; + final ButtonStyle? style; final double buttonWidth; @override @@ -2152,6 +2161,7 @@ class ContextMenuButton extends StatelessWidget { icon: icon, size: iconSize, color: color, + buttonStyle: style, onPressed: () { if (gaScreen != null && gaItem != null) { ga.select(gaScreen!, gaItem!); diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_controller.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_controller.dart index 164a7e5d582..f3d1bb5f4e0 100644 --- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_controller.dart +++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_controller.dart @@ -171,6 +171,17 @@ class PropertyEditorController extends DisposableController ); } + Future executeCommand({ + required String commandName, + required List commandArgs, + }) { + return editorClient.executeCommand( + commandName: commandName, + commandArgs: commandArgs, + screenId: gac.PropertyEditorSidebar.id, + ); + } + int hashProperty(EditableProperty property) { final widgetData = editableWidgetData.value; if (widgetData == null) { @@ -212,14 +223,18 @@ class PropertyEditorController extends DisposableController CodeActionResult? refactorsResult; // TODO(https://github.com/flutter/devtools/issues/8652): Enable refactors // in the Property Editor by default. - if (FeatureFlags.propertyEditorRefactors) { - // Get any supported refactors for the current position. + if (editableArgsResult != null && FeatureFlags.propertyEditorRefactors) { + // Fetch the refactors using the start of the editable arguments' range, + // which corresponds to the widget constructor name. This ensures that the + // refactors are always available, even when the cursor is within the + // parameter list. See https://github.com/flutter/devtools/issues/9221. + final position = editableArgsResult.range?.start ?? cursorPosition; // TODO(elliette): Consider updating the widget data immediately without // waiting for the refactors result, then updating the refactor buttons // once the refactors result is available. refactorsResult = await editorClient.getRefactors( textDocument: textDocument, - range: EditorRange(start: cursorPosition, end: cursorPosition), + range: EditorRange(start: position, end: position), screenId: gac.PropertyEditorSidebar.id, ); } @@ -275,6 +290,7 @@ class PropertyEditorController extends DisposableController TextDocument? document, CursorPosition? cursorPosition, EditorRange? range, + List? refactors, }) { setActiveFilter(); if (editableArgsResult != null) { @@ -283,9 +299,7 @@ class PropertyEditorController extends DisposableController .map(argToProperty) .nonNulls .toList(), - // TODO(https://github.com/flutter/devtools/issues/8652): Add tests for - // refactors. - refactors: [], + refactors: refactors ?? [], name: editableArgsResult.name, documentation: editableArgsResult.documentation, fileUri: document?.uriAsString, diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_refactors.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_refactors.dart new file mode 100644 index 00000000000..12c2e65e79b --- /dev/null +++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_refactors.dart @@ -0,0 +1,269 @@ +// Copyright 2025 The Flutter Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. + +import 'package:devtools_app_shared/ui.dart'; +import 'package:flutter/material.dart'; + +import '../../../shared/analytics/analytics.dart' as ga; +import '../../../shared/analytics/constants.dart' as gac; +import '../../../shared/editor/api_classes.dart'; +import '../../../shared/ui/common_widgets.dart'; +import 'property_editor_controller.dart'; +import 'property_editor_types.dart'; + +typedef ApplyRefactorFunction = + Future Function(WrapWithRefactorAction refactor); + +/// Widget for displaying the available "Wrap with" refactors. +/// +/// - The [mainRefactors] are each displayed in a [WrapWithButton]. +/// - Any other refactors are menu options behind the [_WrapWithOverflowButton]. +class WrapWithRefactors extends StatefulWidget { + const WrapWithRefactors({ + required this.refactors, + required this.controller, + super.key, + }); + + static const wrapWithPrefix = 'Wrap with'; + static final buttonIconSize = actionsIconSize; + static Color buttonColor(ThemeData theme) => theme.colorScheme.onSurface; + + final List refactors; + final PropertyEditorController controller; + + @override + State createState() => _WrapWithRefactorsState(); +} + +class _WrapWithRefactorsState extends State { + final _mainRefactorsGroup = []; + final _otherRefactorsGroup = []; + + @override + void initState() { + super.initState(); + _categorizeAndSortRefactors(); + } + + @override + void didUpdateWidget(covariant WrapWithRefactors oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.refactors != oldWidget.refactors) { + _categorizeAndSortRefactors(); + } + } + + @override + Widget build(BuildContext context) { + final showMainRefactors = _mainRefactorsGroup.isNotEmpty; + final showOtherRefactors = _otherRefactorsGroup.isNotEmpty; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.all(densePadding), + child: Text('Wrap with:'), + ), + if (showMainRefactors) + Wrap( + crossAxisAlignment: WrapCrossAlignment.center, + children: [ + for (final refactor in _mainRefactorsGroup) + Padding( + padding: const EdgeInsets.all(densePadding), + child: WrapWithButton( + refactor: refactor, + applyRefactor: _applyRefactor, + ), + ), + if (showOtherRefactors) + Padding( + padding: const EdgeInsets.all(densePadding), + child: _WrapWithOverflowButton( + refactors: _otherRefactorsGroup, + applyRefactor: _applyRefactor, + ), + ), + ], + ), + const PaddedDivider.noPadding(), + ], + ); + } + + void _categorizeAndSortRefactors() { + _mainRefactorsGroup.clear(); + _otherRefactorsGroup.clear(); + for (final refactor in widget.refactors) { + final category = mainRefactors.contains(refactor.label) + ? _mainRefactorsGroup + : _otherRefactorsGroup; + category.add(refactor); + } + // Sort the refactors to match the order in the mainRefactors set. + final mainRefactorsOrder = mainRefactors.toList(); + _mainRefactorsGroup.sort((a, b) { + return mainRefactorsOrder + .indexOf(a.label) + .compareTo(mainRefactorsOrder.indexOf(b.label)); + }); + } + + Future _applyRefactor(WrapWithRefactorAction refactor) { + ga.select( + gac.PropertyEditorSidebar.id, + gac.PropertyEditorSidebar.applyWrapWithRefactorRequest( + refactorName: refactor.label, + ), + ); + + return widget.controller.executeCommand( + commandName: refactor.command, + commandArgs: refactor.args, + ); + } +} + +/// Overflow button for any available refactors not in [mainRefactors]. +class _WrapWithOverflowButton extends StatelessWidget { + const _WrapWithOverflowButton({ + required this.refactors, + required this.applyRefactor, + }); + + final List refactors; + final ApplyRefactorFunction applyRefactor; + + @override + Widget build(BuildContext context) { + return DevToolsTooltip( + message: 'More widgets...', + child: ContextMenuButton( + color: WrapWithRefactors.buttonColor(Theme.of(context)), + icon: Icons.arrow_drop_down, + iconSize: WrapWithRefactors.buttonIconSize, + buttonWidth: buttonMinWidth, + style: _wrapWithButtonStyle, + menuChildren: _refactorOptions(), + ), + ); + } + + List _refactorOptions() { + return refactors.map((refactor) { + return MenuItemButton( + child: Text(refactor.label), + onPressed: () async { + await applyRefactor(refactor); + }, + ); + }).toList(); + } +} + +/// A button which triggers a single "Wrap with" refactor. +@visibleForTesting +class WrapWithButton extends StatelessWidget { + const WrapWithButton({ + super.key, + required this.refactor, + required this.applyRefactor, + }); + + final WrapWithRefactorAction refactor; + final ApplyRefactorFunction applyRefactor; + + // TODO(elliette): Move the inspector icons into a common directory. + static const _iconAssetPath = 'icons/inspector/widget_icons/'; + static const _textButtonFontSize = 16.0; + + String? get _iconAsset { + if (!_refactorsWithIconAsset.contains(refactor.label)) { + return null; + } + + return '$_iconAssetPath${refactor.label.toLowerCase()}.png'; + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final iconAsset = _iconAsset; + + return DevToolsTooltip( + message: refactor.label, + child: TextButton( + style: _wrapWithButtonStyle, + child: iconAsset != null + ? Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Image.asset( + iconAsset, + height: WrapWithRefactors.buttonIconSize, + color: WrapWithRefactors.buttonColor(theme), + ), + ], + ) + : _buttonText(theme: theme), + onPressed: () async { + await applyRefactor(refactor); + }, + ), + ); + } + + Text _buttonText({required ThemeData theme}) { + final label = refactor.label; + assert(label.isNotEmpty); + // Show the first letter of the label as the icon. + if (_refactorsWithLetterIcon.contains(label)) { + return Text( + label[0], + style: theme.regularTextStyle.copyWith( + fontWeight: FontWeight.bold, + fontSize: _textButtonFontSize, + color: WrapWithRefactors.buttonColor(theme), + ), + ); + } + + return Text(label, style: theme.regularTextStyle); + } +} + +const _wrapWithButtonBorderRadius = 4.0; + +final _wrapWithButtonStyle = TextButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(_wrapWithButtonBorderRadius), + ), + padding: const EdgeInsets.all(densePadding), + tapTargetSize: MaterialTapTargetSize.shrinkWrap, + minimumSize: Size.square(buttonMinWidth), +); + +const _wrapWithPadding = 'Padding'; +const _wrapWithContainer = 'Container'; +const _wrapWithColumn = 'Column'; +const _wrapWithRow = 'Row'; +const _wrapWithCenter = 'Center'; +const _wrapWithSizedBox = 'SizedBox'; +const _wrapWithWidget = 'Widget'; + +const _refactorsWithIconAsset = { + _wrapWithPadding, + _wrapWithContainer, + _wrapWithColumn, + _wrapWithRow, + _wrapWithCenter, + _wrapWithSizedBox, +}; + +const _refactorsWithLetterIcon = {_wrapWithWidget}; + +@visibleForTesting +const mainRefactors = {..._refactorsWithLetterIcon, ..._refactorsWithIconAsset}; diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_types.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_types.dart index 21a4af54dc9..764fef21578 100644 --- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_types.dart +++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_types.dart @@ -224,6 +224,24 @@ EditableProperty? argToProperty(EditableArgument argument) { } } +/// Represents a "Wrap with" refactor action. +class WrapWithRefactorAction { + WrapWithRefactorAction(this._refactor); + + final CodeActionCommand _refactor; + + String get label => _extractLabel(_refactor.title); + + String get command => _refactor.command; + + List get args => _refactor.args; + + String _extractLabel(String title) { + final wrapperName = title.split('Wrap with ').last; + return wrapperName == 'widget...' ? 'Widget' : wrapperName; + } +} + /// The following types should match those returned by the Analysis Server. See: /// https://github.com/dart-lang/sdk/blob/154b473cdb65c2686bb44fedec03ba2deddb80fd/pkg/analysis_server/lib/src/lsp/handlers/custom/editable_arguments/handler_editable_arguments.dart#L182 const stringType = 'string'; diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_view.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_view.dart index 0c18a827200..de873df504b 100644 --- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_view.dart +++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_view.dart @@ -12,6 +12,7 @@ import '../../../shared/ui/filter.dart'; import 'property_editor_controller.dart'; import 'property_editor_inputs.dart'; import 'property_editor_messages.dart'; +import 'property_editor_refactors.dart'; import 'property_editor_types.dart'; import 'utils/utils.dart'; @@ -64,6 +65,20 @@ class PropertyEditorView extends StatelessWidget { _WidgetNameAndDocumentation(name: name, documentation: documentation), ); } + + if (refactors.isNotEmpty) { + final wrapWithRefactors = refactors + .where( + (refactor) => + refactor.title.startsWith(WrapWithRefactors.wrapWithPrefix), + ) + .map((refactor) => WrapWithRefactorAction(refactor)) + .toList(); + contents.add( + WrapWithRefactors(refactors: wrapWithRefactors, controller: controller), + ); + } + if (properties.isEmpty) { if (name != null) { contents.add(_NoEditablePropertiesMessage(name: name)); diff --git a/packages/devtools_app/pubspec.yaml b/packages/devtools_app/pubspec.yaml index 5c7304825e5..d28881c1580 100644 --- a/packages/devtools_app/pubspec.yaml +++ b/packages/devtools_app/pubspec.yaml @@ -95,6 +95,8 @@ flutter: - icons/general/ - icons/gutter/ - icons/inspector/ + # TODO(elliette): The inspector icons are also used in the Property Editor. + # They should be moved to a common directory. - icons/inspector/widget_icons/ - icons/memory/ - icons/perf/ diff --git a/packages/devtools_app/test/shared/primitives/feature_flags_test.dart b/packages/devtools_app/test/shared/primitives/feature_flags_test.dart index cd745451118..5018d9c9aa4 100644 --- a/packages/devtools_app/test/shared/primitives/feature_flags_test.dart +++ b/packages/devtools_app/test/shared/primitives/feature_flags_test.dart @@ -20,5 +20,6 @@ void main() { expect(FeatureFlags.inspectorV2, true); expect(FeatureFlags.wasmOptInSetting, true); expect(FeatureFlags.propertyEditor, false); + expect(FeatureFlags.propertyEditorRefactors, false); }); } diff --git a/packages/devtools_app/test/standalone_ui/ide_shared/property_editor/property_editor_test.dart b/packages/devtools_app/test/standalone_ui/ide_shared/property_editor/property_editor_test.dart index 3c90ba34531..2a9113210c8 100644 --- a/packages/devtools_app/test/standalone_ui/ide_shared/property_editor/property_editor_test.dart +++ b/packages/devtools_app/test/standalone_ui/ide_shared/property_editor/property_editor_test.dart @@ -3,12 +3,15 @@ // found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd. import 'dart:async'; +import 'dart:convert'; import 'dart:ui'; import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app/src/shared/analytics/constants.dart' as gac; import 'package:devtools_app/src/shared/editor/api_classes.dart'; +import 'package:devtools_app/src/shared/feature_flags.dart'; import 'package:devtools_app/src/standalone_ui/ide_shared/property_editor/property_editor_controller.dart'; +import 'package:devtools_app/src/standalone_ui/ide_shared/property_editor/property_editor_refactors.dart'; import 'package:devtools_app/src/standalone_ui/ide_shared/property_editor/property_editor_types.dart'; import 'package:devtools_app/src/standalone_ui/ide_shared/property_editor/property_editor_view.dart'; import 'package:devtools_app_shared/ui.dart'; @@ -889,6 +892,207 @@ void main() { }); }); + group('refactors', () { + int refactorCount = 0; + + void initWithRefactors(List refactorNames) { + controller.initForTestsOnly( + document: textDocument1, + cursorPosition: activeCursorPosition1, + editableArgsResult: result1, + refactors: refactorNames.map(_createCommand).toList(), + ); + } + + List wrapWithButtonLabels(WidgetTester tester) => tester + .widgetList( + find.descendant( + of: find.byType(WrapWithButton), + matching: find.byType(DevToolsTooltip), + ), + ) + .map((tooltip) => tooltip.message!) + .toList(); + + Finder wrapWithButtonFinder(String label) => find.ancestor( + of: find.byWidgetPredicate( + (widget) => widget is DevToolsTooltip && widget.message == label, + ), + matching: find.byType(WrapWithButton), + ); + + Finder wrapWithOverflowMenuFinder() => find.descendant( + of: find.byType(DevToolsTooltip), + matching: find.byType(ContextMenuButton), + ); + + setUp(() { + FeatureFlags.propertyEditorRefactors = true; + + refactorCount = 0; + when( + // ignore: discarded_futures, for mocking purposes. + mockEditorClient.executeCommand( + commandName: argThat(isNotNull, named: 'commandName'), + commandArgs: argThat(isNotNull, named: 'commandArgs'), + screenId: 'propertyEditorSidebar', + ), + ).thenAnswer((realInvocation) { + refactorCount++; + return Future.value(GenericApiResponse(success: true)); + }); + }); + + testWidgets('main refactors are displayed in a consistent order', ( + tester, + ) async { + return await tester.runAsync(() async { + // Load the property editor. + initWithRefactors([ + 'Wrap with Row', + 'Wrap with widget...', + 'Wrap with Padding', + 'Wrap with Expanded', + 'Wrap with Container', + 'Wrap with Center', + 'Wrap with Column', + 'Wrap with SizedBox', + ]); + + await tester.pumpWidget(wrap(propertyEditor)); + + // Verify the main refactor buttons are displayed in the expected order. + final expectedMainRefactorsOrder = mainRefactors.toList(); + final mainRefactorButtonLabels = wrapWithButtonLabels(tester); + + expect( + mainRefactorButtonLabels.length, + expectedMainRefactorsOrder.length, + ); + + for (int i = 0; i < expectedMainRefactorsOrder.length; i++) { + expect(mainRefactorButtonLabels[i], expectedMainRefactorsOrder[i]); + } + }); + }); + + testWidgets('shows all available refactors in buttons / dropdown menu', ( + tester, + ) async { + return await tester.runAsync(() async { + // Load the property editor. + initWithRefactors([ + 'Wrap with Row', + 'Wrap with Expanded', + 'Wrap with Container', + 'Wrap with Column', + 'Wrap with SizedBox', + ]); + await tester.pumpWidget(wrap(propertyEditor)); + + // Verify the "Wrap with:" text is displayed. + expect(find.text('Wrap with:'), findsOneWidget); + + // Verify the main refactors are expected. + expect( + wrapWithButtonLabels(tester), + containsAll(['Row', 'Container', 'Column', 'SizedBox']), + ); + + // Verify the buttons in the dropdown menu are hidden. + expect(find.text('Expanded'), findsNothing); + + // Tap on the overflow menu. + final overflowMenu = wrapWithOverflowMenuFinder(); + await tester.tap(overflowMenu); + await tester.pumpAndSettle(); + + // Verify the buttons in the dropdown menu are shown. + expect(find.text('Expanded'), findsOneWidget); + }); + }); + + testWidgets( + 'excludes dropdown button if no extra refactors are available', + (tester) async { + return await tester.runAsync(() async { + // Load the property editor. + initWithRefactors([ + 'Wrap with Row', + 'Wrap with Container', + 'Wrap with Column', + 'Wrap with SizedBox', + ]); + await tester.pumpWidget(wrap(propertyEditor)); + + // Verify the "Wrap with:" text is displayed. + expect(find.text('Wrap with:'), findsOneWidget); + + // Verify the main refactors are expected. + expect( + wrapWithButtonLabels(tester), + containsAll(['Row', 'Container', 'Column', 'SizedBox']), + ); + + // Verify there is no overflow menu. + expect(wrapWithOverflowMenuFinder(), findsNothing); + }); + }, + ); + + testWidgets('selecting overflow menu triggers refactor', (tester) async { + return await tester.runAsync(() async { + // Load the property editor. + initWithRefactors([ + 'Wrap with Row', + 'Wrap with Container', + 'Wrap with Column', + 'Wrap with SizedBox', + 'Wrap with Expanded', + ]); + await tester.pumpWidget(wrap(propertyEditor)); + + // Verify no refactors have been triggered. + expect(refactorCount, equals(0)); + + // Tap on the overflow menu. + final overflowMenu = wrapWithOverflowMenuFinder(); + await tester.tap(overflowMenu); + await tester.pumpAndSettle(); + + // Select a refactor in the overflow menu. + await tester.tap(find.text('Expanded')); + await tester.pumpAndSettle(); + + // Verify a refactor has been triggered. + expect(refactorCount, equals(1)); + }); + }); + + testWidgets('clicking refactor button triggers refactor', (tester) async { + return await tester.runAsync(() async { + // Load the property editor. + initWithRefactors([ + 'Wrap with Row', + 'Wrap with Container', + 'Wrap with Column', + 'Wrap with SizedBox', + ]); + await tester.pumpWidget(wrap(propertyEditor)); + + // Verify no refactors have been triggered. + expect(refactorCount, equals(0)); + + // Click on a "Wrap with" button. + final wrapWithRowButton = wrapWithButtonFinder('Row'); + await tester.tap(wrapWithRowButton); + + // Verify a refactor has been triggered. + expect(refactorCount, equals(1)); + }); + }); + }); + group('widget name and documentation', () { testWidgets('expanding and collapsing documentation', (tester) async { // Load the property editor. @@ -1518,3 +1722,32 @@ const exampleWidgetText = 'For example, the highlighted code below is a constructor invocation of a Text widget:'; const noDartCodeText = 'No Dart code found at the current cursor location.'; const noWidgetText = 'No Flutter widget found at the current cursor location.'; + +const commandArg = ''' +[ + { + "textDocument": { + "uri": "file:///Users/me/flutter_app/lib/main.dart", + "version": 1 + }, + "range": { + "end": { + "character": 10, + "line": 20 + }, + "start": { + "character": 10, + "line": 20 + } + }, + "kind": "refactor.flutter.wrap.generic", + "loggedAction": "dart.assist.flutter.wrap.generic" + } +] +'''; + +CodeActionCommand _createCommand(String title) => CodeActionCommand( + command: 'dart.edit.codeAction.apply', + args: json.decode(commandArg), + title: title, +);