From 0100376cba8be5c7c7a6ab77e2a1b7ce090195d9 Mon Sep 17 00:00:00 2001 From: Elliott Brooks <21270878+elliette@users.noreply.github.com> Date: Wed, 21 May 2025 12:27:54 -0700 Subject: [PATCH 01/12] Add APIs to support refactors from the Property Editor --- .../_property_editor_sidebar_constants.dart | 7 - .../lib/src/shared/editor/api_classes.dart | 145 ++++++++++++++- .../lib/src/shared/editor/editor_client.dart | 168 ++++++++++++------ .../property_editor_controller.dart | 64 ++++--- .../property_editor_inputs.dart | 5 +- .../property_editor/property_editor_view.dart | 14 +- .../property_editor/property_editor_test.dart | 3 +- 7 files changed, 300 insertions(+), 106 deletions(-) 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 73a1882aa1b..3931f10c442 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 @@ -9,13 +9,6 @@ extension PropertyEditorSidebar on Never { /// Analytics id to track events that come from the DTD editor sidebar. static String get id => 'propertyEditorSidebar'; - /// Identifier for errors returned from the getEditableArguments API. - static String get getEditableArgumentsIdentifier => - '${id}Error-getEditableArguments'; - - /// Identifier for errors returned from the editArgument API. - static String get editArgumentIdentifier => '${id}Error-editArgument'; - /// Analytics id for opening the documentation. static String get documentationLink => 'propertyEditorDocumentation'; diff --git a/packages/devtools_app/lib/src/shared/editor/api_classes.dart b/packages/devtools_app/lib/src/shared/editor/api_classes.dart index 612fc47f0ba..21585b409c0 100644 --- a/packages/devtools_app/lib/src/shared/editor/api_classes.dart +++ b/packages/devtools_app/lib/src/shared/editor/api_classes.dart @@ -31,14 +31,36 @@ enum EditorMethod { /// TODO(https://github.com/flutter/devtools/issues/8824): Add tests that these /// are in-sync with analysis_server. enum LspMethod { + codeAction(methodName: 'textDocument/codeAction'), editableArguments(methodName: 'dart/textDocument/editableArguments'), - editArgument(methodName: 'dart/textDocument/editArgument'); + editArgument(methodName: 'dart/textDocument/editArgument'), + executeCommand(methodName: 'workspace/executeCommand'); const LspMethod({required this.methodName}); + /// Returns the [LspMethod] for the given [methodName]. + /// + /// If the [methodName] does not exist, returns null. + static LspMethod? fromMethodName(String methodName) { + for (final method in LspMethod.values) { + if (method.methodName == methodName) return method; + } + return null; + } + final String methodName; - String get experimentalMethodName => 'experimental/$methodName'; + static final _registrationStatus = { + for (final method in LspMethod.values) method: false, + }; + + /// Sets the registration status for this LSP method. + set isRegistered(bool isRegistered) { + _registrationStatus[this] = isRegistered; + } + + /// Gets the current registration status of this LSP method. + bool get isRegistered => _registrationStatus[this] ?? false; } /// Known kinds of events that may come from the editor. @@ -82,12 +104,14 @@ enum EditorEventKind { /// Constants for all fields used in JSON maps to avoid literal strings that /// may have typos sprinkled throughout the API classes. abstract class Field { + static const actions = 'actions'; static const active = 'active'; static const anchor = 'anchor'; static const arguments = 'arguments'; static const backgroundColor = 'backgroundColor'; static const category = 'category'; static const character = 'character'; + static const command = 'command'; static const debuggerType = 'debuggerType'; static const debugSession = 'debugSession'; static const debugSessionId = 'debugSessionId'; @@ -115,7 +139,9 @@ abstract class Field { static const isEditable = 'isEditable'; static const isNullable = 'isNullable'; static const isRequired = 'isRequired'; + static const kind = 'kind'; static const line = 'line'; + static const loggedAction = 'loggedAction'; static const name = 'name'; static const options = 'options'; static const page = 'page'; @@ -133,6 +159,7 @@ abstract class Field { static const supportsForceExternal = 'supportsForceExternal'; static const textDocument = 'textDocument'; static const theme = 'theme'; + static const title = 'title'; static const type = 'type'; static const uri = 'uri'; static const value = 'value'; @@ -501,6 +528,105 @@ class EditableArgumentsResult with Serializable { }; } +/// Constants for [CodeAction] prefixes used to filter the results returned by +/// an [LspMethod.codeAction] request. +abstract class CodeActionPrefixes { + static const flutterWrap = 'refactor.flutter.wrap'; +} + +/// The result of an [LspMethod.codeAction] request to the Analysis Server. +/// +/// Contains a list of [CodeAction]s that can be performed. +class CodeActionResult with Serializable { + CodeActionResult({required this.actions}); + + CodeActionResult.fromJson(List> list) + : this(actions: list.map(CodeAction.fromJson).toList()); + + final List actions; + + @override + Map toJson() => {Field.actions: actions}; +} + +/// A code action (also known as a "Refactor" or "Quick Fix") that can be called +/// via an [LspMethod.executeCommand] request. +/// +/// For example, "Wrap with Center" or "Wrap with Container". +class CodeAction with Serializable { + CodeAction({required this.command, required this.title, required this.args}); + + CodeAction.fromJson(Map map) + : this( + command: map[Field.command] as String, + title: map[Field.title] as String, + args: (map[Field.arguments] as List? ?? []) + .cast>() + .map(CodeActionArgument.fromJson) + .toList(), + ); + + /// The command identifier to send to [LspMethod.executeCommand]. + final String? command; + + /// The human-readable title of the command, e.g., "Wrap with Center". + final String? title; + + /// Arguments that should be passed to [LspMethod.executeCommand] when + /// invoking this action. + final List args; + + @override + Map toJson() => { + Field.command: command, + Field.title: title, + Field.arguments: args, + }; +} + +/// An argument for a [CodeAction]. +/// +/// This includes information about the document and range to which the +/// [CodeAction] should be applied. +class CodeActionArgument with Serializable { + CodeActionArgument({ + required this.textDocument, + required this.range, + required this.kind, + required this.loggedAction, + }); + + CodeActionArgument.fromJson(Map map) + : this( + textDocument: TextDocument.fromJson( + map[Field.textDocument] as Map, + ), + range: EditorRange.fromJson(map[Field.range] as Map), + kind: map[Field.kind] as String?, + loggedAction: map[Field.loggedAction] as String?, + ); + + /// The document to which the [CodeAction] applies. + final TextDocument textDocument; + + /// The range within the [textDocument] to which the [CodeAction] applies. + final EditorRange range; + + /// The kind of action, often a string like "refactor.flutter.wrap.container". + final String? kind; + + /// An identifier used for logging or analytics purposes related to this action. + final String? loggedAction; + + @override + Map toJson() => { + Field.textDocument: textDocument.toJson(), + Field.range: range.toJson(), + Field.kind: kind, + Field.loggedAction: loggedAction, + }; +} + /// Errors that the Analysis Server returns for failed argument edits. /// /// These should be kept in sync with the error coes defined at @@ -554,16 +680,17 @@ enum EditArgumentError { } } -/// Response to an edit argument request. -class EditArgumentResponse { - EditArgumentResponse({required this.success, this.errorMessage, errorCode}) - : _errorCode = errorCode; +/// Generic response representing whether a reqeust was a [success]. +class GenericApiResponse { + GenericApiResponse({ + required this.success, + this.errorMessage, + this.errorCode, + }); final bool success; final String? errorMessage; - final int? _errorCode; - - EditArgumentError? get errorType => EditArgumentError.fromCode(_errorCode); + final int? errorCode; } /// Information about a single editable argument of a widget. diff --git a/packages/devtools_app/lib/src/shared/editor/editor_client.dart b/packages/devtools_app/lib/src/shared/editor/editor_client.dart index b8ebc72264d..f0d01c7f76a 100644 --- a/packages/devtools_app/lib/src/shared/editor/editor_client.dart +++ b/packages/devtools_app/lib/src/shared/editor/editor_client.dart @@ -5,6 +5,7 @@ import 'dart:async'; import 'package:devtools_app_shared/utils.dart'; +import 'package:devtools_shared/devtools_shared.dart'; import 'package:dtd/dtd.dart'; import 'package:flutter/foundation.dart'; import 'package:json_rpc_2/json_rpc_2.dart'; @@ -47,8 +48,15 @@ class EditorClient extends DisposableController final isRegistered = kind == 'ServiceRegistered'; final method = data.data['method'] as String; final capabilities = data.data['capabilities'] as Map?; - - if (method == EditorMethod.getDevices.name) { + final lspMethod = LspMethod.fromMethodName(method); + if (lspMethod != null) { + lspMethod.isRegistered = isRegistered; + if (lspMethod == LspMethod.editableArguments) { + // Update the notifier so that the Property Editor is aware that the + // editableArguments API is registered. + _editableArgumentsApiIsRegistered.value = isRegistered; + } + } else if (method == EditorMethod.getDevices.name) { _supportsGetDevices = isRegistered; } else if (method == EditorMethod.getDebugSessions.name) { _supportsGetDebugSessions = isRegistered; @@ -62,18 +70,6 @@ class EditorClient extends DisposableController _supportsOpenDevToolsPage = isRegistered; _supportsOpenDevToolsForceExternal = capabilities?[Field.supportsForceExternal] == true; - } else if (method == LspMethod.editArgument.methodName) { - _editArgumentMethodName.value = LspMethod.editArgument.methodName; - } else if (method == LspMethod.editArgument.experimentalMethodName) { - _editArgumentMethodName.value = - LspMethod.editArgument.experimentalMethodName; - } else if (method == LspMethod.editableArguments.methodName) { - _editableArgumentsMethodName.value = - LspMethod.editableArguments.methodName; - } else if (method == - LspMethod.editableArguments.experimentalMethodName) { - _editableArgumentsMethodName.value = - LspMethod.editableArguments.experimentalMethodName; } else { return; } @@ -165,13 +161,9 @@ class EditorClient extends DisposableController _supportsOpenDevToolsForceExternal; var _supportsOpenDevToolsForceExternal = false; - ValueListenable get editArgumentMethodName => - _editArgumentMethodName; - final _editArgumentMethodName = ValueNotifier(null); - - ValueListenable get editableArgumentsMethodName => - _editableArgumentsMethodName; - final _editableArgumentsMethodName = ValueNotifier(null); + ValueListenable get editableArgumentsApiIsRegistered => + _editableArgumentsApiIsRegistered; + final _editableArgumentsApiIsRegistered = ValueNotifier(false); /// A stream of [ActiveLocationChangedEvent]s from the edtior. Stream get activeLocationChangedStream => @@ -256,26 +248,90 @@ class EditorClient extends DisposableController Future getEditableArguments({ required TextDocument textDocument, required CursorPosition position, + required String screenId, + }) => _callLspApiAndDeserializeResponse( + requestMethod: LspMethod.editableArguments, + requestParams: { + 'textDocument': textDocument.toJson(), + 'position': position.toJson(), + }, + responseDeserializer: (rawJson) => + EditableArgumentsResult.fromJson(rawJson as Map), + screenId: screenId, + ); + + /// Gets the supported refactors from the Analysis Server. + Future getRefactors({ + required TextDocument textDocument, + required EditorRange range, + required String screenId, + }) => _callLspApiAndDeserializeResponse( + requestMethod: LspMethod.codeAction, + requestParams: { + 'textDocument': textDocument.toJson(), + 'range': range.toJson(), + 'context': { + 'diagnostics': [], + 'only': [CodeActionPrefixes.flutterWrap], + }, + }, + responseDeserializer: (rawJson) => CodeActionResult.fromJson( + (rawJson as List).cast>(), + ), + screenId: screenId, + ); + + /// Requests that the Analysis Server makes a code edit for an argument. + Future editArgument({ + required TextDocument textDocument, + required CursorPosition position, + required String name, + required T value, + required String screenId, + }) => _callLspApiAndRespond( + requestMethod: LspMethod.editArgument, + requestParams: { + 'textDocument': textDocument.toJson(), + 'position': position.toJson(), + 'edit': {'name': name, 'newValue': value}, + }, + screenId: screenId, + ); + + /// Requests that the Analysis Server makes a code edit for an argument. + Future applyRefactor({ + required String commandName, + required List commandArgs, + required String screenId, + }) => _callLspApiAndRespond( + requestMethod: LspMethod.executeCommand, + requestParams: { + 'command': commandName, + 'arguments': commandArgs.map((e) => e.toJson()).toList(), + }, + screenId: screenId, + ); + + Future _callLspApiAndDeserializeResponse({ + required LspMethod requestMethod, + required Map requestParams, + required T? Function(Object? rawJson) responseDeserializer, + required String screenId, }) async { - final method = editableArgumentsMethodName.value; - if (method == null) return null; + if (!requestMethod.isRegistered) { + return null; + } String? errorMessage; StackTrace? stack; - EditableArgumentsResult? result; + T? result; try { final response = await _callLspApi( - method, - params: { - 'type': 'Object', // This is required by DTD. - 'textDocument': textDocument.toJson(), - 'position': position.toJson(), - }, + requestMethod.methodName, + params: _addRequiredDtdParams(requestParams), ); - final rawResult = response.result[Field.result]; - result = rawResult != null - ? EditableArgumentsResult.fromJson(rawResult as Map) - : null; + final rawJson = response.result[Field.result]; + result = rawJson != null ? responseDeserializer(rawJson) : null; } on RpcException catch (e, st) { // We expect content modified errors if a user edits their code before the // request completes. Therefore it is safe to ignore. @@ -290,27 +346,23 @@ class EditorClient extends DisposableController if (errorMessage != null) { reportError( errorMessage, - errorType: PropertyEditorSidebar.getEditableArgumentsIdentifier, + errorType: _lspErrorType(screenId: screenId, method: requestMethod), stack: stack, ); } } - return result; } - /// Requests that the Analysis Server makes a code edit for an argument. - Future editArgument({ - required TextDocument textDocument, - required CursorPosition position, - required String name, - required T value, + Future _callLspApiAndRespond({ + required LspMethod requestMethod, + required Map requestParams, + required String screenId, }) async { - final method = editArgumentMethodName.value; - if (method == null) { - return EditArgumentResponse( + if (!requestMethod.isRegistered) { + return GenericApiResponse( success: false, - errorMessage: 'API is unavailable.', + errorMessage: 'API is not available.', ); } @@ -318,15 +370,10 @@ class EditorClient extends DisposableController StackTrace? stack; try { await _callLspApi( - method, - params: { - 'type': 'Object', // This is required by DTD. - 'textDocument': textDocument.toJson(), - 'position': position.toJson(), - 'edit': {'name': name, 'newValue': value}, - }, + requestMethod.methodName, + params: _addRequiredDtdParams(requestParams), ); - return EditArgumentResponse(success: true); + return GenericApiResponse(success: true); } on RpcException catch (e, st) { errorMessage = e.message; stack = st; @@ -337,12 +384,21 @@ class EditorClient extends DisposableController if (errorMessage != null) { reportError( errorMessage, - errorType: PropertyEditorSidebar.editArgumentIdentifier, + errorType: _lspErrorType(screenId: screenId, method: requestMethod), stack: stack, ); } } - return EditArgumentResponse(success: false, errorMessage: errorMessage); + return GenericApiResponse(success: false, errorMessage: errorMessage); + } + + String _lspErrorType({required String screenId, required LspMethod method}) => + '${screenId}Error-${method.name}'; + + Map _addRequiredDtdParams(Map params) { + // Specifying type as 'Object' is required by DTD. + params.putIfAbsent('type', () => 'Object'); + return params; } Future _call( 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 b36f1c550f4..4516830f1e6 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 @@ -17,6 +17,7 @@ import 'property_editor_types.dart'; typedef EditableWidgetData = ({ List properties, + List refactors, String? name, String? documentation, String? fileUri, @@ -24,7 +25,7 @@ typedef EditableWidgetData = ({ }); typedef EditArgumentFunction = - Future Function({ + Future Function({ required String name, required T value, }); @@ -59,11 +60,11 @@ class PropertyEditorController extends DisposableController bool get waitingForFirstEvent => _waitingForFirstEvent; bool _waitingForFirstEvent = true; - late final Debouncer _editableArgsDebouncer; + late final Debouncer _requestDebouncer; late final Timer _checkConnectionTimer; - static const _editableArgsDebounceDuration = Duration(milliseconds: 600); + static const _requestDebounceDuration = Duration(milliseconds: 600); static const _checkConnectionInterval = Duration(minutes: 1); @@ -82,7 +83,7 @@ class PropertyEditorController extends DisposableController @override void init() { super.init(); - _editableArgsDebouncer = Debouncer(duration: _editableArgsDebounceDuration); + _requestDebouncer = Debouncer(duration: _requestDebounceDuration); _checkConnectionTimer = _periodicallyCheckConnection( _checkConnectionInterval, ); @@ -111,6 +112,7 @@ class PropertyEditorController extends DisposableController if (!textDocument.uriAsString.endsWith('.dart')) { _editableWidgetData.value = ( properties: [], + refactors: [], name: null, documentation: null, range: null, @@ -118,8 +120,8 @@ class PropertyEditorController extends DisposableController ); return; } - _editableArgsDebouncer.run( - () => _updateWithEditableArgs( + _requestDebouncer.run( + () => _updateWithEditableWidgetData( textDocument: textDocument, cursorPosition: cursorPosition, ), @@ -130,7 +132,7 @@ class PropertyEditorController extends DisposableController @override void dispose() { - _editableArgsDebouncer.dispose(); + _requestDebouncer.dispose(); _checkConnectionTimer.cancel(); super.dispose(); } @@ -152,7 +154,7 @@ class PropertyEditorController extends DisposableController ..addAll(filtered); } - Future editArgument({ + Future editArgument({ required String name, required T value, }) async { @@ -164,6 +166,7 @@ class PropertyEditorController extends DisposableController position: position, name: name, value: value, + screenId: gac.PropertyEditorSidebar.id, ); } @@ -193,32 +196,33 @@ class PropertyEditorController extends DisposableController ); } - Future _updateWithEditableArgs({ + Future _updateWithEditableWidgetData({ required TextDocument textDocument, required CursorPosition cursorPosition, }) async { _currentDocument = textDocument; _currentCursorPosition = cursorPosition; // Get the editable arguments for the current position. - final result = await editorClient.getEditableArguments( + final editableArgsResult = await editorClient.getEditableArguments( textDocument: textDocument, position: cursorPosition, + screenId: gac.PropertyEditorSidebar.id, ); - final properties = (result?.args ?? []) - .map(argToProperty) - .nonNulls - // Filter out any deprecated properties that aren't set. - .where((property) => !property.isDeprecated || property.hasArgument) - .toList(); - final name = result?.name; - final range = result?.range; - + // Get any supported refactors for the current position. + final refactorsResult = await editorClient.getRefactors( + textDocument: textDocument, + range: EditorRange(start: cursorPosition, end: cursorPosition), + screenId: gac.PropertyEditorSidebar.id, + ); + // Update the widget data. + final name = editableArgsResult?.name; _editableWidgetData.value = ( - properties: properties, + properties: _extractProperties(editableArgsResult), + refactors: _extractRefactors(refactorsResult), name: name, - documentation: result?.documentation, + documentation: editableArgsResult?.documentation, fileUri: _currentDocument?.uriAsString, - range: range, + range: editableArgsResult?.range, ); filterData(activeFilter.value); // Register impression. @@ -228,6 +232,19 @@ class PropertyEditorController extends DisposableController ); } + List _extractProperties(EditableArgumentsResult? result) => + (result?.args ?? []) + .map(argToProperty) + .nonNulls + // Filter out any deprecated properties that aren't set. + .where((property) => !property.isDeprecated || property.hasArgument) + .toList(); + + List _extractRefactors(CodeActionResult? result) => + (result?.actions ?? []) + .where((action) => action.title != null && action.command != null) + .toList(); + Timer _periodicallyCheckConnection(Duration interval) { return Timer.periodic(interval, (timer) { final isClosed = editorClient.isDtdClosed; @@ -259,6 +276,9 @@ class PropertyEditorController extends DisposableController .map(argToProperty) .nonNulls .toList(), + // TODO(https://github.com/flutter/devtools/issues/8652): Add tests for + // 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_inputs.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_inputs.dart index 4c1b68eafd5..9bef80edd4e 100644 --- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_inputs.dart +++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_inputs.dart @@ -380,14 +380,15 @@ mixin _PropertyInputMixin on State { } void _handleServerResponse( - EditArgumentResponse? errorResponse, { + GenericApiResponse? errorResponse, { required EditableProperty property, }) { final succeeded = errorResponse == null || errorResponse.success; if (!succeeded) { setState(() { + final errorType = EditArgumentError.fromCode(errorResponse.errorCode); _serverError = - '${errorResponse.errorType?.message ?? 'Encountered unknown error.'} (Property: ${property.name})'; + '${errorType ?? 'Encountered unknown error.'} (Property: ${property.name})'; }); ga.reportError('property-editor $_serverError'); } 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 810d55e16f3..0c18a827200 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 @@ -24,20 +24,16 @@ class PropertyEditorView extends StatelessWidget { Widget build(BuildContext context) { return MultiValueListenableBuilder( listenables: [ - controller.editorClient.editArgumentMethodName, - controller.editorClient.editableArgumentsMethodName, + controller.editorClient.editableArgumentsApiIsRegistered, controller.editableWidgetData, ], builder: (_, values, _) { - final editArgumentMethodName = values.first as String?; - final editableArgumentsMethodName = values.second as String?; - - if (editArgumentMethodName == null || - editableArgumentsMethodName == null) { + final editableArgumentsApiIsRegistered = values.first as bool; + if (!editableArgumentsApiIsRegistered) { return const CenteredCircularProgressIndicator(); } - final editableWidgetData = values.third as EditableWidgetData?; + final editableWidgetData = values.second as EditableWidgetData?; return SelectionArea( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -56,7 +52,7 @@ class PropertyEditorView extends StatelessWidget { return [introSentence, const HowToUseMessage()]; } - final (:properties, :name, :documentation, :fileUri, :range) = + final (:properties, :refactors, :name, :documentation, :fileUri, :range) = editableWidgetData; if (fileUri != null && !fileUri.endsWith('.dart')) { return [const NoDartCodeMessage(), const HowToUseMessage()]; 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 bbc2f72d6b1..85704f62208 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 @@ -587,6 +587,7 @@ void main() { position: argThat(isNotNull, named: 'position'), name: argThat(isNotNull, named: 'name'), value: argThat(anything, named: 'value'), + screenId: 'propertyEditorSidebar', ), ).thenAnswer((realInvocation) { final calledWithArgs = realInvocation.namedArguments; @@ -598,7 +599,7 @@ void main() { ); return Future.value( - EditArgumentResponse( + GenericApiResponse( success: success, errorCode: success ? null : -32019, ), From 2c88aa89cafb9233ec29bc2d713f2cb318436ffc Mon Sep 17 00:00:00 2001 From: Elliott Brooks <21270878+elliette@users.noreply.github.com> Date: Wed, 21 May 2025 12:46:38 -0700 Subject: [PATCH 02/12] Protect behind feature flag --- packages/devtools_app/.vscode/launch.json | 11 ++++++++++- .../lib/src/shared/editor/editor_client.dart | 2 +- .../lib/src/shared/feature_flags.dart | 5 +++++ .../property_editor_controller.dart | 18 ++++++++++++------ 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/packages/devtools_app/.vscode/launch.json b/packages/devtools_app/.vscode/launch.json index 2ab1290adfa..e0ad61c69dd 100644 --- a/packages/devtools_app/.vscode/launch.json +++ b/packages/devtools_app/.vscode/launch.json @@ -61,11 +61,20 @@ "request": "attach", }, { - "name": "standalone_ui/property_editor_sidebar", + "name": "property_editor_sidebar", "request": "launch", "type": "dart", "program": "test/test_infra/scenes/standalone_ui/property_editor_sidebar.stager_app.g.dart", }, + { + "name": "property_editor_sidebar + experiments", + "request": "launch", + "type": "dart", + "program": "test/test_infra/scenes/standalone_ui/property_editor_sidebar.stager_app.g.dart", + "args": [ + "--dart-define=enable_experiments=true" + ] + }, ] } diff --git a/packages/devtools_app/lib/src/shared/editor/editor_client.dart b/packages/devtools_app/lib/src/shared/editor/editor_client.dart index f0d01c7f76a..cd1c54b1e10 100644 --- a/packages/devtools_app/lib/src/shared/editor/editor_client.dart +++ b/packages/devtools_app/lib/src/shared/editor/editor_client.dart @@ -362,7 +362,7 @@ class EditorClient extends DisposableController if (!requestMethod.isRegistered) { return GenericApiResponse( success: false, - errorMessage: 'API is not available.', + errorMessage: 'API is unavailable.', ); } diff --git a/packages/devtools_app/lib/src/shared/feature_flags.dart b/packages/devtools_app/lib/src/shared/feature_flags.dart index c15e1298b80..6079842b46f 100644 --- a/packages/devtools_app/lib/src/shared/feature_flags.dart +++ b/packages/devtools_app/lib/src/shared/feature_flags.dart @@ -95,6 +95,11 @@ extension FeatureFlags on Never { /// https://github.com/flutter/devtools/issues/7854 static bool propertyEditor = enableExperiments; + /// Flag to enable refactors in the Flutter Property Editor sidebar. + /// + /// https://github.com/flutter/devtools/issues/8652 + static bool propertyEditorRefactors = enableExperiments; + /// Stores a map of all the feature flags for debugging purposes. /// /// When adding a new flag, you are responsible for adding it to this map as 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 4516830f1e6..b094df2db92 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 @@ -11,6 +11,7 @@ import '../../../shared/analytics/analytics.dart' as ga; import '../../../shared/analytics/constants.dart' as gac; import '../../../shared/editor/api_classes.dart'; import '../../../shared/editor/editor_client.dart'; +import '../../../shared/feature_flags.dart'; import '../../../shared/ui/filter.dart'; import '../../../shared/utils/utils.dart'; import 'property_editor_types.dart'; @@ -208,12 +209,17 @@ class PropertyEditorController extends DisposableController position: cursorPosition, screenId: gac.PropertyEditorSidebar.id, ); - // Get any supported refactors for the current position. - final refactorsResult = await editorClient.getRefactors( - textDocument: textDocument, - range: EditorRange(start: cursorPosition, end: cursorPosition), - screenId: gac.PropertyEditorSidebar.id, - ); + 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. + refactorsResult = await editorClient.getRefactors( + textDocument: textDocument, + range: EditorRange(start: cursorPosition, end: cursorPosition), + screenId: gac.PropertyEditorSidebar.id, + ); + } // Update the widget data. final name = editableArgsResult?.name; _editableWidgetData.value = ( From 6d1667bd0f122ec8d7fc18146402987955f2366d Mon Sep 17 00:00:00 2001 From: Elliott Brooks <21270878+elliette@users.noreply.github.com> Date: Wed, 21 May 2025 13:27:02 -0700 Subject: [PATCH 03/12] Update Flutter SDK to get refactors support --- flutter-candidate.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/flutter-candidate.txt b/flutter-candidate.txt index ef5c9d3396f..281bf477ad8 100644 --- a/flutter-candidate.txt +++ b/flutter-candidate.txt @@ -1 +1 @@ -48f87a5fe76aa65f89f37cc716e50b34933e78e9 +dd671fae53d37eb15e0f8fc94cd52c2f2ff147ee From 8740d154f31cfb77d9043cb9c9ff8acb1949eb4c Mon Sep 17 00:00:00 2001 From: Elliott Brooks <21270878+elliette@users.noreply.github.com> Date: Wed, 21 May 2025 15:27:24 -0700 Subject: [PATCH 04/12] Fix analyzer errors --- .../ide_shared/property_editor/property_editor_test.dart | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) 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 85704f62208..0f6892aa403 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 @@ -45,11 +45,8 @@ void main() { mockEditorClient = MockEditorClient(); when( - mockEditorClient.editArgumentMethodName, - ).thenReturn(ValueNotifier(LspMethod.editArgument.methodName)); - when( - mockEditorClient.editableArgumentsMethodName, - ).thenReturn(ValueNotifier(LspMethod.editableArguments.methodName)); + mockEditorClient.editableArgumentsApiIsRegistered, + ).thenReturn(ValueNotifier(true)); when( mockEditorClient.activeLocationChangedStream, ).thenAnswer((_) => eventStream); @@ -99,6 +96,7 @@ void main() { mockEditorClient.getEditableArguments( textDocument: location.document, position: location.position, + screenId: 'propertyEditorSidebar', ), ).thenAnswer((realInvocation) { getEditableArgsCalled?.complete(); From 13d97547cd1d245afc270278f45feda84317a0df Mon Sep 17 00:00:00 2001 From: Elliott Brooks <21270878+elliette@users.noreply.github.com> Date: Wed, 21 May 2025 16:03:34 -0700 Subject: [PATCH 05/12] Fix property editor test --- .../ide_shared/property_editor/property_editor_inputs.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_inputs.dart b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_inputs.dart index 9bef80edd4e..fe8d13280c3 100644 --- a/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_inputs.dart +++ b/packages/devtools_app/lib/src/standalone_ui/ide_shared/property_editor/property_editor_inputs.dart @@ -388,7 +388,7 @@ mixin _PropertyInputMixin on State { setState(() { final errorType = EditArgumentError.fromCode(errorResponse.errorCode); _serverError = - '${errorType ?? 'Encountered unknown error.'} (Property: ${property.name})'; + '${errorType?.message ?? 'Encountered unknown error.'} (Property: ${property.name})'; }); ga.reportError('property-editor $_serverError'); } From 36f6e6038cd5432a173beb62efbab6403276c410 Mon Sep 17 00:00:00 2001 From: Elliott Brooks <21270878+elliette@users.noreply.github.com> Date: Wed, 21 May 2025 16:06:02 -0700 Subject: [PATCH 06/12] Update goldens --- ...tion_inspector_errors_2_error_selected.png | Bin 60715 -> 60687 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/packages/devtools_app/test/test_infra/goldens/integration_inspector_errors_2_error_selected.png b/packages/devtools_app/test/test_infra/goldens/integration_inspector_errors_2_error_selected.png index 9e909a88f528440ffcde405958878506151dd261..80f0bf9c286436d821b025d3787d38fb58f250ed 100644 GIT binary patch delta 30666 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 delta 30486 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 HN@xNACTBdQ From ee0fe555a1f6a073ed4484ca90cb210b182366b8 Mon Sep 17 00:00:00 2001 From: Elliott Brooks <21270878+elliette@users.noreply.github.com> Date: Thu, 22 May 2025 09:44:53 -0700 Subject: [PATCH 07/12] Fix deprecated members use --- .../lib/src/shared/primitives/custom_pointer_scroll_view.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/devtools_app/lib/src/shared/primitives/custom_pointer_scroll_view.dart b/packages/devtools_app/lib/src/shared/primitives/custom_pointer_scroll_view.dart index f1199350ec8..eb0e45f5d2a 100644 --- a/packages/devtools_app/lib/src/shared/primitives/custom_pointer_scroll_view.dart +++ b/packages/devtools_app/lib/src/shared/primitives/custom_pointer_scroll_view.dart @@ -821,7 +821,7 @@ class _RenderScrollSemantics extends RenderProxyBox { if (child.isTagged(RenderViewport.excludeFromScrolling)) { excluded.add(child); } else { - if (!child.hasFlag(SemanticsFlag.isHidden)) { + if (!child.flagsCollection.isHidden) { firstVisibleIndex ??= child.indexInParent; } included.add(child); From abe353c34b01ae7bc20c09d7cb1e9c0f87349969 Mon Sep 17 00:00:00 2001 From: Elliott Brooks <21270878+elliette@users.noreply.github.com> Date: Fri, 23 May 2025 11:18:52 -0700 Subject: [PATCH 08/12] Respond to first of PR comments --- .../lib/src/shared/editor/api_classes.dart | 85 +++++-------------- .../lib/src/shared/editor/editor_client.dart | 8 +- .../property_editor_controller.dart | 9 +- 3 files changed, 31 insertions(+), 71 deletions(-) diff --git a/packages/devtools_app/lib/src/shared/editor/api_classes.dart b/packages/devtools_app/lib/src/shared/editor/api_classes.dart index 21585b409c0..1fd10d07fcc 100644 --- a/packages/devtools_app/lib/src/shared/editor/api_classes.dart +++ b/packages/devtools_app/lib/src/shared/editor/api_classes.dart @@ -41,15 +41,15 @@ enum LspMethod { /// Returns the [LspMethod] for the given [methodName]. /// /// If the [methodName] does not exist, returns null. - static LspMethod? fromMethodName(String methodName) { - for (final method in LspMethod.values) { - if (method.methodName == methodName) return method; - } - return null; - } + static LspMethod? fromMethodName(String methodName) => + _methodNameToMethodLookup[methodName]; final String methodName; + static final _methodNameToMethodLookup = { + for (final method in LspMethod.values) method.methodName: method, + }; + static final _registrationStatus = { for (final method in LspMethod.values) method: false, }; @@ -141,7 +141,6 @@ abstract class Field { static const isRequired = 'isRequired'; static const kind = 'kind'; static const line = 'line'; - static const loggedAction = 'loggedAction'; static const name = 'name'; static const options = 'options'; static const page = 'page'; @@ -528,22 +527,22 @@ class EditableArgumentsResult with Serializable { }; } -/// Constants for [CodeAction] prefixes used to filter the results returned by -/// an [LspMethod.codeAction] request. +/// Constants for [CodeActionCommand] prefixes used to filter the results +/// returned by an [LspMethod.codeAction] request. abstract class CodeActionPrefixes { static const flutterWrap = 'refactor.flutter.wrap'; } /// The result of an [LspMethod.codeAction] request to the Analysis Server. /// -/// Contains a list of [CodeAction]s that can be performed. +/// Contains a list of [CodeActionCommand]s that can be performed. class CodeActionResult with Serializable { CodeActionResult({required this.actions}); CodeActionResult.fromJson(List> list) - : this(actions: list.map(CodeAction.fromJson).toList()); + : this(actions: list.map(CodeActionCommand.fromJson).toList()); - final List actions; + final List actions; @override Map toJson() => {Field.actions: actions}; @@ -553,17 +552,18 @@ class CodeActionResult with Serializable { /// via an [LspMethod.executeCommand] request. /// /// For example, "Wrap with Center" or "Wrap with Container". -class CodeAction with Serializable { - CodeAction({required this.command, required this.title, required this.args}); +class CodeActionCommand with Serializable { + CodeActionCommand({ + required this.command, + required this.title, + required this.args, + }); - CodeAction.fromJson(Map map) + CodeActionCommand.fromJson(Map map) : this( command: map[Field.command] as String, title: map[Field.title] as String, - args: (map[Field.arguments] as List? ?? []) - .cast>() - .map(CodeActionArgument.fromJson) - .toList(), + args: map[Field.arguments] as List? ?? [], ); /// The command identifier to send to [LspMethod.executeCommand]. @@ -574,7 +574,7 @@ class CodeAction with Serializable { /// Arguments that should be passed to [LspMethod.executeCommand] when /// invoking this action. - final List args; + final List args; @override Map toJson() => { @@ -584,49 +584,6 @@ class CodeAction with Serializable { }; } -/// An argument for a [CodeAction]. -/// -/// This includes information about the document and range to which the -/// [CodeAction] should be applied. -class CodeActionArgument with Serializable { - CodeActionArgument({ - required this.textDocument, - required this.range, - required this.kind, - required this.loggedAction, - }); - - CodeActionArgument.fromJson(Map map) - : this( - textDocument: TextDocument.fromJson( - map[Field.textDocument] as Map, - ), - range: EditorRange.fromJson(map[Field.range] as Map), - kind: map[Field.kind] as String?, - loggedAction: map[Field.loggedAction] as String?, - ); - - /// The document to which the [CodeAction] applies. - final TextDocument textDocument; - - /// The range within the [textDocument] to which the [CodeAction] applies. - final EditorRange range; - - /// The kind of action, often a string like "refactor.flutter.wrap.container". - final String? kind; - - /// An identifier used for logging or analytics purposes related to this action. - final String? loggedAction; - - @override - Map toJson() => { - Field.textDocument: textDocument.toJson(), - Field.range: range.toJson(), - Field.kind: kind, - Field.loggedAction: loggedAction, - }; -} - /// Errors that the Analysis Server returns for failed argument edits. /// /// These should be kept in sync with the error coes defined at @@ -680,7 +637,7 @@ enum EditArgumentError { } } -/// Generic response representing whether a reqeust was a [success]. +/// Generic response representing whether a request was a [success]. class GenericApiResponse { GenericApiResponse({ required this.success, diff --git a/packages/devtools_app/lib/src/shared/editor/editor_client.dart b/packages/devtools_app/lib/src/shared/editor/editor_client.dart index cd1c54b1e10..e45b83ad1f4 100644 --- a/packages/devtools_app/lib/src/shared/editor/editor_client.dart +++ b/packages/devtools_app/lib/src/shared/editor/editor_client.dart @@ -298,16 +298,16 @@ class EditorClient extends DisposableController screenId: screenId, ); - /// Requests that the Analysis Server makes a code edit for an argument. - Future applyRefactor({ + /// Requests that the Analysis Server execute the given [commandName]. + Future executeCommand({ required String commandName, - required List commandArgs, + required List commandArgs, required String screenId, }) => _callLspApiAndRespond( requestMethod: LspMethod.executeCommand, requestParams: { 'command': commandName, - 'arguments': commandArgs.map((e) => e.toJson()).toList(), + 'arguments': commandArgs, }, screenId: screenId, ); 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 b094df2db92..5effbf630f5 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 @@ -18,7 +18,7 @@ import 'property_editor_types.dart'; typedef EditableWidgetData = ({ List properties, - List refactors, + List refactors, String? name, String? documentation, String? fileUri, @@ -214,6 +214,9 @@ class PropertyEditorController extends DisposableController // in the Property Editor by default. if (FeatureFlags.propertyEditorRefactors) { // Get any supported refactors for the current position. + // 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), @@ -246,8 +249,8 @@ class PropertyEditorController extends DisposableController .where((property) => !property.isDeprecated || property.hasArgument) .toList(); - List _extractRefactors(CodeActionResult? result) => - (result?.actions ?? []) + List _extractRefactors(CodeActionResult? result) => + (result?.actions ?? []) .where((action) => action.title != null && action.command != null) .toList(); From a77c314c54f7b146c6a00bde04212a9adfcf2cb2 Mon Sep 17 00:00:00 2001 From: Elliott Brooks <21270878+elliette@users.noreply.github.com> Date: Fri, 23 May 2025 11:19:26 -0700 Subject: [PATCH 09/12] Update test --- .../ide_shared/property_editor/property_editor_test.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 0f6892aa403..3c90ba34531 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 @@ -6,6 +6,7 @@ import 'dart:async'; 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/standalone_ui/ide_shared/property_editor/property_editor_controller.dart'; import 'package:devtools_app/src/standalone_ui/ide_shared/property_editor/property_editor_types.dart'; @@ -96,7 +97,7 @@ void main() { mockEditorClient.getEditableArguments( textDocument: location.document, position: location.position, - screenId: 'propertyEditorSidebar', + screenId: gac.PropertyEditorSidebar.id, ), ).thenAnswer((realInvocation) { getEditableArgsCalled?.complete(); From 64e19bdd009cf10bffbf89ebf8be8d1b3a18fb02 Mon Sep 17 00:00:00 2001 From: Elliott Brooks <21270878+elliette@users.noreply.github.com> Date: Fri, 23 May 2025 16:14:41 -0700 Subject: [PATCH 10/12] Add new test file --- .../shared/editor/editor_client_test.dart | 803 ++++++++++++++++++ .../lib/src/mocks/generated.dart | 2 + packages/devtools_test/pubspec.yaml | 1 + 3 files changed, 806 insertions(+) create mode 100644 packages/devtools_app/test/shared/editor/editor_client_test.dart diff --git a/packages/devtools_app/test/shared/editor/editor_client_test.dart b/packages/devtools_app/test/shared/editor/editor_client_test.dart new file mode 100644 index 00000000000..69526c3153b --- /dev/null +++ b/packages/devtools_app/test/shared/editor/editor_client_test.dart @@ -0,0 +1,803 @@ +// 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 'dart:convert'; + +import 'package:devtools_app/src/shared/editor/api_classes.dart'; +import 'package:devtools_app/src/shared/editor/editor_client.dart'; +import 'package:devtools_test/devtools_test.dart'; +import 'package:dtd/dtd.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; + +void main() { + late MockDartToolingDaemon mockDtd; + late EditorClient editorClient; + + final methodToResponseJson = { + LspMethod.codeAction: _codeActionResponseJson, + LspMethod.editableArguments: _editableArgumentsResponseJson, + LspMethod.editArgument: 'null', + LspMethod.executeCommand: 'null', + }; + + setUp(() { + mockDtd = MockDartToolingDaemon(); + + for (final MapEntry(key: method, value: responseJson) + in methodToResponseJson.entries) { + when( + mockDtd.call( + lspServiceName, + method.methodName, + params: anyNamed('params'), + ), + ).thenAnswer((_) async => _createDtdResponse(responseJson)); + } + + for (final method in LspMethod.values) { + method.isRegistered = true; + } + + editorClient = EditorClient(mockDtd); + }); + + group('getRefactors', () { + test('deserializes refactors from the CodeActionResult', () async { + final result = await editorClient.getRefactors( + textDocument: _textDocument, + range: _editorRange, + screenId: _fakeScreenId, + ); + + // Verify the expected request was sent. + verify( + mockDtd.call( + lspServiceName, + LspMethod.codeAction.methodName, + params: json.decode(_codeActionRequestJson), + ), + ).called(1); + + // Verify deserialization of the response succeeded. + expect(result, isA()); + const expectedRefactors = [ + 'Wrap with widget...', + 'Wrap with Center', + 'Wrap with Container', + 'Wrap with Expanded', + 'Wrap with Flexible', + 'Wrap with Padding', + 'Wrap with SizedBox', + 'Wrap with Column', + 'Wrap with Row', + 'Wrap with Builder', + 'Wrap with ValueListenableBuilder', + 'Wrap with StreamBuilder', + 'Wrap with FutureBuilder', + ]; + expect( + result!.actions.map((a) => a.title), + containsAll(expectedRefactors), + ); + }); + + test('returns null when API is unavailable', () async { + LspMethod.codeAction.isRegistered = false; + + final result = await editorClient.getRefactors( + textDocument: _textDocument, + range: _editorRange, + screenId: _fakeScreenId, + ); + + // Verify that the request was never sent. + verifyNever(mockDtd.call(any, any, params: anyNamed('params'))); + + // Verify the response is null. + expect(result, isNull); + }); + }); + + group('editableArguments', () { + test('deserializes arguments from the EditableArgumentsResult', () async { + final result = await editorClient.getEditableArguments( + textDocument: _textDocument, + position: _cursorPosition, + screenId: _fakeScreenId, + ); + + // Verify the expected request was sent. + verify( + mockDtd.call( + lspServiceName, + LspMethod.editableArguments.methodName, + params: json.decode(_editableArgumentsRequestJson), + ), + ).called(1); + + // Verify deserialization of the response succeeded. + final expectedArgs = [ + 'mainAxisAlignment - MainAxisAlignment.end', + 'mainAxisSize - null', + 'crossAxisAlignment - null', + 'textDirection - null', + 'verticalDirection - null', + 'textBaseline - null', + 'spacing - null', + ]; + + expect( + result!.args.map((arg) => '${arg.name} - ${arg.value}'), + containsAll(expectedArgs), + ); + }); + + test('returns null when API is not available', () async { + LspMethod.editableArguments.isRegistered = false; + + final result = await editorClient.getEditableArguments( + textDocument: _textDocument, + position: _cursorPosition, + screenId: _fakeScreenId, + ); + + // Verify that the request was never sent. + verifyNever(mockDtd.call(any, any, params: anyNamed('params'))); + + // Verify the response is null. + expect(result, isNull); + }); + }); + + group('executeCommand', () { + test('sends an executeCommand request', () async { + final result = await editorClient.executeCommand( + commandName: 'dart.edit.codeAction.apply', + commandArgs: [json.decode(_executeCommandArg) as Map], + screenId: _fakeScreenId, + ); + + // Verify the expected request was sent. + verify( + mockDtd.call( + lspServiceName, + LspMethod.executeCommand.methodName, + params: json.decode(_executeCommandRequestJson), + ), + ).called(1); + + // Verify the response was successful. + expect(result.success, isTrue); + expect(result.errorMessage, isNull); + }); + + test('returns failure response when API is not available', () async { + LspMethod.executeCommand.isRegistered = false; + + final result = await editorClient.executeCommand( + commandName: 'dart.edit.codeAction.apply', + commandArgs: [json.decode(_executeCommandArg) as Map], + screenId: _fakeScreenId, + ); + + // Verify the request was never sent. + verifyNever(mockDtd.call(any, any, params: anyNamed('params'))); + + // Verify the failure response. + expect(result.success, isFalse); + expect(result.errorMessage, 'API is unavailable.'); + }); + }); + + group('editArgument', () { + test('sends an editArgument request', () async { + final result = await editorClient.editArgument( + textDocument: _textDocument, + position: _cursorPosition, + name: 'mainAxisAlignment', + value: 'MainAxisAlignment.center', + screenId: _fakeScreenId, + ); + + // Verify the expected request was sent. + verify( + mockDtd.call( + lspServiceName, + LspMethod.editArgument.methodName, + params: json.decode(_editArgumentRequestJson), + ), + ).called(1); + + // Verify the response is successful. + expect(result.success, isTrue); + expect(result.errorMessage, isNull); + }); + + test('returns failure response when API is not available', () async { + LspMethod.editArgument.isRegistered = false; + + final result = await editorClient.editArgument( + textDocument: _textDocument, + position: _cursorPosition, + name: 'mainAxisAlignment', + value: 'MainAxisAlignment.center', + screenId: _fakeScreenId, + ); + + // Verify that the request was never sent. + verifyNever(mockDtd.call(any, any, params: anyNamed('params'))); + + // Verify the failure response. + expect(result.success, isFalse); + expect(result.errorMessage, 'API is unavailable.'); + }); + }); +} + +const _fakeScreenId = 'DevToolsScreen'; +const _documentUri = 'file:///Users/me/flutter_app/lib/main.dart'; +const _documentVersion = 1; +const _cursorLine = 10; +const _cursorChar = 20; + +final _textDocument = TextDocument( + uriAsString: _documentUri, + version: _documentVersion, +); +final _cursorPosition = CursorPosition( + line: _cursorLine, + character: _cursorChar, +); +final _editorRange = EditorRange(start: _cursorPosition, end: _cursorPosition); + +DTDResponse _createDtdResponse(String jsonStr) { + final result = json.decode(_wrapJsonInResult(jsonStr)); + return DTDResponse('1', 'type', result); +} + +String _wrapJsonInResult(String jsonStr) => '{"result": $jsonStr}'; + +const _editArgumentRequestJson = + ''' +{ + "textDocument": { + "uri": "$_documentUri", + "version": $_documentVersion + }, + "position": { + "character": $_cursorChar, + "line": $_cursorLine + }, + "edit": { + "name": "mainAxisAlignment", + "newValue": "MainAxisAlignment.center" + }, + "type": "Object" +} +'''; + +const _executeCommandArg = + ''' + { + "textDocument": { + "uri": "$_documentUri", + "version": $_documentVersion + }, + "range": { + "end": { + "character": $_cursorChar, + "line": $_cursorLine + }, + "start": { + "character": $_cursorChar, + "line": $_cursorLine + } + }, + "kind": "refactor.flutter.wrap.futureBuilder", + "loggedAction": "dart.assist.flutter.wrap.futureBuilder" + } +'''; + +const _executeCommandRequestJson = + ''' +{ + "command": "dart.edit.codeAction.apply", + "arguments": [ + $_executeCommandArg + ], + "type": "Object" +} +'''; + +const _editableArgumentsRequestJson = + ''' +{ + "textDocument": { + "uri": "$_documentUri", + "version": $_documentVersion + }, + "position": { + "character": $_cursorChar, + "line": $_cursorLine + }, + "type": "Object" +} +'''; + +const _codeActionRequestJson = + ''' +{ + "textDocument": { + "uri": "$_documentUri", + "version": $_documentVersion + }, + "range": { + "start": { + "character": $_cursorChar, + "line": $_cursorLine + }, + "end": { + "character": $_cursorChar, + "line": $_cursorLine + } + }, + "context": { + "diagnostics": [], + "only": [ + "refactor.flutter.wrap" + ] + }, + "type": "Object" +} +'''; + +const _editableArgumentsResponseJson = + ''' +{ + "arguments": [ + { + "defaultValue": "MainAxisAlignment.start", + "documentation": "Creates a vertical array of children.", + "hasArgument": true, + "isDeprecated": false, + "isEditable": true, + "isNullable": false, + "isRequired": false, + "name": "mainAxisAlignment", + "options": [ + "MainAxisAlignment.start", + "MainAxisAlignment.end", + "MainAxisAlignment.center", + "MainAxisAlignment.spaceBetween", + "MainAxisAlignment.spaceAround", + "MainAxisAlignment.spaceEvenly" + ], + "type": "enum", + "value": "MainAxisAlignment.end" + }, + { + "defaultValue": "MainAxisSize.max", + "documentation": "Creates a vertical array of children.", + "hasArgument": false, + "isDeprecated": false, + "isEditable": true, + "isNullable": false, + "isRequired": false, + "name": "mainAxisSize", + "options": [ + "MainAxisSize.min", + "MainAxisSize.max" + ], + "type": "enum" + }, + { + "defaultValue": "CrossAxisAlignment.center", + "documentation": "Creates a vertical array of children.", + "hasArgument": false, + "isDeprecated": false, + "isEditable": true, + "isNullable": false, + "isRequired": false, + "name": "crossAxisAlignment", + "options": [ + "CrossAxisAlignment.start", + "CrossAxisAlignment.end", + "CrossAxisAlignment.center", + "CrossAxisAlignment.stretch", + "CrossAxisAlignment.baseline" + ], + "type": "enum" + }, + { + "documentation": "Creates a vertical array of children.", + "hasArgument": false, + "isDeprecated": false, + "isEditable": true, + "isNullable": true, + "isRequired": false, + "name": "textDirection", + "options": [ + "TextDirection.rtl", + "TextDirection.ltr" + ], + "type": "enum" + }, + { + "defaultValue": "VerticalDirection.down", + "documentation": "Creates a vertical array of children.", + "hasArgument": false, + "isDeprecated": false, + "isEditable": true, + "isNullable": false, + "isRequired": false, + "name": "verticalDirection", + "options": [ + "VerticalDirection.up", + "VerticalDirection.down" + ], + "type": "enum" + }, + { + "documentation": "Creates a vertical array of children.", + "hasArgument": false, + "isDeprecated": false, + "isEditable": true, + "isNullable": true, + "isRequired": false, + "name": "textBaseline", + "options": [ + "TextBaseline.alphabetic", + "TextBaseline.ideographic" + ], + "type": "enum" + }, + { + "defaultValue": 0.0, + "documentation": "Creates a vertical array of children.", + "hasArgument": false, + "isDeprecated": false, + "isEditable": true, + "isNullable": false, + "isRequired": false, + "name": "spacing", + "type": "double" + } + ], + "documentation": "Creates a vertical array of children.", + "name": "Column", + "range": { + "end": { + "character": 13, + "line": 64 + }, + "start": { + "character": 19, + "line": 48 + } + }, + "textDocument": { + "uri": "$_documentUri", + "version": $_documentVersion + } +} +'''; + +const _codeActionResponseJson = + ''' +[ + { + "arguments": [ + { + "textDocument": { + "uri": "$_documentUri", + "version": $_documentVersion + }, + "range": { + "end": { + "character": $_cursorChar, + "line": $_cursorLine + }, + "start": { + "character": $_cursorChar, + "line": $_cursorLine + } + }, + "kind": "refactor.flutter.wrap.generic", + "loggedAction": "dart.assist.flutter.wrap.generic" + } + ], + "command": "dart.edit.codeAction.apply", + "title": "Wrap with widget..." + }, + { + "arguments": [ + { + "textDocument": { + "uri": "$_documentUri", + "version": $_documentVersion + }, + "range": { + "end": { + "character": $_cursorChar, + "line": $_cursorLine + }, + "start": { + "character": $_cursorChar, + "line": $_cursorLine + } + }, + "kind": "refactor.flutter.wrap.center", + "loggedAction": "dart.assist.flutter.wrap.center" + } + ], + "command": "dart.edit.codeAction.apply", + "title": "Wrap with Center" + }, + { + "arguments": [ + { + "textDocument": { + "uri": "$_documentUri", + "version": $_documentVersion + }, + "range": { + "end": { + "character": $_cursorChar, + "line": $_cursorLine + }, + "start": { + "character": $_cursorChar, + "line": $_cursorLine + } + }, + "kind": "refactor.flutter.wrap.container", + "loggedAction": "dart.assist.flutter.wrap.container" + } + ], + "command": "dart.edit.codeAction.apply", + "title": "Wrap with Container" + }, + { + "arguments": [ + { + "textDocument": { + "uri": "$_documentUri", + "version": $_documentVersion + }, + "range": { + "end": { + "character": $_cursorChar, + "line": $_cursorLine + }, + "start": { + "character": $_cursorChar, + "line": $_cursorLine + } + }, + "kind": "refactor.flutter.wrap.expanded", + "loggedAction": "dart.assist.flutter.wrap.expanded" + } + ], + "command": "dart.edit.codeAction.apply", + "title": "Wrap with Expanded" + }, + { + "arguments": [ + { + "textDocument": { + "uri": "$_documentUri", + "version": $_documentVersion + }, + "range": { + "end": { + "character": $_cursorChar, + "line": $_cursorLine + }, + "start": { + "character": $_cursorChar, + "line": $_cursorLine + } + }, + "kind": "refactor.flutter.wrap.flexible", + "loggedAction": "dart.assist.flutter.wrap.flexible" + } + ], + "command": "dart.edit.codeAction.apply", + "title": "Wrap with Flexible" + }, + { + "arguments": [ + { + "textDocument": { + "uri": "$_documentUri", + "version": $_documentVersion + }, + "range": { + "end": { + "character": $_cursorChar, + "line": $_cursorLine + }, + "start": { + "character": $_cursorChar, + "line": $_cursorLine + } + }, + "kind": "refactor.flutter.wrap.padding", + "loggedAction": "dart.assist.flutter.wrap.padding" + } + ], + "command": "dart.edit.codeAction.apply", + "title": "Wrap with Padding" + }, + { + "arguments": [ + { + "textDocument": { + "uri": "$_documentUri", + "version": $_documentVersion + }, + "range": { + "end": { + "character": $_cursorChar, + "line": $_cursorLine + }, + "start": { + "character": $_cursorChar, + "line": $_cursorLine + } + }, + "kind": "refactor.flutter.wrap.sizedBox", + "loggedAction": "dart.assist.flutter.wrap.sizedBox" + } + ], + "command": "dart.edit.codeAction.apply", + "title": "Wrap with SizedBox" + }, + { + "arguments": [ + { + "textDocument": { + "uri": "$_documentUri", + "version": $_documentVersion + }, + "range": { + "end": { + "character": $_cursorChar, + "line": $_cursorLine + }, + "start": { + "character": $_cursorChar, + "line": $_cursorLine + } + }, + "kind": "refactor.flutter.wrap.column", + "loggedAction": "dart.assist.flutter.wrap.column" + } + ], + "command": "dart.edit.codeAction.apply", + "title": "Wrap with Column" + }, + { + "arguments": [ + { + "textDocument": { + "uri": "$_documentUri", + "version": $_documentVersion + }, + "range": { + "end": { + "character": $_cursorChar, + "line": $_cursorLine + }, + "start": { + "character": $_cursorChar, + "line": $_cursorLine + } + }, + "kind": "refactor.flutter.wrap.row", + "loggedAction": "dart.assist.flutter.wrap.row" + } + ], + "command": "dart.edit.codeAction.apply", + "title": "Wrap with Row" + }, + { + "arguments": [ + { + "textDocument": { + "uri": "$_documentUri", + "version": $_documentVersion + }, + "range": { + "end": { + "character": $_cursorChar, + "line": $_cursorLine + }, + "start": { + "character": $_cursorChar, + "line": $_cursorLine + } + }, + "kind": "refactor.flutter.wrap.builder", + "loggedAction": "dart.assist.flutter.wrap.builder" + } + ], + "command": "dart.edit.codeAction.apply", + "title": "Wrap with Builder" + }, + { + "arguments": [ + { + "textDocument": { + "uri": "$_documentUri", + "version": $_documentVersion + }, + "range": { + "end": { + "character": $_cursorChar, + "line": $_cursorLine + }, + "start": { + "character": $_cursorChar, + "line": $_cursorLine + } + }, + "kind": "refactor.flutter.wrap.valueListenableBuilder", + "loggedAction": "dart.assist.flutter.wrap.valueListenableBuilder" + } + ], + "command": "dart.edit.codeAction.apply", + "title": "Wrap with ValueListenableBuilder" + }, + { + "arguments": [ + { + "textDocument": { + "uri": "$_documentUri", + "version": $_documentVersion + }, + "range": { + "end": { + "character": $_cursorChar, + "line": $_cursorLine + }, + "start": { + "character": $_cursorChar, + "line": $_cursorLine + } + }, + "kind": "refactor.flutter.wrap.streamBuilder", + "loggedAction": "dart.assist.flutter.wrap.streamBuilder" + } + ], + "command": "dart.edit.codeAction.apply", + "title": "Wrap with StreamBuilder" + }, + { + "arguments": [ + { + "textDocument": { + "uri": "$_documentUri", + "version": $_documentVersion + }, + "range": { + "end": { + "character": $_cursorChar, + "line": $_cursorLine + }, + "start": { + "character": $_cursorChar, + "line": $_cursorLine + } + }, + "kind": "refactor.flutter.wrap.futureBuilder", + "loggedAction": "dart.assist.flutter.wrap.futureBuilder" + } + ], + "command": "dart.edit.codeAction.apply", + "title": "Wrap with FutureBuilder" + } +] +'''; diff --git a/packages/devtools_test/lib/src/mocks/generated.dart b/packages/devtools_test/lib/src/mocks/generated.dart index 6bc83a802cf..404f6a2175e 100644 --- a/packages/devtools_test/lib/src/mocks/generated.dart +++ b/packages/devtools_test/lib/src/mocks/generated.dart @@ -4,6 +4,7 @@ import 'package:devtools_app/devtools_app.dart'; import 'package:devtools_app_shared/service.dart'; +import 'package:dtd/dtd.dart'; import 'package:mockito/annotations.dart'; import 'package:vm_service/vm_service.dart'; @@ -51,5 +52,6 @@ import 'package:vm_service/vm_service.dart'; MockSpec(), MockSpec(), MockSpec(), + MockSpec(), ]) void main() {} diff --git a/packages/devtools_test/pubspec.yaml b/packages/devtools_test/pubspec.yaml index 7387257835b..b60b5a4a017 100644 --- a/packages/devtools_test/pubspec.yaml +++ b/packages/devtools_test/pubspec.yaml @@ -20,6 +20,7 @@ dependencies: devtools_app: devtools_app_shared: devtools_shared: + dtd: ^2.5.1 flutter: sdk: flutter flutter_test: From d891ad807942841098ff0f4b8da48919398175ea Mon Sep 17 00:00:00 2001 From: Elliott Brooks <21270878+elliette@users.noreply.github.com> Date: Fri, 23 May 2025 16:24:04 -0700 Subject: [PATCH 11/12] Format --- .../devtools_app/lib/src/shared/editor/editor_client.dart | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/devtools_app/lib/src/shared/editor/editor_client.dart b/packages/devtools_app/lib/src/shared/editor/editor_client.dart index e45b83ad1f4..bb93305a0ef 100644 --- a/packages/devtools_app/lib/src/shared/editor/editor_client.dart +++ b/packages/devtools_app/lib/src/shared/editor/editor_client.dart @@ -305,10 +305,7 @@ class EditorClient extends DisposableController required String screenId, }) => _callLspApiAndRespond( requestMethod: LspMethod.executeCommand, - requestParams: { - 'command': commandName, - 'arguments': commandArgs, - }, + requestParams: {'command': commandName, 'arguments': commandArgs}, screenId: screenId, ); From 2a7df2182df7cc7da6738b270c8eeb4d38e385e8 Mon Sep 17 00:00:00 2001 From: Elliott Brooks <21270878+elliette@users.noreply.github.com> Date: Tue, 27 May 2025 10:29:29 -0700 Subject: [PATCH 12/12] command and title are never null --- packages/devtools_app/lib/src/shared/editor/api_classes.dart | 4 ++-- .../property_editor/property_editor_controller.dart | 4 +--- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/packages/devtools_app/lib/src/shared/editor/api_classes.dart b/packages/devtools_app/lib/src/shared/editor/api_classes.dart index 1fd10d07fcc..b30b3a32c20 100644 --- a/packages/devtools_app/lib/src/shared/editor/api_classes.dart +++ b/packages/devtools_app/lib/src/shared/editor/api_classes.dart @@ -567,10 +567,10 @@ class CodeActionCommand with Serializable { ); /// The command identifier to send to [LspMethod.executeCommand]. - final String? command; + final String command; /// The human-readable title of the command, e.g., "Wrap with Center". - final String? title; + final String title; /// Arguments that should be passed to [LspMethod.executeCommand] when /// invoking this action. 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 5effbf630f5..164a7e5d582 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 @@ -250,9 +250,7 @@ class PropertyEditorController extends DisposableController .toList(); List _extractRefactors(CodeActionResult? result) => - (result?.actions ?? []) - .where((action) => action.title != null && action.command != null) - .toList(); + (result?.actions ?? []).toList(); Timer _periodicallyCheckConnection(Duration interval) { return Timer.periodic(interval, (timer) {