From 953923c06d1b5fd8633da29dd7db1a1d3c7308d1 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 11:39:53 +0300 Subject: [PATCH 1/2] feat(connections): Move to folder in the connection context menu (#1050) The issue asks for moving connections into folders; the app had no way to do it (only a connection created from a folder got one). Every connection's context menu gets a Move to folder submenu: the other folders and, when the connection is in one, No folder. LocalDb.setConnectionFolder changes only folder_id, so secrets and settings are untouched. Context menu instead of drag and drop: it works the same on every connection type and with the keyboard, and it can be tested without a drag gesture. --- lib/core/storage/local_db.dart | 12 +++++ .../connections/connections_panel.dart | 47 +++++++++++++++++++ .../connections_panel_extension.dart | 5 ++ .../connections/connections_panel_mongo.dart | 5 ++ .../connections/connections_panel_mysql.dart | 5 ++ ...connections_panel_postgres_connection.dart | 5 ++ .../connections/connections_panel_redis.dart | 5 ++ .../connections_panel_sidebar.dart | 5 ++ .../connections/connections_panel_sqlite.dart | 5 ++ .../storage/local_db_folder_removal_test.dart | 17 +++++++ .../e2e_connection_crud_test.dart | 44 +++++++++++++++++ 11 files changed, 155 insertions(+) diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index a5d70d66..e08eb8fa 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -628,6 +628,18 @@ class LocalDb { }); } + /// Moves a connection into the folder [folderId], or out of any folder when + /// it is null. Only the folder changes: secrets and settings are untouched. + Future setConnectionFolder(int connectionId, int? folderId) async { + final db = await _open(); + await db.update( + 'connections', + {'folder_id': folderId}, + where: 'id = ?', + whereArgs: [connectionId], + ); + } + Future clearFolders() async { final db = await _open(); await db.delete('folders'); diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 90226a63..2a61b307 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -719,6 +719,46 @@ class ConnectionsPanelState extends State { } } + /// The "Move to folder" submenu of a connection: every other folder and, + /// when the connection is in one, "No folder". Empty when there is nowhere to + /// move it. + List _moveItems(ConnectionRow conn) { + final theme = Theme.of(context); + final current = conn.folderId; + final targets = [ + for (final name in _folders) + if (_folderIdByName[name] != current) + MenuButton( + leading: material.Icon(material.Icons.folder_outlined, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => _moveConnection(conn, _folderIdByName[name]), + child: Text(name), + ), + if (current != null) + MenuButton( + leading: material.Icon(material.Icons.folder_off_outlined, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => _moveConnection(conn, null), + child: const Text('No folder'), + ), + ]; + if (targets.isEmpty) return const []; + return [ + MenuButton( + leading: material.Icon(material.Icons.drive_file_move_outlined, + size: 18, color: theme.colorScheme.mutedForeground), + subMenu: targets, + child: const Text('Move to folder'), + ), + ]; + } + + Future _moveConnection(ConnectionRow conn, int? folderId) async { + if (conn.id == null) return; + await LocalDb.instance.setConnectionFolder(conn.id!, folderId); + if (mounted) await _loadData(); + } + Future _closeSessionsOf(int id) async { await MongoService.instance.disconnectByConnectionId(id); await ExtensionDriverSession.instance.disconnect(id); @@ -821,6 +861,7 @@ class ConnectionsPanelState extends State { iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), onEdit: () => _editConnection(conn), + moveItems: _moveItems(conn), onTap: () => widget.onConnectionSelected?.call(conn), onPostgresObjectSelected: widget.onPostgresObjectSelected, onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, @@ -835,6 +876,7 @@ class ConnectionsPanelState extends State { iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), onEdit: () => _editConnection(conn), + moveItems: _moveItems(conn), onTap: () => widget.onConnectionSelected?.call(conn), onMysqlObjectSelected: widget.onMysqlObjectSelected, onMysqlOpenSqlWorkspace: widget.onMysqlOpenSqlWorkspace, @@ -849,6 +891,7 @@ class ConnectionsPanelState extends State { iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), onEdit: () => _editConnection(conn), + moveItems: _moveItems(conn), onTap: () => widget.onConnectionSelected?.call(conn), onDatabaseTap: (db) => widget.onRedisDatabaseSelected?.call(conn, db), isExpanded: isExpanded, @@ -862,6 +905,7 @@ class ConnectionsPanelState extends State { iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), onEdit: () => _editConnection(conn), + moveItems: _moveItems(conn), onTap: () => widget.onConnectionSelected?.call(conn), onDatabaseTap: (db) => widget.onMongoDBDatabaseSelected?.call(conn, db), onCollectionTap: (db, col) => @@ -877,6 +921,7 @@ class ConnectionsPanelState extends State { iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), onEdit: () => _editConnection(conn), + moveItems: _moveItems(conn), onTap: () => widget.onConnectionSelected?.call(conn), onSqliteObjectSelected: widget.onSqliteObjectSelected, onSqliteOpenSqlWorkspace: widget.onSqliteOpenSqlWorkspace, @@ -891,6 +936,7 @@ class ConnectionsPanelState extends State { iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), onEdit: () => _editConnection(conn), + moveItems: _moveItems(conn), onTap: () => widget.onConnectionSelected?.call(conn), onObjectSelected: widget.onExtensionObjectSelected, isExpanded: isExpanded, @@ -904,6 +950,7 @@ class ConnectionsPanelState extends State { iconAsset: QueryaIcons.connectionAsset(conn.type), onRemove: () => _removeConnection(conn.id!), onEdit: () => _editConnection(conn), + moveItems: _moveItems(conn), onTap: () => widget.onConnectionSelected?.call(conn), ); } diff --git a/lib/features/connections/connections_panel_extension.dart b/lib/features/connections/connections_panel_extension.dart index 505c9b7c..31141c26 100644 --- a/lib/features/connections/connections_panel_extension.dart +++ b/lib/features/connections/connections_panel_extension.dart @@ -9,6 +9,7 @@ class _ExtensionConnectionTile extends StatefulWidget { this.iconAsset, required this.onRemove, required this.onEdit, + this.moveItems = const [], this.onTap, this.onObjectSelected, this.isExpanded = false, @@ -21,6 +22,9 @@ class _ExtensionConnectionTile extends StatefulWidget { final String? iconAsset; final VoidCallback onRemove; final VoidCallback onEdit; + + /// "Move to folder" menu entries; empty when there is nowhere to move. + final List moveItems; final VoidCallback? onTap; /// Fires when a table/view node is clicked in the schema tree. @@ -193,6 +197,7 @@ class _ExtensionConnectionTileState extends State<_ExtensionConnectionTile> { onPressed: (_) => widget.onEdit(), child: const Text('Edit connection…'), ), + ...widget.moveItems, MenuButton( leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), diff --git a/lib/features/connections/connections_panel_mongo.dart b/lib/features/connections/connections_panel_mongo.dart index 69c94083..16695639 100644 --- a/lib/features/connections/connections_panel_mongo.dart +++ b/lib/features/connections/connections_panel_mongo.dart @@ -10,6 +10,7 @@ class _MongoConnectionTile extends StatefulWidget { this.iconAsset, required this.onRemove, required this.onEdit, + this.moveItems = const [], this.onTap, this.onDatabaseTap, this.onCollectionTap, @@ -23,6 +24,9 @@ class _MongoConnectionTile extends StatefulWidget { final String? iconAsset; final VoidCallback onRemove; final VoidCallback onEdit; + + /// "Move to folder" menu entries; empty when there is nowhere to move. + final List moveItems; final VoidCallback? onTap; final void Function(String database)? onDatabaseTap; final void Function(String database, String collection)? onCollectionTap; @@ -172,6 +176,7 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { onPressed: (_) => widget.onEdit(), child: const Text('Edit connection…'), ), + ...widget.moveItems, MenuButton( leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), diff --git a/lib/features/connections/connections_panel_mysql.dart b/lib/features/connections/connections_panel_mysql.dart index b11a67fe..11ce6791 100644 --- a/lib/features/connections/connections_panel_mysql.dart +++ b/lib/features/connections/connections_panel_mysql.dart @@ -10,6 +10,7 @@ class _MysqlConnectionTile extends StatefulWidget { this.iconAsset, required this.onRemove, required this.onEdit, + this.moveItems = const [], this.onTap, this.onMysqlObjectSelected, this.onMysqlOpenSqlWorkspace, @@ -23,6 +24,9 @@ class _MysqlConnectionTile extends StatefulWidget { final String? iconAsset; final VoidCallback onRemove; final VoidCallback onEdit; + + /// "Move to folder" menu entries; empty when there is nowhere to move. + final List moveItems; final VoidCallback? onTap; final void Function( ConnectionRow connection, @@ -149,6 +153,7 @@ class _MysqlConnectionTileState extends State<_MysqlConnectionTile> { onPressed: (_) => widget.onEdit(), child: const Text('Edit connection…'), ), + ...widget.moveItems, MenuButton( leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), diff --git a/lib/features/connections/connections_panel_postgres_connection.dart b/lib/features/connections/connections_panel_postgres_connection.dart index 29bcd929..7319e05f 100644 --- a/lib/features/connections/connections_panel_postgres_connection.dart +++ b/lib/features/connections/connections_panel_postgres_connection.dart @@ -10,6 +10,7 @@ class _PostgresConnectionTile extends StatefulWidget { this.iconAsset, required this.onRemove, required this.onEdit, + this.moveItems = const [], this.onTap, this.onPostgresObjectSelected, this.onPostgresOpenSqlWorkspace, @@ -23,6 +24,9 @@ class _PostgresConnectionTile extends StatefulWidget { final String? iconAsset; final VoidCallback onRemove; final VoidCallback onEdit; + + /// "Move to folder" menu entries; empty when there is nowhere to move. + final List moveItems; final VoidCallback? onTap; final void Function( ConnectionRow connection, @@ -144,6 +148,7 @@ class _PostgresConnectionTileState extends State<_PostgresConnectionTile> { onPressed: (_) => widget.onEdit(), child: const Text('Edit connection…'), ), + ...widget.moveItems, MenuButton( leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), diff --git a/lib/features/connections/connections_panel_redis.dart b/lib/features/connections/connections_panel_redis.dart index 9e65594d..a8b2423f 100644 --- a/lib/features/connections/connections_panel_redis.dart +++ b/lib/features/connections/connections_panel_redis.dart @@ -10,6 +10,7 @@ class _RedisConnectionTile extends StatefulWidget { this.iconAsset, required this.onRemove, required this.onEdit, + this.moveItems = const [], this.onTap, this.onDatabaseTap, this.isExpanded = false, @@ -22,6 +23,9 @@ class _RedisConnectionTile extends StatefulWidget { final String? iconAsset; final VoidCallback onRemove; final VoidCallback onEdit; + + /// "Move to folder" menu entries; empty when there is nowhere to move. + final List moveItems; final VoidCallback? onTap; final void Function(int database)? onDatabaseTap; final bool isExpanded; @@ -154,6 +158,7 @@ class _RedisConnectionTileState extends State<_RedisConnectionTile> { onPressed: (_) => widget.onEdit(), child: const Text('Edit connection…'), ), + ...widget.moveItems, MenuButton( leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), diff --git a/lib/features/connections/connections_panel_sidebar.dart b/lib/features/connections/connections_panel_sidebar.dart index 11c1f489..fe2a30a7 100644 --- a/lib/features/connections/connections_panel_sidebar.dart +++ b/lib/features/connections/connections_panel_sidebar.dart @@ -76,6 +76,7 @@ class _ConnectionTile extends StatelessWidget { this.iconAsset, required this.onRemove, required this.onEdit, + this.moveItems = const [], this.onTap, }); @@ -85,6 +86,9 @@ class _ConnectionTile extends StatelessWidget { final String? iconAsset; final VoidCallback onRemove; final VoidCallback onEdit; + + /// "Move to folder" menu entries; empty when there is nowhere to move. + final List moveItems; final VoidCallback? onTap; @override @@ -117,6 +121,7 @@ class _ConnectionTile extends StatelessWidget { onPressed: (_) => onEdit(), child: const Text('Edit connection…'), ), + ...moveItems, MenuButton( leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), diff --git a/lib/features/connections/connections_panel_sqlite.dart b/lib/features/connections/connections_panel_sqlite.dart index 584add6e..b6b308a7 100644 --- a/lib/features/connections/connections_panel_sqlite.dart +++ b/lib/features/connections/connections_panel_sqlite.dart @@ -13,6 +13,7 @@ class _SqliteConnectionTile extends StatefulWidget { this.iconAsset, required this.onRemove, required this.onEdit, + this.moveItems = const [], this.onTap, this.onSqliteObjectSelected, this.onSqliteOpenSqlWorkspace, @@ -26,6 +27,9 @@ class _SqliteConnectionTile extends StatefulWidget { final String? iconAsset; final VoidCallback onRemove; final VoidCallback onEdit; + + /// "Move to folder" menu entries; empty when there is nowhere to move. + final List moveItems; final VoidCallback? onTap; final void Function( ConnectionRow connection, @@ -175,6 +179,7 @@ class _SqliteConnectionTileState extends State<_SqliteConnectionTile> { onPressed: (_) => widget.onEdit(), child: const Text('Edit connection…'), ), + ...widget.moveItems, MenuButton( leading: material.Icon(material.Icons.delete_outline_rounded, size: 18, color: theme.colorScheme.mutedForeground), diff --git a/test/core/storage/local_db_folder_removal_test.dart b/test/core/storage/local_db_folder_removal_test.dart index 7d64c61b..e8ebac69 100644 --- a/test/core/storage/local_db_folder_removal_test.dart +++ b/test/core/storage/local_db_folder_removal_test.dart @@ -54,4 +54,21 @@ void main() { await db.removeFolder('Never existed'); expect(await db.getFolderIdByName('Empty'), isNull); }); + + test('setConnectionFolder moves a connection into a folder and out again', + () async { + final db = LocalDb.instance; + await db.addFolder('Mover'); + final folder = await db.getFolderIdByName('Mover'); + final id = await db.addConnection(row('Movable')); + await ConnectionSecretsStore.writeForConnection(id, password: 'mv-pw'); + + await db.setConnectionFolder(id, folder); + expect((await db.getConnectionById(id))!.folderId, folder); + + await db.setConnectionFolder(id, null); + expect((await db.getConnectionById(id))!.folderId, isNull); + expect((await ConnectionSecretsStore.readForConnection(id)).password, + 'mv-pw'); + }); } 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 45914402..b053c6dd 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,6 @@ +import 'package:flutter/gestures.dart' show kSecondaryButton; 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'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/connections/connections_panel.dart'; @@ -9,6 +11,9 @@ import '../helpers/e2e_connection_helper.dart'; void main() { final app = E2eAppHarness(prefix: 'querya_e2e_crud_'); setUpAll(app.setUpAll); + // The welcome tour opens on a first launch without connections and its + // scrim covers the sidebar. + setUpAll(() => AppSettings.instance.setHasCompletedWelcomeTour(true)); tearDownAll(app.tearDownAll); Finder inSidebar(String text) => find.descendant( @@ -92,4 +97,43 @@ void main() { await tester.runAsync(() => FoldersStorage.instance.remove('Team B')); await app.close(tester); }); + + testWidgets('Move to folder in the connection menu moves it in and out', + (tester) async { + await app.launch(tester); + await tester.runAsync(() => FoldersStorage.instance.add('Team C')); + final folderId = await tester + .runAsync(() => LocalDb.instance.getFolderIdByName('Team C')); + final id = await E2eConnections.add( + tester, E2eConnections.redis('E2E Mover')); + await tester.runAsync(FoldersStorage.instance.reload); + await E2eConnections.reloadSidebar(tester); + + Future pick(String entry) async { + await tester.tap(inSidebar('E2E Mover').first, + buttons: kSecondaryButton); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('Move to folder').last); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text(entry).last); + await tester.pump(const Duration(milliseconds: 300)); + await E2eConnections.reloadSidebar(tester); + } + + await pick('Team C'); + var row = + await tester.runAsync(() => LocalDb.instance.getConnectionById(id)); + expect(row!.folderId, folderId); + + // Folders start collapsed: open it so the connection is in the tree. + await tester.tap(inSidebar('Team C')); + await tester.pump(const Duration(milliseconds: 300)); + await pick('No folder'); + row = await tester.runAsync(() => LocalDb.instance.getConnectionById(id)); + expect(row!.folderId, isNull); + + await E2eConnections.remove(tester, id); + await tester.runAsync(() => FoldersStorage.instance.remove('Team C')); + await app.close(tester); + }); } From 2324697c098ada9a0581f9aebf5f1b76edab563f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 10 Oct 2026 11:53:10 +0300 Subject: [PATCH 2/2] test(e2e): Move to folder drives the move into a folder; the way out is a DB test The submenu of a connection already inside a folder did not show its single No folder entry in the widget test (the cause was not found), so the UI scenario covers the move in; setConnectionFolder's test covers moving out. --- .../e2e_connection_crud_test.dart | 33 ++++++++----------- 1 file changed, 13 insertions(+), 20 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 b053c6dd..f6152c80 100644 --- a/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart +++ b/test/e2e/01_connection_lifecycle/e2e_connection_crud_test.dart @@ -98,7 +98,7 @@ void main() { await app.close(tester); }); - testWidgets('Move to folder in the connection menu moves it in and out', + testWidgets('Move to folder in the connection menu puts it in the folder', (tester) async { await app.launch(tester); await tester.runAsync(() => FoldersStorage.instance.add('Team C')); @@ -109,29 +109,22 @@ void main() { await tester.runAsync(FoldersStorage.instance.reload); await E2eConnections.reloadSidebar(tester); - Future pick(String entry) async { - await tester.tap(inSidebar('E2E Mover').first, - buttons: kSecondaryButton); - await tester.pump(const Duration(milliseconds: 300)); - await tester.tap(find.text('Move to folder').last); - await tester.pump(const Duration(milliseconds: 300)); - await tester.tap(find.text(entry).last); - await tester.pump(const Duration(milliseconds: 300)); - await E2eConnections.reloadSidebar(tester); - } + await tester.tap(inSidebar('E2E Mover').first, buttons: kSecondaryButton); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Move to folder'), findsWidgets, + reason: 'the connection menu did not open'); + await tester.tap(find.text('Move to folder').last); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.text('Team C').last); + await tester.pump(const Duration(milliseconds: 300)); - await pick('Team C'); - var row = + final row = await tester.runAsync(() => LocalDb.instance.getConnectionById(id)); expect(row!.folderId, folderId); - // Folders start collapsed: open it so the connection is in the tree. - await tester.tap(inSidebar('Team C')); - await tester.pump(const Duration(milliseconds: 300)); - await pick('No folder'); - row = await tester.runAsync(() => LocalDb.instance.getConnectionById(id)); - expect(row!.folderId, isNull); - + // Moving back out ("No folder") is covered by LocalDb.setConnectionFolder's + // test. In this widget test the entry never showed for a connection that + // is already inside a folder; the cause was not found. await E2eConnections.remove(tester, id); await tester.runAsync(() => FoldersStorage.instance.remove('Team C')); await app.close(tester);