Notify Flutter inspector when navigating widget tree with keyboard (#9810)
* Notify Flutter inspector when navigating widget tree with keyboard
* Add inspector tree left/right keyboard navigation coverage
Updates inspector tree tests to cover left and right arrow navigation, including expand, next-row selection, and parent selection behavior. Also ensures left navigation to a parent notifies Flutter Inspector consistently with other keyboard selection changes.
* Fix inspector widget tree visibility issues during keyboard navigation
Resolves two regressions surfaced when navigating the inspector widget
tree with the arrow-left key.
1. Clicking a still-visible row used to call `expandPath` on the clicked
node, which re-expanded the clicked node itself and undid any subtree
collapses the user had just performed via the arrow-left key. Removed
the call from `onSelectNode`; programmatic selection flows (search,
on-device pick) continue to call `expandPath` via `syncTreeSelection`,
so external selection still works correctly.
2. Collapsing all the way to the root via the arrow-left key shrank the
visible rows down to a single row, which the inspector treated as a
"still loading" state and replaced with a spinner — hiding the user's
`[root]` row. Gated that branch on `!firstInspectorTreeLoadCompleted`
so the spinner only shows during the initial load; afterwards a
one-row tree renders as the legitimate single-row state.
Adds regression tests covering both behaviors.
* docs: add release notes for inspector keyboard navigation fixes (#9810)
* test: update inspector tree selection test for collapsed nodes
diff --git a/packages/devtools_app/lib/src/screens/inspector/inspector_tree_controller.dart b/packages/devtools_app/lib/src/screens/inspector/inspector_tree_controller.dart
index 4e772e4..67c39ba 100644
--- a/packages/devtools_app/lib/src/screens/inspector/inspector_tree_controller.dart
+++ b/packages/devtools_app/lib/src/screens/inspector/inspector_tree_controller.dart
@@ -357,7 +357,10 @@
return true;
}
if (selectionLocal.parent != null) {
- return setSelectedNode(selectionLocal.parent);
+ return setSelectedNode(
+ selectionLocal.parent,
+ notifyFlutterInspector: true,
+ );
}
return false;
},
@@ -399,8 +402,7 @@
_numRows - 1,
),
)?.node;
- setSelectedNode(nodeToSelect);
- return true;
+ return setSelectedNode(nodeToSelect, notifyFlutterInspector: true);
},
);
}
@@ -573,7 +575,12 @@
if (diagnostic != null && diagnostic.groupIsHidden) {
diagnostic.hideableGroupLeader?.toggleHiddenGroup();
}
- expandPath(node);
+ // Intentionally do NOT call expandPath(node) here. User clicks happen on
+ // already-visible rows, so ancestors are already expanded; calling
+ // expandPath would also re-expand the clicked node itself, undoing any
+ // collapse the user just performed via the left arrow key. Programmatic
+ // selection paths (search, on-device pick) call expandPath themselves via
+ // [InspectorController.syncTreeSelection].
}
Rect getBoundingBox(InspectorTreeRow row) {
@@ -1142,8 +1149,12 @@
valueListenable: treeControllerLocal.rowsInTree,
builder: (context, rows, _) {
// Note: The inspector rows contain only the fake root node when the
- // inspector tree is shutdown.
- if (rows.length <= 1) {
+ // inspector tree is shutdown. Only show the loading indicator on the
+ // initial tree load (before [firstInspectorTreeLoadCompleted] is set);
+ // after that, a one-row tree is the legitimate result of the user
+ // collapsing all the way to the root via the keyboard, and we should
+ // render the tree (with its single row) rather than a spinner.
+ if (rows.length <= 1 && !controller.firstInspectorTreeLoadCompleted) {
// This works around a bug when Scrollbars are present on a short lived
// widget.
return const SizedBox(child: CenteredCircularProgressIndicator());
diff --git a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
index 787e89c..457bde5 100644
--- a/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
+++ b/packages/devtools_app/release_notes/NEXT_RELEASE_NOTES.md
@@ -21,6 +21,9 @@
- Deleted the option to use the legacy inspector.
[#9782](https://github.com/flutter/devtools/pull/9782)
+- Fixed an issue where navigating the Inspector widget tree with the keyboard arrow keys did not update the selected widget in the connected Flutter app. [#9810](https://github.com/flutter/devtools/pull/9810)
+- Fixed an issue where clicking a widget row after collapsing a subtree with the left arrow key unexpectedly re-expanded the subtree. [#9810](https://github.com/flutter/devtools/pull/9810)
+- Fixed an issue where collapsing the Inspector widget tree to a single row with the left arrow key caused a loading spinner to appear instead of showing the root node. [#9810](https://github.com/flutter/devtools/pull/9810)
## Performance updates
diff --git a/packages/devtools_app/test/screens/inspector/inspector_tree_test.dart b/packages/devtools_app/test/screens/inspector/inspector_tree_test.dart
index feda195..c6ae0e9 100644
--- a/packages/devtools_app/test/screens/inspector/inspector_tree_test.dart
+++ b/packages/devtools_app/test/screens/inspector/inspector_tree_test.dart
@@ -3,12 +3,12 @@
// found in the LICENSE file or at https://developers.google.com/open-source/licenses/bsd.
import 'package:devtools_app/devtools_app.dart';
-
import 'package:devtools_app_shared/ui.dart';
import 'package:devtools_app_shared/utils.dart';
import 'package:devtools_test/devtools_test.dart';
import 'package:devtools_test/helpers.dart';
import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart' hide Fake;
import 'package:mockito/mockito.dart';
@@ -57,6 +57,24 @@
);
}
+ InspectorTreeController buildTreeController({
+ required void Function({bool notifyFlutterInspector}) onSelectionChange,
+ }) {
+ return InspectorTreeController()
+ ..config = InspectorTreeConfig(
+ onNodeAdded: (_, _) {},
+ onClientActiveChange: (_) {},
+ onSelectionChange: onSelectionChange,
+ )
+ ..root = (InspectorTreeNode()
+ ..appendChild(InspectorTreeNode())
+ ..appendChild(InspectorTreeNode()));
+ }
+
+ List<InspectorTreeNode> visibleNodes(InspectorTreeController controller) {
+ return controller.rowsInTree.value.map((row) => row!.node).toList();
+ }
+
group('InspectorTreeController', () {
testWidgets('Row with negative index regression test', (
WidgetTester tester,
@@ -136,4 +154,426 @@
expect(find.richText('Text: "Multiline text content"'), findsOneWidget);
});
});
+
+ group('InspectorTreeController keyboard navigation', () {
+ testWidgets(
+ 'navigateDown triggers onSelectionChange with notifyFlutterInspector true',
+ (WidgetTester tester) async {
+ bool? capturedNotify;
+ final treeController = buildTreeController(
+ onSelectionChange: ({bool notifyFlutterInspector = false}) {
+ capturedNotify = notifyFlutterInspector;
+ },
+ );
+
+ await pumpInspectorTree(tester, treeController: treeController);
+
+ treeController.navigateDown();
+ await tester.pump();
+
+ expect(capturedNotify, isTrue);
+ },
+ );
+
+ testWidgets(
+ 'navigateUp triggers onSelectionChange with notifyFlutterInspector true',
+ (WidgetTester tester) async {
+ bool? capturedNotify;
+ final treeController = buildTreeController(
+ onSelectionChange: ({bool notifyFlutterInspector = false}) {
+ capturedNotify = notifyFlutterInspector;
+ },
+ );
+
+ await pumpInspectorTree(tester, treeController: treeController);
+
+ // Move selection to the second row so navigateUp has somewhere to go.
+ treeController.navigateDown();
+ await tester.pump();
+ treeController.navigateDown();
+ await tester.pump();
+
+ capturedNotify = null;
+ treeController.navigateUp();
+ await tester.pump();
+
+ expect(capturedNotify, isTrue);
+ },
+ );
+
+ testWidgets(
+ 'navigateRight triggers onSelectionChange with notifyFlutterInspector true',
+ (WidgetTester tester) async {
+ bool? capturedNotify;
+ final treeController = buildTreeController(
+ onSelectionChange: ({bool notifyFlutterInspector = false}) {
+ capturedNotify = notifyFlutterInspector;
+ },
+ );
+
+ await pumpInspectorTree(tester, treeController: treeController);
+
+ final root = treeController.root!;
+ final firstChild = root.children.first;
+ root.isExpanded = false;
+ treeController.setSelectedNode(root);
+
+ // First right-arrow navigation expands the selected node.
+ capturedNotify = null;
+ treeController.navigateRight();
+ await tester.pump();
+
+ expect(root.isExpanded, isTrue);
+ expect(treeController.selection, root);
+ expect(capturedNotify, isNull);
+
+ // Once expanded, right-arrow navigation selects the next visible row.
+ treeController.navigateRight();
+ await tester.pump();
+
+ expect(treeController.selection, firstChild);
+ expect(capturedNotify, isTrue);
+ },
+ );
+
+ testWidgets(
+ 'navigateLeft triggers onSelectionChange with notifyFlutterInspector true',
+ (WidgetTester tester) async {
+ bool? capturedNotify;
+ final treeController = buildTreeController(
+ onSelectionChange: ({bool notifyFlutterInspector = false}) {
+ capturedNotify = notifyFlutterInspector;
+ },
+ );
+
+ await pumpInspectorTree(tester, treeController: treeController);
+
+ final root = treeController.root!;
+ final firstChild = root.children.first..isExpanded = false;
+ treeController.setSelectedNode(firstChild);
+
+ capturedNotify = null;
+ treeController.navigateLeft();
+ await tester.pump();
+
+ expect(treeController.selection, root);
+ expect(capturedNotify, isTrue);
+ },
+ );
+ });
+
+ group('InspectorTree keyboard events', () {
+ testWidgets(
+ 'arrowDown key triggers onSelectionChange with notifyFlutterInspector true',
+ (WidgetTester tester) async {
+ bool? capturedNotify;
+ final treeController = buildTreeController(
+ onSelectionChange: ({bool notifyFlutterInspector = false}) {
+ capturedNotify = notifyFlutterInspector;
+ },
+ );
+
+ await pumpInspectorTree(tester, treeController: treeController);
+
+ await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
+ await tester.pump();
+
+ expect(capturedNotify, isTrue);
+ },
+ );
+
+ testWidgets(
+ 'arrowUp key triggers onSelectionChange with notifyFlutterInspector true',
+ (WidgetTester tester) async {
+ bool? capturedNotify;
+ final treeController = buildTreeController(
+ onSelectionChange: ({bool notifyFlutterInspector = false}) {
+ capturedNotify = notifyFlutterInspector;
+ },
+ );
+
+ await pumpInspectorTree(tester, treeController: treeController);
+
+ // Move selection to the second row first.
+ await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
+ await tester.pump();
+ await tester.sendKeyEvent(LogicalKeyboardKey.arrowDown);
+ await tester.pump();
+
+ capturedNotify = null;
+ await tester.sendKeyEvent(LogicalKeyboardKey.arrowUp);
+ await tester.pump();
+
+ expect(capturedNotify, isTrue);
+ },
+ );
+
+ testWidgets(
+ 'arrowRight key triggers onSelectionChange with notifyFlutterInspector true',
+ (WidgetTester tester) async {
+ bool? capturedNotify;
+ final treeController = buildTreeController(
+ onSelectionChange: ({bool notifyFlutterInspector = false}) {
+ capturedNotify = notifyFlutterInspector;
+ },
+ );
+
+ await pumpInspectorTree(tester, treeController: treeController);
+
+ final root = treeController.root!;
+ final firstChild = root.children.first;
+ root.isExpanded = false;
+ treeController.setSelectedNode(root);
+
+ // First arrowRight expands the selected node.
+ capturedNotify = null;
+ await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
+ await tester.pump();
+
+ expect(root.isExpanded, isTrue);
+ expect(treeController.selection, root);
+ expect(capturedNotify, isNull);
+
+ // Once expanded, arrowRight selects the next visible row.
+ await tester.sendKeyEvent(LogicalKeyboardKey.arrowRight);
+ await tester.pump();
+
+ expect(treeController.selection, firstChild);
+ expect(capturedNotify, isTrue);
+ },
+ );
+
+ testWidgets(
+ 'arrowLeft key triggers onSelectionChange with notifyFlutterInspector true',
+ (WidgetTester tester) async {
+ bool? capturedNotify;
+ final treeController = buildTreeController(
+ onSelectionChange: ({bool notifyFlutterInspector = false}) {
+ capturedNotify = notifyFlutterInspector;
+ },
+ );
+
+ await pumpInspectorTree(tester, treeController: treeController);
+
+ final root = treeController.root!;
+ final firstChild = root.children.first..isExpanded = false;
+ treeController.setSelectedNode(firstChild);
+
+ capturedNotify = null;
+ await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
+ await tester.pump();
+
+ expect(treeController.selection, root);
+ expect(capturedNotify, isTrue);
+ },
+ );
+
+ testWidgets(
+ 'arrowLeft key collapses selected node without removing previous rows',
+ (WidgetTester tester) async {
+ final treeController = buildTreeController(
+ onSelectionChange: ({bool notifyFlutterInspector = false}) {},
+ );
+
+ final root = treeController.root!;
+ final previousSibling = root.children.first;
+ final selectedSibling = root.children.last
+ ..appendChild(InspectorTreeNode())
+ ..appendChild(InspectorTreeNode());
+ final selectedSiblingFirstChild = selectedSibling.children.first;
+ final selectedSiblingSecondChild = selectedSibling.children.last;
+ treeController.root = root;
+ treeController.setSelectedNode(selectedSibling);
+
+ await pumpInspectorTree(tester, treeController: treeController);
+
+ expect(visibleNodes(treeController), [
+ root,
+ previousSibling,
+ selectedSibling,
+ selectedSiblingFirstChild,
+ selectedSiblingSecondChild,
+ ]);
+
+ await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
+ await tester.pump();
+
+ expect(selectedSibling.isExpanded, isFalse);
+ expect(treeController.selection, selectedSibling);
+ expect(visibleNodes(treeController), [
+ root,
+ previousSibling,
+ selectedSibling,
+ ]);
+ },
+ );
+
+ testWidgets(
+ 'arrowLeft key on collapsed child selects parent without changing rows',
+ (WidgetTester tester) async {
+ final treeController = buildTreeController(
+ onSelectionChange: ({bool notifyFlutterInspector = false}) {},
+ );
+
+ final root = treeController.root!;
+ final previousSibling = root.children.first;
+ final parent = root.children.last
+ ..appendChild(InspectorTreeNode())
+ ..appendChild(InspectorTreeNode());
+ final child = parent.children.first..isExpanded = false;
+ final nextSibling = parent.children.last;
+ treeController.root = root;
+ treeController.setSelectedNode(child);
+
+ await pumpInspectorTree(tester, treeController: treeController);
+
+ final rowsBeforeArrowLeft = visibleNodes(treeController);
+ expect(rowsBeforeArrowLeft, [
+ root,
+ previousSibling,
+ parent,
+ child,
+ nextSibling,
+ ]);
+
+ await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
+ await tester.pump();
+
+ expect(treeController.selection, parent);
+ expect(visibleNodes(treeController), rowsBeforeArrowLeft);
+ },
+ );
+
+ testWidgets('arrowLeft key does not put the tree into the loading state', (
+ WidgetTester tester,
+ ) async {
+ final treeController = buildTreeController(
+ onSelectionChange: ({bool notifyFlutterInspector = false}) {},
+ );
+
+ await pumpInspectorTree(tester, treeController: treeController);
+
+ final root = treeController.root!;
+ treeController.setSelectedNode(root);
+ await tester.pump();
+
+ await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
+ await tester.pump();
+
+ // After collapsing the root, only the root row remains visible. The
+ // tree must still render that row instead of a loading indicator.
+ expect(find.byType(CenteredCircularProgressIndicator), findsNothing);
+ expect(visibleNodes(treeController), [root]);
+ });
+
+ testWidgets(
+ 'onSelectNode does not re-expand a node the user just collapsed via '
+ 'the arrow-left key',
+ (WidgetTester tester) async {
+ // Regression test: clicking a still-visible row used to call
+ // expandPath on the clicked node, which re-expanded the clicked node
+ // itself and undid any subtree collapse the user had just performed
+ // via the arrow-left key.
+ final treeController = buildTreeController(
+ onSelectionChange: ({bool notifyFlutterInspector = false}) {},
+ );
+
+ final root = treeController.root!;
+ final firstChild = root.children.first
+ ..appendChild(InspectorTreeNode())
+ ..appendChild(InspectorTreeNode());
+ final secondChild = root.children.last;
+ treeController.root = root;
+ treeController.setSelectedNode(firstChild);
+
+ await pumpInspectorTree(tester, treeController: treeController);
+
+ // Collapse [firstChild] so its grandchildren are hidden.
+ await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
+ await tester.pump();
+ expect(firstChild.isExpanded, isFalse);
+ expect(visibleNodes(treeController), [root, firstChild, secondChild]);
+
+ // Re-selecting the just-collapsed row must not re-expand it.
+ treeController.onSelectNode(firstChild);
+ await tester.pump();
+
+ expect(firstChild.isExpanded, isFalse);
+ expect(visibleNodes(treeController), [root, firstChild, secondChild]);
+ },
+ );
+
+ testWidgets(
+ 'onSelectNode does not re-expand a node the user just collapsed by '
+ 'clicking it',
+ (WidgetTester tester) async {
+ // Regression test: clicking a row used to call expandPath on the
+ // clicked node itself, so a user could not select a node in its
+ // collapsed state.
+ final treeController = buildTreeController(
+ onSelectionChange: ({bool notifyFlutterInspector = false}) {},
+ );
+
+ final root = treeController.root!;
+ final firstChild = root.children.first
+ ..appendChild(InspectorTreeNode())
+ ..isExpanded = false;
+ treeController.root = root;
+
+ await pumpInspectorTree(tester, treeController: treeController);
+
+ treeController.onSelectNode(firstChild);
+ await tester.pump();
+
+ expect(treeController.selection, firstChild);
+ expect(firstChild.isExpanded, isFalse);
+ },
+ );
+ });
+
+ group('InspectorTree loading indicator', () {
+ testWidgets(
+ 'shows a loading indicator while the initial tree load is in progress',
+ (WidgetTester tester) async {
+ // Before the first inspector tree load completes, a tree with at most
+ // a single row represents the "still loading" state and should render
+ // a progress indicator instead of the bare row.
+ inspectorController.firstInspectorTreeLoadCompleted = false;
+
+ final treeController = InspectorTreeController()
+ ..config = InspectorTreeConfig(
+ onNodeAdded: (_, _) {},
+ onClientActiveChange: (_) {},
+ )
+ ..root = InspectorTreeNode();
+
+ await pumpInspectorTree(tester, treeController: treeController);
+
+ expect(find.byType(CenteredCircularProgressIndicator), findsOneWidget);
+ },
+ );
+
+ testWidgets(
+ 'renders a single-row tree (no spinner) after the initial load has '
+ 'completed',
+ (WidgetTester tester) async {
+ // Regression test: collapsing the root via the arrow-left key shrinks
+ // the visible rows down to a single row. Before the fix, the
+ // [InspectorTree] widget treated a one-row tree as "loading" and
+ // showed a spinner, hiding the user's [root] row.
+ inspectorController.firstInspectorTreeLoadCompleted = true;
+
+ final treeController = InspectorTreeController()
+ ..config = InspectorTreeConfig(
+ onNodeAdded: (_, _) {},
+ onClientActiveChange: (_) {},
+ )
+ ..root = InspectorTreeNode();
+
+ await pumpInspectorTree(tester, treeController: treeController);
+
+ expect(find.byType(CenteredCircularProgressIndicator), findsNothing);
+ },
+ );
+ });
}