diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index e6219d8c..8c83a172 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -902,6 +902,9 @@ class ConnectionsPanelState extends State { if (id == null) return tile; final colors = context.colors; return material.Draggable( + // Keyed by the connection: a row keeps its own expanded branches when + // another connection is inserted above it (#1821). + key: material.ValueKey('connection-$id'), data: id, maxSimultaneousDrags: 1, onDragStarted: () { @@ -1309,6 +1312,24 @@ class ConnectionsPanelState extends State { horizontal: 12, vertical: 12), sliver: material.SliverList( delegate: material.SliverChildBuilderDelegate( + findChildIndexCallback: (key) { + if (key is! material.ValueKey) return null; + final v = key.value; + final offset = showEmptyState ? 1 : 0; + if (v.startsWith('folder-')) { + final i = filteredFolders + .indexOf(v.substring('folder-'.length)); + return i < 0 ? null : i + offset; + } + if (v.startsWith('connection-')) { + final i = rootConnections.indexWhere( + (c) => 'connection-${c.id}' == v); + return i < 0 + ? null + : i + offset + filteredFolders.length; + } + return null; + }, (context, index) { if (showEmptyState && index == 0) { return material.Padding( @@ -1325,6 +1346,7 @@ class ConnectionsPanelState extends State { if (folderIndex < filteredFolders.length) { final name = filteredFolders[folderIndex]; return _FolderTile( + key: material.ValueKey('folder-$name'), name: name, initiallyExpanded: q.isNotEmpty || _expandedFolders.contains(name), diff --git a/lib/features/connections/connections_panel_sidebar.dart b/lib/features/connections/connections_panel_sidebar.dart index 9464f0e5..8154cafd 100644 --- a/lib/features/connections/connections_panel_sidebar.dart +++ b/lib/features/connections/connections_panel_sidebar.dart @@ -183,6 +183,7 @@ class _ConnectionTile extends StatelessWidget { class _FolderTile extends StatefulWidget { const _FolderTile({ + super.key, required this.name, required this.initiallyExpanded, required this.onExpansionCommitted, diff --git a/test/features/connections/connections_panel_keeps_rows_test.dart b/test/features/connections/connections_panel_keeps_rows_test.dart new file mode 100644 index 00000000..e164dcfc --- /dev/null +++ b/test/features/connections/connections_panel_keeps_rows_test.dart @@ -0,0 +1,106 @@ +import 'dart:io'; + +import 'package:flutter/material.dart' as material; +import 'package:flutter/material.dart' show MaterialApp; +import 'package:flutter_test/flutter_test.dart'; +import 'package:path_provider_platform_interface/path_provider_platform_interface.dart'; +import 'package:querya_desktop/core/storage/folders_storage.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connections_panel.dart'; + +import '../../support/querya_theme_test_shell.dart'; + +class _FakePathProvider extends PathProviderPlatform { + _FakePathProvider(this._root); + final String _root; + + @override + Future getApplicationSupportPath() async => _root; + + @override + Future getTemporaryPath() async => _root; + + @override + Future getApplicationDocumentsPath() async => _root; +} + +ConnectionRow _row(String name) => ConnectionRow( + type: 'postgresql', + name: name, + host: '127.0.0.1', + port: 5432, + createdAt: DateTime.now().toUtc().toIso8601String(), + ); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late Directory tempDir; + + setUpAll(() async { + tempDir = await Directory.systemTemp.createTemp('querya_rows_keep_test_'); + PathProviderPlatform.instance = _FakePathProvider(tempDir.path); + await LocalDb.initFfi(); + await LocalDb.instance.close(); + await FoldersStorage.instance.reload(); + }); + + tearDownAll(() async { + await LocalDb.instance.close(); + if (await tempDir.exists()) await tempDir.delete(recursive: true); + }); + + testWidgets( + 'a connection added above another leaves the other row\'s state alone ' + '(#1821)', (tester) async { + late int zetaId; + await tester.runAsync(() async { + zetaId = await LocalDb.instance.addConnection(_row('Zeta')); + }); + + await tester.binding.setSurfaceSize(const material.Size(400, 720)); + addTearDown(() => tester.binding.setSurfaceSize(null)); + await tester.pumpWidget( + MaterialApp( + theme: queryaDarkMaterialTheme, + darkTheme: queryaDarkMaterialTheme, + themeMode: material.ThemeMode.dark, + builder: (context, child) => material.Material(child: child), + home: material.SizedBox.expand( + child: ConnectionsPanel( + skipInitialDbLoadForTest: true, + onPostgresOpenSqlWorkspace: (_, {database, schema, name, kind}) {}, + ), + ), + ), + ); + final panel = + tester.state(find.byType(ConnectionsPanel)); + Future reload() async { + await tester.runAsync(() async { + await panel.reloadConnectionsFromDb(); + await Future.delayed(const Duration(milliseconds: 1)); + }); + await tester.pump(const Duration(milliseconds: 16)); + } + + await reload(); + final zeta = find.byKey(material.ValueKey('connection-$zetaId')); + final before = tester.state(zeta); + + await tester.runAsync(() async { + await LocalDb.instance.addConnection(_row('Alpha')); + }); + await reload(); + + expect(find.text('Alpha'), findsOneWidget); + expect(identical(tester.state(zeta), before), isTrue, + reason: 'the Zeta row was rebuilt as a new element'); + + await tester.runAsync(() async { + for (final c in await LocalDb.instance.getConnections()) { + await LocalDb.instance.removeConnection(c.id!); + } + }); + }); +}