From cf2c35cd7fae9e2716fb8330b7e9fe5619762fef Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 10:02:12 +0300 Subject: [PATCH 1/3] feat(sql-workspace): one-row toolbar, Diagram on the tab strip (#1150) Run, Explain and Cancel are the first group, the editor tools (History, Format, Open, Save) are icon buttons with tooltips, and the database name, transaction badge, Commit / Rollback while a transaction is open and the Session menu come last. The row scrolls sideways on a narrow window instead of wrapping. Begin lives in the Session menu. The Query caption and the Data Output bar are gone, and Diagram moves to the right end of the query tab strip. --- .../workspace/generic_sql_workspace.dart | 332 +++++++++--------- lib/features/workspace/sql_query_tab_bar.dart | 8 + .../e2e_transaction_guard_test.dart | 11 +- .../generic_sql_workspace_shortcuts_test.dart | 22 ++ 4 files changed, 209 insertions(+), 164 deletions(-) diff --git a/lib/features/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart index 97b6515e..cadb9a6a 100644 --- a/lib/features/workspace/generic_sql_workspace.dart +++ b/lib/features/workspace/generic_sql_workspace.dart @@ -1152,6 +1152,15 @@ class GenericSqlWorkspaceState extends material.State { onClose: _sessions.length > 1 ? (index) => unawaited(closeTab(index)) : null, + trailing: widget.showDiagram + ? QueryaActionButton( + key: const material.ValueKey('open_diagram_tab'), + label: 'Diagram', + icon: material.Icons.account_tree_outlined, + tooltip: 'Schema diagram', + onPressed: openDiagramTab, + ) + : null, ), material.Expanded( child: material.IndexedStack( @@ -1185,8 +1194,6 @@ class GenericSqlWorkspaceState extends material.State { onOpenTable: _openTableFromDiagram, ); } - final theme = Theme.of(context); - final accent = context.workbench.accent; return VerticalSplitPane( key: material.ValueKey(session.id), @@ -1198,156 +1205,7 @@ class GenericSqlWorkspaceState extends material.State { material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: SqlEditorChrome.sqlToolbarDecoration(context), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - mainAxisSize: material.MainAxisSize.min, - children: [ - material.Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: material.WrapCrossAlignment.center, - children: [ - if (widget.headerBadge != null) - widget.headerBadge! - else - const Text('Query').semiBold().small(), - if (effectiveDatabase.isNotEmpty) - Text('DB: $effectiveDatabase').muted().small(), - if (widget.delegate.supportsTransactions) _txBadge(), - QueryaActionButton( - label: 'History', - icon: material.Icons.history_rounded, - size: ButtonSize.small, - onPressed: widget.connectionRow.id != null && !session.running - ? () => _openHistory(session) - : null, - ), - material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - QueryaActionButton( - key: const material.ValueKey('run_script'), - label: 'Execute (F5)', - icon: material.Icons.play_arrow_rounded, - loading: session.running, - onPressed: () => execute(session), - ), - const material.SizedBox(width: 4), - QueryaActionMenu( - key: const material.ValueKey('run_menu'), - items: [ - QueryaActionMenuItem( - value: true, - label: 'Run statement (${_modLabel}Enter)', - icon: material.Icons.short_text_rounded, - ), - const QueryaActionMenuItem( - value: false, - label: 'Run script (F5)', - icon: material.Icons.list_alt_rounded, - ), - ], - onSelected: (atCursor) { - if (!session.running) { - unawaited(execute(session, atCursor)); - } - }, - child: const material.Icon( - material.Icons.expand_more_rounded, - size: 16, - ), - ), - ], - ), - if (widget.delegate.supportsExplain) - QueryaActionButton( - key: const material.ValueKey('explain_query'), - label: 'Explain', - icon: material.Icons.account_tree_outlined, - tooltip: 'Show the query plan', - onPressed: session.running ? null : () => explain(session), - ), - if (session.running && widget.delegate.supportsCancel) - QueryaActionButton( - key: const material.ValueKey('cancel_query'), - label: 'Cancel', - icon: material.Icons.stop_rounded, - isDestructive: true, - tooltip: 'Interrupt the running query', - onPressed: () => cancelRunning(session), - ), - if (widget.showDiagram) - QueryaActionButton( - key: const material.ValueKey('open_diagram_tab'), - label: 'Diagram', - onPressed: openDiagramTab, - ), - if (widget.extraToolbarTrailing?.call(context, session) case final extra?) - extra, - ], - ), - if (widget.supportsAutocommit || widget.supportsStmtTimeout || widget.delegate.supportsTransactions) ...[ - const Gap(8), - material.Wrap( - spacing: 8, - runSpacing: 8, - crossAxisAlignment: material.WrapCrossAlignment.center, - children: [ - // Autocommit, statement timeout, Begin and Preferences live - // in one Session menu. - material.IgnorePointer( - ignoring: session.running, - child: material.Opacity( - opacity: session.running ? 0.5 : 1, - child: QueryaActionMenu<_SessionChoice>( - key: const material.ValueKey('session_menu'), - items: _sessionMenuItems(), - onSelected: (choice) => _onSessionChoice(choice), - child: material.Padding( - padding: const material.EdgeInsets.symmetric( - horizontal: 10, vertical: 6), - child: material.Row( - mainAxisSize: material.MainAxisSize.min, - children: [ - 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), - material.Icon( - material.Icons.expand_more_rounded, - size: 16, - color: accent), - ], - ), - ), - ), - ), - ), - // Commit and Rollback only while a transaction is open; Begin - // otherwise. - if (widget.delegate.supportsTransactions) - if (_txOpen == true) ...[ - QueryaActionButton( - label: 'Commit', - onPressed: session.running ? null : () => runTxCommand('COMMIT'), - ), - QueryaActionButton( - label: 'Rollback', - onPressed: session.running ? null : () => runTxCommand('ROLLBACK'), - ), - ] else - QueryaActionButton( - label: 'Begin', - onPressed: session.running ? null : () => runTxCommand('BEGIN'), - ), - ], - ), - ], - ], - ), + child: _buildToolbarRow(context, session), ), const Divider(height: 1), Expanded( @@ -1361,16 +1219,6 @@ class GenericSqlWorkspaceState extends material.State { bottom: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - material.Container( - constraints: const material.BoxConstraints(minHeight: 44), - padding: const material.EdgeInsets.symmetric(horizontal: 12), - decoration: material.BoxDecoration( - color: theme.colorScheme.muted.withValues(alpha: 0.6), - ), - alignment: material.Alignment.centerLeft, - child: const Text('Data Output').semiBold().small(), - ), - const Divider(height: 1), Expanded( child: ResultsTab( columns: session.columns, @@ -1398,6 +1246,166 @@ class GenericSqlWorkspaceState extends material.State { ); } + /// The toolbar in one row: Run and Explain, the editor tools, then the + /// session state. On a narrow window it scrolls sideways instead of wrapping. + material.Widget _buildToolbarRow( + material.BuildContext context, + SqlQueryTabSession session, + ) { + final accent = context.workbench.accent; + final historyEnabled = widget.connectionRow.id != null && !session.running; + return material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.Row( + children: [ + if (widget.headerBadge != null) ...[ + widget.headerBadge!, + const material.SizedBox(width: 12), + ], + QueryaActionButton( + key: const material.ValueKey('run_script'), + label: 'Execute (F5)', + icon: material.Icons.play_arrow_rounded, + loading: session.running, + onPressed: () => execute(session), + ), + const material.SizedBox(width: 4), + QueryaActionMenu( + key: const material.ValueKey('run_menu'), + items: [ + QueryaActionMenuItem( + value: true, + label: 'Run statement (${_modLabel}Enter)', + icon: material.Icons.short_text_rounded, + ), + const QueryaActionMenuItem( + value: false, + label: 'Run script (F5)', + icon: material.Icons.list_alt_rounded, + ), + ], + onSelected: (atCursor) { + if (!session.running) { + unawaited(execute(session, atCursor)); + } + }, + child: const material.Icon( + material.Icons.expand_more_rounded, + size: 16, + ), + ), + if (widget.delegate.supportsExplain) ...[ + const material.SizedBox(width: 8), + QueryaActionButton( + key: const material.ValueKey('explain_query'), + label: 'Explain', + icon: material.Icons.account_tree_outlined, + tooltip: 'Show the query plan', + onPressed: session.running ? null : () => explain(session), + ), + ], + if (session.running && widget.delegate.supportsCancel) ...[ + const material.SizedBox(width: 8), + QueryaActionButton( + key: const material.ValueKey('cancel_query'), + label: 'Cancel', + icon: material.Icons.stop_rounded, + isDestructive: true, + tooltip: 'Interrupt the running query', + onPressed: () => cancelRunning(session), + ), + ], + const material.SizedBox(width: 16), + QueryaIconButton( + key: const material.ValueKey('history_button'), + icon: material.Icons.history_rounded, + tooltip: 'History (${_modLabel}H)', + onPressed: historyEnabled ? () => _openHistory(session) : null, + ), + QueryaIconButton( + key: const material.ValueKey('format_button'), + icon: material.Icons.auto_fix_high_rounded, + tooltip: 'Format (Shift+Alt+F)', + onPressed: session.running ? null : () => session.formatSql(), + ), + QueryaIconButton( + key: const material.ValueKey('open_sql_button'), + icon: material.Icons.folder_open_outlined, + tooltip: 'Open .sql file', + onPressed: () => unawaited(openSqlFile()), + ), + QueryaIconButton( + key: const material.ValueKey('save_sql_button'), + icon: material.Icons.save_outlined, + tooltip: 'Save .sql file', + onPressed: () => unawaited(saveSqlFile()), + ), + const material.SizedBox(width: 16), + if (effectiveDatabase.isNotEmpty) ...[ + QueryaBadge.status( + effectiveDatabase, + status: QueryaBadgeStatus.neutral, + ), + const material.SizedBox(width: 8), + ], + if (widget.delegate.supportsTransactions) ...[ + _txBadge(), + if (_txOpen == true) ...[ + const material.SizedBox(width: 8), + QueryaActionButton( + label: 'Commit', + onPressed: + session.running ? null : () => runTxCommand('COMMIT'), + ), + const material.SizedBox(width: 4), + QueryaActionButton( + label: 'Rollback', + onPressed: + session.running ? null : () => runTxCommand('ROLLBACK'), + ), + ], + const material.SizedBox(width: 8), + ], + // Autocommit, statement timeout, Begin and Preferences live in one + // Session menu. + material.IgnorePointer( + ignoring: session.running, + child: material.Opacity( + opacity: session.running ? 0.5 : 1, + child: QueryaActionMenu<_SessionChoice>( + key: const material.ValueKey('session_menu'), + items: _sessionMenuItems(), + onSelected: (choice) => _onSessionChoice(choice), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + 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), + material.Icon(material.Icons.expand_more_rounded, + size: 16, color: accent), + ], + ), + ), + ), + ), + ), + if (widget.extraToolbarTrailing?.call(context, session) case final extra?) ...[ + const material.SizedBox(width: 8), + extra, + ], + ], + ), + ); + } + /// 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/sql_query_tab_bar.dart b/lib/features/workspace/sql_query_tab_bar.dart index f05159df..029095be 100644 --- a/lib/features/workspace/sql_query_tab_bar.dart +++ b/lib/features/workspace/sql_query_tab_bar.dart @@ -11,6 +11,7 @@ class SqlQueryTabBar extends material.StatelessWidget { required this.onSelect, required this.onAdd, this.onClose, + this.trailing, }); final List sessions; @@ -19,6 +20,9 @@ class SqlQueryTabBar extends material.StatelessWidget { final material.VoidCallback onAdd; final material.ValueChanged? onClose; + /// Shown at the right end of the strip (the schema diagram button). + final material.Widget? trailing; + @override material.Widget build(material.BuildContext context) { final theme = Theme.of(context); @@ -45,6 +49,10 @@ class SqlQueryTabBar extends material.StatelessWidget { canClose: sessions.length > 1 ? (_) => true : (_) => false, ), ), + if (trailing != null) ...[ + const material.SizedBox(width: 8), + trailing!, + ], ], ), ); diff --git a/test/e2e/02_sql_workspaces/e2e_transaction_guard_test.dart b/test/e2e/02_sql_workspaces/e2e_transaction_guard_test.dart index 1393f8fe..820a69e4 100644 --- a/test/e2e/02_sql_workspaces/e2e_transaction_guard_test.dart +++ b/test/e2e/02_sql_workspaces/e2e_transaction_guard_test.dart @@ -43,6 +43,13 @@ void main() { await E2eSqlWorkspace.settle(tester); } + /// Begin lives in the Session menu, not on the toolbar. + Future beginFromSessionMenu(WidgetTester tester) async { + await tester.tap(find.byKey(const material.ValueKey('session_menu'))); + await tester.pumpAndSettle(); + await tapAndSettle(tester, 'Begin transaction'); + } + testWidgets('Begin opens a transaction, Rollback and Commit close it', timeout: _timeout, (tester) async { final delegate = _TxDelegate(); @@ -51,14 +58,14 @@ void main() { // Unknown until the first command refreshes the state. expect(find.text('Transaction —'), findsOneWidget); - await tapAndSettle(tester, 'Begin'); + await beginFromSessionMenu(tester); expect(find.text('Transaction open'), findsOneWidget); expect(find.text('OK: BEGIN'), findsWidgets); await tapAndSettle(tester, 'Rollback'); expect(find.text('Auto-commit'), findsOneWidget); - await tapAndSettle(tester, 'Begin'); + await beginFromSessionMenu(tester); await tapAndSettle(tester, 'Commit'); expect(find.text('Auto-commit'), findsOneWidget); diff --git a/test/features/workspace/generic_sql_workspace_shortcuts_test.dart b/test/features/workspace/generic_sql_workspace_shortcuts_test.dart index 14c855f7..58b48dd0 100644 --- a/test/features/workspace/generic_sql_workspace_shortcuts_test.dart +++ b/test/features/workspace/generic_sql_workspace_shortcuts_test.dart @@ -339,4 +339,26 @@ void main() { expect(state.activeSession.statusLine, '3 statements · 6 rows affected'); expect(state.activeSession.affectedRows, 6); }); + + testWidgets('the toolbar stays on one row on a narrow window', timeout: _timeout, + (tester) async { + await pumpWorkspace(tester, FakeSqlExecutionDelegate()); + await tester.binding.setSurfaceSize(const material.Size(800, 700)); + await tester.pumpAndSettle(); + + final run = find.byKey(const material.ValueKey('run_script')); + final session = find.byKey(const material.ValueKey('session_menu')); + expect(run, findsOneWidget); + expect(session, findsOneWidget); + expect(tester.getTopLeft(session).dy, closeTo(tester.getTopLeft(run).dy, 1)); + }); + + testWidgets('no Query caption and no Data Output bar above the results', + timeout: _timeout, (tester) async { + await pumpWorkspace(tester, FakeSqlExecutionDelegate()); + + expect(find.text('Query'), findsNothing); + expect(find.text('Data Output'), findsNothing); + expect(find.byKey(const material.ValueKey('open_diagram_tab')), findsOneWidget); + }); } From c09d329520968107e831e5c027248a90dab08ece Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 10:06:43 +0300 Subject: [PATCH 2/3] fix(sql-workspace): wrap toolbar icon buttons' icons in Icon widgets (#1150) QueryaIconButton takes its icon as a widget, not as IconData. --- lib/features/workspace/generic_sql_workspace.dart | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/features/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart index cadb9a6a..c7bc7649 100644 --- a/lib/features/workspace/generic_sql_workspace.dart +++ b/lib/features/workspace/generic_sql_workspace.dart @@ -1318,25 +1318,25 @@ class GenericSqlWorkspaceState extends material.State { const material.SizedBox(width: 16), QueryaIconButton( key: const material.ValueKey('history_button'), - icon: material.Icons.history_rounded, + icon: const material.Icon(material.Icons.history_rounded), tooltip: 'History (${_modLabel}H)', onPressed: historyEnabled ? () => _openHistory(session) : null, ), QueryaIconButton( key: const material.ValueKey('format_button'), - icon: material.Icons.auto_fix_high_rounded, + icon: const material.Icon(material.Icons.auto_fix_high_rounded), tooltip: 'Format (Shift+Alt+F)', onPressed: session.running ? null : () => session.formatSql(), ), QueryaIconButton( key: const material.ValueKey('open_sql_button'), - icon: material.Icons.folder_open_outlined, + icon: const material.Icon(material.Icons.folder_open_outlined), tooltip: 'Open .sql file', onPressed: () => unawaited(openSqlFile()), ), QueryaIconButton( key: const material.ValueKey('save_sql_button'), - icon: material.Icons.save_outlined, + icon: const material.Icon(material.Icons.save_outlined), tooltip: 'Save .sql file', onPressed: () => unawaited(saveSqlFile()), ), From ebb7137734fe16e26ef6477d36cb8a154ea4270c Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 9 Oct 2026 10:10:01 +0300 Subject: [PATCH 3/3] test(sql-workspace): compare the toolbar row by centre, not top edge (#1150) The Session menu is a little taller than the Run button, so their top edges differ while both sit on one row. --- .../workspace/generic_sql_workspace_shortcuts_test.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/features/workspace/generic_sql_workspace_shortcuts_test.dart b/test/features/workspace/generic_sql_workspace_shortcuts_test.dart index 58b48dd0..2491d3f2 100644 --- a/test/features/workspace/generic_sql_workspace_shortcuts_test.dart +++ b/test/features/workspace/generic_sql_workspace_shortcuts_test.dart @@ -350,7 +350,7 @@ void main() { final session = find.byKey(const material.ValueKey('session_menu')); expect(run, findsOneWidget); expect(session, findsOneWidget); - expect(tester.getTopLeft(session).dy, closeTo(tester.getTopLeft(run).dy, 1)); + expect(tester.getCenter(session).dy, closeTo(tester.getCenter(run).dy, 1)); }); testWidgets('no Query caption and no Data Output bar above the results',