diff --git a/lib/features/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart index 97b6515e..c7bc7649 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: const material.Icon(material.Icons.history_rounded), + tooltip: 'History (${_modLabel}H)', + onPressed: historyEnabled ? () => _openHistory(session) : null, + ), + QueryaIconButton( + key: const material.ValueKey('format_button'), + 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: const material.Icon(material.Icons.folder_open_outlined), + tooltip: 'Open .sql file', + onPressed: () => unawaited(openSqlFile()), + ), + QueryaIconButton( + key: const material.ValueKey('save_sql_button'), + icon: const material.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..2491d3f2 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.getCenter(session).dy, closeTo(tester.getCenter(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); + }); }