From b4d442a5179cc38db1954d0c57883f781aaca192 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 25 Sep 2026 12:30:16 +0300 Subject: [PATCH 1/2] fix(tabs): wrap QueryaTabStrip in horizontal scrollable and preserve GlobalKeys on tab count change (Closes #898) --- .../extensions/extension_workspace_home.dart | 14 +- lib/features/mysql/mysql_workspace_home.dart | 14 +- .../postgresql/postgres_workspace_home.dart | 14 +- .../sqlite/sqlite_workspace_home.dart | 14 +- lib/features/workspace/sql_query_tab_bar.dart | 17 +- lib/shared/widgets/querya_tab_strip.dart | 101 ++++++++- test/shared/querya_tab_strip_test.dart | 200 +++++++++++++++++- 7 files changed, 334 insertions(+), 40 deletions(-) diff --git a/lib/features/extensions/extension_workspace_home.dart b/lib/features/extensions/extension_workspace_home.dart index bef8c0f..ce6d7cc 100644 --- a/lib/features/extensions/extension_workspace_home.dart +++ b/lib/features/extensions/extension_workspace_home.dart @@ -119,11 +119,15 @@ class _ExtensionWorkspaceHomeState child: Text('Return to ${widget.lastSelectedExtensionObject!.name}'), ), ], - const Spacer(), - QueryaTabStrip( - labels: const ['Overview', 'SQL'], - selectedIndex: _tab, - onSelected: (index) => unawaited(_selectTab(index)), + Expanded( + child: material.Align( + alignment: material.Alignment.centerRight, + child: QueryaTabStrip( + labels: const ['Overview', 'SQL'], + selectedIndex: _tab, + onSelected: (index) => unawaited(_selectTab(index)), + ), + ), ), ], ), diff --git a/lib/features/mysql/mysql_workspace_home.dart b/lib/features/mysql/mysql_workspace_home.dart index 7d8f982..0e0194b 100644 --- a/lib/features/mysql/mysql_workspace_home.dart +++ b/lib/features/mysql/mysql_workspace_home.dart @@ -120,11 +120,15 @@ class _MysqlWorkspaceHomeState extends material.State { Text('Return to ${widget.lastSelectedMysqlObject!.name}'), ), ], - const Spacer(), - QueryaTabStrip( - labels: const ['Overview', 'SQL'], - selectedIndex: _tab, - onSelected: (index) => unawaited(_selectTab(index)), + Expanded( + child: material.Align( + alignment: material.Alignment.centerRight, + child: QueryaTabStrip( + labels: const ['Overview', 'SQL'], + selectedIndex: _tab, + onSelected: (index) => unawaited(_selectTab(index)), + ), + ), ), ], ), diff --git a/lib/features/postgresql/postgres_workspace_home.dart b/lib/features/postgresql/postgres_workspace_home.dart index 53a6de0..b344322 100644 --- a/lib/features/postgresql/postgres_workspace_home.dart +++ b/lib/features/postgresql/postgres_workspace_home.dart @@ -134,11 +134,15 @@ class _PostgresWorkspaceHomeState child: Text('Return to ${widget.lastSelectedPostgresObject!.name}'), ), ], - const Spacer(), - QueryaTabStrip( - labels: const ['Overview', 'SQL'], - selectedIndex: _tab, - onSelected: (index) => unawaited(_selectTab(index)), + Expanded( + child: material.Align( + alignment: material.Alignment.centerRight, + child: QueryaTabStrip( + labels: const ['Overview', 'SQL'], + selectedIndex: _tab, + onSelected: (index) => unawaited(_selectTab(index)), + ), + ), ), ], ), diff --git a/lib/features/sqlite/sqlite_workspace_home.dart b/lib/features/sqlite/sqlite_workspace_home.dart index 19aefd4..eb19ed7 100644 --- a/lib/features/sqlite/sqlite_workspace_home.dart +++ b/lib/features/sqlite/sqlite_workspace_home.dart @@ -112,11 +112,15 @@ class _SqliteWorkspaceHomeState extends material.State { 'Return to ${widget.lastSelectedSqliteObject!.name}'), ), ], - const Spacer(), - QueryaTabStrip( - labels: const ['Overview', 'SQL'], - selectedIndex: _tab, - onSelected: _selectTab, + Expanded( + child: material.Align( + alignment: material.Alignment.centerRight, + child: QueryaTabStrip( + labels: const ['Overview', 'SQL'], + selectedIndex: _tab, + onSelected: _selectTab, + ), + ), ), ], ), diff --git a/lib/features/workspace/sql_query_tab_bar.dart b/lib/features/workspace/sql_query_tab_bar.dart index 1249411..f05159d 100644 --- a/lib/features/workspace/sql_query_tab_bar.dart +++ b/lib/features/workspace/sql_query_tab_bar.dart @@ -36,16 +36,13 @@ class SqlQueryTabBar extends material.StatelessWidget { child: material.Row( children: [ material.Expanded( - child: material.SingleChildScrollView( - scrollDirection: material.Axis.horizontal, - child: QueryaTabStrip( - labels: sessions.map((s) => s.title).toList(), - selectedIndex: selectedIndex, - onSelected: onSelect, - onClose: onClose, - onAdd: onAdd, - canClose: sessions.length > 1 ? (_) => true : (_) => false, - ), + child: QueryaTabStrip( + labels: sessions.map((s) => s.title).toList(), + selectedIndex: selectedIndex, + onSelected: onSelect, + onClose: onClose, + onAdd: onAdd, + canClose: sessions.length > 1 ? (_) => true : (_) => false, ), ), ], diff --git a/lib/shared/widgets/querya_tab_strip.dart b/lib/shared/widgets/querya_tab_strip.dart index 698bec2..e379d09 100644 --- a/lib/shared/widgets/querya_tab_strip.dart +++ b/lib/shared/widgets/querya_tab_strip.dart @@ -1,3 +1,4 @@ +import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart' as material; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; @@ -23,6 +24,7 @@ class QueryaTabStrip extends material.StatefulWidget { this.onClose, this.onAdd, this.canClose, + this.scrollController, }) : assert(labels.length > 0); final List labels; @@ -31,12 +33,13 @@ class QueryaTabStrip extends material.StatefulWidget { final material.ValueChanged? onClose; final material.VoidCallback? onAdd; final bool Function(int index)? canClose; + final material.ScrollController? scrollController; @override - material.State createState() => _QueryaTabStripState(); + material.State createState() => QueryaTabStripState(); } -class _QueryaTabStripState extends material.State +class QueryaTabStripState extends material.State with material.TickerProviderStateMixin { late List _focusNodes; late List _focused; @@ -45,14 +48,32 @@ class _QueryaTabStripState extends material.State late final QueryaSpringController _indicatorLeft; late final QueryaSpringController _indicatorWidth; + material.ScrollController? _internalScrollController; + material.ScrollController get _effectiveScrollController => + widget.scrollController ?? + (_internalScrollController ??= material.ScrollController()); + var _indicatorReady = false; var _layoutScheduled = false; + @material.visibleForTesting + List get tabKeysForTesting => List.unmodifiable(_tabKeys); + + @material.visibleForTesting + bool get indicatorReadyForTesting => _indicatorReady; + + @material.visibleForTesting + material.ScrollController get scrollControllerForTesting => + _effectiveScrollController; + @override void initState() { super.initState(); _indicatorLeft = QueryaSpringController(vsync: this); _indicatorWidth = QueryaSpringController(vsync: this); + if (widget.scrollController == null) { + _internalScrollController = material.ScrollController(); + } _createFocusState(); } @@ -79,10 +100,16 @@ class _QueryaTabStripState extends material.State @override void didUpdateWidget(covariant QueryaTabStrip oldWidget) { super.didUpdateWidget(oldWidget); + if (oldWidget.scrollController != widget.scrollController) { + if (oldWidget.scrollController == null && widget.scrollController != null) { + _internalScrollController?.dispose(); + _internalScrollController = null; + } else if (oldWidget.scrollController != null && widget.scrollController == null) { + _internalScrollController = material.ScrollController(); + } + } if (oldWidget.labels.length != widget.labels.length) { - _disposeFocusNodes(); - _createFocusState(); - _indicatorReady = false; + _syncTabState(oldWidget.labels.length, widget.labels.length); } if (oldWidget.selectedIndex != widget.selectedIndex || oldWidget.labels.length != widget.labels.length || @@ -101,6 +128,25 @@ class _QueryaTabStripState extends material.State _tabKeys = List.generate(widget.labels.length, (_) => material.GlobalKey()); } + void _syncTabState(int oldLength, int newLength) { + if (newLength > oldLength) { + for (var i = oldLength; i < newLength; i++) { + _focusNodes.add( + material.FocusNode(debugLabel: 'Querya tab ${widget.labels[i]}'), + ); + _focused.add(false); + _tabKeys.add(material.GlobalKey()); + } + } else if (newLength < oldLength) { + for (var i = newLength; i < oldLength; i++) { + _focusNodes[i].dispose(); + } + _focusNodes.removeRange(newLength, oldLength); + _focused.removeRange(newLength, oldLength); + _tabKeys.removeRange(newLength, oldLength); + } + } + void _disposeFocusNodes() { for (final node in _focusNodes) { node.dispose(); @@ -112,6 +158,7 @@ class _QueryaTabStripState extends material.State _indicatorLeft.dispose(); _indicatorWidth.dispose(); _disposeFocusNodes(); + _internalScrollController?.dispose(); super.dispose(); } @@ -167,11 +214,17 @@ class _QueryaTabStripState extends material.State _indicatorLeft.jumpTo(offset.dx); _indicatorWidth.jumpTo(width); setState(() => _indicatorReady = true); - return; + } else { + _indicatorLeft.animateTo(offset.dx); + _indicatorWidth.animateTo(width); } - _indicatorLeft.animateTo(offset.dx); - _indicatorWidth.animateTo(width); + material.Scrollable.ensureVisible( + tabContext, + alignment: 0.5, + duration: context.motionDuration(QueryaMotion.fast), + curve: context.motionCurve(QueryaMotion.enter), + ); } @override @@ -293,7 +346,7 @@ class _QueryaTabStripState extends material.State ], ); - return material.KeyedSubtree( + final stripContent = material.KeyedSubtree( key: _stripKey, child: material.Stack( alignment: material.Alignment.centerLeft, @@ -308,6 +361,36 @@ class _QueryaTabStripState extends material.State ], ), ); + + return material.IntrinsicWidth( + child: material.Listener( + onPointerSignal: (signal) { + if (signal is PointerScrollEvent && + _effectiveScrollController.hasClients) { + if (signal.scrollDelta.dy != 0 && signal.scrollDelta.dx == 0) { + final target = (_effectiveScrollController.offset + + signal.scrollDelta.dy) + .clamp( + 0.0, + _effectiveScrollController.position.maxScrollExtent, + ); + if (target != _effectiveScrollController.offset) { + _effectiveScrollController.jumpTo(target); + } + } + } + }, + child: material.ScrollConfiguration( + behavior: + const material.ScrollBehavior().copyWith(scrollbars: false), + child: material.SingleChildScrollView( + controller: _effectiveScrollController, + scrollDirection: material.Axis.horizontal, + child: stripContent, + ), + ), + ), + ); } } diff --git a/test/shared/querya_tab_strip_test.dart b/test/shared/querya_tab_strip_test.dart index 3ef0399..51133b9 100644 --- a/test/shared/querya_tab_strip_test.dart +++ b/test/shared/querya_tab_strip_test.dart @@ -1,4 +1,4 @@ -import 'dart:ui' show Tristate; +import 'dart:ui' show PointerDeviceKind, Tristate; import 'package:flutter/material.dart' as material; import 'package:flutter/services.dart'; @@ -328,4 +328,202 @@ void main() { closeTo(sql.left - strip.left, 1.0), ); }); + + testWidgets( + 'preserves existing GlobalKey instances when tab count changes (incremental sync)', + (tester) async { + var labels = ['Server', 'SQL']; + var selected = 0; + + await tester.pumpWidget( + stripShell( + child: material.StatefulBuilder( + builder: (context, setState) => material.Center( + child: QueryaTabStrip( + labels: labels, + selectedIndex: selected, + onSelected: (index) => setState(() => selected = index), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pumpAndSettle(); + + final state = tester.state(find.byType(QueryaTabStrip)); + final initialKeys = List.from(state.tabKeysForTesting); + expect(initialKeys.length, 2); + expect(state.indicatorReadyForTesting, isTrue); + + // Add a third tab + labels = ['Server', 'SQL', 'History']; + await tester.pumpWidget( + stripShell( + child: material.StatefulBuilder( + builder: (context, setState) => material.Center( + child: QueryaTabStrip( + labels: labels, + selectedIndex: selected, + onSelected: (index) => setState(() => selected = index), + ), + ), + ), + ), + ); + await tester.pump(); + + // Verify existing keys were preserved, new key appended, and indicatorReady stayed true + final updatedKeys = state.tabKeysForTesting; + expect(updatedKeys.length, 3); + expect(updatedKeys[0], same(initialKeys[0])); + expect(updatedKeys[1], same(initialKeys[1])); + expect(updatedKeys[2], isNot(same(initialKeys[0]))); + expect(updatedKeys[2], isNot(same(initialKeys[1]))); + expect(state.indicatorReadyForTesting, isTrue); + + // Remove tabs down to 1 + labels = ['Server']; + await tester.pumpWidget( + stripShell( + child: material.StatefulBuilder( + builder: (context, setState) => material.Center( + child: QueryaTabStrip( + labels: labels, + selectedIndex: 0, + onSelected: (index) => setState(() => selected = index), + ), + ), + ), + ), + ); + await tester.pump(); + + final singleKeyList = state.tabKeysForTesting; + expect(singleKeyList.length, 1); + expect(singleKeyList[0], same(initialKeys[0])); + expect(state.indicatorReadyForTesting, isTrue); + }); + + testWidgets( + 'handles narrow width constraints without RenderFlex overflow and scrolls horizontally', + (tester) async { + final scrollController = material.ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + stripShell( + child: material.Center( + child: material.SizedBox( + width: 120, + child: QueryaTabStrip( + scrollController: scrollController, + labels: const ['First Long Tab', 'Second Long Tab', 'Third Long Tab'], + selectedIndex: 0, + onSelected: (_) {}, + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pumpAndSettle(); + + // No overflow occurred (Flutter test framework fails if RenderFlex overflowed) + expect(find.byType(QueryaTabStrip), findsOneWidget); + expect(scrollController.hasClients, isTrue); + expect(scrollController.position.maxScrollExtent, greaterThan(0.0)); + }); + + testWidgets('supports pointer scroll event for mouse wheel scrolling', + (tester) async { + final scrollController = material.ScrollController(); + addTearDown(scrollController.dispose); + + await tester.pumpWidget( + stripShell( + child: material.Center( + child: material.SizedBox( + width: 120, + child: QueryaTabStrip( + scrollController: scrollController, + labels: const ['First Long Tab', 'Second Long Tab', 'Third Long Tab'], + selectedIndex: 0, + onSelected: (_) {}, + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(scrollController.offset, 0.0); + + // Send vertical mouse wheel scroll + final center = tester.getCenter(find.byType(QueryaTabStrip)); + final pointer = TestPointer(1, PointerDeviceKind.mouse); + await tester.sendEventToBinding( + pointer.hover(center), + ); + await tester.sendEventToBinding( + pointer.scroll(const material.Offset(0, 40)), + ); + await tester.pump(); + + expect(scrollController.offset, 40.0); + }); + + testWidgets('auto-scrolls to selected tab when selection changes', + (tester) async { + final scrollController = material.ScrollController(); + addTearDown(scrollController.dispose); + var selected = 0; + + await tester.pumpWidget( + stripShell( + child: material.StatefulBuilder( + builder: (context, setState) => material.Center( + child: material.SizedBox( + width: 120, + child: QueryaTabStrip( + scrollController: scrollController, + labels: const ['First Long Tab', 'Second Long Tab', 'Third Long Tab'], + selectedIndex: selected, + onSelected: (i) => setState(() => selected = i), + ), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(scrollController.offset, 0.0); + + // Switch selection to the third tab + selected = 2; + await tester.pumpWidget( + stripShell( + child: material.StatefulBuilder( + builder: (context, setState) => material.Center( + child: material.SizedBox( + width: 120, + child: QueryaTabStrip( + scrollController: scrollController, + labels: const ['First Long Tab', 'Second Long Tab', 'Third Long Tab'], + selectedIndex: selected, + onSelected: (i) => setState(() => selected = i), + ), + ), + ), + ), + ), + ); + await tester.pump(); + await tester.pumpAndSettle(); + + expect(scrollController.offset, greaterThan(0.0)); + }); } From 00a6a175ec6a9fd775db71a3d49e027a158a6aca Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 25 Sep 2026 12:39:07 +0300 Subject: [PATCH 2/2] fix(tabs): resolve growable list, parent data, and alignment policy issues --- .../extensions/extension_workspace_home.dart | 2 +- lib/features/mysql/mysql_workspace_home.dart | 2 +- lib/features/postgresql/postgres_workspace_home.dart | 2 +- lib/features/sqlite/sqlite_workspace_home.dart | 2 +- lib/shared/widgets/querya_tab_strip.dart | 4 ++-- test/shared/querya_tab_strip_test.dart | 12 ++++++------ 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/lib/features/extensions/extension_workspace_home.dart b/lib/features/extensions/extension_workspace_home.dart index ce6d7cc..1cc9cb9 100644 --- a/lib/features/extensions/extension_workspace_home.dart +++ b/lib/features/extensions/extension_workspace_home.dart @@ -119,7 +119,7 @@ class _ExtensionWorkspaceHomeState child: Text('Return to ${widget.lastSelectedExtensionObject!.name}'), ), ], - Expanded( + material.Expanded( child: material.Align( alignment: material.Alignment.centerRight, child: QueryaTabStrip( diff --git a/lib/features/mysql/mysql_workspace_home.dart b/lib/features/mysql/mysql_workspace_home.dart index 0e0194b..6299954 100644 --- a/lib/features/mysql/mysql_workspace_home.dart +++ b/lib/features/mysql/mysql_workspace_home.dart @@ -120,7 +120,7 @@ class _MysqlWorkspaceHomeState extends material.State { Text('Return to ${widget.lastSelectedMysqlObject!.name}'), ), ], - Expanded( + material.Expanded( child: material.Align( alignment: material.Alignment.centerRight, child: QueryaTabStrip( diff --git a/lib/features/postgresql/postgres_workspace_home.dart b/lib/features/postgresql/postgres_workspace_home.dart index b344322..adf0ae6 100644 --- a/lib/features/postgresql/postgres_workspace_home.dart +++ b/lib/features/postgresql/postgres_workspace_home.dart @@ -134,7 +134,7 @@ class _PostgresWorkspaceHomeState child: Text('Return to ${widget.lastSelectedPostgresObject!.name}'), ), ], - Expanded( + material.Expanded( child: material.Align( alignment: material.Alignment.centerRight, child: QueryaTabStrip( diff --git a/lib/features/sqlite/sqlite_workspace_home.dart b/lib/features/sqlite/sqlite_workspace_home.dart index eb19ed7..d66cf6a 100644 --- a/lib/features/sqlite/sqlite_workspace_home.dart +++ b/lib/features/sqlite/sqlite_workspace_home.dart @@ -112,7 +112,7 @@ class _SqliteWorkspaceHomeState extends material.State { 'Return to ${widget.lastSelectedSqliteObject!.name}'), ), ], - Expanded( + material.Expanded( child: material.Align( alignment: material.Alignment.centerRight, child: QueryaTabStrip( diff --git a/lib/shared/widgets/querya_tab_strip.dart b/lib/shared/widgets/querya_tab_strip.dart index e379d09..a451b07 100644 --- a/lib/shared/widgets/querya_tab_strip.dart +++ b/lib/shared/widgets/querya_tab_strip.dart @@ -124,7 +124,7 @@ class QueryaTabStripState extends material.State (index) => material.FocusNode(debugLabel: 'Querya tab ${widget.labels[index]}'), ); - _focused = List.filled(widget.labels.length, false); + _focused = List.filled(widget.labels.length, false, growable: true); _tabKeys = List.generate(widget.labels.length, (_) => material.GlobalKey()); } @@ -221,7 +221,7 @@ class QueryaTabStripState extends material.State material.Scrollable.ensureVisible( tabContext, - alignment: 0.5, + alignmentPolicy: material.ScrollPositionAlignmentPolicy.keepVisibleAtEnd, duration: context.motionDuration(QueryaMotion.fast), curve: context.motionCurve(QueryaMotion.enter), ); diff --git a/test/shared/querya_tab_strip_test.dart b/test/shared/querya_tab_strip_test.dart index 51133b9..2e53003 100644 --- a/test/shared/querya_tab_strip_test.dart +++ b/test/shared/querya_tab_strip_test.dart @@ -418,7 +418,7 @@ void main() { width: 120, child: QueryaTabStrip( scrollController: scrollController, - labels: const ['First Long Tab', 'Second Long Tab', 'Third Long Tab'], + labels: const ['Tab 1', 'Tab 2', 'Tab 3', 'Tab 4'], selectedIndex: 0, onSelected: (_) {}, ), @@ -447,7 +447,7 @@ void main() { width: 120, child: QueryaTabStrip( scrollController: scrollController, - labels: const ['First Long Tab', 'Second Long Tab', 'Third Long Tab'], + labels: const ['Tab 1', 'Tab 2', 'Tab 3', 'Tab 4'], selectedIndex: 0, onSelected: (_) {}, ), @@ -488,7 +488,7 @@ void main() { width: 120, child: QueryaTabStrip( scrollController: scrollController, - labels: const ['First Long Tab', 'Second Long Tab', 'Third Long Tab'], + labels: const ['Tab 1', 'Tab 2', 'Tab 3', 'Tab 4'], selectedIndex: selected, onSelected: (i) => setState(() => selected = i), ), @@ -502,8 +502,8 @@ void main() { expect(scrollController.offset, 0.0); - // Switch selection to the third tab - selected = 2; + // Switch selection to the fourth tab + selected = 3; await tester.pumpWidget( stripShell( child: material.StatefulBuilder( @@ -512,7 +512,7 @@ void main() { width: 120, child: QueryaTabStrip( scrollController: scrollController, - labels: const ['First Long Tab', 'Second Long Tab', 'Third Long Tab'], + labels: const ['Tab 1', 'Tab 2', 'Tab 3', 'Tab 4'], selectedIndex: selected, onSelected: (i) => setState(() => selected = i), ),