diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 39add6f0..16f0ccd6 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -2,7 +2,6 @@ import 'dart:async' show Timer; import 'package:flutter/material.dart' as material show - AlertDialog, TextEditingValue, ValueListenableBuilder, BuildContext, @@ -44,7 +43,6 @@ import 'package:flutter/material.dart' as material Colors, Padding, Widget, - Navigator, ValueKey, FontWeight, VoidCallback, @@ -58,7 +56,8 @@ import 'package:flutter/material.dart' as material InputBorder, TextEditingController, FocusNode; -import 'package:flutter/services.dart' show LogicalKeyboardKey; +import 'package:flutter/services.dart' + show Clipboard, ClipboardData, LogicalKeyboardKey; import 'package:querya_desktop/core/actions/querya_schema_object.dart'; import 'package:querya_desktop/core/actions/querya_schema_object_cache.dart'; import 'package:querya_desktop/core/actions/querya_schema_object_loader.dart'; @@ -330,6 +329,7 @@ class _ConnectionsTreeSelection { this.selectedExtensionObject, this.selectedRedisDb, this.selectedMongoDb, + this.selectedMongoCollection, }); static const empty = _ConnectionsTreeSelection(); @@ -350,6 +350,7 @@ class _ConnectionsTreeSelection { final ({String database, String name})? selectedExtensionObject; final int? selectedRedisDb; final String? selectedMongoDb; + final String? selectedMongoCollection; @override bool operator ==(Object other) { @@ -361,7 +362,8 @@ class _ConnectionsTreeSelection { selectedSqliteObject == other.selectedSqliteObject && selectedExtensionObject == other.selectedExtensionObject && selectedRedisDb == other.selectedRedisDb && - selectedMongoDb == other.selectedMongoDb; + selectedMongoDb == other.selectedMongoDb && + selectedMongoCollection == other.selectedMongoCollection; } @override @@ -373,6 +375,7 @@ class _ConnectionsTreeSelection { selectedExtensionObject, selectedRedisDb, selectedMongoDb, + selectedMongoCollection, ); } @@ -462,9 +465,11 @@ class ConnectionsPanel extends StatefulWidget { this.selectedExtensionObject, this.selectedRedisDb, this.selectedMongoDb, + this.selectedMongoCollection, this.onConnectionSelected, this.onRedisDatabaseSelected, this.onMongoDBDatabaseSelected, + this.onMongoCollectionSelected, this.onPostgresObjectSelected, this.onPostgresOpenSqlWorkspace, this.onMysqlObjectSelected, @@ -492,6 +497,7 @@ class ConnectionsPanel extends StatefulWidget { final ({String database, String name})? selectedExtensionObject; final int? selectedRedisDb; final String? selectedMongoDb; + final String? selectedMongoCollection; /// Called when the user taps a connection tile. final void Function(ConnectionRow connection)? onConnectionSelected; @@ -504,6 +510,13 @@ class ConnectionsPanel extends StatefulWidget { final void Function(ConnectionRow connection, String database)? onMongoDBDatabaseSelected; + /// Called when the user taps a MongoDB collection node in the tree. + final void Function( + ConnectionRow connection, + String database, + String collection, + )? onMongoCollectionSelected; + /// Called when the user taps a PostgreSQL table, view, function, or sequence. final void Function( ConnectionRow connection, @@ -603,6 +616,7 @@ class ConnectionsPanelState extends State { selectedExtensionObject: widget.selectedExtensionObject, selectedRedisDb: widget.selectedRedisDb, selectedMongoDb: widget.selectedMongoDb, + selectedMongoCollection: widget.selectedMongoCollection, ); } @@ -840,6 +854,8 @@ class ConnectionsPanelState extends State { onEdit: () => _editConnection(conn), onTap: () => widget.onConnectionSelected?.call(conn), onDatabaseTap: (db) => widget.onMongoDBDatabaseSelected?.call(conn, db), + onCollectionTap: (db, col) => + widget.onMongoCollectionSelected?.call(conn, db, col), isExpanded: isExpanded, onExpandedChanged: handleExpandedChanged, ); diff --git a/lib/features/connections/connections_panel_mongo.dart b/lib/features/connections/connections_panel_mongo.dart index a990e903..69c94083 100644 --- a/lib/features/connections/connections_panel_mongo.dart +++ b/lib/features/connections/connections_panel_mongo.dart @@ -12,6 +12,7 @@ class _MongoConnectionTile extends StatefulWidget { required this.onEdit, this.onTap, this.onDatabaseTap, + this.onCollectionTap, this.isExpanded = false, this.onExpandedChanged, }); @@ -24,6 +25,7 @@ class _MongoConnectionTile extends StatefulWidget { final VoidCallback onEdit; final VoidCallback? onTap; final void Function(String database)? onDatabaseTap; + final void Function(String database, String collection)? onCollectionTap; final bool isExpanded; final ValueChanged? onExpandedChanged; @@ -99,25 +101,13 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { Future _deleteDatabase(String dbName) async { if (!mounted) return; - final ok = await showAppDialog( + final ok = await QueryaConfirmDialog.show( context: context, - barrierDismissible: true, - builder: (ctx) => material.AlertDialog( - title: const Text('Drop database?'), - content: Text( + title: 'Drop database?', + message: 'Permanently delete database "$dbName"? This cannot be undone.', - ), - actions: [ - OutlineButton( - onPressed: () => material.Navigator.of(ctx).pop(false), - child: const Text('Cancel'), - ), - DestructiveButton( - onPressed: () => material.Navigator.of(ctx).pop(true), - child: const Text('Drop database'), - ), - ], - ), + confirmLabel: 'Drop database', + isDestructive: true, ); if (ok != true || !mounted) return; try { @@ -295,6 +285,7 @@ class _MongoConnectionTileState extends State<_MongoConnectionTile> { connection: widget.connection, databases: _databases, onDatabaseTap: widget.onDatabaseTap, + onCollectionTap: widget.onCollectionTap, onDeleteDatabase: _deleteDatabase, onRefreshDatabases: () { setState(() => _databases = []); @@ -318,6 +309,7 @@ class _MongoDatabasesNode extends StatelessWidget { required this.onRefreshDatabases, required this.onDeleteDatabase, this.onDatabaseTap, + this.onCollectionTap, }); final ConnectionRow connection; @@ -325,6 +317,7 @@ class _MongoDatabasesNode extends StatelessWidget { final VoidCallback onRefreshDatabases; final Future Function(String name) onDeleteDatabase; final void Function(String database)? onDatabaseTap; + final void Function(String database, String collection)? onCollectionTap; @override Widget build(BuildContext context) { @@ -338,9 +331,11 @@ class _MongoDatabasesNode extends StatelessWidget { itemBuilder: (context, index) { final db = databases[index]; return _MongoDatabaseNode( + key: material.ValueKey('mongo-db-${connection.id ?? 0}-$db'), connection: connection, name: db, onTap: () => onDatabaseTap?.call(db), + onCollectionTap: onCollectionTap, onDelete: () => onDeleteDatabase(db), onRefreshDatabases: onRefreshDatabases, ); @@ -350,13 +345,15 @@ class _MongoDatabasesNode extends StatelessWidget { } } -class _MongoDatabaseNode extends StatelessWidget { +class _MongoDatabaseNode extends StatefulWidget { const _MongoDatabaseNode({ + super.key, required this.connection, required this.name, required this.onTap, required this.onDelete, required this.onRefreshDatabases, + this.onCollectionTap, }); final ConnectionRow connection; @@ -364,6 +361,275 @@ class _MongoDatabaseNode extends StatelessWidget { final VoidCallback onTap; final VoidCallback onDelete; final VoidCallback onRefreshDatabases; + final void Function(String database, String collection)? onCollectionTap; + + @override + State<_MongoDatabaseNode> createState() => _MongoDatabaseNodeState(); +} + +class _MongoDatabaseNodeState extends State<_MongoDatabaseNode> { + bool _expanded = false; + bool _loading = false; + String? _error; + List _collections = []; + final _filterController = material.TextEditingController(); + final _filterDebouncer = FilterDebouncer(); + String _filter = ''; + + @override + void dispose() { + _filterDebouncer.cancel(); + _filterController.dispose(); + super.dispose(); + } + + void _toggle() { + setState(() => _expanded = !_expanded); + if (_expanded && _collections.isEmpty && !_loading) { + _loadCollections(); + } + } + + Future _loadCollections() async { + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final conn = + await MongoService.instance.ensureConnected(widget.connection); + final colls = await conn.listCollections(widget.name); + colls.sort(); + if (!mounted) return; + setState(() { + _collections = colls; + _loading = false; + _error = null; + }); + final cacheId = widget.connection.id; + if (cacheId != null) { + QueryaSchemaObjectCache.instance.merge( + cacheId, + QueryaSchemaObjectCache.scopeMongo(widget.name), + [ + for (final name in colls) + QueryaSchemaObject.mongo( + database: widget.name, + name: name, + ), + ], + ); + } + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + + Future _dropCollection(String collName) async { + if (!mounted) return; + final ok = await QueryaConfirmDialog.show( + context: context, + title: 'Drop collection?', + message: + 'Permanently delete collection "$collName" from database "${widget.name}"? This cannot be undone.', + confirmLabel: 'Drop collection', + isDestructive: true, + ); + if (ok != true || !mounted) return; + try { + final conn = + await MongoService.instance.ensureConnected(widget.connection); + await MongoService.instance.dropCollection(conn, widget.name, collName); + if (mounted) { + await _loadCollections(); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + }); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final query = _filter.trim().toLowerCase(); + final matching = query.isEmpty + ? _collections + : _collections.where((it) => it.toLowerCase().contains(query)).toList(); + + final showFilter = _collections.length >= 8 || _filter.isNotEmpty; + + return QueryaTreeIndentGuide( + depth: 1, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + _ConnectionsTreeSelectionBuilder( + select: (sel) => + sel.selectedConnectionId == widget.connection.id && + sel.selectedMongoDb == widget.name && + sel.selectedMongoCollection == null, + builder: (context, isSelected) { + return QueryaConnectionTreeRow( + label: widget.name, + isSelected: isSelected, + leading: material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: context.motionDuration(QueryaMotion.treeExpand), + curve: context.motionCurve(QueryaMotion.treeExpandCurve), + child: material.Icon( + QueryaIcons.expandClosed, + size: QueryaIconSizes.treeExpand, + color: theme.colorScheme.mutedForeground, + ), + ), + ), + ), + icon: QueryaIcons.database, + iconSize: QueryaIconSizes.treeConnection, + iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), + textStyle: material.TextStyle( + fontSize: 12, + color: theme.colorScheme.foreground, + ), + verticalPadding: 3, + expanded: _expanded, + onTap: () { + widget.onTap(); + if (!_expanded) { + _toggle(); + } + }, + connection: widget.connection, + onContextRefresh: () { + widget.onRefreshDatabases(); + if (_expanded) { + _loadCollections(); + } + }, + onOpenSqlWorkspace: null, + onContextDelete: widget.onDelete, + contextDeleteLabel: 'Delete database', + ); + }, + ), + QueryaAnimatedExpand( + expanded: _expanded, + estimatedChildCount: _collections.length, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + if (_loading) + const ConnectionTreeLoadingRow.nested() + else if (_error != null) + TreeLoadError( + title: 'Could not load collections', + message: _error!, + onRetry: _loadCollections, + ) + else ...[ + if (showFilter) + TreeObjectFilterBar( + controller: _filterController, + hintText: 'Filter collections...', + onChanged: (val) => _filterDebouncer.run(() { + if (mounted) setState(() => _filter = val); + }), + onClear: () { + _filterDebouncer.cancel(); + setState(() { + _filter = ''; + _filterController.clear(); + }); + }, + filteredCount: matching.length, + totalCount: _collections.length, + ), + if (matching.isEmpty && _collections.isNotEmpty) + material.Padding( + padding: QueryaTreeTokens.emptyFilterPadding, + child: const Text('No matching collections') + .muted() + .xSmall(), + ) + else if (_collections.isEmpty && !_loading) + material.Padding( + padding: const material.EdgeInsets.only( + left: 28, + top: 4, + bottom: 4, + ), + child: const Text('No collections') + .muted() + .xSmall(), + ) + else + QueryaTreeIndentGuide( + depth: 1, + child: lazyConnectionTreeList( + context: context, + itemCount: matching.length, + itemExtent: kConnectionTreeRowExtent, + itemBuilder: (context, index) { + final collName = matching[index]; + return _MongoCollectionNode( + key: material.ValueKey( + 'mongo-col-${widget.connection.id}-${widget.name}-$collName', + ), + connection: widget.connection, + database: widget.name, + name: collName, + onTap: () { + widget.onCollectionTap?.call(widget.name, collName); + }, + onRefreshCollections: _loadCollections, + onDropCollection: _dropCollection, + ); + }, + ), + ), + ], + ], + ), + ), + ], + ), + ); + } +} + +class _MongoCollectionNode extends StatelessWidget { + const _MongoCollectionNode({ + super.key, + required this.connection, + required this.database, + required this.name, + required this.onTap, + required this.onRefreshCollections, + required this.onDropCollection, + }); + + final ConnectionRow connection; + final String database; + final String name; + final VoidCallback onTap; + final VoidCallback onRefreshCollections; + final Future Function(String name) onDropCollection; @override Widget build(BuildContext context) { @@ -371,28 +637,64 @@ class _MongoDatabaseNode extends StatelessWidget { return _ConnectionsTreeSelectionBuilder( select: (sel) => sel.selectedConnectionId == connection.id && - sel.selectedMongoDb == name, + sel.selectedMongoDb == database && + sel.selectedMongoCollection == name, builder: (context, isSelected) { - return QueryaTreeIndentGuide( - depth: 1, - padding: const material.EdgeInsets.only(top: 2, bottom: 2), + return ContextMenu( + items: [ + MenuButton( + leading: material.Icon( + material.Icons.description_outlined, + size: 18, + color: theme.colorScheme.primary, + ), + onPressed: (_) => onTap(), + child: const Text('Open collection'), + ), + MenuButton( + leading: material.Icon( + material.Icons.copy_rounded, + size: 18, + color: theme.colorScheme.mutedForeground, + ), + onPressed: (_) { + Clipboard.setData(ClipboardData(text: name)); + }, + child: const Text('Copy name'), + ), + const MenuDivider(), + MenuButton( + leading: material.Icon( + material.Icons.refresh_rounded, + size: 18, + color: theme.colorScheme.mutedForeground, + ), + onPressed: (_) => onRefreshCollections(), + child: const Text('Refresh'), + ), + MenuButton( + leading: material.Icon( + material.Icons.delete_outline_rounded, + size: 18, + color: theme.colorScheme.destructive, + ), + onPressed: (_) => onDropCollection(name), + child: const Text('Drop collection'), + ), + ], child: QueryaConnectionTreeRow( label: name, isSelected: isSelected, - icon: QueryaIcons.database, - iconSize: QueryaIconSizes.treeConnection, - iconColor: theme.colorScheme.primary.withValues(alpha: 0.7), + icon: material.Icons.table_chart_outlined, + iconSize: QueryaIconSizes.treeLeaf, + iconColor: QueryaTreeTokens.leafIconColor(theme.colorScheme.primary), textStyle: material.TextStyle( - fontSize: 12, + fontSize: 11, color: theme.colorScheme.foreground, ), - verticalPadding: 3, + verticalPadding: 2, onTap: onTap, - connection: connection, - onContextRefresh: onRefreshDatabases, - onOpenSqlWorkspace: null, - onContextDelete: onDelete, - contextDeleteLabel: 'Delete database', + connection: null, ), ); }, diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 8ef270be..4918b351 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -411,14 +411,42 @@ class _MainScreenState extends State { ) async { final ws = _workspace.value; if (ws.activeConnection?.id == connection.id && - ws.activeMongoDB == database) { + ws.activeMongoDB == database && + ws.selectedMongoCollection == null) { return; } if (!await _allowUnsavedNavigation()) return; if (!mounted) return; + setState(() => _pendingMongoCollection = null); _workspace.value = _workspace.value.selectMongoDb(connection, database); } + void _onMongoCollectionSelected( + ConnectionRow connection, + String database, + String collection, + ) { + unawaited(_selectMongoCollection(connection, database, collection)); + } + + Future _selectMongoCollection( + ConnectionRow connection, + String database, + String collection, + ) async { + final ws = _workspace.value; + if (ws.activeConnection?.id == connection.id && + ws.activeMongoDB == database && + ws.selectedMongoCollection == collection) { + return; + } + if (!await _allowUnsavedNavigation()) return; + if (!mounted) return; + setState(() => _pendingMongoCollection = collection); + _workspace.value = + _workspace.value.selectMongoCollection(connection, database, collection); + } + void _onPostgresOpenSqlWorkspace( ConnectionRow connection, { String? database, @@ -609,10 +637,10 @@ class _MainScreenState extends State { ); } case QueryaSchemaObjectKind.collection: - setState(() => _pendingMongoCollection = object.name); - _onMongoDBDatabaseSelected( + _onMongoCollectionSelected( conn, object.database ?? conn.databaseName ?? '', + object.name, ); } } @@ -1047,6 +1075,7 @@ class _MainScreenState extends State { onExtensionObjectSelected: _onExtensionObjectSelected, onRedisDatabaseSelected: _onRedisDatabaseSelected, onMongoDBDatabaseSelected: _onMongoDBDatabaseSelected, + onMongoCollectionSelected: _onMongoCollectionSelected, onPostgresOpenSqlWorkspace: _onPostgresOpenSqlWorkspace, onMysqlOpenSqlWorkspace: _onMysqlOpenSqlWorkspace, onSqliteOpenSqlWorkspace: _onSqliteOpenSqlWorkspace, @@ -1112,6 +1141,7 @@ class _MainContentSplit extends StatefulWidget { required this.onExtensionObjectSelected, required this.onRedisDatabaseSelected, required this.onMongoDBDatabaseSelected, + required this.onMongoCollectionSelected, required this.onPostgresOpenSqlWorkspace, required this.onMysqlOpenSqlWorkspace, required this.onSqliteOpenSqlWorkspace, @@ -1154,6 +1184,7 @@ class _MainContentSplit extends StatefulWidget { ) onExtensionObjectSelected; final void Function(ConnectionRow, int) onRedisDatabaseSelected; final void Function(ConnectionRow, String) onMongoDBDatabaseSelected; + final void Function(ConnectionRow, String, String) onMongoCollectionSelected; final OnPostgresOpenSqlWorkspace onPostgresOpenSqlWorkspace; final void Function(ConnectionRow) onMysqlOpenSqlWorkspace; final void Function(ConnectionRow) onSqliteOpenSqlWorkspace; @@ -1357,6 +1388,8 @@ class _MainContentSplitState extends State<_MainContentSplit> widget.onRedisDatabaseSelected, onMongoDBDatabaseSelected: widget.onMongoDBDatabaseSelected, + onMongoCollectionSelected: + widget.onMongoCollectionSelected, onPostgresObjectSelected: widget.onPostgresObjectSelected, onPostgresOpenSqlWorkspace: @@ -1546,6 +1579,7 @@ class _MainContentSplitState extends State<_MainContentSplit> onRequestOpenTour: widget.onRequestOpenTour, onOpenConnection: widget.onOpenConnection, initialMongoCollection: widget.initialMongoCollection, + selectedMongoCollection: ws.selectedMongoCollection, ); }, ), @@ -1571,6 +1605,7 @@ class _ConnectionsPanelSlot extends StatefulWidget { required this.onSqliteObjectSelected, required this.onRedisDatabaseSelected, required this.onMongoDBDatabaseSelected, + required this.onMongoCollectionSelected, required this.onPostgresOpenSqlWorkspace, required this.onMysqlOpenSqlWorkspace, required this.onSqliteOpenSqlWorkspace, @@ -1600,6 +1635,7 @@ class _ConnectionsPanelSlot extends StatefulWidget { ) onSqliteObjectSelected; final void Function(ConnectionRow, int) onRedisDatabaseSelected; final void Function(ConnectionRow, String) onMongoDBDatabaseSelected; + final void Function(ConnectionRow, String, String) onMongoCollectionSelected; final OnPostgresOpenSqlWorkspace onPostgresOpenSqlWorkspace; final void Function(ConnectionRow) onMysqlOpenSqlWorkspace; final void Function(ConnectionRow) onSqliteOpenSqlWorkspace; @@ -1642,9 +1678,11 @@ class _ConnectionsPanelSlotState extends State<_ConnectionsPanelSlot> { selectedExtensionObject: ws.selectedExtensionObject, selectedRedisDb: ws.activeRedisDb, selectedMongoDb: ws.activeMongoDB, + selectedMongoCollection: ws.selectedMongoCollection, onConnectionSelected: widget.onConnectionSelected, onRedisDatabaseSelected: widget.onRedisDatabaseSelected, onMongoDBDatabaseSelected: widget.onMongoDBDatabaseSelected, + onMongoCollectionSelected: widget.onMongoCollectionSelected, onPostgresObjectSelected: widget.onPostgresObjectSelected, onPostgresOpenSqlWorkspace: widget.onPostgresOpenSqlWorkspace, onMysqlObjectSelected: widget.onMysqlObjectSelected, diff --git a/lib/features/main_screen/main_screen_workspace_state.dart b/lib/features/main_screen/main_screen_workspace_state.dart index 1b781da2..a85b4333 100644 --- a/lib/features/main_screen/main_screen_workspace_state.dart +++ b/lib/features/main_screen/main_screen_workspace_state.dart @@ -11,6 +11,7 @@ class MainScreenWorkspaceState { this.activeConnection, this.activeRedisDb, this.activeMongoDB, + this.selectedMongoCollection, this.selectedPostgresObject, this.postgresSqlTabRequestToken = 0, this.postgresSqlEditorContext, @@ -25,6 +26,7 @@ class MainScreenWorkspaceState { this.lastSelectedSqliteObject, this.lastSelectedExtensionObject, this.lastSelectedMongoDb, + this.lastSelectedMongoCollection, this.lastSelectedRedisDb, this.isReadOnly = false, }); @@ -32,6 +34,7 @@ class MainScreenWorkspaceState { final ConnectionRow? activeConnection; final int? activeRedisDb; final String? activeMongoDB; + final String? selectedMongoCollection; final ({ String database, String schema, @@ -95,6 +98,9 @@ class MainScreenWorkspaceState { /// Last MongoDB database opened for 1-click return from server stats. final String? lastSelectedMongoDb; + /// Last MongoDB collection opened for 1-click return from server stats. + final String? lastSelectedMongoCollection; + /// Last Redis database index opened for 1-click return from server stats. final int? lastSelectedRedisDb; @@ -107,6 +113,7 @@ class MainScreenWorkspaceState { activeConnection: activeConnection, activeRedisDb: activeRedisDb, activeMongoDB: activeMongoDB, + selectedMongoCollection: selectedMongoCollection, selectedPostgresObject: selectedPostgresObject, postgresSqlTabRequestToken: postgresSqlTabRequestToken, postgresSqlEditorContext: postgresSqlEditorContext, @@ -121,6 +128,7 @@ class MainScreenWorkspaceState { lastSelectedSqliteObject: lastSelectedSqliteObject, lastSelectedExtensionObject: lastSelectedExtensionObject, lastSelectedMongoDb: lastSelectedMongoDb, + lastSelectedMongoCollection: lastSelectedMongoCollection, lastSelectedRedisDb: lastSelectedRedisDb, isReadOnly: !isReadOnly, ); @@ -132,6 +140,7 @@ class MainScreenWorkspaceState { activeConnection: connection, activeRedisDb: null, activeMongoDB: null, + selectedMongoCollection: null, selectedPostgresObject: null, postgresSqlTabRequestToken: postgresSqlTabRequestToken, postgresSqlEditorContext: null, @@ -152,6 +161,8 @@ class MainScreenWorkspaceState { : null, lastSelectedMongoDb: same ? (activeMongoDB ?? lastSelectedMongoDb) : null, + lastSelectedMongoCollection: + same ? (selectedMongoCollection ?? lastSelectedMongoCollection) : null, lastSelectedRedisDb: same ? (activeRedisDb ?? lastSelectedRedisDb) : null, isReadOnly: false, @@ -165,6 +176,7 @@ class MainScreenWorkspaceState { activeConnection: activeConnection, activeRedisDb: null, activeMongoDB: null, + selectedMongoCollection: null, selectedPostgresObject: null, postgresSqlTabRequestToken: postgresSqlTabRequestToken, postgresSqlEditorContext: postgresSqlEditorContext, @@ -183,6 +195,8 @@ class MainScreenWorkspaceState { lastSelectedExtensionObject: selectedExtensionObject ?? lastSelectedExtensionObject, lastSelectedMongoDb: activeMongoDB ?? lastSelectedMongoDb, + lastSelectedMongoCollection: + selectedMongoCollection ?? lastSelectedMongoCollection, lastSelectedRedisDb: activeRedisDb ?? lastSelectedRedisDb, isReadOnly: isReadOnly, ); @@ -230,6 +244,13 @@ class MainScreenWorkspaceState { break; case 'mongodb': if (lastSelectedMongoDb != null) { + if (lastSelectedMongoCollection != null) { + return selectMongoCollection( + conn, + lastSelectedMongoDb!, + lastSelectedMongoCollection!, + ); + } return selectMongoDb(conn, lastSelectedMongoDb!); } break; @@ -282,6 +303,7 @@ class MainScreenWorkspaceState { lastSelectedSqliteObject: null, lastSelectedExtensionObject: null, lastSelectedMongoDb: null, + lastSelectedMongoCollection: null, lastSelectedRedisDb: null, isReadOnly: isReadOnly, ); @@ -315,6 +337,7 @@ class MainScreenWorkspaceState { lastSelectedSqliteObject: null, lastSelectedExtensionObject: null, lastSelectedMongoDb: null, + lastSelectedMongoCollection: null, lastSelectedRedisDb: null, isReadOnly: isReadOnly, ); @@ -346,6 +369,7 @@ class MainScreenWorkspaceState { lastSelectedSqliteObject: sq, lastSelectedExtensionObject: null, lastSelectedMongoDb: null, + lastSelectedMongoCollection: null, lastSelectedRedisDb: null, isReadOnly: isReadOnly, ); @@ -378,6 +402,7 @@ class MainScreenWorkspaceState { lastSelectedSqliteObject: null, lastSelectedExtensionObject: ext, lastSelectedMongoDb: null, + lastSelectedMongoCollection: null, lastSelectedRedisDb: null, isReadOnly: isReadOnly, ); @@ -401,6 +426,7 @@ class MainScreenWorkspaceState { lastSelectedSqliteObject: null, lastSelectedExtensionObject: null, lastSelectedMongoDb: null, + lastSelectedMongoCollection: null, lastSelectedRedisDb: db, isReadOnly: isReadOnly, ); @@ -412,6 +438,36 @@ class MainScreenWorkspaceState { activeConnection: connection, activeRedisDb: null, activeMongoDB: database, + selectedMongoCollection: null, + selectedPostgresObject: null, + postgresSqlTabRequestToken: postgresSqlTabRequestToken, + postgresSqlEditorContext: null, + postgresSqlEditorContextToken: 0, + selectedMysqlObject: null, + mysqlSqlTabRequestToken: mysqlSqlTabRequestToken, + selectedSqliteObject: null, + sqliteSqlTabRequestToken: sqliteSqlTabRequestToken, + lastSelectedPostgresObject: null, + lastSelectedMysqlObject: null, + lastSelectedSqliteObject: null, + lastSelectedExtensionObject: null, + lastSelectedMongoDb: database, + lastSelectedMongoCollection: null, + lastSelectedRedisDb: null, + isReadOnly: isReadOnly, + ); + } + + MainScreenWorkspaceState selectMongoCollection( + ConnectionRow connection, + String database, + String collection, + ) { + return MainScreenWorkspaceState( + activeConnection: connection, + activeRedisDb: null, + activeMongoDB: database, + selectedMongoCollection: collection, selectedPostgresObject: null, postgresSqlTabRequestToken: postgresSqlTabRequestToken, postgresSqlEditorContext: null, @@ -425,6 +481,7 @@ class MainScreenWorkspaceState { lastSelectedSqliteObject: null, lastSelectedExtensionObject: null, lastSelectedMongoDb: database, + lastSelectedMongoCollection: collection, lastSelectedRedisDb: null, isReadOnly: isReadOnly, ); @@ -490,6 +547,7 @@ class MainScreenWorkspaceState { lastSelectedSqliteObject: lastSelectedSqliteObject, lastSelectedExtensionObject: lastSelectedExtensionObject, lastSelectedMongoDb: lastSelectedMongoDb, + lastSelectedMongoCollection: lastSelectedMongoCollection, lastSelectedRedisDb: lastSelectedRedisDb, isReadOnly: isReadOnly, ); @@ -513,6 +571,7 @@ class MainScreenWorkspaceState { lastSelectedSqliteObject: lastSelectedSqliteObject, lastSelectedExtensionObject: lastSelectedExtensionObject, lastSelectedMongoDb: lastSelectedMongoDb, + lastSelectedMongoCollection: lastSelectedMongoCollection, lastSelectedRedisDb: lastSelectedRedisDb, isReadOnly: isReadOnly, ); @@ -536,6 +595,7 @@ class MainScreenWorkspaceState { lastSelectedSqliteObject: lastSelectedSqliteObject, lastSelectedExtensionObject: lastSelectedExtensionObject, lastSelectedMongoDb: lastSelectedMongoDb, + lastSelectedMongoCollection: lastSelectedMongoCollection, lastSelectedRedisDb: lastSelectedRedisDb, isReadOnly: isReadOnly, ); @@ -548,6 +608,7 @@ class MainScreenWorkspaceState { activeConnection?.id == other.activeConnection?.id && activeRedisDb == other.activeRedisDb && activeMongoDB == other.activeMongoDB && + selectedMongoCollection == other.selectedMongoCollection && _pgEquals(selectedPostgresObject, other.selectedPostgresObject) && postgresSqlTabRequestToken == other.postgresSqlTabRequestToken && _pgEquals(postgresSqlEditorContext, other.postgresSqlEditorContext) && @@ -564,6 +625,7 @@ class MainScreenWorkspaceState { _extensionEquals( lastSelectedExtensionObject, other.lastSelectedExtensionObject) && lastSelectedMongoDb == other.lastSelectedMongoDb && + lastSelectedMongoCollection == other.lastSelectedMongoCollection && lastSelectedRedisDb == other.lastSelectedRedisDb && isReadOnly == other.isReadOnly; } @@ -573,6 +635,7 @@ class MainScreenWorkspaceState { activeConnection?.id, activeRedisDb, activeMongoDB, + selectedMongoCollection, selectedPostgresObject == null ? 0 : Object.hash( @@ -640,6 +703,7 @@ class MainScreenWorkspaceState { lastSelectedExtensionObject!.name, ), lastSelectedMongoDb, + lastSelectedMongoCollection, lastSelectedRedisDb, isReadOnly, ]); diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index 6cf76d76..0eeb2696 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -70,6 +70,7 @@ class WorkspacePanel extends StatefulWidget { this.onRequestOpenTour, this.onOpenConnection, this.initialMongoCollection, + this.selectedMongoCollection, }); final VoidCallback? onRequestLaunchDemo; @@ -87,6 +88,9 @@ class WorkspacePanel extends StatefulWidget { /// null = show stats, non-null = show data explorer for that db. final String? selectedMongoDb; + /// When set, the user selected a specific MongoDB collection in the sidebar tree. + final String? selectedMongoCollection; + /// When set, the user selected a PostgreSQL object in the sidebar tree. /// null = show stats, non-null = show table/grid or definition view. final ({ @@ -324,7 +328,8 @@ class _WorkspacePanelState extends State { key: ValueKey('mongo_${activeConn.id}_db_$mongoDb'), connectionRow: activeConn, database: mongoDb, - initialCollection: widget.initialMongoCollection, + initialCollection: widget.selectedMongoCollection ?? + widget.initialMongoCollection, ), ); break; diff --git a/lib/features/mongodb/mongo_explorer_view.dart b/lib/features/mongodb/mongo_explorer_view.dart index 08c7abf9..5d4a903a 100644 --- a/lib/features/mongodb/mongo_explorer_view.dart +++ b/lib/features/mongodb/mongo_explorer_view.dart @@ -84,6 +84,18 @@ class _MongoExplorerViewState extends material.State { if (oldWidget.connectionRow.id != widget.connectionRow.id) { _clearLocalConnectionRef(); _connect(); + } else { + if (oldWidget.database != widget.database && widget.database != null) { + _selectedDatabase = widget.database; + } + if (widget.initialCollection != null && + widget.initialCollection != _selectedCollection) { + _navigateToCollection(widget.initialCollection!); + } else if (widget.initialCollection == null && + oldWidget.initialCollection != null && + _selectedCollection != null) { + _navigateToCollections(); + } } } diff --git a/test/features/connections/connections_panel_layout_test.dart b/test/features/connections/connections_panel_layout_test.dart index fda22e1c..ea72c1f7 100644 --- a/test/features/connections/connections_panel_layout_test.dart +++ b/test/features/connections/connections_panel_layout_test.dart @@ -392,6 +392,33 @@ void main() { expect(find.byType(ConnectionsPanel), findsOneWidget); }); + testWidgets('ConnectionsPanel propagates Mongo collection selection', + (tester) async { + await pumpWidgetWithSurfaceSize( + tester, + const material.Size(400, 600), + ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: material.SizedBox( + width: 400, + height: 600, + child: ConnectionsPanel( + skipInitialDbLoadForTest: true, + selectedConnectionId: 14, + selectedMongoDb: 'app', + selectedMongoCollection: 'users', + onMongoCollectionSelected: (conn, db, col) {}, + ), + ), + ), + ); + await tester.pump(); + + expect(find.byType(ConnectionsPanel), findsOneWidget); + }); + testWidgets('SERVERS header uses compact top padding (UI-05)', (tester) async { await pumpWidgetWithSurfaceSize( diff --git a/test/features/main_screen/main_screen_workspace_state_test.dart b/test/features/main_screen/main_screen_workspace_state_test.dart index 779dbdb8..9a509823 100644 --- a/test/features/main_screen/main_screen_workspace_state_test.dart +++ b/test/features/main_screen/main_screen_workspace_state_test.dart @@ -378,11 +378,56 @@ void main() { final stateMongo = stateExt.selectMongoDb(mongoConn, 'app'); expect(stateMongo.lastSelectedExtensionObject, isNull); expect(stateMongo.lastSelectedMongoDb, 'app'); + expect(stateMongo.lastSelectedMongoCollection, isNull); expect(stateMongo.lastSelectedRedisDb, isNull); - final stateRedis = stateMongo.selectRedisDb(redisConn, 2); + final stateMongoColl = stateExt.selectMongoCollection(mongoConn, 'app', 'users'); + expect(stateMongoColl.lastSelectedExtensionObject, isNull); + expect(stateMongoColl.activeMongoDB, 'app'); + expect(stateMongoColl.selectedMongoCollection, 'users'); + expect(stateMongoColl.lastSelectedMongoDb, 'app'); + expect(stateMongoColl.lastSelectedMongoCollection, 'users'); + + final stateRedis = stateMongoColl.selectRedisDb(redisConn, 2); expect(stateRedis.lastSelectedMongoDb, isNull); + expect(stateRedis.lastSelectedMongoCollection, isNull); expect(stateRedis.lastSelectedRedisDb, 2); }); + + test('selectMongoCollection and restoreLastSelectedObject', () { + final state = MainScreenWorkspaceState.empty.selectMongoCollection( + mongoConn, + 'analytics', + 'events', + ); + expect(state.activeMongoDB, 'analytics'); + expect(state.selectedMongoCollection, 'events'); + expect(state.lastSelectedMongoDb, 'analytics'); + expect(state.lastSelectedMongoCollection, 'events'); + + final home = state.unselectActiveObject(); + expect(home.activeMongoDB, isNull); + expect(home.selectedMongoCollection, isNull); + expect(home.lastSelectedMongoDb, 'analytics'); + expect(home.lastSelectedMongoCollection, 'events'); + + final restored = home.restoreLastSelectedObject(); + expect(restored.activeMongoDB, 'analytics'); + expect(restored.selectedMongoCollection, 'events'); + expect(restored.lastSelectedMongoDb, 'analytics'); + expect(restored.lastSelectedMongoCollection, 'events'); + }); + + test('selectMongoDb clears selectedMongoCollection', () { + final stateColl = MainScreenWorkspaceState.empty.selectMongoCollection( + mongoConn, + 'analytics', + 'events', + ); + final stateDb = stateColl.selectMongoDb(mongoConn, 'analytics'); + expect(stateDb.activeMongoDB, 'analytics'); + expect(stateDb.selectedMongoCollection, isNull); + expect(stateDb.lastSelectedMongoCollection, isNull); + }); }); } diff --git a/test/features/main_screen/workspace_panel_layout_test.dart b/test/features/main_screen/workspace_panel_layout_test.dart index b97f82fa..e09132dd 100644 --- a/test/features/main_screen/workspace_panel_layout_test.dart +++ b/test/features/main_screen/workspace_panel_layout_test.dart @@ -6,6 +6,7 @@ import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/features/main_screen/workspace_panel.dart'; import 'package:querya_desktop/features/connections/connections_panel.dart' show SqliteObjectKind; +import 'package:querya_desktop/features/mongodb/mongo_explorer_view.dart'; import 'package:querya_desktop/features/redis/redis_explorer_view.dart'; import 'package:querya_desktop/features/redis/redis_view.dart'; @@ -319,5 +320,39 @@ void main() { await tester.tap(returnButton); expect(restored, isTrue); }); + + testWidgets( + 'MongoExplorerView receives selectedMongoCollection from WorkspacePanel', + (tester) async { + const mongoConn = ConnectionRow( + id: 77, + type: 'mongodb', + name: 'mongo-test', + host: '127.0.0.1', + port: 27017, + createdAt: '0', + ); + await pumpWidgetWithSurfaceSize( + tester, + const material.Size(800, 600), + queryaThemeTestShell( + child: const material.SizedBox.expand( + child: WorkspacePanel( + activeConnection: mongoConn, + selectedMongoDb: 'test_db', + selectedMongoCollection: 'orders', + ), + ), + ), + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + final explorer = + tester.widget(find.byType(MongoExplorerView)); + expect(explorer.database, 'test_db'); + expect(explorer.initialCollection, 'orders'); + }); }); } +