Skip to content

Commit 45c2a19

Browse files
committed
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.
1 parent 063a9e9 commit 45c2a19

2 files changed

Lines changed: 223 additions & 3 deletions

File tree

packages/devtools_app/lib/src/screens/inspector/inspector_tree_controller.dart

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -575,7 +575,12 @@ class InspectorTreeController extends DisposableController
575575
if (diagnostic != null && diagnostic.groupIsHidden) {
576576
diagnostic.hideableGroupLeader?.toggleHiddenGroup();
577577
}
578-
expandPath(node);
578+
// Intentionally do NOT call expandPath(node) here. User clicks happen on
579+
// already-visible rows, so ancestors are already expanded; calling
580+
// expandPath would also re-expand the clicked node itself, undoing any
581+
// collapse the user just performed via the left arrow key. Programmatic
582+
// selection paths (search, on-device pick) call expandPath themselves via
583+
// [InspectorController.syncTreeSelection].
579584
}
580585

581586
Rect getBoundingBox(InspectorTreeRow row) {
@@ -1144,8 +1149,12 @@ class _InspectorTreeState extends State<InspectorTree>
11441149
valueListenable: treeControllerLocal.rowsInTree,
11451150
builder: (context, rows, _) {
11461151
// Note: The inspector rows contain only the fake root node when the
1147-
// inspector tree is shutdown.
1148-
if (rows.length <= 1) {
1152+
// inspector tree is shutdown. Only show the loading indicator on the
1153+
// initial tree load (before [firstInspectorTreeLoadCompleted] is set);
1154+
// after that, a one-row tree is the legitimate result of the user
1155+
// collapsing all the way to the root via the keyboard, and we should
1156+
// render the tree (with its single row) rather than a spinner.
1157+
if (rows.length <= 1 && !controller.firstInspectorTreeLoadCompleted) {
11491158
// This works around a bug when Scrollbars are present on a short lived
11501159
// widget.
11511160
return const SizedBox(child: CenteredCircularProgressIndicator());

packages/devtools_app/test/screens/inspector/inspector_tree_test.dart

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,10 @@ void main() {
7171
..appendChild(InspectorTreeNode()));
7272
}
7373

74+
List<InspectorTreeNode> visibleNodes(InspectorTreeController controller) {
75+
return controller.rowsInTree.value.map((row) => row!.node).toList();
76+
}
77+
7478
group('InspectorTreeController', () {
7579
testWidgets('Row with negative index regression test', (
7680
WidgetTester tester,
@@ -363,5 +367,212 @@ void main() {
363367
expect(capturedNotify, isTrue);
364368
},
365369
);
370+
371+
testWidgets(
372+
'arrowLeft key collapses selected node without removing previous rows',
373+
(WidgetTester tester) async {
374+
final treeController = buildTreeController(
375+
onSelectionChange: ({bool notifyFlutterInspector = false}) {},
376+
);
377+
378+
final root = treeController.root!;
379+
final previousSibling = root.children.first;
380+
final selectedSibling = root.children.last
381+
..appendChild(InspectorTreeNode())
382+
..appendChild(InspectorTreeNode());
383+
final selectedSiblingFirstChild = selectedSibling.children.first;
384+
final selectedSiblingSecondChild = selectedSibling.children.last;
385+
treeController.root = root;
386+
treeController.setSelectedNode(selectedSibling);
387+
388+
await pumpInspectorTree(tester, treeController: treeController);
389+
390+
expect(visibleNodes(treeController), [
391+
root,
392+
previousSibling,
393+
selectedSibling,
394+
selectedSiblingFirstChild,
395+
selectedSiblingSecondChild,
396+
]);
397+
398+
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
399+
await tester.pump();
400+
401+
expect(selectedSibling.isExpanded, isFalse);
402+
expect(treeController.selection, selectedSibling);
403+
expect(visibleNodes(treeController), [
404+
root,
405+
previousSibling,
406+
selectedSibling,
407+
]);
408+
},
409+
);
410+
411+
testWidgets(
412+
'arrowLeft key on collapsed child selects parent without changing rows',
413+
(WidgetTester tester) async {
414+
final treeController = buildTreeController(
415+
onSelectionChange: ({bool notifyFlutterInspector = false}) {},
416+
);
417+
418+
final root = treeController.root!;
419+
final previousSibling = root.children.first;
420+
final parent = root.children.last
421+
..appendChild(InspectorTreeNode())
422+
..appendChild(InspectorTreeNode());
423+
final child = parent.children.first..isExpanded = false;
424+
final nextSibling = parent.children.last;
425+
treeController.root = root;
426+
treeController.setSelectedNode(child);
427+
428+
await pumpInspectorTree(tester, treeController: treeController);
429+
430+
final rowsBeforeArrowLeft = visibleNodes(treeController);
431+
expect(rowsBeforeArrowLeft, [
432+
root,
433+
previousSibling,
434+
parent,
435+
child,
436+
nextSibling,
437+
]);
438+
439+
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
440+
await tester.pump();
441+
442+
expect(treeController.selection, parent);
443+
expect(visibleNodes(treeController), rowsBeforeArrowLeft);
444+
},
445+
);
446+
447+
testWidgets('arrowLeft key does not put the tree into the loading state', (
448+
WidgetTester tester,
449+
) async {
450+
final treeController = buildTreeController(
451+
onSelectionChange: ({bool notifyFlutterInspector = false}) {},
452+
);
453+
454+
await pumpInspectorTree(tester, treeController: treeController);
455+
456+
final root = treeController.root!;
457+
treeController.setSelectedNode(root);
458+
await tester.pump();
459+
460+
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
461+
await tester.pump();
462+
463+
// After collapsing the root, only the root row remains visible. The
464+
// tree must still render that row instead of a loading indicator.
465+
expect(find.byType(CenteredCircularProgressIndicator), findsNothing);
466+
expect(visibleNodes(treeController), [root]);
467+
});
468+
469+
testWidgets(
470+
'onSelectNode does not re-expand collapsed siblings of the clicked node',
471+
(WidgetTester tester) async {
472+
// Regression test: clicking a still-visible row used to call
473+
// expandPath on the clicked node, which re-expanded the clicked node
474+
// itself and undid any subtree collapses the user had just performed
475+
// via the arrow-left key.
476+
final treeController = buildTreeController(
477+
onSelectionChange: ({bool notifyFlutterInspector = false}) {},
478+
);
479+
480+
final root = treeController.root!;
481+
final firstChild = root.children.first
482+
..appendChild(InspectorTreeNode())
483+
..appendChild(InspectorTreeNode());
484+
final secondChild = root.children.last;
485+
treeController.root = root;
486+
treeController.setSelectedNode(firstChild);
487+
488+
await pumpInspectorTree(tester, treeController: treeController);
489+
490+
// Collapse [firstChild] so its grandchildren are hidden.
491+
await tester.sendKeyEvent(LogicalKeyboardKey.arrowLeft);
492+
await tester.pump();
493+
expect(firstChild.isExpanded, isFalse);
494+
expect(visibleNodes(treeController), [root, firstChild, secondChild]);
495+
496+
// Selecting another visible row must not re-expand [firstChild].
497+
treeController.onSelectNode(secondChild);
498+
await tester.pump();
499+
500+
expect(firstChild.isExpanded, isFalse);
501+
expect(visibleNodes(treeController), [root, firstChild, secondChild]);
502+
},
503+
);
504+
505+
testWidgets(
506+
'onSelectNode does not re-expand a node the user just collapsed by '
507+
'clicking it',
508+
(WidgetTester tester) async {
509+
// Regression test: clicking a row used to call expandPath on the
510+
// clicked node itself, so a user could not select a node in its
511+
// collapsed state.
512+
final treeController = buildTreeController(
513+
onSelectionChange: ({bool notifyFlutterInspector = false}) {},
514+
);
515+
516+
final root = treeController.root!;
517+
final firstChild = root.children.first
518+
..appendChild(InspectorTreeNode())
519+
..isExpanded = false;
520+
treeController.root = root;
521+
522+
await pumpInspectorTree(tester, treeController: treeController);
523+
524+
treeController.onSelectNode(firstChild);
525+
await tester.pump();
526+
527+
expect(treeController.selection, firstChild);
528+
expect(firstChild.isExpanded, isFalse);
529+
},
530+
);
531+
});
532+
533+
group('InspectorTree loading indicator', () {
534+
testWidgets(
535+
'shows a loading indicator while the initial tree load is in progress',
536+
(WidgetTester tester) async {
537+
// Before the first inspector tree load completes, a tree with at most
538+
// a single row represents the "still loading" state and should render
539+
// a progress indicator instead of the bare row.
540+
inspectorController.firstInspectorTreeLoadCompleted = false;
541+
542+
final treeController = InspectorTreeController()
543+
..config = InspectorTreeConfig(
544+
onNodeAdded: (_, _) {},
545+
onClientActiveChange: (_) {},
546+
)
547+
..root = InspectorTreeNode();
548+
549+
await pumpInspectorTree(tester, treeController: treeController);
550+
551+
expect(find.byType(CenteredCircularProgressIndicator), findsOneWidget);
552+
},
553+
);
554+
555+
testWidgets(
556+
'renders a single-row tree (no spinner) after the initial load has '
557+
'completed',
558+
(WidgetTester tester) async {
559+
// Regression test: collapsing the root via the arrow-left key shrinks
560+
// the visible rows down to a single row. Before the fix, the
561+
// [InspectorTree] widget treated a one-row tree as "loading" and
562+
// showed a spinner, hiding the user's [root] row.
563+
inspectorController.firstInspectorTreeLoadCompleted = true;
564+
565+
final treeController = InspectorTreeController()
566+
..config = InspectorTreeConfig(
567+
onNodeAdded: (_, _) {},
568+
onClientActiveChange: (_) {},
569+
)
570+
..root = InspectorTreeNode();
571+
572+
await pumpInspectorTree(tester, treeController: treeController);
573+
574+
expect(find.byType(CenteredCircularProgressIndicator), findsNothing);
575+
},
576+
);
366577
});
367578
}

0 commit comments

Comments
 (0)