From 05e5e28cea7e32ce3bb45fa92d0a47ef828e3824 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 23 Oct 2026 11:44:24 +0300 Subject: [PATCH 1/4] ux(sql-workspace): the run controls move into the query tab strip; one control row above the editor (#1558) The toolbar was its own 50 px row under the tab strip. It now sits right of the tabs (tabs keep at least 180 px, the controls get the rest and give up their labels as before). Diagram tabs show only the Diagram button. --- .../workspace/generic_sql_workspace.dart | 94 ++++++++----------- lib/features/workspace/sql_query_tab_bar.dart | 66 +++++++++---- .../workspace/sql_workspace_toolbar.dart | 10 +- .../multi_tab_sql_workspace_test.dart | 41 ++++++++ .../sql_workspace_chrome_guards_test.dart | 7 +- 5 files changed, 139 insertions(+), 79 deletions(-) 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..62184218 100644 --- a/lib/features/workspace/sql_query_tab_bar.dart +++ b/lib/features/workspace/sql_query_tab_bar.dart @@ -14,6 +14,7 @@ class SqlQueryTabBar extends material.StatelessWidget { required this.onSelect, required this.onAdd, this.onClose, + this.actions, this.trailing, }); @@ -23,33 +24,62 @@ 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. + static const double minTabsWidth = 180; + @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, - ), - ), - if (trailing != null) ...[ - const material.SizedBox(width: 8), - trailing!, - ], - ], + child: material.LayoutBuilder( + builder: (context, constraints) { + 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 - minTabsWidth - 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/test/features/workspace/multi_tab_sql_workspace_test.dart b/test/features/workspace/multi_tab_sql_workspace_test.dart index 2b10c608..f4f83dca 100644 --- a/test/features/workspace/multi_tab_sql_workspace_test.dart +++ b/test/features/workspace/multi_tab_sql_workspace_test.dart @@ -154,6 +154,47 @@ 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('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')); }); } From 1114b152ef9c0d2b30478c3067ec8aa89778b03a Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 23 Oct 2026 11:51:06 +0300 Subject: [PATCH 2/4] fix(sql-workspace): the tab strip keeps room for every tab and the add button next to the run controls (#1558) --- lib/features/workspace/sql_query_tab_bar.dart | 18 ++++++++-- .../multi_tab_sql_workspace_test.dart | 34 +++++++++++++++++++ 2 files changed, 50 insertions(+), 2 deletions(-) diff --git a/lib/features/workspace/sql_query_tab_bar.dart b/lib/features/workspace/sql_query_tab_bar.dart index 62184218..2c1270f2 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'; @@ -32,8 +34,13 @@ class SqlQueryTabBar extends material.StatelessWidget { /// 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. + /// Width kept for the tabs when the actions are wide: at least this much, + /// and more as tabs are added ([tabSlotWidth] each, plus the add button), up + /// to half the strip, so a new tab and the add button are never pushed out of + /// view by the controls. static const double minTabsWidth = 180; + static const double tabSlotWidth = 120; + static const double addButtonWidth = 40; @override material.Widget build(material.BuildContext context) { @@ -44,6 +51,13 @@ class SqlQueryTabBar extends material.StatelessWidget { decoration: SqlEditorChrome.sqlToolbarDecoration(context), child: material.LayoutBuilder( builder: (context, constraints) { + final tabsWidth = math.min( + math.max( + minTabsWidth, + sessions.length * tabSlotWidth + addButtonWidth, + ), + constraints.maxWidth / 2, + ); final tail = material.Row( mainAxisSize: material.MainAxisSize.min, children: [ @@ -71,7 +85,7 @@ class SqlQueryTabBar extends material.StatelessWidget { const material.SizedBox(width: 8), material.ConstrainedBox( constraints: material.BoxConstraints( - maxWidth: (constraints.maxWidth - minTabsWidth - 8) + maxWidth: (constraints.maxWidth - tabsWidth - 8) .clamp(0.0, double.infinity), ), child: tail, diff --git a/test/features/workspace/multi_tab_sql_workspace_test.dart b/test/features/workspace/multi_tab_sql_workspace_test.dart index f4f83dca..5b1bfb77 100644 --- a/test/features/workspace/multi_tab_sql_workspace_test.dart +++ b/test/features/workspace/multi_tab_sql_workspace_test.dart @@ -195,6 +195,40 @@ void main() { tab.dispose(); }); + testWidgets('wide actions never push the add button out of reach (#1558)', + (tester) async { + final tabs = [ + for (var i = 1; i <= 5; 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'], From 2785343c9f85492402dfde50d695bb527e09875a Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 23 Oct 2026 12:00:50 +0300 Subject: [PATCH 3/4] fix(sql-workspace): the tab strip reserves the width its tabs really take (QueryaTabStrip.naturalWidth) (#1558) --- lib/features/workspace/sql_query_tab_bar.dart | 16 ++++---- lib/shared/widgets/querya_tab_strip.dart | 32 +++++++++++++++ .../multi_tab_sql_workspace_test.dart | 39 ++++++++++++++++++- 3 files changed, 79 insertions(+), 8 deletions(-) diff --git a/lib/features/workspace/sql_query_tab_bar.dart b/lib/features/workspace/sql_query_tab_bar.dart index 2c1270f2..7824b73d 100644 --- a/lib/features/workspace/sql_query_tab_bar.dart +++ b/lib/features/workspace/sql_query_tab_bar.dart @@ -35,12 +35,11 @@ class SqlQueryTabBar extends material.StatelessWidget { final material.Widget? trailing; /// Width kept for the tabs when the actions are wide: at least this much, - /// and more as tabs are added ([tabSlotWidth] each, plus the add button), up - /// to half the strip, so a new tab and the add button are never pushed out of - /// view by the controls. + /// 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 tabSlotWidth = 120; - static const double addButtonWidth = 40; + static const double maxTabsShare = 0.6; @override material.Widget build(material.BuildContext context) { @@ -54,9 +53,12 @@ class SqlQueryTabBar extends material.StatelessWidget { final tabsWidth = math.min( math.max( minTabsWidth, - sessions.length * tabSlotWidth + addButtonWidth, + QueryaTabStrip.naturalWidth( + [for (final s in sessions) s.title], + closable: sessions.length > 1, + ), ), - constraints.maxWidth / 2, + constraints.maxWidth * maxTabsShare, ); final tail = material.Row( mainAxisSize: material.MainAxisSize.min, diff --git a/lib/shared/widgets/querya_tab_strip.dart b/lib/shared/widgets/querya_tab_strip.dart index 7c5ffc0e..fa810969 100644 --- a/lib/shared/widgets/querya_tab_strip.dart +++ b/lib/shared/widgets/querya_tab_strip.dart @@ -39,6 +39,38 @@ 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. + width += text.width + 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 5b1bfb77..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'; @@ -195,10 +196,46 @@ void main() { 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 <= 5; i++) + for (var i = 1; i <= 3; i++) SqlQueryTabSession(id: '$i', title: 'Query $i'), ]; var added = 0; From ba8ce76666729ed296e97c39abc174213be4b132 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 23 Oct 2026 12:06:18 +0300 Subject: [PATCH 4/4] test(sql-workspace): naturalWidth keeps 4 px of slack per tab --- lib/shared/widgets/querya_tab_strip.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/lib/shared/widgets/querya_tab_strip.dart b/lib/shared/widgets/querya_tab_strip.dart index fa810969..a31800e8 100644 --- a/lib/shared/widgets/querya_tab_strip.dart +++ b/lib/shared/widgets/querya_tab_strip.dart @@ -62,8 +62,9 @@ class QueryaTabStrip extends material.StatefulWidget { textDirection: material.TextDirection.ltr, maxLines: 1, )..layout(); - // Border 2 + 2, side padding, the 2 px gap and 24 px close target. - width += text.width + 4 + (closable ? 10 + 2 + 24 + 5 : 24); + // 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.