From 99ae1187432a556dc3259e012b46a73f4f069ca9 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 14:04:55 +0300 Subject: [PATCH 1/2] feat(explain): a click on a plan node shows every field the driver reported (#1164) The plan tree showed operation, relation, rows and a cost bar; the other fields the driver reported were parsed but not shown. A click on a node now opens a panel with all of them; a second click closes it. --- lib/features/workspace/plan_tree_view.dart | 89 +++++++++++++++++-- .../workspace/plan_tree_view_test.dart | 46 ++++++++++ 2 files changed, 129 insertions(+), 6 deletions(-) create mode 100644 test/features/workspace/plan_tree_view_test.dart diff --git a/lib/features/workspace/plan_tree_view.dart b/lib/features/workspace/plan_tree_view.dart index 58491fd7..0a35525b 100644 --- a/lib/features/workspace/plan_tree_view.dart +++ b/lib/features/workspace/plan_tree_view.dart @@ -3,14 +3,23 @@ import 'package:querya_desktop/features/workspace/query_plan.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; /// The plan as a tree, top-down. The node with the largest own cost is -/// highlighted; each node shows its share of the total cost as a bar. -class PlanTreeView extends material.StatelessWidget { +/// highlighted; each node shows its share of the total cost as a bar. A click +/// on a node shows every field the driver reported for it. +class PlanTreeView extends material.StatefulWidget { const PlanTreeView({super.key, required this.root}); final PlanNode root; + @override + material.State createState() => _PlanTreeViewState(); +} + +class _PlanTreeViewState extends material.State { + PlanNode? _selected; + @override material.Widget build(material.BuildContext context) { + final root = widget.root; final hottest = QueryPlanParser.hottest(root); final total = root.cost ?? _sumOwn(root); final rows = []; @@ -19,7 +28,9 @@ class PlanTreeView extends material.StatelessWidget { node: node, depth: depth, hottest: identical(node, hottest), + selected: identical(node, _selected), total: total, + onTap: () => setState(() => _selected = identical(node, _selected) ? null : node), )); for (final child in node.children) { add(child, depth + 1); @@ -27,11 +38,15 @@ class PlanTreeView extends material.StatelessWidget { } add(root, 0); + final selected = _selected; return material.SingleChildScrollView( padding: const material.EdgeInsets.all(8), child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, - children: rows, + children: [ + ...rows, + if (selected != null) _DetailsPanel(node: selected), + ], ), ); } @@ -50,13 +65,17 @@ class _PlanRow extends material.StatelessWidget { required this.node, required this.depth, required this.hottest, + required this.selected, required this.total, + required this.onTap, }); final PlanNode node; final int depth; final bool hottest; + final bool selected; final double total; + final material.VoidCallback onTap; @override material.Widget build(material.BuildContext context) { @@ -69,13 +88,20 @@ class _PlanRow extends material.StatelessWidget { '${node.actualRows == null ? '' : ' → ${node.actualRows!.round()}'}'; return material.Padding( padding: material.EdgeInsets.only(left: depth * 18.0, bottom: 6), - child: material.Container( + child: material.GestureDetector( + behavior: material.HitTestBehavior.opaque, + onTap: onTap, + child: material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 10, vertical: 6), decoration: material.BoxDecoration( color: hottest ? wb.accent.withValues(alpha: 0.12) : null, border: material.Border.all( - color: hottest ? wb.accent : wb.borderSubtle, - width: hottest ? 1.5 : 1, + color: selected + ? Theme.of(context).colorScheme.foreground + : hottest + ? wb.accent + : wb.borderSubtle, + width: hottest || selected ? 1.5 : 1, ), borderRadius: material.BorderRadius.circular(6), ), @@ -107,6 +133,57 @@ class _PlanRow extends material.StatelessWidget { ], ], ), + ), + ), + ); + } +} + +/// Every field the driver reported for a node, in the order it reported them. +class _DetailsPanel extends material.StatelessWidget { + const _DetailsPanel({required this.node}); + + final PlanNode node; + + @override + material.Widget build(material.BuildContext context) { + final wb = context.workbench; + final fields = { + 'Operation': node.operation, + if (node.relation != null) 'Relation': node.relation!, + if (node.estimatedRows != null) + 'Estimated rows': '${node.estimatedRows!.round()}', + if (node.actualRows != null) 'Actual rows': '${node.actualRows!.round()}', + if (node.cost != null) 'Cost': node.cost!.toStringAsFixed(2), + if (node.ownCost != null) 'Own cost': node.ownCost!.toStringAsFixed(2), + ...node.details, + }; + return material.Container( + margin: const material.EdgeInsets.only(top: 8), + padding: const material.EdgeInsets.all(10), + decoration: material.BoxDecoration( + border: material.Border.all(color: wb.borderSubtle), + borderRadius: material.BorderRadius.circular(6), + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + for (final e in fields.entries) + material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 2), + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.SizedBox( + width: 140, + child: material.Text(e.key, + style: material.TextStyle(color: wb.mutedForeground)), + ), + material.Expanded(child: material.SelectableText(e.value)), + ], + ), + ), + ], ), ); } diff --git a/test/features/workspace/plan_tree_view_test.dart b/test/features/workspace/plan_tree_view_test.dart new file mode 100644 index 00000000..c2c6cd22 --- /dev/null +++ b/test/features/workspace/plan_tree_view_test.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/features/workspace/plan_tree_view.dart'; +import 'package:querya_desktop/features/workspace/query_plan.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +void main() { + testWidgets('a click on a plan node shows every field it reported (#1164)', + (t) async { + final root = PlanNode( + operation: 'Hash Join', + cost: 500, + children: [ + PlanNode( + operation: 'Seq Scan', + relation: 'orders', + cost: 300, + details: {'Startup Cost': '0.00', 'Filter': 'paid'}, + ), + ], + ); + await t.pumpWidget(queryaThemeTestShell( + child: material.SizedBox( + width: 600, + height: 600, + child: PlanTreeView(root: root), + ), + )); + await t.pump(); + + expect(find.text('Seq Scan'), findsOneWidget); + expect(find.text('Filter'), findsNothing); + + await t.tap(find.text('Seq Scan')); + await t.pump(); + expect(find.text('Filter'), findsOneWidget); + expect(find.text('paid'), findsOneWidget); + expect(find.text('Startup Cost'), findsOneWidget); + + // A second click closes the details again. + await t.tap(find.text('Seq Scan')); + await t.pump(); + expect(find.text('Filter'), findsNothing); + }); +} From 029f83e980ddfe9c6d28e3caaf5d518e4870dae2 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 14:07:36 +0300 Subject: [PATCH 2/2] test(explain): tap the plan tree row, not the name repeated in the panel (#1164) --- test/features/workspace/plan_tree_view_test.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/features/workspace/plan_tree_view_test.dart b/test/features/workspace/plan_tree_view_test.dart index c2c6cd22..ec1262cf 100644 --- a/test/features/workspace/plan_tree_view_test.dart +++ b/test/features/workspace/plan_tree_view_test.dart @@ -32,14 +32,15 @@ void main() { expect(find.text('Seq Scan'), findsOneWidget); expect(find.text('Filter'), findsNothing); - await t.tap(find.text('Seq Scan')); + // The name shows again in the panel, so tap the tree row (the first one). + await t.tap(find.text('Seq Scan').first); await t.pump(); expect(find.text('Filter'), findsOneWidget); expect(find.text('paid'), findsOneWidget); expect(find.text('Startup Cost'), findsOneWidget); // A second click closes the details again. - await t.tap(find.text('Seq Scan')); + await t.tap(find.text('Seq Scan').first); await t.pump(); expect(find.text('Filter'), findsNothing); });