Show widget file path in inspector (#9972)
* Show widget file path in inspector
* Handle dispose and null ids
* update release note
* Move location to the tab bar
* show on all tabs
* fix analyzer
* Grab tab trailing directly
* Update goldens
diff --git a/packages/devtools_app/lib/src/screens/inspector/inspector_controller.dart b/packages/devtools_app/lib/src/screens/inspector/inspector_controller.dart
index 171231e..bf772c4 100644
--- a/packages/devtools_app/lib/src/screens/inspector/inspector_controller.dart
+++ b/packages/devtools_app/lib/src/screens/inspector/inspector_controller.dart
@@ -31,6 +31,7 @@
import '../../shared/diagnostics/diagnostics_node.dart';
import '../../shared/diagnostics/inspector_service.dart';
import '../../shared/diagnostics/primitives/instance_ref.dart';
+import '../../shared/diagnostics/primitives/source_location.dart';
import '../../shared/framework/screen_controllers.dart';
import '../../shared/globals.dart';
import '../../shared/managers/notifications.dart';
@@ -55,6 +56,9 @@
/// Layout properties for the widget.
LayoutProperties? layoutProperties,
+
+ /// Source location where the selected widget was created.
+ InspectorSourceLocation? creationLocation,
});
/// This class is based on the InspectorPanel class from the Flutter IntelliJ
@@ -255,6 +259,7 @@
widgetProperties: [],
renderProperties: [],
layoutProperties: null,
+ creationLocation: null,
));
/// Whether the implementation widgets are hidden in the widget tree.
@@ -817,6 +822,7 @@
final widgetProperties = <RemoteDiagnosticsNode>[];
final renderProperties = <RemoteDiagnosticsNode>[];
LayoutProperties? layoutProperties;
+ InspectorSourceLocation? creationLocation;
final diagnostic = node?.diagnostic;
final objectGroupApi = diagnostic?.objectGroupApi;
if (diagnostic != null && objectGroupApi != null) {
@@ -824,7 +830,7 @@
// Fetch widget properties:
final wProperties = await diagnostic.getProperties(objectGroupApi);
// Check if the selected node has changed, and if so return early:
- if (_selectedNode.value != node) {
+ if (disposed || _selectedNode.value != node) {
return;
}
widgetProperties.addAll(
@@ -838,11 +844,22 @@
diagnostic,
forFlexLayout: false,
);
+ // Fetch creation location from the details subtree. Summary tree nodes
+ // omit creationLocation when loaded with fullDetails: false.
+ final detailsNode = await objectGroupApi.getDetailsSubtree(
+ diagnostic,
+ subtreeDepth: 0,
+ );
+ // Check if the selected node has changed, and if so return early:
+ if (disposed || _selectedNode.value != node) {
+ return;
+ }
+ creationLocation = detailsNode?.creationLocation;
// Fetch RenderObject properties:
for (final renderObject in renderProperties) {
final rProperties = await renderObject.getProperties(objectGroupApi);
// Check if the selected node has changed, and if so return early:
- if (_selectedNode.value != node) {
+ if (disposed || _selectedNode.value != node) {
return;
}
renderProperties.addAll(rProperties);
@@ -851,10 +868,12 @@
_log.warning(e, st);
}
}
+ if (disposed) return;
_selectedNodeProperties.value = (
widgetProperties: widgetProperties,
renderProperties: renderProperties,
layoutProperties: layoutProperties,
+ creationLocation: creationLocation,
);
}
diff --git a/packages/devtools_app/lib/src/screens/inspector/widget_properties/properties_view.dart b/packages/devtools_app/lib/src/screens/inspector/widget_properties/properties_view.dart
index 34ec1ac..9a339db 100644
--- a/packages/devtools_app/lib/src/screens/inspector/widget_properties/properties_view.dart
+++ b/packages/devtools_app/lib/src/screens/inspector/widget_properties/properties_view.dart
@@ -2,14 +2,18 @@
// 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:async';
import 'dart:math';
+import 'package:devtools_app_shared/service.dart';
import 'package:devtools_app_shared/ui.dart';
+import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import '../../../shared/analytics/constants.dart' as gac;
import '../../../shared/console/widgets/description.dart';
import '../../../shared/diagnostics/diagnostics_node.dart';
+import '../../../shared/globals.dart';
import '../../../shared/primitives/utils.dart';
import '../../../shared/ui/tab.dart';
import '../inspector_controller.dart';
@@ -76,12 +80,14 @@
final widgetProperties = properties.widgetProperties;
final renderProperties = properties.renderProperties;
final layoutProperties = properties.layoutProperties;
-
final renderTabExists = renderProperties.isNotEmpty;
final flexExplorerTabExists = selectedNode?.isFlexLayout ?? false;
return AnalyticsTabbedView(
gaScreen: gac.inspector,
+ trailingWidgets: [
+ WidgetCreationLocationTrailing(controller: widget.controller),
+ ],
onTabChanged: (int tabIndex) {
_lastSelectedTab = _getTabForIndex(
tabIndex,
@@ -160,6 +166,90 @@
];
}
+/// Displays the source file path for the selected widget in the tab bar.
+///
+/// Matches the legacy inspector format: `filename.dart:line:column`.
+/// Tapping the link navigates the connected IDE to the source location.
+class WidgetCreationLocationTrailing extends StatefulWidget {
+ const WidgetCreationLocationTrailing({super.key, required this.controller});
+
+ final InspectorController controller;
+
+ @override
+ State<WidgetCreationLocationTrailing> createState() =>
+ _WidgetCreationLocationTrailingState();
+}
+
+class _WidgetCreationLocationTrailingState
+ extends State<WidgetCreationLocationTrailing> {
+ late final TapGestureRecognizer _tapRecognizer;
+
+ @override
+ void initState() {
+ super.initState();
+ _tapRecognizer = TapGestureRecognizer()
+ ..onTap = () {
+ unawaited(_navigateToLocation());
+ };
+ }
+
+ @override
+ void dispose() {
+ _tapRecognizer.dispose();
+ super.dispose();
+ }
+
+ Future<void> _navigateToLocation() async {
+ final location =
+ widget.controller.selectedNodeProperties.value.creationLocation;
+ final file = location?.getFile();
+ if (file == null) {
+ return;
+ }
+
+ await serviceConnection.serviceManager.service?.navigateToCode(
+ fileUriString: file,
+ line: location!.getLine(),
+ column: location.getColumn(),
+ source: 'devtools.inspector',
+ );
+ }
+
+ @override
+ Widget build(BuildContext context) {
+ return ValueListenableBuilder<WidgetTreeNodeProperties>(
+ valueListenable: widget.controller.selectedNodeProperties,
+ builder: (context, properties, _) {
+ final location = properties.creationLocation;
+ final file = location?.getFile();
+ if (file == null) {
+ return const SizedBox.shrink();
+ }
+
+ final line = location!.getLine();
+ final column = location.getColumn();
+ final shortLocation = '${fileNameFromUri(file)}:$line:$column';
+ final fullLocation = '$file:$line:$column';
+
+ return Padding(
+ padding: const EdgeInsets.symmetric(horizontal: denseSpacing),
+ child: DevToolsTooltip(
+ message: fullLocation,
+ child: RichText(
+ overflow: TextOverflow.ellipsis,
+ text: TextSpan(
+ text: shortLocation,
+ style: Theme.of(context).linkTextStyle,
+ recognizer: _tapRecognizer,
+ ),
+ ),
+ ),
+ );
+ },
+ );
+ }
+}
+
/// Displays a widget's properties, including the layout properties and a
/// layout visualizer.
class PropertiesView extends StatefulWidget {
diff --git a/packages/devtools_app/lib/src/shared/diagnostics/inspector_service.dart b/packages/devtools_app/lib/src/shared/diagnostics/inspector_service.dart
index e983755..6081c5d 100644
--- a/packages/devtools_app/lib/src/shared/diagnostics/inspector_service.dart
+++ b/packages/devtools_app/lib/src/shared/diagnostics/inspector_service.dart
@@ -922,6 +922,24 @@
}
@override
+ Future<RemoteDiagnosticsNode?> getDetailsSubtree(
+ RemoteDiagnosticsNode? node, {
+ int subtreeDepth = 2,
+ }) async {
+ if (node == null || node.valueRef.id == null) return null;
+ return parseDiagnosticsNodeDaemon(
+ invokeServiceMethodDaemonParams(
+ WidgetInspectorServiceExtensions.getDetailsSubtree.name,
+ {
+ 'objectGroup': groupName,
+ 'arg': node.valueRef.id,
+ 'subtreeDepth': subtreeDepth.toString(),
+ },
+ ),
+ );
+ }
+
+ @override
bool isLocalClass(RemoteDiagnosticsNode node) =>
inspectorService.isLocalClass(node);
}
diff --git a/packages/devtools_app/lib/src/shared/diagnostics/object_group_api.dart b/packages/devtools_app/lib/src/shared/diagnostics/object_group_api.dart
index b1195a1..328d50b 100644
--- a/packages/devtools_app/lib/src/shared/diagnostics/object_group_api.dart
+++ b/packages/devtools_app/lib/src/shared/diagnostics/object_group_api.dart
@@ -39,4 +39,10 @@
);
Future<List<T>> getProperties(InspectorInstanceRef instanceRef);
+
+ /// Returns a details subtree for [node], including creation location data.
+ ///
+ /// Pass a small [subtreeDepth] (for example `0`) when only node-level details
+ /// such as creation location are needed.
+ Future<T?> getDetailsSubtree(T? node, {int subtreeDepth = 2});
}
diff --git a/packages/devtools_app/lib/src/shared/ui/tab.dart b/packages/devtools_app/lib/src/shared/ui/tab.dart
index 6961b55..07cb85a 100644
--- a/packages/devtools_app/lib/src/shared/ui/tab.dart
+++ b/packages/devtools_app/lib/src/shared/ui/tab.dart
@@ -73,7 +73,7 @@
/// value. This ensures that data being refreshed, or widget tree rebuilds don't
/// send spurious analytics events.
class AnalyticsTabbedView extends StatefulWidget {
- AnalyticsTabbedView({
+ const AnalyticsTabbedView({
super.key,
required this.tabs,
required this.gaScreen,
@@ -82,15 +82,18 @@
this.initialSelectedIndex,
this.analyticsSessionIdentifier,
this.staticSingleTab = false,
- }) : trailingWidgets = List.generate(
- tabs.length,
- (index) => tabs[index].tab.trailing ?? const SizedBox(),
- );
+ this.trailingWidgets = const [],
+ });
final List<TabAndView> tabs;
final String gaScreen;
+ /// Shared trailing widgets shown on the right side of the tab bar for every
+ /// tab.
+ ///
+ /// Per-tab trailings from `DevToolsTab.trailing` are shown separately for the
+ /// currently selected tab.
final List<Widget> trailingWidgets;
final int? initialSelectedIndex;
@@ -212,7 +215,13 @@
tabController: _tabController,
staticSingleTab: widget.staticSingleTab,
),
- widget.trailingWidgets[_currentTabControllerIndex],
+ Row(
+ mainAxisSize: MainAxisSize.min,
+ children: [
+ ?widget.tabs[_currentTabControllerIndex].tab.trailing,
+ ...widget.trailingWidgets,
+ ],
+ ),
],
),
),
diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
index 01ad0a7..b8955e4 100644
--- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
+++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
@@ -21,7 +21,10 @@
## Inspector updates
-TODO: Remove this section if there are not any updates.
+* Added the widget source file path to the Inspector details pane
+ (`filename.dart:line:column`), matching legacy Inspector behavior. -
+ [#9972](https://github.com/flutter/devtools/pull/9972),
+ [#9922](https://github.com/flutter/devtools/issues/9922)
## Performance updates
diff --git a/packages/devtools_app/test/screens/inspector/inspector_integration_test.dart b/packages/devtools_app/test/screens/inspector/inspector_integration_test.dart
index 47eb492..3f09b29 100644
--- a/packages/devtools_app/test/screens/inspector/inspector_integration_test.dart
+++ b/packages/devtools_app/test/screens/inspector/inspector_integration_test.dart
@@ -708,22 +708,3 @@
await inspectorService.addPubRootDirectories([rootLibrary]);
}
}
-
-extension _ObjectGroupTestExtension on ObjectGroup {
- Future<RemoteDiagnosticsNode?> getDetailsSubtree(
- RemoteDiagnosticsNode? node, {
- int subtreeDepth = 2,
- }) async {
- if (node == null) return null;
- final args = {
- 'objectGroup': groupName,
- 'arg': node.valueRef.id,
- 'subtreeDepth': subtreeDepth.toString(),
- };
- final json = await invokeServiceMethodDaemonParams(
- WidgetInspectorServiceExtensions.getDetailsSubtree.name,
- args,
- );
- return parseDiagnosticsNodeHelper(json as Map<String, Object?>?);
- }
-}
diff --git a/packages/devtools_app/test/screens/inspector/widget_creation_location_trailing_test.dart b/packages/devtools_app/test/screens/inspector/widget_creation_location_trailing_test.dart
new file mode 100644
index 0000000..7a93514
--- /dev/null
+++ b/packages/devtools_app/test/screens/inspector/widget_creation_location_trailing_test.dart
@@ -0,0 +1,122 @@
+// Copyright 2026 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/src/screens/inspector/inspector_controller.dart';
+import 'package:devtools_app/src/screens/inspector/widget_properties/properties_view.dart';
+import 'package:devtools_app/src/shared/diagnostics/primitives/source_location.dart';
+import 'package:devtools_app_shared/ui.dart';
+import 'package:devtools_app_shared/utils.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter/material.dart';
+import 'package:flutter_test/flutter_test.dart' hide Fake;
+import 'package:mockito/mockito.dart';
+
+void main() {
+ setUp(() {
+ setGlobal(IdeTheme, IdeTheme());
+ });
+
+ Widget wrapTrailing(Widget child) {
+ return MaterialApp(
+ theme: themeFor(
+ isDarkTheme: false,
+ ideTheme: IdeTheme(),
+ theme: ThemeData(useMaterial3: true, colorScheme: lightColorScheme),
+ ),
+ home: Scaffold(body: child),
+ );
+ }
+
+ testWidgets('shows file name with line and column', (
+ WidgetTester tester,
+ ) async {
+ final controller = _TestInspectorController();
+ addTearDown(controller.dispose);
+
+ final location = InspectorSourceLocation({
+ 'file': 'file:///Users/prismo/flutter_app/lib/main.dart',
+ 'line': 109,
+ 'column': 23,
+ }, null);
+ controller.setProperties((
+ widgetProperties: const [],
+ renderProperties: const [],
+ layoutProperties: null,
+ creationLocation: location,
+ ));
+
+ await tester.pumpWidget(
+ wrapTrailing(WidgetCreationLocationTrailing(controller: controller)),
+ );
+
+ expect(find.byType(RichText), findsOneWidget);
+ final richText = tester.widget<RichText>(find.byType(RichText));
+ expect(richText.text.toPlainText(), 'main.dart:109:23');
+ });
+
+ testWidgets('tooltip includes the full file URI', (
+ WidgetTester tester,
+ ) async {
+ final controller = _TestInspectorController();
+ addTearDown(controller.dispose);
+
+ final location = InspectorSourceLocation({
+ 'file': 'file:///Users/prismo/flutter_app/lib/main.dart',
+ 'line': 109,
+ 'column': 23,
+ }, null);
+ controller.setProperties((
+ widgetProperties: const [],
+ renderProperties: const [],
+ layoutProperties: null,
+ creationLocation: location,
+ ));
+
+ await tester.pumpWidget(
+ wrapTrailing(WidgetCreationLocationTrailing(controller: controller)),
+ );
+
+ final tooltip =
+ tester.widget(find.byType(DevToolsTooltip)) as DevToolsTooltip;
+ expect(
+ tooltip.message,
+ 'file:///Users/prismo/flutter_app/lib/main.dart:109:23',
+ );
+ });
+
+ testWidgets('hides when creation location is unavailable', (
+ WidgetTester tester,
+ ) async {
+ final controller = _TestInspectorController();
+ addTearDown(controller.dispose);
+
+ await tester.pumpWidget(
+ wrapTrailing(WidgetCreationLocationTrailing(controller: controller)),
+ );
+
+ expect(find.byType(RichText), findsNothing);
+ });
+}
+
+class _TestInspectorController extends Fake implements InspectorController {
+ final _selectedNodeProperties = ValueNotifier<WidgetTreeNodeProperties>((
+ widgetProperties: const [],
+ renderProperties: const [],
+ layoutProperties: null,
+ creationLocation: null,
+ ));
+
+ @override
+ ValueListenable<WidgetTreeNodeProperties> get selectedNodeProperties =>
+ _selectedNodeProperties;
+
+ void setProperties(WidgetTreeNodeProperties properties) {
+ _selectedNodeProperties.value = properties;
+ }
+
+ @override
+ void dispose() {
+ _selectedNodeProperties.dispose();
+ }
+}
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 31bbdf6..284f6c0 100644
--- 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
Binary files differ
diff --git a/packages/devtools_app/test/test_infra/goldens/integration_inspector_implementation_widgets_collapsed.png b/packages/devtools_app/test/test_infra/goldens/integration_inspector_implementation_widgets_collapsed.png
index 8dd6dad..4544741 100644
--- a/packages/devtools_app/test/test_infra/goldens/integration_inspector_implementation_widgets_collapsed.png
+++ b/packages/devtools_app/test/test_infra/goldens/integration_inspector_implementation_widgets_collapsed.png
Binary files differ
diff --git a/packages/devtools_app/test/test_infra/goldens/integration_inspector_implementation_widgets_hidden.png b/packages/devtools_app/test/test_infra/goldens/integration_inspector_implementation_widgets_hidden.png
index 8431c62..4c6d3d0 100644
--- a/packages/devtools_app/test/test_infra/goldens/integration_inspector_implementation_widgets_hidden.png
+++ b/packages/devtools_app/test/test_infra/goldens/integration_inspector_implementation_widgets_hidden.png
Binary files differ
diff --git a/packages/devtools_app/test/test_infra/goldens/integration_inspector_select_center.png b/packages/devtools_app/test/test_infra/goldens/integration_inspector_select_center.png
index b02d1e0..211c291 100644
--- a/packages/devtools_app/test/test_infra/goldens/integration_inspector_select_center.png
+++ b/packages/devtools_app/test/test_infra/goldens/integration_inspector_select_center.png
Binary files differ
diff --git a/packages/devtools_app/test/test_infra/goldens/memory/load_offline_data_profile_tab.png b/packages/devtools_app/test/test_infra/goldens/memory/load_offline_data_profile_tab.png
index 112c1d0..390d7a4 100644
--- a/packages/devtools_app/test/test_infra/goldens/memory/load_offline_data_profile_tab.png
+++ b/packages/devtools_app/test/test_infra/goldens/memory/load_offline_data_profile_tab.png
Binary files differ
diff --git a/packages/devtools_test/lib/src/mocks/mocks.dart b/packages/devtools_test/lib/src/mocks/mocks.dart
index ca204b3..c803c75 100644
--- a/packages/devtools_test/lib/src/mocks/mocks.dart
+++ b/packages/devtools_test/lib/src/mocks/mocks.dart
@@ -81,6 +81,7 @@
widgetProperties: [],
renderProperties: [],
layoutProperties: null,
+ creationLocation: null,
));
@override