diff --git a/lib/features/workspace/generic_sql_workspace.dart b/lib/features/workspace/generic_sql_workspace.dart index 8a60d577..751db788 100644 --- a/lib/features/workspace/generic_sql_workspace.dart +++ b/lib/features/workspace/generic_sql_workspace.dart @@ -1247,6 +1247,9 @@ class GenericSqlWorkspaceState extends material.State { onClose: _sessions.length > 1 ? (index) => unawaited(closeTab(index)) : null, + actions: _activeSession.isDiagram + ? null + : _buildToolbar(context, _activeSession), trailing: widget.showDiagram ? QueryaButton.secondary( key: const material.ValueKey('open_diagram_tab'), @@ -1276,6 +1279,40 @@ class GenericSqlWorkspaceState extends material.State { ); } + /// The run controls of [session], shown in the tab strip. + material.Widget _buildToolbar( + material.BuildContext context, + SqlQueryTabSession session, + ) { + return SqlWorkspaceToolbar( + session: session, + delegate: widget.delegate, + effectiveDatabase: effectiveDatabase, + autocommit: _autocommit, + supportsAutocommit: widget.supportsAutocommit, + supportsStmtTimeout: widget.supportsStmtTimeout, + txOpen: _txOpen, + queryTimeoutSeconds: _queryTimeoutSeconds, + headerBadge: widget.headerBadge, + extraTrailing: widget.extraToolbarTrailing?.call(context, session), + historyEnabled: widget.connectionRow.id != null && !session.running, + onExecute: (statementAtCursor) => + unawaited(execute(session, statementAtCursor)), + onExplain: () => unawaited(explain(session)), + onCancel: () => unawaited(cancelRunning(session)), + onOpenHistory: () => _openHistory(session), + onOpenFile: () => unawaited(openSqlFile()), + onSaveFile: () => unawaited(saveSqlFile()), + onRunTxCommand: (cmd) => unawaited(runTxCommand(cmd)), + onToggleAutocommit: () { + invalidateAllPanes(); + setState(() => _autocommit = !_autocommit); + widget.onAutocommitChanged?.call(_autocommit); + }, + onTimeoutChanged: _onStmtTimeoutChanged, + ); + } + material.Widget _buildSessionPane( material.BuildContext context, SqlQueryTabSession session, @@ -1306,45 +1343,9 @@ class GenericSqlWorkspaceState extends material.State { key: material.ValueKey(session.id), fraction: session.topFraction, maxFraction: 0.85, - top: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - _SqlWorkspaceToolbarContainer( - child: SqlWorkspaceToolbar( - session: session, - delegate: widget.delegate, - effectiveDatabase: effectiveDatabase, - autocommit: _autocommit, - supportsAutocommit: widget.supportsAutocommit, - supportsStmtTimeout: widget.supportsStmtTimeout, - txOpen: _txOpen, - queryTimeoutSeconds: _queryTimeoutSeconds, - headerBadge: widget.headerBadge, - extraTrailing: widget.extraToolbarTrailing?.call(context, session), - historyEnabled: widget.connectionRow.id != null && !session.running, - onExecute: (statementAtCursor) => unawaited(execute(session, statementAtCursor)), - onExplain: () => unawaited(explain(session)), - onCancel: () => unawaited(cancelRunning(session)), - onOpenHistory: () => _openHistory(session), - onOpenFile: () => unawaited(openSqlFile()), - onSaveFile: () => unawaited(saveSqlFile()), - onRunTxCommand: (cmd) => unawaited(runTxCommand(cmd)), - onToggleAutocommit: () { - invalidateAllPanes(); - setState(() => _autocommit = !_autocommit); - widget.onAutocommitChanged?.call(_autocommit); - }, - onTimeoutChanged: _onStmtTimeoutChanged, - ), - ), - const QueryaDivider(height: 1), - Expanded( - child: QueryEditorTab( - controller: session.controller, - fontSize: _editorFontSize, - ), - ), - ], + top: QueryEditorTab( + controller: session.controller, + fontSize: _editorFontSize, ), bottom: material.Padding( // The results sit in a card like the editor above them. @@ -1402,18 +1403,3 @@ class GenericSqlWorkspaceState extends material.State { ); } } - -class _SqlWorkspaceToolbarContainer extends StatelessWidget { - const _SqlWorkspaceToolbarContainer({required this.child}); - - final material.Widget child; - - @override - material.Widget build(material.BuildContext context) { - return material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), - decoration: SqlEditorChrome.sqlToolbarDecoration(context), - child: child, - ); - } -} diff --git a/lib/features/workspace/sql_query_tab_bar.dart b/lib/features/workspace/sql_query_tab_bar.dart index f473a7bf..7824b73d 100644 --- a/lib/features/workspace/sql_query_tab_bar.dart +++ b/lib/features/workspace/sql_query_tab_bar.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:flutter/widgets.dart'; import 'package:flutter/material.dart' as material; import 'package:querya_desktop/features/workspace/sql_editor_chrome.dart'; @@ -14,6 +16,7 @@ class SqlQueryTabBar extends material.StatelessWidget { required this.onSelect, required this.onAdd, this.onClose, + this.actions, this.trailing, }); @@ -23,33 +26,76 @@ class SqlQueryTabBar extends material.StatelessWidget { final material.VoidCallback onAdd; final material.ValueChanged? onClose; + /// The run controls of the active query tab, right of the tabs. They get + /// what the tabs leave ([minTabsWidth] stays with the tabs), so the strip is + /// the only control row above the editor (#1558). + final material.Widget? actions; + /// Shown at the right end of the strip (the schema diagram button). final material.Widget? trailing; + /// Width kept for the tabs when the actions are wide: at least this much, + /// and enough for every tab and the add button as tabs are added, up to + /// [maxTabsShare] of the strip, so a new tab and the add button are not pushed + /// out of view by the controls. + static const double minTabsWidth = 180; + static const double maxTabsShare = 0.6; + @override material.Widget build(material.BuildContext context) { - // The same strip as the toolbar under it: workbench surface, hairline. + // The same surface as the rest of the SQL pane: workbench surface, hairline. return material.Container( height: 40, padding: const material.EdgeInsets.symmetric(horizontal: 8), decoration: SqlEditorChrome.sqlToolbarDecoration(context), - child: material.Row( - children: [ - material.Expanded( - child: QueryaTabStrip( - labels: sessions.map((s) => s.title).toList(), - selectedIndex: selectedIndex, - onSelected: onSelect, - onClose: onClose, - onAdd: onAdd, - canClose: sessions.length > 1 ? (_) => true : (_) => false, + child: material.LayoutBuilder( + builder: (context, constraints) { + final tabsWidth = math.min( + math.max( + minTabsWidth, + QueryaTabStrip.naturalWidth( + [for (final s in sessions) s.title], + closable: sessions.length > 1, + ), ), - ), - if (trailing != null) ...[ - const material.SizedBox(width: 8), - trailing!, - ], - ], + constraints.maxWidth * maxTabsShare, + ); + final tail = material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + // Flexible: the actions are measured against what is left after + // the trailing button, and size their labels to it. + if (actions != null) material.Flexible(child: actions!), + if (actions != null && trailing != null) + const material.SizedBox(width: 8), + if (trailing != null) trailing!, + ], + ); + return material.Row( + children: [ + material.Expanded( + child: QueryaTabStrip( + labels: sessions.map((s) => s.title).toList(), + selectedIndex: selectedIndex, + onSelected: onSelect, + onClose: onClose, + onAdd: onAdd, + canClose: sessions.length > 1 ? (_) => true : (_) => false, + ), + ), + if (actions != null || trailing != null) ...[ + const material.SizedBox(width: 8), + material.ConstrainedBox( + constraints: material.BoxConstraints( + maxWidth: (constraints.maxWidth - tabsWidth - 8) + .clamp(0.0, double.infinity), + ), + child: tail, + ), + ], + ], + ); + }, ), ); } diff --git a/lib/features/workspace/sql_workspace_toolbar.dart b/lib/features/workspace/sql_workspace_toolbar.dart index a602c56a..9b873203 100644 --- a/lib/features/workspace/sql_workspace_toolbar.dart +++ b/lib/features/workspace/sql_workspace_toolbar.dart @@ -31,7 +31,8 @@ class OpenPreferencesChoice extends SqlWorkspaceSessionChoice { const OpenPreferencesChoice(); } -/// Standalone single-row toolbar for SQL workspaces. +/// The run controls of a SQL workspace. They sit right of the query tabs in +/// the tab strip, so they size themselves to the width the tabs leave (#1558). /// /// Encapsulates statement and script execution controls, explain/cancel actions, /// history, format, file operations, active database/transaction indicators, @@ -84,9 +85,10 @@ class SqlWorkspaceToolbar extends StatelessWidget { final void Function(int? seconds) onTimeoutChanged; /// Below this width only the run button keeps its label; the rest are - /// icons. Below [iconOnlyWidth] the run button is an icon too. - static const double labelsWidth = 900; - static const double iconOnlyWidth = 620; + /// icons. Below [iconOnlyWidth] the run button is an icon too. The widths are + /// of the room the tab strip leaves, not of the window. + static const double labelsWidth = 640; + static const double iconOnlyWidth = 420; static String get _modLabel => Platform.isMacOS ? '⌘' : 'Ctrl+'; List> _sessionMenuItems() => [ diff --git a/lib/shared/widgets/querya_tab_strip.dart b/lib/shared/widgets/querya_tab_strip.dart index 7c5ffc0e..a31800e8 100644 --- a/lib/shared/widgets/querya_tab_strip.dart +++ b/lib/shared/widgets/querya_tab_strip.dart @@ -39,6 +39,39 @@ class QueryaTabStrip extends material.StatefulWidget { final bool Function(int index)? canClose; final material.ScrollController? scrollController; + /// Width the strip needs to show every tab and the add button without + /// scrolling: each tab at its widest (selected, with its close target) and + /// the add button. Mirrors the metrics of the tabs below; a test keeps the + /// two in step. + static double naturalWidth( + List labels, { + required bool closable, + bool addButton = true, + }) { + var width = 0.0; + for (var i = 0; i < labels.length; i++) { + final text = material.TextPainter( + text: material.TextSpan( + text: labels[i], + style: const material.TextStyle( + fontSize: 14, + height: 20 / 14, + fontWeight: material.FontWeight.w600, + ), + ), + textDirection: material.TextDirection.ltr, + maxLines: 1, + )..layout(); + // Border 2 + 2, side padding, the 2 px gap and 24 px close target, and 4 px + // of slack: the measured strip is ~2 px per tab wider than the sum. + width += text.width + 4 + 4 + (closable ? 10 + 2 + 24 + 5 : 24); + if (i > 0) width += 6; + } + // 4 px gap and the add button: 16 px icon with 6 px padding. + if (addButton) width += 4 + 28; + return width; + } + /// Tighter strip for toolbars: the same as [QueryaControlSize.sm], as tall /// as a dense icon button instead of a full tab row. final bool dense; diff --git a/test/features/workspace/multi_tab_sql_workspace_test.dart b/test/features/workspace/multi_tab_sql_workspace_test.dart index 2b10c608..a2a12909 100644 --- a/test/features/workspace/multi_tab_sql_workspace_test.dart +++ b/test/features/workspace/multi_tab_sql_workspace_test.dart @@ -4,6 +4,7 @@ import 'package:querya_desktop/core/actions/sql_editor_command_bridge.dart'; import 'package:querya_desktop/features/workspace/data_grid_staging_buffer.dart'; import 'package:querya_desktop/features/workspace/sql_query_tab_bar.dart'; import 'package:querya_desktop/features/workspace/sql_query_tab_session.dart'; +import 'package:querya_desktop/shared/widgets/querya_tab_strip.dart'; import '../../support/querya_theme_test_shell.dart'; @@ -154,6 +155,117 @@ void main() { tab2.dispose(); }); + testWidgets('actions sit right of the tabs and leave them their width (#1558)', + (tester) async { + final tab = SqlQueryTabSession(id: '1', title: 'Orders'); + double? seenMaxWidth; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.SizedBox( + width: 600, + child: SqlQueryTabBar( + sessions: [tab], + selectedIndex: 0, + onSelect: (_) {}, + onAdd: () {}, + actions: material.LayoutBuilder( + builder: (context, constraints) { + seenMaxWidth = constraints.maxWidth; + return const material.SizedBox( + key: material.ValueKey('wide_actions'), + width: 900, + height: 32, + ); + }, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + // 600 less the strip padding, the gap and the width kept for the tabs. + expect(seenMaxWidth, lessThanOrEqualTo(600 - SqlQueryTabBar.minTabsWidth)); + final actions = tester.getRect(find.byKey(const material.ValueKey('wide_actions'))); + final tabs = tester.getRect(find.text('Orders')); + expect(actions.left, greaterThanOrEqualTo(tabs.right)); + + tab.dispose(); + }); + + testWidgets('QueryaTabStrip.naturalWidth is what the strip really takes', + (tester) async { + final titles = ['Query 1', 'Orders by day', 'x']; + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.Align( + alignment: material.Alignment.topLeft, + child: material.SizedBox( + width: 1200, + child: QueryaTabStrip( + labels: titles, + selectedIndex: 1, + onSelected: (_) {}, + onAdd: () {}, + onClose: (_) {}, + canClose: (_) => true, + ), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + final strip = tester.getRect(find.byType(QueryaTabStrip)); + final add = tester.getRect( + find.byKey(const material.ValueKey('querya_tab_add_button'))); + // The widest case: the selected tab is the one measured at w600, and the + // others are narrower, so the estimate is never short. + final real = add.right - strip.left; + final natural = QueryaTabStrip.naturalWidth(titles, closable: true); + expect(natural, greaterThanOrEqualTo(real - 0.5)); + expect(natural, lessThan(real + 12 * titles.length)); + }); + + testWidgets('wide actions never push the add button out of reach (#1558)', + (tester) async { + final tabs = [ + for (var i = 1; i <= 3; i++) + SqlQueryTabSession(id: '$i', title: 'Query $i'), + ]; + var added = 0; + + await tester.pumpWidget( + queryaThemeTestShell( + child: material.Scaffold( + body: material.SizedBox( + width: 800, + child: SqlQueryTabBar( + sessions: tabs, + selectedIndex: 0, + onSelect: (_) {}, + onAdd: () => added++, + actions: const material.SizedBox(width: 900, height: 32), + ), + ), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const material.ValueKey('querya_tab_add_button'))); + expect(added, 1); + + for (final t in tabs) { + t.dispose(); + } + }); + testWidgets('shows unsaved tab changes dialog when closing dirty tab', (tester) async { final buffer = DataGridStagingBuffer( columns: ['id', 'name'], diff --git a/test/guards/sql_workspace_chrome_guards_test.dart b/test/guards/sql_workspace_chrome_guards_test.dart index 585db10d..8b609862 100644 --- a/test/guards/sql_workspace_chrome_guards_test.dart +++ b/test/guards/sql_workspace_chrome_guards_test.dart @@ -42,10 +42,11 @@ void main() { expect(explain, isNot(diagram)); }); - test('the tab strip and the toolbar share one surface decoration', () { + test('the run controls live in the tab strip, not in a row of their own', () { expect(code('lib/features/workspace/sql_query_tab_bar.dart'), contains('SqlEditorChrome.sqlToolbarDecoration')); - expect(code('lib/features/workspace/generic_sql_workspace.dart'), - contains('SqlEditorChrome.sqlToolbarDecoration')); + final workspace = code('lib/features/workspace/generic_sql_workspace.dart'); + expect(workspace, isNot(contains('_SqlWorkspaceToolbarContainer'))); + expect(workspace, contains('actions: _activeSession.isDiagram')); }); }