From 33c04da035d5ea24060ae838ba2ea3d28f1c1764 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 12:26:23 +0300 Subject: [PATCH 01/11] feat(connections): drag a connection onto a folder, or onto the list to leave one Connection tiles are draggable (the dragged copy shows the name and type icon). A folder is a drop target and is highlighted while a connection that would change folder is over it; the list itself is a target meaning 'no folder'. A drop that changes nothing is refused. The folder opens to show what was dropped. Uses the same LocalDb.setConnectionFolder as the context menu's Move to folder. --- .../connections/connections_panel.dart | 75 ++++++++++++++++++- .../connections_panel_sidebar.dart | 32 ++++++++ .../e2e_connection_crud_test.dart | 50 +++++++++++++ 3 files changed, 156 insertions(+), 1 deletion(-) diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 2a61b307..28f06353 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -839,7 +839,74 @@ class ConnectionsPanelState extends State { } } + /// A connection tile that can be dragged onto a folder (or onto the list + /// itself to leave its folder). Widget _buildConnectionTile(ConnectionRow conn) { + final tile = _connectionTileCore(conn); + final id = conn.id; + if (id == null) return tile; + final theme = Theme.of(context); + return material.Draggable( + data: id, + maxSimultaneousDrags: 1, + dragAnchorStrategy: material.pointerDragAnchorStrategy, + feedback: material.Material( + color: material.Colors.transparent, + child: material.Container( + padding: + const material.EdgeInsets.symmetric(horizontal: 12, vertical: 8), + decoration: material.BoxDecoration( + color: theme.colorScheme.popover, + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all(color: theme.colorScheme.primary), + ), + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon(QueryaIcons.connectionIcon(conn.type), + size: 16, color: theme.colorScheme.primary), + const material.SizedBox(width: 8), + material.Text(conn.name, + style: material.TextStyle( + fontSize: 13, color: theme.colorScheme.foreground)), + ], + ), + ), + ), + childWhenDragging: material.Opacity(opacity: 0.4, child: tile), + child: tile, + ); + } + + ConnectionRow? _connectionById(int id) { + for (final c in _connections) { + if (c.id == id) return c; + } + return null; + } + + /// Whether dropping connection [id] on [folderName] (null: the list itself, + /// meaning "no folder") would change anything. + bool _acceptsDrop(int id, String? folderName) { + final conn = _connectionById(id); + if (conn == null) return false; + final target = folderName == null ? null : _folderIdByName[folderName]; + if (folderName != null && target == null) return false; + return conn.folderId != target; + } + + Future _dropConnection(int id, String? folderName) async { + final conn = _connectionById(id); + if (conn == null || !_acceptsDrop(id, folderName)) return; + final target = folderName == null ? null : _folderIdByName[folderName]; + await _moveConnection(conn, target); + if (folderName != null && mounted) { + // Show what was dropped. + setState(() => _expandedFolders.add(folderName)); + } + } + + Widget _connectionTileCore(ConnectionRow conn) { final isSelected = widget.selectedConnectionId != null && widget.selectedConnectionId == conn.id; final isExpanded = _expandedConnections.contains(conn.id); @@ -1144,7 +1211,10 @@ class ConnectionsPanelState extends State { ), Expanded( child: material.RepaintBoundary( - child: material.CustomScrollView( + child: material.DragTarget( + onWillAcceptWithDetails: (d) => _acceptsDrop(d.data, null), + onAcceptWithDetails: (d) => _dropConnection(d.data, null), + builder: (context, candidates, _) => material.CustomScrollView( slivers: [ material.SliverPadding( padding: const material.EdgeInsets.symmetric( @@ -1182,6 +1252,8 @@ class ConnectionsPanelState extends State { c.folderId == _folderIdByName[name]) .toList(), onRemove: () => _removeFolder(name), + acceptsDrop: (id) => _acceptsDrop(id, name), + onDrop: (id) => _dropConnection(id, name), onNewConnection: (folderName) async { final folderId = await LocalDb.instance .getFolderIdByName(folderName); @@ -1249,6 +1321,7 @@ class ConnectionsPanelState extends State { ), ], ), + ), ), ), ], diff --git a/lib/features/connections/connections_panel_sidebar.dart b/lib/features/connections/connections_panel_sidebar.dart index fe2a30a7..0025b00b 100644 --- a/lib/features/connections/connections_panel_sidebar.dart +++ b/lib/features/connections/connections_panel_sidebar.dart @@ -188,12 +188,20 @@ class _FolderTile extends StatefulWidget { required this.onNewConnection, required this.iconForType, required this.onRemoveConnection, + this.acceptsDrop, + this.onDrop, this.onConnectionTap, this.onRedisDatabaseTap, this.onMongoDBDatabaseTap, this.buildConnectionTile, }); + /// Whether dropping connection [id] on this folder changes anything. + final bool Function(int id)? acceptsDrop; + + /// A connection was dropped on this folder. + final void Function(int id)? onDrop; + final String name; final bool initiallyExpanded; final void Function(String folderName, bool expanded) onExpansionCommitted; @@ -239,6 +247,30 @@ class _FolderTileState extends State<_FolderTile> { @override Widget build(BuildContext context) { final theme = Theme.of(context); + final drop = widget.onDrop; + final menu = _buildMenu(context, theme); + if (drop == null) return menu; + return material.DragTarget( + onWillAcceptWithDetails: (d) => widget.acceptsDrop?.call(d.data) ?? true, + onAcceptWithDetails: (d) => drop(d.data), + builder: (context, candidates, _) => material.DecoratedBox( + key: material.ValueKey('folder-drop-${widget.name}'), + position: material.DecorationPosition.foreground, + decoration: material.BoxDecoration( + borderRadius: material.BorderRadius.circular(6), + border: material.Border.all( + color: candidates.isEmpty + ? material.Colors.transparent + : theme.colorScheme.primary, + width: 1.5, + ), + ), + child: menu, + ), + ); + } + + Widget _buildMenu(BuildContext context, ThemeData theme) { return ContextMenu( items: [ MenuButton( diff --git a/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart b/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart index f6152c80..c697ea82 100644 --- a/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart +++ b/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/gestures.dart' show kSecondaryButton; +import 'package:flutter/painting.dart' show Offset; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/folders_storage.dart'; @@ -129,4 +130,53 @@ void main() { await tester.runAsync(() => FoldersStorage.instance.remove('Team C')); await app.close(tester); }); + + + testWidgets('a connection is dragged into a folder and out of it again', + (tester) async { + await app.launch(tester); + await tester.runAsync(() => FoldersStorage.instance.add('Team D')); + final folderId = await tester + .runAsync(() => LocalDb.instance.getFolderIdByName('Team D')); + final id = await E2eConnections.add( + tester, E2eConnections.redis('E2E Dragged')); + await tester.runAsync(FoldersStorage.instance.reload); + await E2eConnections.reloadSidebar(tester); + + Future dragTo(Offset from, Offset to) async { + final gesture = await tester.startGesture(from); + // Past the drag slop, then onto the target in a few steps. + await gesture.moveBy(const Offset(0, 24)); + await tester.pump(const Duration(milliseconds: 50)); + await gesture.moveTo(to); + await tester.pump(const Duration(milliseconds: 50)); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 100))); + await tester.pump(const Duration(milliseconds: 300)); + } + + await dragTo( + tester.getCenter(inSidebar('E2E Dragged').first), + tester.getCenter(inSidebar('Team D')), + ); + var row = + await tester.runAsync(() => LocalDb.instance.getConnectionById(id)); + expect(row!.folderId, folderId, reason: 'dropped on the folder'); + + // The folder opens to show it; drag it onto the empty part of the list. + expect(inSidebar('E2E Dragged'), findsOneWidget); + final panel = tester.getRect(find.byType(ConnectionsPanel)); + await dragTo( + tester.getCenter(inSidebar('E2E Dragged').first), + Offset(panel.center.dx, panel.bottom - 24), + ); + row = await tester.runAsync(() => LocalDb.instance.getConnectionById(id)); + expect(row!.folderId, isNull, reason: 'dropped on the list'); + + await E2eConnections.remove(tester, id); + await tester.runAsync(() => FoldersStorage.instance.remove('Team D')); + await app.close(tester); + }); } From b99b21853ddce1ffb954b94ff29b728a5c5df0ed Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 12:29:18 +0300 Subject: [PATCH 02/11] fix(connections): import the drag widgets the panel files use The panel imports material with a show list; the drag widgets were not in it. --- lib/features/connections/connections_panel.dart | 6 ++++++ .../01_connection_lifecycle/e2e_connection_crud_test.dart | 1 - 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 28f06353..47caf630 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -2,6 +2,12 @@ import 'dart:async' show Timer; import 'package:flutter/material.dart' as material show + DecoratedBox, + DecorationPosition, + DragTarget, + Draggable, + Opacity, + pointerDragAnchorStrategy, TextEditingValue, ValueListenableBuilder, BuildContext, diff --git a/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart b/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart index c697ea82..91a9663b 100644 --- a/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart +++ b/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart @@ -1,5 +1,4 @@ import 'package:flutter/gestures.dart' show kSecondaryButton; -import 'package:flutter/painting.dart' show Offset; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/folders_storage.dart'; From 431cda69ff303bd3462427777999d7b45e60066a Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 12:32:41 +0300 Subject: [PATCH 03/11] test(e2e): drop the connection on the list's own scroll area --- .../e2e_connection_crud_test.dart | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart b/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart index 91a9663b..47d779ed 100644 --- a/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart +++ b/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart @@ -1,4 +1,5 @@ import 'package:flutter/gestures.dart' show kSecondaryButton; +import 'package:flutter/widgets.dart' show CustomScrollView; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/folders_storage.dart'; @@ -166,10 +167,15 @@ void main() { // The folder opens to show it; drag it onto the empty part of the list. expect(inSidebar('E2E Dragged'), findsOneWidget); - final panel = tester.getRect(find.byType(ConnectionsPanel)); + // The empty part of the list, inside the scroll view that is the target + // (the panel itself also has a header and a footer). + final list = tester.getRect(find.descendant( + of: find.byType(ConnectionsPanel), + matching: find.byType(CustomScrollView), + )); await dragTo( tester.getCenter(inSidebar('E2E Dragged').first), - Offset(panel.center.dx, panel.bottom - 24), + Offset(list.center.dx, list.bottom - 24), ); row = await tester.runAsync(() => LocalDb.instance.getConnectionById(id)); expect(row!.folderId, isNull, reason: 'dropped on the list'); From 62e80ed710b0480ac32021d119f95e132eef694a Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 12:38:31 +0300 Subject: [PATCH 04/11] fix(connections): a new folder does not list the top-level connections The folder's contents were looked up with folderId == _folderIdByName[name]. A folder created from the menu was added to the list of names but not to the name-to-id map, so the lookup was folderId == null: every connection outside a folder showed inside the new folder, and dropping on it did nothing. - _createFolder reloads the panel data, so the id is known. - connectionsInFolder returns nothing for an unknown id instead of matching the connections that have none. The drag e2e waits for the database write after the drop. --- .../connections/connections_panel.dart | 27 +++++++++++++--- .../e2e_connection_crud_test.dart | 18 +++++++---- .../connections_in_folder_test.dart | 32 +++++++++++++++++++ 3 files changed, 66 insertions(+), 11 deletions(-) create mode 100644 test/features/connections/connections_in_folder_test.dart diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 47caf630..17324aed 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -684,7 +684,9 @@ class ConnectionsPanelState extends State { final name = await showNewFolderDialog(menuContext); if (name == null || !mounted) return; await FoldersStorage.instance.add(name); - if (mounted) setState(() => _folders = FoldersStorage.instance.folders); + // Reload, not only the names: the new folder needs its id in + // [_folderIdByName] before its contents are looked up. + if (mounted) await _loadData(); } Future _createConnection({ @@ -1253,10 +1255,10 @@ class ConnectionsPanelState extends State { _expandedFolders.remove(folderName); } }, - connections: filteredConnections - .where((c) => - c.folderId == _folderIdByName[name]) - .toList(), + connections: connectionsInFolder( + filteredConnections, + _folderIdByName[name], + ), onRemove: () => _removeFolder(name), acceptsDrop: (id) => _acceptsDrop(id, name), onDrop: (id) => _dropConnection(id, name), @@ -1337,3 +1339,18 @@ class ConnectionsPanelState extends State { ); } } + +/// The connections that live in the folder with id [folderId]. A folder whose +/// id is not known yet holds none: matching `folderId == null` there listed +/// every top-level connection inside a freshly created folder. +@visibleForTesting +List connectionsInFolder( + List connections, + int? folderId, +) { + if (folderId == null) return const []; + return [ + for (final c in connections) + if (c.folderId == folderId) c, + ]; +} diff --git a/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart b/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart index 47d779ed..94ce5930 100644 --- a/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart +++ b/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart @@ -173,12 +173,18 @@ void main() { of: find.byType(ConnectionsPanel), matching: find.byType(CustomScrollView), )); - await dragTo( - tester.getCenter(inSidebar('E2E Dragged').first), - Offset(list.center.dx, list.bottom - 24), - ); - row = await tester.runAsync(() => LocalDb.instance.getConnectionById(id)); - expect(row!.folderId, isNull, reason: 'dropped on the list'); + final drop = Offset(list.center.dx, list.bottom - 24); + await dragTo(tester.getCenter(inSidebar('E2E Dragged').first), drop); + // The write is real I/O: give it time before reading. + for (var i = 0; i < 20; i++) { + row = await tester.runAsync(() => LocalDb.instance.getConnectionById(id)); + if (row!.folderId == null) break; + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50))); + await tester.pump(const Duration(milliseconds: 100)); + } + expect(row!.folderId, isNull, + reason: 'dropped on the list at $drop; list $list'); await E2eConnections.remove(tester, id); await tester.runAsync(() => FoldersStorage.instance.remove('Team D')); diff --git a/test/features/connections/connections_in_folder_test.dart b/test/features/connections/connections_in_folder_test.dart new file mode 100644 index 00000000..0f536b94 --- /dev/null +++ b/test/features/connections/connections_in_folder_test.dart @@ -0,0 +1,32 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/features/connections/connections_panel.dart'; + +ConnectionRow _row(int id, {int? folderId}) => ConnectionRow( + id: id, + type: 'postgresql', + name: 'conn $id', + folderId: folderId, + createdAt: DateTime.utc(2026).toIso8601String(), + ); + +void main() { + final all = [ + _row(1), + _row(2, folderId: 10), + _row(3, folderId: 10), + _row(4, folderId: 11), + ]; + + test('a folder holds the connections that carry its id', () { + expect(connectionsInFolder(all, 10).map((c) => c.id), [2, 3]); + expect(connectionsInFolder(all, 11).map((c) => c.id), [4]); + expect(connectionsInFolder(all, 12), isEmpty); + }); + + test('a folder whose id is not known yet is empty, not the top level', () { + // A freshly created folder is listed before its id is read; matching + // folderId == null there put every top-level connection inside it. + expect(connectionsInFolder(all, null), isEmpty); + }); +} From 1718473ff3ca9e85df352e47d36b0b61e42840ea Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 12:41:45 +0300 Subject: [PATCH 05/11] test(e2e): name the generic of the polled read --- test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart b/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart index 94ce5930..fcfa9136 100644 --- a/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart +++ b/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart @@ -177,7 +177,8 @@ void main() { await dragTo(tester.getCenter(inSidebar('E2E Dragged').first), drop); // The write is real I/O: give it time before reading. for (var i = 0; i < 20; i++) { - row = await tester.runAsync(() => LocalDb.instance.getConnectionById(id)); + row = await tester + .runAsync(() => LocalDb.instance.getConnectionById(id)); if (row!.folderId == null) break; await tester.runAsync( () => Future.delayed(const Duration(milliseconds: 50))); From b98605fe66dc32e68c974fbefd8df0dc6b34f218 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 12:45:02 +0300 Subject: [PATCH 06/11] feat(connections): a drop strip appears while a connection is dragged out of a folder The empty part of the list was already a target for 'no folder', but nothing says so. While a connection that lives in a folder is dragged, a labelled strip ('Drop here to take it out of the folder') shows above the list and highlights under the pointer. The e2e drops on it, so it no longer depends on where the empty part of the list happens to be. --- .../connections/connections_panel.dart | 42 +++++++++++++++++++ .../e2e_connection_crud_test.dart | 31 +++++++------- 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 17324aed..a88d6bae 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -2,6 +2,7 @@ import 'dart:async' show Timer; import 'package:flutter/material.dart' as material show + Center, DecoratedBox, DecorationPosition, DragTarget, @@ -573,6 +574,9 @@ class ConnectionsPanelState extends State { List _folders = []; List _connections = []; Map _folderIdByName = {}; + + /// The connection being dragged, while a drag is in progress. + int? _draggedId; final Set _expandedFolders = {}; final Set _expandedConnections = {}; @@ -857,6 +861,12 @@ class ConnectionsPanelState extends State { return material.Draggable( data: id, maxSimultaneousDrags: 1, + onDragStarted: () { + if (mounted) setState(() => _draggedId = id); + }, + onDragEnd: (_) { + if (mounted) setState(() => _draggedId = null); + }, dragAnchorStrategy: material.pointerDragAnchorStrategy, feedback: material.Material( color: material.Colors.transparent, @@ -1217,6 +1227,38 @@ class ConnectionsPanelState extends State { height: 1, color: theme.colorScheme.border.withValues(alpha: 0.22), ), + // While a connection from a folder is dragged, a labelled place + // to drop it: the empty part of the list works too, but nobody + // would know. + if (_draggedId != null && _acceptsDrop(_draggedId!, null)) + material.DragTarget( + onWillAcceptWithDetails: (d) => _acceptsDrop(d.data, null), + onAcceptWithDetails: (d) => _dropConnection(d.data, null), + builder: (context, candidates, _) => material.Container( + key: const material.ValueKey('drop_out_of_folder'), + margin: const material.EdgeInsets.fromLTRB(12, 8, 12, 0), + padding: const material.EdgeInsets.symmetric(vertical: 10), + decoration: material.BoxDecoration( + color: theme.colorScheme.primary.withValues( + alpha: candidates.isEmpty ? 0.06 : 0.18), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: theme.colorScheme.primary + .withValues(alpha: candidates.isEmpty ? 0.4 : 1), + width: 1.5, + ), + ), + child: material.Center( + child: material.Text( + 'Drop here to take it out of the folder', + style: material.TextStyle( + fontSize: 12, + color: theme.colorScheme.primary, + ), + ), + ), + ), + ), Expanded( child: material.RepaintBoundary( child: material.DragTarget( diff --git a/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart b/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart index fcfa9136..55b1ade5 100644 --- a/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart +++ b/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart @@ -1,5 +1,5 @@ import 'package:flutter/gestures.dart' show kSecondaryButton; -import 'package:flutter/widgets.dart' show CustomScrollView; +import 'package:flutter/widgets.dart' show ValueKey; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/folders_storage.dart'; @@ -161,21 +161,25 @@ void main() { tester.getCenter(inSidebar('E2E Dragged').first), tester.getCenter(inSidebar('Team D')), ); - var row = + final first = await tester.runAsync(() => LocalDb.instance.getConnectionById(id)); - expect(row!.folderId, folderId, reason: 'dropped on the folder'); + expect(first!.folderId, folderId, reason: 'dropped on the folder'); - // The folder opens to show it; drag it onto the empty part of the list. + // The folder opens to show it. While it is dragged a strip appears at the + // top of the list; drop it there. expect(inSidebar('E2E Dragged'), findsOneWidget); - // The empty part of the list, inside the scroll view that is the target - // (the panel itself also has a header and a footer). - final list = tester.getRect(find.descendant( - of: find.byType(ConnectionsPanel), - matching: find.byType(CustomScrollView), - )); - final drop = Offset(list.center.dx, list.bottom - 24); - await dragTo(tester.getCenter(inSidebar('E2E Dragged').first), drop); + final from = tester.getCenter(inSidebar('E2E Dragged').first); + final gesture = await tester.startGesture(from); + await gesture.moveBy(const Offset(0, 24)); + await tester.pump(const Duration(milliseconds: 100)); + final strip = find.byKey(const ValueKey('drop_out_of_folder')); + expect(strip, findsOneWidget, reason: 'the drop strip shows while dragging'); + await gesture.moveTo(tester.getCenter(strip)); + await tester.pump(const Duration(milliseconds: 50)); + await gesture.up(); + await tester.pump(const Duration(milliseconds: 300)); // The write is real I/O: give it time before reading. + ConnectionRow? row; for (var i = 0; i < 20; i++) { row = await tester .runAsync(() => LocalDb.instance.getConnectionById(id)); @@ -184,8 +188,7 @@ void main() { () => Future.delayed(const Duration(milliseconds: 50))); await tester.pump(const Duration(milliseconds: 100)); } - expect(row!.folderId, isNull, - reason: 'dropped on the list at $drop; list $list'); + expect(row!.folderId, isNull, reason: 'dropped on the strip'); await E2eConnections.remove(tester, id); await tester.runAsync(() => FoldersStorage.instance.remove('Team D')); From dec7b10fb66c4628a14121ac4985df333813ba17 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 12:48:11 +0300 Subject: [PATCH 07/11] test(e2e): wait for the panel to reload before the second drag The drop writes the folder id, then the panel reloads from the database: real I/O. The test started its next drag at once, from the old layout and with the connection still outside the folder in the panel's state, so there was nothing to take out and no drop strip. --- .../e2e_connection_crud_test.dart | 23 ++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart b/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart index 55b1ade5..6302531a 100644 --- a/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart +++ b/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart @@ -165,9 +165,26 @@ void main() { await tester.runAsync(() => LocalDb.instance.getConnectionById(id)); expect(first!.folderId, folderId, reason: 'dropped on the folder'); - // The folder opens to show it. While it is dragged a strip appears at the - // top of the list; drop it there. - expect(inSidebar('E2E Dragged'), findsOneWidget); + // The panel reloads from the database after the drop (real I/O); the tile + // is under its folder once it has. Dragging before that starts from the + // old layout with the old folder. + for (var i = 0; i < 40; i++) { + final tile = inSidebar('E2E Dragged'); + final folder = inSidebar('Team D'); + if (tile.evaluate().isNotEmpty && + folder.evaluate().isNotEmpty && + tester.getCenter(tile.first).dy > tester.getCenter(folder.first).dy) { + break; + } + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50))); + await tester.pump(const Duration(milliseconds: 100)); + } + expect( + tester.getCenter(inSidebar('E2E Dragged').first).dy, + greaterThan(tester.getCenter(inSidebar('Team D').first).dy), + reason: 'the connection is listed under its folder', + ); final from = tester.getCenter(inSidebar('E2E Dragged').first); final gesture = await tester.startGesture(from); await gesture.moveBy(const Offset(0, 24)); From 73b83789009667d536f59feed0a14533474a8cf9 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 12:51:13 +0300 Subject: [PATCH 08/11] fix(connections): the drop strip no longer rebuilds the tile being dragged The strip was a collection-if child of the panel's Column, so showing it shifted the list below it by one slot and Flutter rebuilt the whole list, including the tile under the pointer: the drag was dropped the moment it started. The strip now has a fixed slot (a SizedBox when hidden). --- lib/features/connections/connections_panel.dart | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index a88d6bae..d4d6c5ab 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -1230,8 +1230,11 @@ class ConnectionsPanelState extends State { // While a connection from a folder is dragged, a labelled place // to drop it: the empty part of the list works too, but nobody // would know. - if (_draggedId != null && _acceptsDrop(_draggedId!, null)) - material.DragTarget( + // A fixed slot: inserting and removing a child here would shift + // the list below it, and rebuilding it drops the tile that is + // being dragged (and the drag with it). + _draggedId != null && _acceptsDrop(_draggedId!, null) + ? material.DragTarget( onWillAcceptWithDetails: (d) => _acceptsDrop(d.data, null), onAcceptWithDetails: (d) => _dropConnection(d.data, null), builder: (context, candidates, _) => material.Container( @@ -1258,7 +1261,8 @@ class ConnectionsPanelState extends State { ), ), ), - ), + ) + : const material.SizedBox.shrink(), Expanded( child: material.RepaintBoundary( child: material.DragTarget( From 7d47d33f6f65c4de780491ca81f4b321add330ae Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 12:54:14 +0300 Subject: [PATCH 09/11] test(e2e): let the folder finish unfolding before the second drag --- test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart | 3 +++ 1 file changed, 3 insertions(+) diff --git a/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart b/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart index 6302531a..f0639dc8 100644 --- a/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart +++ b/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart @@ -185,6 +185,9 @@ void main() { greaterThan(tester.getCenter(inSidebar('Team D').first).dy), reason: 'the connection is listed under its folder', ); + // The folder is still unfolding (an animated, clipped height): a press on + // a half-revealed tile lands on whatever is behind it. + await tester.pump(const Duration(milliseconds: 600)); final from = tester.getCenter(inSidebar('E2E Dragged').first); final gesture = await tester.startGesture(from); await gesture.moveBy(const Offset(0, 24)); From e0a7311ed925f951047be45424d640e966b7bfd8 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 12:57:28 +0300 Subject: [PATCH 10/11] test(e2e): say what the panel knows when the drop strip is missing --- lib/features/connections/connections_panel.dart | 10 ++++++++++ .../e2e_connection_crud_test.dart | 11 +++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index d4d6c5ab..04bc57a6 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -577,6 +577,16 @@ class ConnectionsPanelState extends State { /// The connection being dragged, while a drag is in progress. int? _draggedId; + + @visibleForTesting + int? get draggedConnectionIdForTesting => _draggedId; + + /// `id: folderId` of the connections the panel currently holds. + @visibleForTesting + Map get connectionFoldersForTesting => { + for (final c in _connections) + if (c.id != null) c.id!: c.folderId, + }; final Set _expandedFolders = {}; final Set _expandedConnections = {}; diff --git a/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart b/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart index f0639dc8..c4dc3134 100644 --- a/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart +++ b/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart @@ -1,5 +1,5 @@ import 'package:flutter/gestures.dart' show kSecondaryButton; -import 'package:flutter/widgets.dart' show ValueKey; +import 'package:flutter/widgets.dart' show Draggable, ValueKey; import 'package:flutter_test/flutter_test.dart'; import 'package:querya_desktop/core/storage/app_settings.dart'; import 'package:querya_desktop/core/storage/folders_storage.dart'; @@ -193,7 +193,14 @@ void main() { await gesture.moveBy(const Offset(0, 24)); await tester.pump(const Duration(milliseconds: 100)); final strip = find.byKey(const ValueKey('drop_out_of_folder')); - expect(strip, findsOneWidget, reason: 'the drop strip shows while dragging'); + final state = + tester.state(find.byType(ConnectionsPanel)); + expect(strip, findsOneWidget, + reason: 'the drop strip shows while dragging; ' + 'dragging=${state.draggedConnectionIdForTesting} ' + 'folders=${state.connectionFoldersForTesting} id=$id ' + 'draggables=${find.byType(Draggable).evaluate().length} ' + 'pressed at $from'); await gesture.moveTo(tester.getCenter(strip)); await tester.pump(const Duration(milliseconds: 50)); await gesture.up(); From 98467d7433293a96f673858e2b1c17e97791b657 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 13:01:52 +0300 Subject: [PATCH 11/11] test(e2e): wait for the panel to hold the new folder, not for a tile position The diagnostic showed the drag did start (dragging=8) but the panel still had the connection outside any folder (folders={8: null}). The wait checked that the tile sat below the folder, which a top-level tile also does: folders are listed first. It now waits until the panel's own state has the folder id. --- .../e2e_connection_crud_test.dart | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart b/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart index c4dc3134..f001f951 100644 --- a/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart +++ b/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart @@ -168,23 +168,20 @@ void main() { // The panel reloads from the database after the drop (real I/O); the tile // is under its folder once it has. Dragging before that starts from the // old layout with the old folder. + // The panel reloads from the database after the drop (real I/O). Until it + // has, it still holds the connection outside the folder: a drag started + // then has nothing to take out. (Its tile's position says nothing here: + // folders are listed first, so a top-level tile is below them too.) + final panel = + tester.state(find.byType(ConnectionsPanel)); for (var i = 0; i < 40; i++) { - final tile = inSidebar('E2E Dragged'); - final folder = inSidebar('Team D'); - if (tile.evaluate().isNotEmpty && - folder.evaluate().isNotEmpty && - tester.getCenter(tile.first).dy > tester.getCenter(folder.first).dy) { - break; - } + if (panel.connectionFoldersForTesting[id] == folderId) break; await tester.runAsync( () => Future.delayed(const Duration(milliseconds: 50))); await tester.pump(const Duration(milliseconds: 100)); } - expect( - tester.getCenter(inSidebar('E2E Dragged').first).dy, - greaterThan(tester.getCenter(inSidebar('Team D').first).dy), - reason: 'the connection is listed under its folder', - ); + expect(panel.connectionFoldersForTesting[id], folderId, + reason: 'the panel reloaded after the drop'); // The folder is still unfolding (an animated, clipped height): a press on // a half-revealed tile lands on whatever is behind it. await tester.pump(const Duration(milliseconds: 600));