Selecting an implementation widget with the on-device inspector opens the code location for the nearest project widget (#176530)

Fixes https://github.com/flutter/devtools/issues/9252

Previously, if a user selected a widget that was not created in their
project files using the on-device inspector, we would open the code
location for that widget. Now, we open the code location of the nearest
ancestor widget in their project files. (e.g., a user selects `RichText`
in the framework, we open the `Text` widget in their project)

Users can still open the code location for a Flutter framework or
third-party package widget by explicitly selecting it in the DevTools
widget tree. See more details/rationale on issue comment
https://github.com/flutter/devtools/issues/9252#issuecomment-3368491936

_Note: This resolves one of the top user complaints in the 2025 DevTools
user survey._


![project_file_fix](https://github.com/user-attachments/assets/e3839ada-52b6-4d4d-b1d7-7fea93c7380c)
This commit is contained in:
Elliott Brooks 2025-10-07 14:05:21 -07:00 committed by GitHub
parent 3dbd33d3aa
commit a50b9d56d4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 308 additions and 271 deletions

View File

@ -1613,21 +1613,36 @@ mixin WidgetInspectorService {
switch (object) {
case Element() when object != selection.currentElement:
selection.currentElement = object;
_sendInspectEvent(selection.currentElement);
_notifyToolsOfSelection(selection.currentElement);
return true;
case RenderObject() when object != selection.current:
selection.current = object;
_sendInspectEvent(selection.current);
_notifyToolsOfSelection(selection.current);
return true;
}
return false;
}
/// Notify attached tools to navigate to an object's source location.
void _sendInspectEvent(Object? object) {
/// Notify connected tools (e.g. Flutter DevTools, IDE plugins) that a new
/// widget has been selected.
///
/// This method triggers two actions:
/// 1. It calls [developer.inspect] on the provided [object], making it
/// available for inspection in Flutter DevTools.
/// 2. It posts a 'navigate' [ToolEvent] with the source code location of the
/// selected widget, allowing IDEs to navigate to the corresponding file
/// and line.
///
/// If [restrictToProjectFiles] is true and the selected widget is not from
/// the local project (i.e., it's from the Flutter framework or a package),
/// the 'navigate' event will point to the nearest ancestor widget that is
/// part of the local project.
void _notifyToolsOfSelection(Object? object, {bool restrictToProjectFiles = false}) {
inspect(object);
final _Location? location = _getSelectedWidgetLocation();
final _Location? location = _getSelectedWidgetLocation(
restrictToSummaryTree: restrictToProjectFiles,
);
if (location != null) {
postEvent('navigate', <String, Object>{
'fileUri': location.file, // URI file path of the location.
@ -2463,8 +2478,18 @@ mixin WidgetInspectorService {
return _safeJsonEncode(_getSelectedSummaryWidget(null, groupName));
}
_Location? _getSelectedWidgetLocation() {
return _getCreationLocation(_getSelectedWidgetDiagnosticsNode(null)?.value);
/// Returns the creation location of the currently selected widget.
///
/// If [restrictToSummaryTree] is true and the currently selected widget is
/// not in the summary tree (i.e. not created by the current project), this
/// method will instead return the location of its nearest ancestor widget
/// that is in the summary tree.
_Location? _getSelectedWidgetLocation({bool restrictToSummaryTree = false}) {
final DiagnosticsNode? selectedNode = restrictToSummaryTree
? _getSelectedSummaryDiagnosticsNode(null)
: _getSelectedWidgetDiagnosticsNode(null);
return _getCreationLocation(selectedNode?.value);
}
DiagnosticsNode? _getSelectedSummaryDiagnosticsNode(String? previousSelectionId) {
@ -3019,7 +3044,10 @@ class _WidgetInspectorState extends State<WidgetInspector> with WidgetsBindingOb
selection.clear();
} else {
// Otherwise notify DevTools of the current selection.
WidgetInspectorService.instance._sendInspectEvent(selection.current);
WidgetInspectorService.instance._notifyToolsOfSelection(
selection.current,
restrictToProjectFiles: true,
);
}
}
@ -3029,7 +3057,10 @@ class _WidgetInspectorState extends State<WidgetInspector> with WidgetsBindingOb
}
if (_lastPointerLocation != null) {
_inspectAt(_lastPointerLocation!);
WidgetInspectorService.instance._sendInspectEvent(selection.current);
WidgetInspectorService.instance._notifyToolsOfSelection(
selection.current,
restrictToProjectFiles: true,
);
}
}

View File

@ -939,7 +939,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
});
testWidgets(
'WidgetInspector Exit Selection Mode button',
'On-device selection test',
(WidgetTester tester) async {
// Enable widget selection mode.
WidgetInspectorService.instance.isSelectMode = true;
@ -947,23 +947,12 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
final GlobalKey inspectorKey = GlobalKey();
setupDefaultPubRootDirectory(service);
Widget exitWidgetSelectionButtonBuilder(
BuildContext context, {
required VoidCallback onPressed,
required String semanticsLabel,
required GlobalKey key,
}) {
return Material(
child: ElevatedButton(onPressed: onPressed, key: key, child: null),
);
}
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: WidgetInspector(
key: inspectorKey,
exitWidgetSelectionButtonBuilder: exitWidgetSelectionButtonBuilder,
exitWidgetSelectionButtonBuilder: null,
tapBehaviorButtonBuilder: null,
moveExitWidgetSelectionButtonBuilder: null,
child: const Text('Child 1'),
@ -991,286 +980,303 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
final String file = event['fileUri']! as String;
final int line = event['line']! as int;
final int column = event['column']! as int;
expect(file, endsWith('text.dart'));
expect(file, endsWith('widget_inspector_test.dart'));
// We don't hardcode the actual lines the widgets are created on as that
// would make this test fragile.
expect(line, isNotNull);
// Column numbers are more stable than line numbers.
expect(column, equals(16));
expect(column, equals(28));
},
// [intended] Test requires --track-widget-creation flag.
skip: !WidgetInspectorService.instance.isWidgetCreationTracked(),
);
testWidgets(
'[LTR] WidgetInspector Move Exit Selection Mode button to the right then left',
(WidgetTester tester) async {
WidgetInspectorService.instance.isSelectMode = true;
final GlobalKey inspectorKey = GlobalKey();
setupDefaultPubRootDirectory(service);
Widget exitWidgetSelectionButtonBuilder(
BuildContext context, {
required VoidCallback onPressed,
required String semanticsLabel,
required GlobalKey key,
}) {
return Material(
child: ElevatedButton(
onPressed: onPressed,
key: key,
child: const Text('EXIT SELECT MODE'),
),
);
}
Widget moveWidgetSelectionButtonBuilder(
BuildContext context, {
required VoidCallback onPressed,
required String semanticsLabel,
bool usesDefaultAlignment = true,
}) {
return Material(
child: ElevatedButton(
onPressed: onPressed,
child: Text(usesDefaultAlignment ? 'MOVE RIGHT' : 'MOVE LEFT'),
),
);
}
Finder buttonFinder(String buttonText) {
return find.ancestor(of: find.text(buttonText), matching: find.byType(ElevatedButton));
}
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: WidgetInspector(
key: inspectorKey,
exitWidgetSelectionButtonBuilder: exitWidgetSelectionButtonBuilder,
moveExitWidgetSelectionButtonBuilder: moveWidgetSelectionButtonBuilder,
tapBehaviorButtonBuilder: null,
child: const Text('APP'),
),
group('On-device inspector buttons', () {
Widget exitWidgetSelectionButtonBuilder(
BuildContext context, {
required VoidCallback onPressed,
required String semanticsLabel,
required GlobalKey key,
}) {
return Material(
child: ElevatedButton(
onPressed: onPressed,
key: key,
child: const Text('EXIT SELECT MODE'),
),
);
}
final Finder exitButton = buttonFinder('EXIT SELECT MODE');
expect(exitButton, findsOneWidget);
final Finder moveRightButton = buttonFinder('MOVE RIGHT');
expect(moveRightButton, findsOneWidget);
final double initialExitButtonX = tester.getCenter(exitButton).dx;
await tester.tap(moveRightButton);
await tester.pump();
final Finder moveLeftButton = buttonFinder('MOVE LEFT');
expect(moveLeftButton, findsOneWidget);
final double movedExitButtonX = tester.getCenter(exitButton).dx;
expect(initialExitButtonX, lessThan(movedExitButtonX), reason: 'LTR: should move right');
await tester.tap(moveLeftButton);
await tester.pump();
final double finalExitButtonX = tester.getCenter(exitButton).dx;
expect(finalExitButtonX, equals(initialExitButtonX));
},
// [intended] Test requires --track-widget-creation flag.
skip: !WidgetInspectorService.instance.isWidgetCreationTracked(),
);
testWidgets(
'[RTL] WidgetInspector Move Exit Selection Mode button to the left then right',
(WidgetTester tester) async {
WidgetInspectorService.instance.isSelectMode = true;
final GlobalKey inspectorKey = GlobalKey();
setupDefaultPubRootDirectory(service);
Widget exitWidgetSelectionButtonBuilder(
BuildContext context, {
required VoidCallback onPressed,
required String semanticsLabel,
required GlobalKey key,
}) {
return Material(
child: ElevatedButton(
onPressed: onPressed,
key: key,
child: const Text('EXIT SELECT MODE'),
),
);
}
Widget moveWidgetSelectionButtonBuilder(
BuildContext context, {
required VoidCallback onPressed,
required String semanticsLabel,
bool usesDefaultAlignment = true,
}) {
return Material(
child: ElevatedButton(
onPressed: onPressed,
child: Text(usesDefaultAlignment ? 'MOVE RIGHT' : 'MOVE LEFT'),
),
);
}
Finder buttonFinder(String buttonText) {
return find.ancestor(of: find.text(buttonText), matching: find.byType(ElevatedButton));
}
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.rtl,
child: WidgetInspector(
key: inspectorKey,
exitWidgetSelectionButtonBuilder: exitWidgetSelectionButtonBuilder,
moveExitWidgetSelectionButtonBuilder: moveWidgetSelectionButtonBuilder,
tapBehaviorButtonBuilder: null,
child: const Text('APP'),
),
Widget moveWidgetSelectionButtonBuilder(
BuildContext context, {
required VoidCallback onPressed,
required String semanticsLabel,
bool usesDefaultAlignment = true,
}) {
return Material(
child: ElevatedButton(
onPressed: onPressed,
child: Text(usesDefaultAlignment ? 'MOVE RIGHT' : 'MOVE LEFT'),
),
);
}
final Finder exitButton = buttonFinder('EXIT SELECT MODE');
expect(exitButton, findsOneWidget);
final Finder moveRightButton = buttonFinder('MOVE RIGHT');
expect(moveRightButton, findsOneWidget);
final double initialExitButtonX = tester.getCenter(exitButton).dx;
await tester.tap(moveRightButton);
await tester.pump();
final Finder moveLeftButton = buttonFinder('MOVE LEFT');
expect(moveLeftButton, findsOneWidget);
final double movedExitButtonX = tester.getCenter(exitButton).dx;
expect(initialExitButtonX, greaterThan(movedExitButtonX), reason: 'RTL: should move left');
await tester.tap(moveLeftButton);
await tester.pump();
final double finalExitButtonX = tester.getCenter(exitButton).dx;
expect(finalExitButtonX, equals(initialExitButtonX));
},
// [intended] Test requires --track-widget-creation flag.
skip: !WidgetInspectorService.instance.isWidgetCreationTracked(),
);
testWidgets(
'WidgetInspector Tap behavior button',
(WidgetTester tester) async {
Widget exitWidgetSelectionButtonBuilder(
BuildContext context, {
required VoidCallback onPressed,
required String semanticsLabel,
required GlobalKey key,
}) {
return Material(
child: ElevatedButton(onPressed: onPressed, key: key, child: null),
);
}
Widget tapBehaviorButtonBuilder(
BuildContext context, {
required VoidCallback onPressed,
required String semanticsLabel,
required bool selectionOnTapEnabled,
}) {
return Material(
child: ElevatedButton(
onPressed: onPressed,
child: Text(selectionOnTapEnabled ? 'SELECTION ON TAP' : 'APP INTERACTION ON TAP'),
),
);
}
Finder buttonFinder(String buttonText) {
return find.ancestor(of: find.text(buttonText), matching: find.byType(ElevatedButton));
}
int navigateEventsCount() =>
service.dispatchedEvents('navigate', stream: 'ToolEvent').length;
// Enable widget selection mode.
WidgetInspectorService.instance.isSelectMode = true;
// Pump the test widget.
final GlobalKey inspectorKey = GlobalKey();
setupDefaultPubRootDirectory(service);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: WidgetInspector(
key: inspectorKey,
exitWidgetSelectionButtonBuilder: exitWidgetSelectionButtonBuilder,
tapBehaviorButtonBuilder: tapBehaviorButtonBuilder,
moveExitWidgetSelectionButtonBuilder: null,
child: const Row(children: <Widget>[Text('Child 1'), Text('Child 2')]),
),
Widget tapBehaviorButtonBuilder(
BuildContext context, {
required VoidCallback onPressed,
required String semanticsLabel,
required bool selectionOnTapEnabled,
}) {
return Material(
child: ElevatedButton(
onPressed: onPressed,
child: Text(selectionOnTapEnabled ? 'SELECTION ON TAP' : 'APP INTERACTION ON TAP'),
),
);
}
// Verify there are no navigate events yet.
expect(navigateEventsCount(), equals(0));
Finder buttonFinder(String buttonText) {
return find.ancestor(of: find.text(buttonText), matching: find.byType(ElevatedButton));
}
// Tap on the first child widget.
final Finder child1 = find.text('Child 1');
await tester.tap(child1, warnIfMissed: false);
await tester.pump();
int navigateEventsCount() => service.dispatchedEvents('navigate', stream: 'ToolEvent').length;
// Verify the selection matches the first child widget.
final Element child1Element = child1.evaluate().first;
expect(service.selection.current, equals(child1Element.renderObject));
testWidgets(
'Exit select mode button',
(WidgetTester tester) async {
// Enable widget selection mode.
WidgetInspectorService.instance.isSelectMode = true;
// Verify that a navigate event was sent.
expect(navigateEventsCount(), equals(1));
final GlobalKey inspectorKey = GlobalKey();
setupDefaultPubRootDirectory(service);
// Tap on the SELECTION ON TAP button.
final Finder tapBehaviorButtonBefore = buttonFinder('SELECTION ON TAP');
expect(tapBehaviorButtonBefore, findsOneWidget);
await tester.tap(tapBehaviorButtonBefore);
await tester.pump();
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: WidgetInspector(
key: inspectorKey,
exitWidgetSelectionButtonBuilder: exitWidgetSelectionButtonBuilder,
tapBehaviorButtonBuilder: null,
moveExitWidgetSelectionButtonBuilder: null,
child: const Column(children: <Widget>[Text('Child 1'), Text('Child 2')]),
),
),
);
// Verify the tap behavior button's UI has been updated.
expect(tapBehaviorButtonBefore, findsNothing);
final Finder tapBehaviorButtonAfter = buttonFinder('APP INTERACTION ON TAP');
expect(tapBehaviorButtonAfter, findsOneWidget);
// tap on child 1
final Finder child1 = find.text('Child 1');
await tester.tap(child1, warnIfMissed: false);
await tester.pump();
// Tap on the second child widget.
final Finder child2 = find.text('Child 2');
await tester.tap(child2, warnIfMissed: false);
await tester.pump();
// ensure that developer.inspect was called on child 1
expect(
service.inspectedObjects(),
equals(<RenderObject?>[child1.evaluate().first.renderObject]),
);
// Verify there is no selection.
expect(service.selection.current, isNull);
// ensure that there was a single navigate event
expect(navigateEventsCount(), equals(1));
// Verify no navigate events were sent.
expect(navigateEventsCount(), equals(1));
// tap the exit selection mode button
final Finder exitButton = buttonFinder('EXIT SELECT MODE');
expect(exitButton, findsOneWidget);
await tester.tap(exitButton);
await tester.pump();
// Tap on the SELECTION ON TAP button again.
await tester.tap(tapBehaviorButtonAfter);
await tester.pump();
// tap on child 2
final Finder child2 = find.text('Child 2');
await tester.tap(child2, warnIfMissed: false);
await tester.pump();
// Verify the tap behavior button's UI has been reset.
expect(tapBehaviorButtonAfter, findsNothing);
expect(tapBehaviorButtonBefore, findsOneWidget);
// ensure that developer.inspect was still only called on child 1
expect(
service.inspectedObjects(),
equals(<RenderObject?>[child1.evaluate().first.renderObject]),
);
// Tap on the second child widget again.
await tester.tap(child2, warnIfMissed: false);
await tester.pump();
// ensure that there is still only a single navigate event
expect(navigateEventsCount(), equals(1));
},
// [intended] Test requires --track-widget-creation flag.
skip: !WidgetInspectorService.instance.isWidgetCreationTracked(),
);
// Verify the selection now matches the second child widget.
final Element child2Element = child2.evaluate().first;
expect(service.selection.current, equals(child2Element.renderObject));
testWidgets(
'[LTR] Move button group to the right then left',
(WidgetTester tester) async {
WidgetInspectorService.instance.isSelectMode = true;
final GlobalKey inspectorKey = GlobalKey();
setupDefaultPubRootDirectory(service);
// Verify another navigate event was sent.
expect(navigateEventsCount(), equals(2));
},
// [intended] Test requires --track-widget-creation flag.
skip: !WidgetInspectorService.instance.isWidgetCreationTracked(),
);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: WidgetInspector(
key: inspectorKey,
exitWidgetSelectionButtonBuilder: exitWidgetSelectionButtonBuilder,
moveExitWidgetSelectionButtonBuilder: moveWidgetSelectionButtonBuilder,
tapBehaviorButtonBuilder: null,
child: const Text('APP'),
),
),
);
final Finder exitButton = buttonFinder('EXIT SELECT MODE');
expect(exitButton, findsOneWidget);
final Finder moveRightButton = buttonFinder('MOVE RIGHT');
expect(moveRightButton, findsOneWidget);
final double initialExitButtonX = tester.getCenter(exitButton).dx;
await tester.tap(moveRightButton);
await tester.pump();
final Finder moveLeftButton = buttonFinder('MOVE LEFT');
expect(moveLeftButton, findsOneWidget);
final double movedExitButtonX = tester.getCenter(exitButton).dx;
expect(initialExitButtonX, lessThan(movedExitButtonX), reason: 'LTR: should move right');
await tester.tap(moveLeftButton);
await tester.pump();
final double finalExitButtonX = tester.getCenter(exitButton).dx;
expect(finalExitButtonX, equals(initialExitButtonX));
},
// [intended] Test requires --track-widget-creation flag.
skip: !WidgetInspectorService.instance.isWidgetCreationTracked(),
);
testWidgets(
'[RTL] Move button group to the left then right',
(WidgetTester tester) async {
WidgetInspectorService.instance.isSelectMode = true;
final GlobalKey inspectorKey = GlobalKey();
setupDefaultPubRootDirectory(service);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.rtl,
child: WidgetInspector(
key: inspectorKey,
exitWidgetSelectionButtonBuilder: exitWidgetSelectionButtonBuilder,
moveExitWidgetSelectionButtonBuilder: moveWidgetSelectionButtonBuilder,
tapBehaviorButtonBuilder: null,
child: const Text('APP'),
),
),
);
final Finder exitButton = buttonFinder('EXIT SELECT MODE');
expect(exitButton, findsOneWidget);
final Finder moveRightButton = buttonFinder('MOVE RIGHT');
expect(moveRightButton, findsOneWidget);
final double initialExitButtonX = tester.getCenter(exitButton).dx;
await tester.tap(moveRightButton);
await tester.pump();
final Finder moveLeftButton = buttonFinder('MOVE LEFT');
expect(moveLeftButton, findsOneWidget);
final double movedExitButtonX = tester.getCenter(exitButton).dx;
expect(
initialExitButtonX,
greaterThan(movedExitButtonX),
reason: 'RTL: should move left',
);
await tester.tap(moveLeftButton);
await tester.pump();
final double finalExitButtonX = tester.getCenter(exitButton).dx;
expect(finalExitButtonX, equals(initialExitButtonX));
},
// [intended] Test requires --track-widget-creation flag.
skip: !WidgetInspectorService.instance.isWidgetCreationTracked(),
);
testWidgets(
'Tap behavior button',
(WidgetTester tester) async {
// Enable widget selection mode.
WidgetInspectorService.instance.isSelectMode = true;
// Pump the test widget.
final GlobalKey inspectorKey = GlobalKey();
setupDefaultPubRootDirectory(service);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: WidgetInspector(
key: inspectorKey,
exitWidgetSelectionButtonBuilder: exitWidgetSelectionButtonBuilder,
tapBehaviorButtonBuilder: tapBehaviorButtonBuilder,
moveExitWidgetSelectionButtonBuilder: null,
child: const Row(children: <Widget>[Text('Child 1'), Text('Child 2')]),
),
),
);
// Verify there are no navigate events yet.
expect(navigateEventsCount(), equals(0));
// Tap on the first child widget.
final Finder child1 = find.text('Child 1');
await tester.tap(child1, warnIfMissed: false);
await tester.pump();
// Verify the selection matches the first child widget.
final Element child1Element = child1.evaluate().first;
expect(service.selection.current, equals(child1Element.renderObject));
// Verify that a navigate event was sent.
expect(navigateEventsCount(), equals(1));
// Tap on the SELECTION ON TAP button.
final Finder tapBehaviorButtonBefore = buttonFinder('SELECTION ON TAP');
expect(tapBehaviorButtonBefore, findsOneWidget);
await tester.tap(tapBehaviorButtonBefore);
await tester.pump();
// Verify the tap behavior button's UI has been updated.
expect(tapBehaviorButtonBefore, findsNothing);
final Finder tapBehaviorButtonAfter = buttonFinder('APP INTERACTION ON TAP');
expect(tapBehaviorButtonAfter, findsOneWidget);
// Tap on the second child widget.
final Finder child2 = find.text('Child 2');
await tester.tap(child2, warnIfMissed: false);
await tester.pump();
// Verify there is no selection.
expect(service.selection.current, isNull);
// Verify no navigate events were sent.
expect(navigateEventsCount(), equals(1));
// Tap on the SELECTION ON TAP button again.
await tester.tap(tapBehaviorButtonAfter);
await tester.pump();
// Verify the tap behavior button's UI has been reset.
expect(tapBehaviorButtonAfter, findsNothing);
expect(tapBehaviorButtonBefore, findsOneWidget);
// Tap on the second child widget again.
await tester.tap(child2, warnIfMissed: false);
await tester.pump();
// Verify the selection now matches the second child widget.
final Element child2Element = child2.evaluate().first;
expect(service.selection.current, equals(child2Element.renderObject));
// Verify another navigate event was sent.
expect(navigateEventsCount(), equals(2));
},
// [intended] Test requires --track-widget-creation flag.
skip: !WidgetInspectorService.instance.isWidgetCreationTracked(),
);
});
testWidgets('test transformDebugCreator will re-order if after stack trace', (
WidgetTester tester,