diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index 038e6a28..8acd192c 100644 --- a/.flutter-plugins-dependencies +++ b/.flutter-plugins-dependencies @@ -1 +1 @@ -{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"path_provider_foundation","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.6.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_darwin-2.4.2\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"path_provider_android","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_android-2.2.22\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_android","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_android-2.4.2+2\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"bitsdojo_window_macos","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_macos-0.1.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.6.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_darwin-2.4.2\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"bitsdojo_window_linux","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_linux-0.1.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_linux-2.2.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"bitsdojo_window_windows","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_windows-0.1.6\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_windows-2.3.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false}],"web":[]},"dependencyGraph":[{"name":"bitsdojo_window","dependencies":["bitsdojo_window_windows","bitsdojo_window_macos","bitsdojo_window_linux"]},{"name":"bitsdojo_window_linux","dependencies":[]},{"name":"bitsdojo_window_macos","dependencies":[]},{"name":"bitsdojo_window_windows","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]}],"date_created":"2026-03-08 13:00:51.851139","version":"3.38.5","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file +{"info":"This is a generated file; do not edit or check into version control.","plugins":{"ios":[{"name":"path_provider_foundation","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.6.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_darwin-2.4.2\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"android":[{"name":"path_provider_android","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_android-2.2.22\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"sqflite_android","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_android-2.4.2+2\\\\","native_build":true,"dependencies":[],"dev_dependency":false}],"macos":[{"name":"bitsdojo_window_macos","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_macos-0.1.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_foundation","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_foundation-2.6.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false},{"name":"sqflite_darwin","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\sqflite_darwin-2.4.2\\\\","shared_darwin_source":true,"native_build":true,"dependencies":[],"dev_dependency":false}],"linux":[{"name":"bitsdojo_window_linux","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_linux-0.1.4\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_linux","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_linux-2.2.1\\\\","native_build":false,"dependencies":[],"dev_dependency":false}],"windows":[{"name":"bitsdojo_window_windows","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\bitsdojo_window_windows-0.1.6\\\\","native_build":true,"dependencies":[],"dev_dependency":false},{"name":"path_provider_windows","path":"C:\\\\Users\\\\junte\\\\AppData\\\\Local\\\\Pub\\\\Cache\\\\hosted\\\\pub.dev\\\\path_provider_windows-2.3.0\\\\","native_build":false,"dependencies":[],"dev_dependency":false}],"web":[]},"dependencyGraph":[{"name":"bitsdojo_window","dependencies":["bitsdojo_window_windows","bitsdojo_window_macos","bitsdojo_window_linux"]},{"name":"bitsdojo_window_linux","dependencies":[]},{"name":"bitsdojo_window_macos","dependencies":[]},{"name":"bitsdojo_window_windows","dependencies":[]},{"name":"path_provider","dependencies":["path_provider_android","path_provider_foundation","path_provider_linux","path_provider_windows"]},{"name":"path_provider_android","dependencies":[]},{"name":"path_provider_foundation","dependencies":[]},{"name":"path_provider_linux","dependencies":[]},{"name":"path_provider_windows","dependencies":[]},{"name":"sqflite","dependencies":["sqflite_android","sqflite_darwin"]},{"name":"sqflite_android","dependencies":[]},{"name":"sqflite_darwin","dependencies":[]}],"date_created":"2026-03-10 05:45:19.165964","version":"3.41.4","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/lib/core/database/mongodb_connection.dart b/lib/core/database/mongodb_connection.dart index 1fafdbe7..916e8de4 100644 --- a/lib/core/database/mongodb_connection.dart +++ b/lib/core/database/mongodb_connection.dart @@ -212,4 +212,24 @@ class MongoConnection { return false; } } + + /// Drops (deletes) a database. + Future dropDatabase(String databaseName) async { + if (!isConnected || _db == null) { + throw StateError('Not connected to MongoDB'); + } + + try { + final dbUri = buildUriForDatabase(databaseName); + final db = await Db.create(dbUri); + await db.open(); + try { + await db.drop(); + } finally { + await db.close(); + } + } catch (e) { + rethrow; + } + } } diff --git a/lib/core/storage/local_db.dart b/lib/core/storage/local_db.dart index afdea28b..e7dbda98 100644 --- a/lib/core/storage/local_db.dart +++ b/lib/core/storage/local_db.dart @@ -5,7 +5,7 @@ import 'package:path_provider/path_provider.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; const _dbName = 'querya.db'; -const _dbVersion = 2; +const _dbVersion = 3; /// Local SQLite database for folders and connections. /// File: [applicationSupport]/querya_desktop/querya.db @@ -60,7 +60,7 @@ class LocalDb { auth_source TEXT, use_ssl INTEGER NOT NULL DEFAULT 0, connection_string TEXT, - folder_id INTEGER REFERENCES folders(id), + folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, sort_order INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL ) @@ -75,6 +75,35 @@ class LocalDb { await db.execute('ALTER TABLE connections ADD COLUMN use_ssl INTEGER NOT NULL DEFAULT 0'); await db.execute('ALTER TABLE connections ADD COLUMN connection_string TEXT'); } + if (oldVersion < 3) { + await db.execute('PRAGMA foreign_keys=ON'); + await db.execute(''' + CREATE TABLE connections_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + type TEXT NOT NULL, + name TEXT NOT NULL, + host TEXT, + port INTEGER, + username TEXT, + password TEXT, + database_name TEXT, + auth_source TEXT, + use_ssl INTEGER NOT NULL DEFAULT 0, + connection_string TEXT, + folder_id INTEGER REFERENCES folders(id) ON DELETE CASCADE, + sort_order INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL + ) + '''); + await db.execute(''' + INSERT INTO connections_new + SELECT id, type, name, host, port, username, password, database_name, + auth_source, use_ssl, connection_string, folder_id, sort_order, created_at + FROM connections + '''); + await db.execute('DROP TABLE connections'); + await db.execute('ALTER TABLE connections_new RENAME TO connections'); + } } Future> getFolders() async { diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 6d79ec72..a25f025b 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart' as material show Padding, Container, BoxDecoration, Border, BorderSide, InkWell, Icon, Icons, IconData, Image, EdgeInsets, BorderRadius, CrossAxisAlignment, MainAxisSize, MouseRegion, SystemMouseCursors, DefaultTextStyle, TextStyle, CustomScrollView, SliverToBoxAdapter, SliverFillRemaining, SliverPadding, GestureDetector, HitTestBehavior, SizedBox, Column, AnimatedRotation, Row, BoxFit, Text, TextOverflow, Expanded, CircularProgressIndicator; +import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/redis_connection.dart'; import 'package:querya_desktop/core/database/redis_info.dart'; import 'package:querya_desktop/core/storage/folders_storage.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; +import 'package:querya_desktop/features/mongodb/mongo_database_dialog.dart'; import 'package:querya_desktop/features/mongodb/mongodb_connection_form.dart'; import 'package:querya_desktop/features/redis/redis_connection_form.dart'; import 'new_connection_dialog.dart'; @@ -16,6 +18,7 @@ class ConnectionsPanel extends StatefulWidget { super.key, this.onConnectionSelected, this.onRedisDatabaseSelected, + this.onMongoDBDatabaseSelected, }); /// Called when the user taps a connection tile. @@ -25,6 +28,10 @@ class ConnectionsPanel extends StatefulWidget { final void Function(ConnectionRow connection, int database)? onRedisDatabaseSelected; + /// Called when the user taps a MongoDB database node in the tree. + final void Function(ConnectionRow connection, String database)? + onMongoDBDatabaseSelected; + @override State createState() => _ConnectionsPanelState(); } @@ -194,6 +201,7 @@ class _ConnectionsPanelState extends State { onRemoveConnection: _removeConnection, onConnectionTap: widget.onConnectionSelected, onRedisDatabaseTap: widget.onRedisDatabaseSelected, + onMongoDBDatabaseTap: widget.onMongoDBDatabaseSelected, ), // Root connections (no folder) for (final conn in rootConnections) @@ -206,13 +214,22 @@ class _ConnectionsPanelState extends State { onTap: () => widget.onConnectionSelected?.call(conn), onDatabaseTap: (db) => widget.onRedisDatabaseSelected?.call(conn, db), ) - : _ConnectionTile( - connection: conn, - icon: _iconForType(conn.type), - iconAsset: _iconAssetForType(conn.type), - onRemove: () => _removeConnection(conn.id!), - onTap: () => widget.onConnectionSelected?.call(conn), - ), + : conn.type == 'mongodb' + ? _MongoConnectionTile( + connection: conn, + icon: _iconForType(conn.type), + iconAsset: _iconAssetForType(conn.type), + onRemove: () => _removeConnection(conn.id!), + onTap: () => widget.onConnectionSelected?.call(conn), + onDatabaseTap: (db) => widget.onMongoDBDatabaseSelected?.call(conn, db), + ) + : _ConnectionTile( + connection: conn, + icon: _iconForType(conn.type), + iconAsset: _iconAssetForType(conn.type), + onRemove: () => _removeConnection(conn.id!), + onTap: () => widget.onConnectionSelected?.call(conn), + ), // Empty state if (_connections.isEmpty && _folders.isEmpty) const material.Padding( @@ -403,6 +420,7 @@ class _FolderTile extends StatelessWidget { required this.onRemoveConnection, this.onConnectionTap, this.onRedisDatabaseTap, + this.onMongoDBDatabaseTap, }); final String name; @@ -415,6 +433,7 @@ class _FolderTile extends StatelessWidget { final Future Function(int id) onRemoveConnection; final void Function(ConnectionRow connection)? onConnectionTap; final void Function(ConnectionRow connection, int database)? onRedisDatabaseTap; + final void Function(ConnectionRow connection, String database)? onMongoDBDatabaseTap; @override Widget build(BuildContext context) { @@ -489,13 +508,22 @@ class _FolderTile extends StatelessWidget { onTap: () => onConnectionTap?.call(conn), onDatabaseTap: (db) => onRedisDatabaseTap?.call(conn, db), ) - : _ConnectionTile( - connection: conn, - icon: iconForType(conn.type), - iconAsset: _ConnectionsPanelState._iconAssetForType(conn.type), - onRemove: () => onRemoveConnection(conn.id!), - onTap: () => onConnectionTap?.call(conn), - ), + : conn.type == 'mongodb' + ? _MongoConnectionTile( + connection: conn, + icon: iconForType(conn.type), + iconAsset: _ConnectionsPanelState._iconAssetForType(conn.type), + onRemove: () => onRemoveConnection(conn.id!), + onTap: () => onConnectionTap?.call(conn), + onDatabaseTap: (db) => onMongoDBDatabaseTap?.call(conn, db), + ) + : _ConnectionTile( + connection: conn, + icon: iconForType(conn.type), + iconAsset: _ConnectionsPanelState._iconAssetForType(conn.type), + onRemove: () => onRemoveConnection(conn.id!), + onTap: () => onConnectionTap?.call(conn), + ), ), ], ), @@ -814,3 +842,341 @@ class _RedisDatabaseNode extends StatelessWidget { ); } } + +// ─── MongoDB connection tile with expandable database tree ────────────────── + +class _MongoConnectionTile extends StatefulWidget { + const _MongoConnectionTile({ + required this.connection, + required this.icon, + this.iconAsset, + required this.onRemove, + this.onTap, + this.onDatabaseTap, + }); + + final ConnectionRow connection; + final material.IconData icon; + final String? iconAsset; + final VoidCallback onRemove; + final VoidCallback? onTap; + final void Function(String database)? onDatabaseTap; + + @override + State<_MongoConnectionTile> createState() => _MongoConnectionTileState(); +} + +class _MongoConnectionTileState extends State<_MongoConnectionTile> { + bool _expanded = false; + bool _loading = false; + String? _error; + List _databases = []; + + void _toggle() { + setState(() => _expanded = !_expanded); + if (_expanded && _databases.isEmpty && !_loading) { + _loadDatabases(); + } + } + + Future _loadDatabases() async { + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final c = widget.connection; + final conn = MongoConnection( + id: -1, + name: 'sidebar_probe', + host: c.host ?? 'localhost', + port: c.port ?? 27017, + username: c.username, + password: c.password, + database: c.databaseName, + authSource: c.authSource, + useSSL: c.useSSL, + connectionString: c.connectionString, + ); + await conn.connect(); + final dbs = await conn.listDatabases(); + await conn.disconnect(); + + if (!mounted) return; + setState(() { + _databases = dbs; + _loading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + + Future _createDatabase() async { + final dbName = await showCreateMongoDBDialog(context); + if (dbName == null || !mounted) return; + _databases = []; + await _loadDatabases(); + } + + Future _deleteDatabase(String dbName) async { + if (!mounted) return; + try { + final c = widget.connection; + final conn = MongoConnection( + id: -1, + name: 'sidebar_probe', + host: c.host ?? 'localhost', + port: c.port ?? 27017, + username: c.username, + password: c.password, + database: c.databaseName, + authSource: c.authSource, + useSSL: c.useSSL, + connectionString: c.connectionString, + ); + await conn.connect(); + await conn.dropDatabase(dbName); + await conn.disconnect(); + + if (mounted) { + _databases = []; + await _loadDatabases(); + } + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + }); + } + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final iconWidget = widget.iconAsset != null + ? material.Image.asset( + widget.iconAsset!, + width: 16, + height: 16, + fit: material.BoxFit.contain, + errorBuilder: (_, __, ___) => material.Icon( + widget.icon, + size: 16, + color: theme.colorScheme.primary, + ), + ) + : material.Icon(widget.icon, size: 16, color: theme.colorScheme.primary); + + return ContextMenu( + items: [ + MenuButton( + leading: material.Icon(material.Icons.add_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => _createDatabase(), + child: const Text('Create database'), + ), + MenuButton( + leading: material.Icon(material.Icons.refresh_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) { + _databases = []; + _loadDatabases(); + }, + child: const Text('Refresh databases'), + ), + MenuButton( + leading: material.Icon(material.Icons.delete_outline_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => widget.onRemove(), + child: const Text('Remove connection'), + ), + ], + child: material.Padding( + padding: const material.EdgeInsets.only(bottom: 2), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + // Connection row + material.Row( + children: [ + // Expand/collapse arrow + material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.InkWell( + onTap: _toggle, + borderRadius: material.BorderRadius.circular(4), + child: material.Padding( + padding: const material.EdgeInsets.all(2), + child: material.AnimatedRotation( + turns: _expanded ? 0.25 : 0, + duration: const Duration(milliseconds: 150), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 16, + color: theme.colorScheme.mutedForeground, + ), + ), + ), + ), + ), + // Connection name — clickable for stats + material.Expanded( + child: material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.InkWell( + onTap: widget.onTap, + borderRadius: material.BorderRadius.circular(6), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 4, vertical: 6), + child: material.Row( + children: [ + iconWidget, + const Gap(8), + material.Expanded( + child: material.Column( + crossAxisAlignment: + material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Text( + widget.connection.name, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + color: theme.colorScheme.foreground, + ), + ), + if (widget.connection.host != null) + material.Text( + '${widget.connection.host}:${widget.connection.port ?? ''}', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.mutedForeground, + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ), + ], + ), + // Expanded database children + if (_expanded) ...[ + if (_loading) + material.Padding( + padding: const material.EdgeInsets.only(left: 28, top: 4, bottom: 4), + child: material.Row( + children: [ + const material.SizedBox( + width: 12, + height: 12, + child: material.CircularProgressIndicator(strokeWidth: 1.5), + ), + const Gap(8), + const Text('Loading...').muted().xSmall(), + ], + ), + ), + if (_error != null) + material.Padding( + padding: const material.EdgeInsets.only(left: 28, top: 4, bottom: 4), + child: material.Text( + 'Error', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, color: theme.colorScheme.destructive), + ), + ), + for (final db in _databases) + _MongoDatabaseNode( + name: db, + onTap: () => widget.onDatabaseTap?.call(db), + onDelete: () => _deleteDatabase(db), + ), + ], + ], + ), + ), + ); + } +} + +class _MongoDatabaseNode extends StatelessWidget { + const _MongoDatabaseNode({ + required this.name, + required this.onTap, + required this.onDelete, + }); + + final String name; + final VoidCallback onTap; + final VoidCallback onDelete; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return ContextMenu( + items: [ + MenuButton( + leading: material.Icon(material.Icons.delete_outline_rounded, + size: 18, color: theme.colorScheme.mutedForeground), + onPressed: (_) => onDelete(), + child: const Text('Delete database'), + ), + ], + child: material.Padding( + padding: const material.EdgeInsets.only(left: 24), + child: material.MouseRegion( + cursor: material.SystemMouseCursors.click, + child: material.InkWell( + onTap: onTap, + borderRadius: material.BorderRadius.circular(6), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 8, vertical: 5), + child: material.Row( + children: [ + material.Icon( + material.Icons.storage_rounded, + size: 14, + color: theme.colorScheme.primary.withValues(alpha: 0.7), + ), + const Gap(8), + material.Expanded( + child: material.Text( + name, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 12, + color: theme.colorScheme.foreground, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/main_screen/main_screen.dart b/lib/features/main_screen/main_screen.dart index 43272e96..661de173 100644 --- a/lib/features/main_screen/main_screen.dart +++ b/lib/features/main_screen/main_screen.dart @@ -27,10 +27,14 @@ class _MainScreenState extends State { /// Currently selected Redis database (null = show stats). int? _activeRedisDb; + /// Currently selected MongoDB database (null = show stats). + String? _activeMongoDB; + void _onConnectionSelected(ConnectionRow connection) { setState(() { _activeConnection = connection; _activeRedisDb = null; // Reset: connection click → stats + _activeMongoDB = null; }); } @@ -38,6 +42,15 @@ class _MainScreenState extends State { setState(() { _activeConnection = connection; _activeRedisDb = database; + _activeMongoDB = null; + }); + } + + void _onMongoDBDatabaseSelected(ConnectionRow connection, String database) { + setState(() { + _activeConnection = connection; + _activeRedisDb = null; + _activeMongoDB = database; }); } @@ -63,6 +76,7 @@ class _MainScreenState extends State { child: ConnectionsPanel( onConnectionSelected: _onConnectionSelected, onRedisDatabaseSelected: _onRedisDatabaseSelected, + onMongoDBDatabaseSelected: _onMongoDBDatabaseSelected, ), ), _VerticalResizeHandle( @@ -77,6 +91,7 @@ class _MainScreenState extends State { child: WorkspacePanel( activeConnection: _activeConnection, selectedRedisDb: _activeRedisDb, + selectedMongoDb: _activeMongoDB, ), ), ], diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index a375997c..cfddbe6a 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -3,6 +3,7 @@ import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; import 'package:querya_desktop/features/mongodb/mongo_explorer_view.dart'; +import 'package:querya_desktop/features/mongodb/mongo_stats_view.dart'; import 'package:querya_desktop/features/redis/redis_explorer_view.dart'; import 'package:querya_desktop/features/redis/redis_view.dart'; import 'query_editor_tab.dart'; @@ -14,6 +15,7 @@ class WorkspacePanel extends StatefulWidget { super.key, this.activeConnection, this.selectedRedisDb, + this.selectedMongoDb, }); /// Currently selected connection from the sidebar. @@ -23,6 +25,10 @@ class WorkspacePanel extends StatefulWidget { /// null = show stats, non-null = show data explorer for that db. final int? selectedRedisDb; + /// When set, the user selected a specific MongoDB database in the sidebar tree. + /// null = show stats, non-null = show data explorer for that db. + final String? selectedMongoDb; + @override State createState() => _WorkspacePanelState(); } @@ -36,16 +42,25 @@ class _WorkspacePanelState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); - // If a MongoDB connection is selected, show the MongoDB explorer + // If a MongoDB connection is selected if (widget.activeConnection != null && widget.activeConnection!.type == 'mongodb') { + final mongoDb = widget.selectedMongoDb; return material.Container( color: theme.colorScheme.background, child: material.SizedBox.expand( - child: MongoExplorerView( - key: ValueKey(widget.activeConnection!.id), - connectionRow: widget.activeConnection!, - ), + // DB selected → data explorer; no DB → stats + child: mongoDb != null + ? MongoExplorerView( + key: ValueKey( + 'mongo_${widget.activeConnection!.id}_db_$mongoDb'), + connectionRow: widget.activeConnection!, + database: mongoDb, + ) + : MongoStatsView( + key: ValueKey(widget.activeConnection!.id), + connectionRow: widget.activeConnection!, + ), ), ); } diff --git a/lib/features/mongodb/mongo_database_dialog.dart b/lib/features/mongodb/mongo_database_dialog.dart new file mode 100644 index 00000000..f9bb7c05 --- /dev/null +++ b/lib/features/mongodb/mongo_database_dialog.dart @@ -0,0 +1,122 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/shared/widgets/widgets.dart'; + +/// Shows dialog to create a new MongoDB database. +Future showCreateMongoDBDialog(material.BuildContext context) async { + return showDialog( + context: context, + barrierColor: material.Colors.black54, + builder: (context) => const material.Dialog( + backgroundColor: material.Colors.transparent, + insetPadding: material.EdgeInsets.symmetric(horizontal: 40, vertical: 24), + child: _CreateMongoDBDialogContent(), + ), + ); +} + +class _CreateMongoDBDialogContent extends material.StatefulWidget { + const _CreateMongoDBDialogContent(); + + @override + material.State<_CreateMongoDBDialogContent> createState() => _CreateMongoDBDialogContentState(); +} + +class _CreateMongoDBDialogContentState extends material.State<_CreateMongoDBDialogContent> { + final _nameController = material.TextEditingController(); + + @override + void initState() { + super.initState(); + _nameController.addListener(_onFieldChanged); + } + + void _onFieldChanged() => setState(() {}); + + bool get _formValid => _nameController.text.trim().isNotEmpty; + + void _save() { + if (!_formValid) return; + material.Navigator.of(context).pop(_nameController.text.trim()); + } + + @override + void dispose() { + _nameController.removeListener(_onFieldChanged); + _nameController.dispose(); + super.dispose(); + } + + @override + material.Widget build(material.BuildContext context) { + final theme = Theme.of(context).colorScheme; + final radius = Theme.of(context).radiusXxl; + + return material.Container( + constraints: const material.BoxConstraints(maxWidth: 500), + decoration: material.BoxDecoration( + color: theme.popover, + borderRadius: material.BorderRadius.circular(radius), + border: material.Border.all(color: theme.muted), + ), + child: material.ClipRRect( + borderRadius: material.BorderRadius.circular(radius), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 24, 24, 16), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + material.Row( + children: [ + material.Icon(material.Icons.storage_rounded, size: 24, color: theme.primary), + const Gap(12), + const Text('Create Database').large().semiBold(), + ], + ), + const Gap(8), + const Text('Enter the name for the new MongoDB database.').muted().small(), + ], + ), + ), + const material.Divider(height: 1), + material.Padding( + padding: const material.EdgeInsets.all(24), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + const Text('Database Name').small().semiBold(), + const Gap(8), + TextField( + controller: _nameController, + placeholder: const Text('mydb'), + ), + ], + ), + ), + const material.Divider(height: 1), + material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 24, vertical: 16), + child: material.Row( + mainAxisAlignment: material.MainAxisAlignment.end, + children: [ + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Gap(12), + PrimaryButton( + onPressed: _formValid ? _save : null, + child: const Text('Create'), + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/features/mongodb/mongo_explorer_view.dart b/lib/features/mongodb/mongo_explorer_view.dart index abc3ddc6..efe1798a 100644 --- a/lib/features/mongodb/mongo_explorer_view.dart +++ b/lib/features/mongodb/mongo_explorer_view.dart @@ -27,8 +27,13 @@ enum _Level { databases, collections, documents, document } /// Root widget for MongoDB data browsing. /// Manages navigation state (breadcrumbs) and the active connection. class MongoExplorerView extends material.StatefulWidget { - const MongoExplorerView({super.key, required this.connectionRow}); + const MongoExplorerView({ + super.key, + required this.connectionRow, + this.database, + }); final ConnectionRow connectionRow; + final String? database; @override material.State createState() => _MongoExplorerViewState(); @@ -51,6 +56,9 @@ class _MongoExplorerViewState extends material.State { void initState() { super.initState(); _connect(); + if (widget.database != null) { + _selectedDatabase = widget.database; + } } @override diff --git a/lib/features/mongodb/mongodb_connection_form.dart b/lib/features/mongodb/mongodb_connection_form.dart index 2e35a13c..05c22318 100644 --- a/lib/features/mongodb/mongodb_connection_form.dart +++ b/lib/features/mongodb/mongodb_connection_form.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; @@ -75,6 +77,8 @@ class _MongoConnectionFormContentState extends material.State<_MongoConnectionFo bool _useSSL = false; bool _showPassword = false; bool _isTesting = false; + String? _testResult; + Timer? _dismissTimer; @override void initState() { @@ -92,6 +96,7 @@ class _MongoConnectionFormContentState extends material.State<_MongoConnectionFo @override void dispose() { + _dismissTimer?.cancel(); _nameController.removeListener(_onFieldChanged); _hostController.removeListener(_onFieldChanged); _portController.removeListener(_onFieldChanged); @@ -121,9 +126,26 @@ class _MongoConnectionFormContentState extends material.State<_MongoConnectionFo : _connectionStringController.text.trim(), ); - String? _testResult; + void _showTestResult(String result) { + _dismissTimer?.cancel(); + setState(() { + _isTesting = false; + _testResult = result; + }); + _dismissTimer = Timer(const Duration(seconds: 5), () { + if (mounted) setState(() => _testResult = null); + }); + } + + void _dismissResult() { + _dismissTimer?.cancel(); + _dismissTimer = null; + setState(() => _testResult = null); + } Future _testConnection() async { + _dismissTimer?.cancel(); + _dismissTimer = null; setState(() { _isTesting = true; _testResult = null; @@ -147,19 +169,9 @@ class _MongoConnectionFormContentState extends material.State<_MongoConnectionFo final success = await connection.testConnection(); await connection.disconnect(); - if (mounted) { - setState(() { - _isTesting = false; - _testResult = success ? 'success' : 'failed'; - }); - } + if (mounted) _showTestResult(success ? 'success' : 'failed'); } catch (e) { - if (mounted) { - setState(() { - _isTesting = false; - _testResult = 'error: $e'; - }); - } + if (mounted) _showTestResult('error: $e'); } } @@ -387,55 +399,102 @@ class _MongoConnectionFormContentState extends material.State<_MongoConnectionFo ), const material.Divider(height: 1), if (_testResult != null) - material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 24, vertical: 8), - color: _testResult == 'success' - ? material.Colors.green.withValues(alpha: 0.1) - : material.Colors.red.withValues(alpha: 0.1), - child: Row( - children: [ - material.Icon( - _testResult == 'success' - ? material.Icons.check_circle_rounded - : material.Icons.error_rounded, - size: 16, - color: _testResult == 'success' - ? material.Colors.green - : material.Colors.red, - ), - const Gap(8), - material.Expanded( - child: Text( - _testResult == 'success' - ? 'Connection successful!' - : _testResult!.startsWith('error:') - ? _testResult!.substring(7) - : 'Connection failed', - ).small(), + material.Padding( + padding: const material.EdgeInsets.fromLTRB(24, 8, 16, 8), + child: material.Material( + color: material.Colors.transparent, + child: material.InkWell( + onTap: _dismissResult, + borderRadius: material.BorderRadius.circular(8), + child: material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 12, vertical: 10), + decoration: material.BoxDecoration( + color: _testResult == 'success' + ? theme.primary.withValues(alpha: 0.12) + : theme.destructive.withValues(alpha: 0.12), + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: _testResult == 'success' + ? theme.primary.withValues(alpha: 0.35) + : theme.destructive.withValues(alpha: 0.35), + width: 1, + ), + ), + child: material.Row( + children: [ + material.Icon( + _testResult == 'success' + ? material.Icons.check_circle_outline + : material.Icons.info_outline_rounded, + size: 18, + color: _testResult == 'success' + ? theme.primary + : theme.destructive, + ), + const Gap(10), + material.Expanded( + child: Text( + _testResult == 'success' + ? 'Connection successful!' + : _testResult!.startsWith('error:') + ? _testResult!.substring(7) + : 'Connection failed', + style: material.TextStyle( + fontSize: 13, + color: theme.foreground, + ), + ).small(), + ), + material.IconButton( + icon: material.Icon( + material.Icons.close, + size: 18, + color: theme.mutedForeground, + ), + onPressed: _dismissResult, + style: material.IconButton.styleFrom( + minimumSize: const material.Size(28, 28), + padding: material.EdgeInsets.zero, + ), + ), + ], + ), ), - ], + ), ), ), material.Container( padding: const material.EdgeInsets.symmetric(horizontal: 24, vertical: 16), child: material.Row( - mainAxisAlignment: material.MainAxisAlignment.end, children: [ - GhostButton( - onPressed: () => material.Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - const Gap(12), OutlineButton( - onPressed: _isTesting ? null : _testConnection, + onPressed: _formData.isValid && !_isTesting ? _testConnection : null, leading: _isTesting - ? const material.SizedBox( - width: 16, - height: 16, - child: material.CircularProgressIndicator(strokeWidth: 2), + ? material.SizedBox( + width: 18, + height: 18, + child: material.CircularProgressIndicator( + strokeWidth: 2, + color: theme.primary, + ), ) - : const material.Icon(material.Icons.check_circle_outline, size: 18), - child: const Text('Test Connection'), + : material.Icon( + material.Icons.link_rounded, + size: 18, + color: _formData.isValid ? theme.primary : theme.mutedForeground, + ), + child: Text( + 'Test Connection', + style: material.TextStyle( + fontWeight: material.FontWeight.w500, + color: _formData.isValid ? theme.primary : theme.mutedForeground, + ), + ), + ), + const material.Spacer(), + GhostButton( + onPressed: () => material.Navigator.of(context).pop(), + child: const Text('Cancel'), ), const Gap(12), PrimaryButton( diff --git a/test/core/database/mongodb_connection_test.dart b/test/core/database/mongodb_connection_test.dart index 32f72a65..51a4254a 100644 --- a/test/core/database/mongodb_connection_test.dart +++ b/test/core/database/mongodb_connection_test.dart @@ -181,4 +181,18 @@ void main() { expect(conn.db, isNull); }); }); + + group('MongoConnection.dropDatabase', () { + test('throws StateError when not connected', () async { + final conn = MongoConnection(id: 1, name: 'test', host: 'localhost'); + expect( + () => conn.dropDatabase('mydb'), + throwsA(isA().having( + (e) => e.message, + 'message', + contains('Not connected'), + )), + ); + }); + }); } diff --git a/test/features/mongodb/mongo_database_dialog_test.dart b/test/features/mongodb/mongo_database_dialog_test.dart new file mode 100644 index 00000000..e32eb440 --- /dev/null +++ b/test/features/mongodb/mongo_database_dialog_test.dart @@ -0,0 +1,60 @@ +import 'package:flutter/material.dart' as material; +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/theme/app_theme.dart'; +import 'package:querya_desktop/features/mongodb/mongo_database_dialog.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart'; + +void main() { + group('showCreateMongoDBDialog', () { + testWidgets('dialog shows Create Database title and form', (tester) async { + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () => showCreateMongoDBDialog(context), + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + expect(find.text('Create Database'), findsOneWidget); + expect(find.text('Database Name'), findsOneWidget); + expect(find.text('Create'), findsOneWidget); + expect(find.text('Cancel'), findsOneWidget); + }); + + testWidgets('Cancel closes dialog and returns null', (tester) async { + String? result; + await tester.pumpWidget( + ShadcnApp( + theme: AppTheme.dark, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.dark, + home: material.Builder( + builder: (context) => material.ElevatedButton( + onPressed: () async { + result = await showCreateMongoDBDialog(context); + }, + child: const material.Text('Open'), + ), + ), + ), + ); + + await tester.tap(find.text('Open')); + await tester.pumpAndSettle(); + + await tester.tap(find.text('Cancel')); + await tester.pumpAndSettle(); + + expect(result, isNull); + }); + }); +}