diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 2a61b307..04bc57a6 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -2,6 +2,13 @@ import 'dart:async' show Timer; import 'package:flutter/material.dart' as material show + Center, + DecoratedBox, + DecorationPosition, + DragTarget, + Draggable, + Opacity, + pointerDragAnchorStrategy, TextEditingValue, ValueListenableBuilder, BuildContext, @@ -567,6 +574,19 @@ class ConnectionsPanelState extends State { List _folders = []; List _connections = []; Map _folderIdByName = {}; + + /// 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 = {}; @@ -678,7 +698,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({ @@ -839,7 +861,80 @@ 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, + onDragStarted: () { + if (mounted) setState(() => _draggedId = id); + }, + onDragEnd: (_) { + if (mounted) setState(() => _draggedId = null); + }, + 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); @@ -1142,9 +1237,48 @@ 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. + // 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( + 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, + ), + ), + ), + ), + ) + : const material.SizedBox.shrink(), 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( @@ -1177,11 +1311,13 @@ 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), onNewConnection: (folderName) async { final folderId = await LocalDb.instance .getFolderIdByName(folderName); @@ -1249,6 +1385,7 @@ class ConnectionsPanelState extends State { ), ], ), + ), ), ), ], @@ -1258,3 +1395,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/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..f001f951 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 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'; @@ -129,4 +130,92 @@ 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')), + ); + final first = + await tester.runAsync(() => LocalDb.instance.getConnectionById(id)); + expect(first!.folderId, folderId, reason: 'dropped on the folder'); + + // 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++) { + if (panel.connectionFoldersForTesting[id] == folderId) break; + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 50))); + await tester.pump(const Duration(milliseconds: 100)); + } + 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)); + 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')); + 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(); + 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)); + 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 strip'); + + await E2eConnections.remove(tester, id); + await tester.runAsync(() => FoldersStorage.instance.remove('Team D')); + await app.close(tester); + }); } 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); + }); +}