From c0a675531dad138f25d36b7ed05faa2444101ed0 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 10:36:25 +0300 Subject: [PATCH 1/2] feat(sql-workspace): elapsed time next to the row count; labels hide on a narrow toolbar (#1150) - The results toolbar shows a badge with how long the run took (14 ms, 1.2 s, 2 min 5 s); the whole script for a multi-statement run. - Below 900 px the Run, Explain and Cancel labels and the Session menu text hide and the icons stay; the labels become tooltips. QueryaActionButton gets compact for that. --- .../workspace/generic_sql_workspace.dart | 29 ++++++++++++--- lib/features/workspace/results_tab.dart | 21 +++++++++++ .../workspace/sql_query_tab_session.dart | 3 ++ lib/shared/widgets/querya_action_button.dart | 21 ++++++++--- .../generic_sql_workspace_shortcuts_test.dart | 36 +++++++++++++++++++ 5 files changed, 100 insertions(+), 10 deletions(-) diff --git a/lib/features/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart index a08601cd..764a7126 100644 --- a/lib/features/workspace/generic_sql_workspace.dart +++ b/lib/features/workspace/generic_sql_workspace.dart @@ -661,6 +661,7 @@ class GenericSqlWorkspaceState extends material.State { session.rows = []; session.affectedRows = null; session.statusLine = null; + session.elapsed = null; session.planRoot = null; session.resultGridPrimaryKeys = const []; session.resultGridColumnDataTypes = null; @@ -756,6 +757,7 @@ class GenericSqlWorkspaceState extends material.State { session.stagingBuffer = null; session.statusLine = scriptStatus ?? _resultStatus(result, cols, outRows, null); + session.elapsed = multi ? sw.elapsed : result.elapsed ?? sw.elapsed; session.running = false; }); // Anything that is not a read may change table shapes: forget the cache. @@ -903,6 +905,7 @@ class GenericSqlWorkspaceState extends material.State { session.rows = []; session.affectedRows = null; session.statusLine = null; + session.elapsed = null; session.planRoot = null; session.resultGridPrimaryKeys = const []; session.resultGridColumnDataTypes = null; @@ -1322,6 +1325,7 @@ class GenericSqlWorkspaceState extends material.State { isLoading: session.running, affectedRows: session.affectedRows, statusLine: session.statusLine, + elapsed: session.elapsed, stagingBuffer: session.stagingBuffer, columnDataTypes: session.resultGridColumnDataTypes, onApplyChanges: widget.isReadOnly || @@ -1349,7 +1353,12 @@ class GenericSqlWorkspaceState extends material.State { ) { final accent = context.workbench.accent; final historyEnabled = widget.connectionRow.id != null && !session.running; - return material.SingleChildScrollView( + return material.LayoutBuilder( + builder: (context, constraints) { + // Below the breakpoint the labels hide and the icons stay (the + // tooltips name them), so the toolbar stays on one row. + final compact = constraints.maxWidth < _toolbarCompactWidth; + return material.SingleChildScrollView( scrollDirection: material.Axis.horizontal, child: material.Row( children: [ @@ -1361,6 +1370,7 @@ class GenericSqlWorkspaceState extends material.State { key: const material.ValueKey('run_script'), label: 'Execute (F5)', icon: material.Icons.play_arrow_rounded, + compact: compact, loading: session.running, onPressed: () => execute(session), ), @@ -1395,6 +1405,7 @@ class GenericSqlWorkspaceState extends material.State { key: const material.ValueKey('explain_query'), label: 'Explain', icon: material.Icons.account_tree_outlined, + compact: compact, tooltip: 'Show the query plan', onPressed: session.running ? null : () => explain(session), ), @@ -1405,6 +1416,7 @@ class GenericSqlWorkspaceState extends material.State { key: const material.ValueKey('cancel_query'), label: 'Cancel', icon: material.Icons.stop_rounded, + compact: compact, isDestructive: true, tooltip: 'Interrupt the running query', onPressed: () => cancelRunning(session), @@ -1480,10 +1492,12 @@ class GenericSqlWorkspaceState extends material.State { material.Icon(material.Icons.tune_rounded, size: 16, color: accent), const material.SizedBox(width: 6), - Text(_autocommit - ? 'Session · auto-commit' - : 'Session · manual'), - const material.SizedBox(width: 4), + if (!compact) ...[ + Text(_autocommit + ? 'Session · auto-commit' + : 'Session · manual'), + const material.SizedBox(width: 4), + ], material.Icon(material.Icons.expand_more_rounded, size: 16, color: accent), ], @@ -1499,8 +1513,13 @@ class GenericSqlWorkspaceState extends material.State { ], ), ); + }, + ); } + /// Toolbar width below which button labels hide and only icons stay. + static const double _toolbarCompactWidth = 900; + /// Transaction state as a badge: an open transaction is a warning, because /// work can be lost, and looks different from no transaction. material.Widget _txBadge() { diff --git a/lib/features/workspace/results_tab.dart b/lib/features/workspace/results_tab.dart index c0c41464..bf5f2327 100644 --- a/lib/features/workspace/results_tab.dart +++ b/lib/features/workspace/results_tab.dart @@ -51,6 +51,7 @@ class ResultsTab extends material.StatefulWidget { this.isLoading = false, this.affectedRows, this.statusLine, + this.elapsed, this.showExportToolbar = true, this.stagingBuffer, this.onApplyChanges, @@ -65,6 +66,9 @@ class ResultsTab extends material.StatefulWidget { final bool isLoading; final int? affectedRows; final String? statusLine; + + /// Time the query took, shown as a badge next to the row count. + final Duration? elapsed; final bool showExportToolbar; final DataGridStagingBuffer? stagingBuffer; final material.VoidCallback? onApplyChanges; @@ -417,6 +421,14 @@ class _ResultsTabState extends material.State { ? 'Rows affected: ${widget.affectedRows}' : '${filteredRows.length}${_filterText.isNotEmpty ? ' of ${effectiveRows.length}' : ''} rows'), ).small().semiBold(), + if (widget.elapsed != null) ...[ + const Gap(8), + QueryaBadge( + key: const material.ValueKey('result_elapsed'), + label: formatResultElapsed(widget.elapsed!), + isMonospace: true, + ), + ], const Gap(16), @@ -664,3 +676,12 @@ Future _showSaveFileErrorDialog(material.BuildContext context) { ), ); } + +/// `14 ms`, `1.2 s`, `2 min 5 s`: what the elapsed badge shows. +String formatResultElapsed(Duration d) { + if (d.inMilliseconds < 1000) return '${d.inMilliseconds} ms'; + if (d.inSeconds < 60) { + return '${(d.inMilliseconds / 1000).toStringAsFixed(1)} s'; + } + return '${d.inMinutes} min ${d.inSeconds % 60} s'; +} diff --git a/lib/features/workspace/sql_query_tab_session.dart b/lib/features/workspace/sql_query_tab_session.dart index 88b04d52..bd1560bf 100644 --- a/lib/features/workspace/sql_query_tab_session.dart +++ b/lib/features/workspace/sql_query_tab_session.dart @@ -44,6 +44,9 @@ class SqlQueryTabSession { int? affectedRows; String? statusLine; + /// How long the last run took (the whole script for a multi-statement run). + Duration? elapsed; + /// The EXPLAIN plan as a tree, and whether the result area shows it (or text). PlanNode? planRoot; bool planAsTree = true; diff --git a/lib/shared/widgets/querya_action_button.dart b/lib/shared/widgets/querya_action_button.dart index 441db7d1..4dff999e 100644 --- a/lib/shared/widgets/querya_action_button.dart +++ b/lib/shared/widgets/querya_action_button.dart @@ -20,6 +20,7 @@ class QueryaActionButton extends material.StatelessWidget { this.isDestructive = false, this.tooltip, this.size = ButtonSize.normal, + this.compact = false, }); final String label; @@ -38,6 +39,10 @@ class QueryaActionButton extends material.StatelessWidget { final String? tooltip; final ButtonSize size; + /// Hides the label and keeps the icon (narrow toolbars). The label becomes + /// the tooltip when none is set. Without an [icon], [compact] has no effect. + final bool compact; + @override material.Widget build(material.BuildContext context) { final workbench = context.workbench; @@ -51,18 +56,24 @@ class QueryaActionButton extends material.StatelessWidget { leading = material.Icon(icon, size: 18, color: tone); } + final iconOnly = compact && leading != null; material.Widget button = OutlineButton( size: size, onPressed: enabled ? onPressed : null, leading: leading, - child: isDestructive - ? Text(label, style: material.TextStyle(color: tone)) - : Text(label), + child: iconOnly + ? const material.SizedBox.shrink() + : isDestructive + ? Text(label, style: material.TextStyle(color: tone)) + : Text(label), ); - if (tooltip != null && tooltip!.isNotEmpty) { + final hint = (tooltip != null && tooltip!.isNotEmpty) + ? tooltip! + : (iconOnly ? label : null); + if (hint != null) { button = material.Tooltip( - message: tooltip!, + message: hint, waitDuration: kQueryaTooltipWait, child: button, ); diff --git a/test/features/workspace/generic_sql_workspace_shortcuts_test.dart b/test/features/workspace/generic_sql_workspace_shortcuts_test.dart index 95ffb268..a8c95819 100644 --- a/test/features/workspace/generic_sql_workspace_shortcuts_test.dart +++ b/test/features/workspace/generic_sql_workspace_shortcuts_test.dart @@ -13,6 +13,7 @@ import 'package:querya_desktop/core/database/table_mutation_engine.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/settings/preferences_shortcuts_section.dart'; import 'package:querya_desktop/features/workspace/generic_sql_workspace.dart'; +import 'package:querya_desktop/features/workspace/results_tab.dart' show formatResultElapsed; import 'package:querya_desktop/features/workspace/sql_execution_delegate.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' show OutlineButton; @@ -366,6 +367,41 @@ void main() { expect(tester.getCenter(session).dy, closeTo(tester.getCenter(run).dy, 1)); }); + testWidgets('below the breakpoint the labels hide and the icons stay', + timeout: _timeout, (tester) async { + await pumpWorkspace(tester, FakeSqlExecutionDelegate()); + expect(find.text('Execute (F5)'), findsOneWidget); + expect(find.text('Explain'), findsOneWidget); + + await tester.binding.setSurfaceSize(const material.Size(800, 700)); + await tester.pumpAndSettle(); + + expect(find.text('Execute (F5)'), findsNothing); + expect(find.text('Explain'), findsNothing); + expect(find.byKey(const material.ValueKey('run_script')), findsOneWidget); + expect(find.byKey(const material.ValueKey('explain_query')), findsOneWidget); + expect(find.byTooltip('Show the query plan'), findsOneWidget); + expect(find.byTooltip('Execute (F5)'), findsOneWidget); + }); + + testWidgets('the results toolbar shows how long the query took', + timeout: _timeout, (tester) async { + final delegate = FakeSqlExecutionDelegate(); + await pumpWorkspace(tester, delegate, initialSql: 'SELECT n FROM t'); + expect(find.byKey(const material.ValueKey('result_elapsed')), findsNothing); + + await tester.tap(find.widgetWithText(OutlineButton, 'Execute (F5)')); + await settle(tester); + + expect(find.byKey(const material.ValueKey('result_elapsed')), findsOneWidget); + }); + + test('the elapsed badge reads ms, s and min', () { + expect(formatResultElapsed(const Duration(milliseconds: 14)), '14 ms'); + expect(formatResultElapsed(const Duration(milliseconds: 1234)), '1.2 s'); + expect(formatResultElapsed(const Duration(seconds: 125)), '2 min 5 s'); + }); + testWidgets('no Query caption and no Data Output bar above the results', timeout: _timeout, (tester) async { await pumpWorkspace(tester, FakeSqlExecutionDelegate()); From 10539178c726dec33a50fb89c94fb87225fa31ed Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 10:39:18 +0300 Subject: [PATCH 2/2] test(main-screen): find the Execute button by its key; its label hides on a narrow window --- .../main_screen/workspace_homes_and_preferences_test.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/features/main_screen/workspace_homes_and_preferences_test.dart b/test/features/main_screen/workspace_homes_and_preferences_test.dart index ef08625b..dbf006f2 100644 --- a/test/features/main_screen/workspace_homes_and_preferences_test.dart +++ b/test/features/main_screen/workspace_homes_and_preferences_test.dart @@ -84,7 +84,8 @@ void main() { await tester.pump(); await tester.pump(const Duration(milliseconds: 200)); - expect(find.textContaining('Execute'), findsWidgets); + // The label hides on a narrow window; the button keeps its key. + expect(find.byKey(const material.ValueKey('run_script')), findsOneWidget); }); });