diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index f1aa1ebf..18d87a5c 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-05 22:16:33.139337","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-07 10:36:50.253535","version":"3.38.5","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 254076aa..1fafdbe7 100644 --- a/lib/core/database/mongodb_connection.dart +++ b/lib/core/database/mongodb_connection.dart @@ -78,6 +78,41 @@ class MongoConnection { return buffer.toString(); } + /// Returns a connection URI targeting [databaseName]. + /// + /// When credentials are present and no explicit `authSource` query parameter + /// exists, the method automatically adds `authSource=` (defaults + /// to `admin`) so that authentication succeeds on databases other than the + /// one the user was created in. + String buildUriForDatabase(String databaseName) { + final baseUri = buildConnectionUri(); + final uri = Uri.parse(baseUri); + + // Determine the authSource that should be used. + // 1) Already present in the query → keep it. + // 2) Not present but credentials exist → use the original path db, or + // fall back to "admin" (Mongo's default authSource). + final existingAuthSource = uri.queryParameters['authSource']; + final hasCredentials = + uri.userInfo.isNotEmpty || + (username != null && username!.isNotEmpty); + + Map? newQueryParams; + if (existingAuthSource == null && hasCredentials) { + // Original db from the URI path (strip leading '/') + final origDb = uri.path.replaceFirst('/', ''); + final source = (origDb.isNotEmpty) ? origDb : 'admin'; + newQueryParams = Map.from(uri.queryParameters) + ..['authSource'] = source; + } + + final newUri = uri.replace( + path: '/$databaseName', + queryParameters: newQueryParams ?? uri.queryParameters, + ); + return newUri.toString(); + } + /// Connects to MongoDB server. Future connect() async { if (_isConnected && _db != null) { @@ -98,10 +133,13 @@ class MongoConnection { /// Disconnects from MongoDB server. Future disconnect() async { - if (_db != null && _isConnected) { - await _db!.close(); - _db = null; - _isConnected = false; + _isConnected = false; + final db = _db; + _db = null; + try { + await db?.close(); + } catch (_) { + // Connection may already be closed — ignore. } } @@ -119,7 +157,7 @@ class MongoConnection { try { // Switch to admin database to list all databases - final adminUri = buildConnectionUri().replaceAll(RegExp(r'/[^/?]*(\?|$)'), '/admin\$1'); + final adminUri = buildUriForDatabase('admin'); final adminDb = await Db.create(adminUri); await adminDb.open(); try { @@ -147,7 +185,7 @@ class MongoConnection { try { // Create a new Db connection to the specified database - final dbUri = buildConnectionUri().replaceAll(RegExp(r'/[^/?]*(\?|$)'), '/$databaseName\$1'); + final dbUri = buildUriForDatabase(databaseName); final db = await Db.create(dbUri); await db.open(); try { diff --git a/lib/core/database/mongodb_service.dart b/lib/core/database/mongodb_service.dart index 4d627017..8a7df0eb 100644 --- a/lib/core/database/mongodb_service.dart +++ b/lib/core/database/mongodb_service.dart @@ -10,14 +10,23 @@ class MongoService { final Map _connections = {}; - /// Creates a MongoDB connection from ConnectionRow. + /// Creates (or replaces) a [MongoConnection] for the given [ConnectionRow]. + /// If a connection with the same ID already exists it is disconnected first. MongoConnection createConnection(ConnectionRow row) { if (row.type != 'mongodb') { throw ArgumentError('Connection type must be mongodb'); } + final id = row.id ?? 0; + + // Disconnect previous connection for this ID, if any. + final existing = _connections[id]; + if (existing != null) { + existing.disconnect(); // fire-and-forget; disconnect is safe + } + final connection = MongoConnection( - id: row.id ?? 0, + id: id, name: row.name, host: row.host ?? 'localhost', port: row.port ?? 27017, @@ -66,16 +75,10 @@ class MongoService { throw StateError('Not connected to MongoDB'); } - // Create a new Db connection to the specified database - final dbUri = connection.buildConnectionUri().replaceAll(RegExp(r'/[^/?]*(\?|$)'), '/$database\$1'); - final db = await Db.create(dbUri); - await db.open(); - try { + return _withDb(connection, database, (db) async { final cmd = command.map((k, v) => MapEntry(k, v as Object)); return await db.runCommand(cmd); - } finally { - await db.close(); - } + }); } /// Executes a find query. @@ -93,14 +96,10 @@ class MongoService { throw StateError('Not connected to MongoDB'); } - // Create a new Db connection to the specified database - final dbUri = connection.buildConnectionUri().replaceAll(RegExp(r'/[^/?]*(\?|$)'), '/$database\$1'); - final db = await Db.create(dbUri); - await db.open(); - try { + return _withDb(connection, database, (db) async { final coll = db.collection(collection); final selector = filter ?? {}; - + final stream = coll.find(selector); final results = >[]; int count = 0; @@ -116,9 +115,7 @@ class MongoService { count++; } return results; - } finally { - await db.close(); - } + }); } /// Executes an aggregation pipeline. @@ -132,18 +129,139 @@ class MongoService { throw StateError('Not connected to MongoDB'); } - // Create a new Db connection to the specified database - final dbUri = connection.buildConnectionUri().replaceAll(RegExp(r'/[^/?]*(\?|$)'), '/$database\$1'); - final db = await Db.create(dbUri); - await db.open(); - try { + return _withDb(connection, database, (db) async { final coll = db.collection(collection); - final pipe = pipeline.map((stage) => stage.map((k, v) => MapEntry(k, v as Object))).toList(); + final pipe = pipeline + .map((stage) => stage.map((k, v) => MapEntry(k, v as Object))) + .toList(); final result = await coll.aggregate(pipe); // aggregate returns a Map, wrap it in a List return [Map.from(result)]; + }); + } + + /// Opens a temporary [Db] for the given [database], runs [action], then closes. + Future _withDb( + MongoConnection connection, + String database, + Future Function(Db db) action, + ) async { + if (!connection.isConnected) { + throw StateError('Not connected to MongoDB'); + } + final dbUri = connection.buildUriForDatabase(database); + final db = await Db.create(dbUri); + await db.open(); + try { + return await action(db); } finally { await db.close(); } } + + /// Returns the document count for a collection (with optional filter). + Future countDocuments( + MongoConnection connection, + String database, + String collection, { + Map? filter, + }) async { + return _withDb(connection, database, (db) async { + final result = await db.runCommand({ + 'count': collection, + if (filter != null && filter.isNotEmpty) 'query': filter, + }); + final n = result['n']; + if (n is int) return n; + if (n is num) return n.toInt(); + return int.tryParse(n.toString()) ?? 0; + }); + } + + /// Returns `collStats` for a collection. + Future> getCollectionStats( + MongoConnection connection, + String database, + String collection, + ) async { + return _withDb(connection, database, (db) async { + return await db.runCommand({'collStats': collection}); + }); + } + + /// Inserts a single document, returns the inserted document (with _id). + Future> insertDocument( + MongoConnection connection, + String database, + String collection, + Map document, + ) async { + return _withDb(connection, database, (db) async { + final coll = db.collection(collection); + await coll.insertOne(document); + return document; + }); + } + + /// Updates a single document matched by [filter]. + Future updateDocument( + MongoConnection connection, + String database, + String collection, + Map filter, + Map update, + ) async { + return _withDb(connection, database, (db) async { + final coll = db.collection(collection); + await coll.updateOne(filter, update); + }); + } + + /// Deletes a single document matched by [filter]. + Future deleteDocument( + MongoConnection connection, + String database, + String collection, + Map filter, + ) async { + return _withDb(connection, database, (db) async { + final coll = db.collection(collection); + await coll.deleteOne(filter); + }); + } + + /// Returns index information for a collection. + Future>> getIndexes( + MongoConnection connection, + String database, + String collection, + ) async { + return _withDb(connection, database, (db) async { + final coll = db.collection(collection); + final indexes = await coll.getIndexes(); + return indexes.cast>(); + }); + } + + /// Creates a new collection. + Future createCollection( + MongoConnection connection, + String database, + String collectionName, + ) async { + return _withDb(connection, database, (db) async { + await db.createCollection(collectionName); + }); + } + + /// Drops a collection. + Future dropCollection( + MongoConnection connection, + String database, + String collectionName, + ) async { + return _withDb(connection, database, (db) async { + await db.dropCollection(collectionName); + }); + } } diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 46e504ea..91e15748 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -1,4 +1,4 @@ -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; +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; 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'; @@ -337,14 +337,30 @@ class _ConnectionTile extends StatelessWidget { children: [ iconWidget, const Gap(8), - Expanded( - child: Column( + material.Expanded( + child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.start, mainAxisSize: material.MainAxisSize.min, children: [ - Text(connection.name).small(), + material.Text( + connection.name, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + color: theme.colorScheme.foreground, + ), + ), if (connection.host != null) - Text('${connection.host}:${connection.port ?? ''}').muted().xSmall(), + material.Text( + '${connection.host}:${connection.port ?? ''}', + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.mutedForeground, + ), + ), ], ), ), @@ -424,7 +440,17 @@ class _FolderTile extends StatelessWidget { const Gap(2), material.Icon(material.Icons.folder_rounded, size: 18, color: theme.colorScheme.primary), const Gap(8), - Expanded(child: Text(name).small()), + material.Expanded( + child: material.Text( + name, + overflow: material.TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 13, + color: theme.colorScheme.foreground, + ), + ), + ), ], ), ), diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index f2345118..3d440e06 100644 --- a/lib/features/main_screen/workspace_panel.dart +++ b/lib/features/main_screen/workspace_panel.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart' as material show Container, EdgeInsets, B import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; -import 'package:querya_desktop/features/mongodb/mongo_databases_view.dart'; +import 'package:querya_desktop/features/mongodb/mongo_explorer_view.dart'; import 'package:querya_desktop/features/redis/redis_view.dart'; import 'query_editor_tab.dart'; import 'results_tab.dart'; @@ -30,12 +30,17 @@ class _WorkspacePanelState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); - // If a MongoDB connection is selected, show the databases view + // If a MongoDB connection is selected, show the MongoDB explorer if (widget.activeConnection != null && widget.activeConnection!.type == 'mongodb') { - return MongoDatabasesView( - key: ValueKey(widget.activeConnection!.id), - connectionRow: widget.activeConnection!, + return material.Container( + color: theme.colorScheme.background, + child: material.SizedBox.expand( + child: MongoExplorerView( + key: ValueKey(widget.activeConnection!.id), + connectionRow: widget.activeConnection!, + ), + ), ); } diff --git a/lib/features/mongodb/mongo_collections_view.dart b/lib/features/mongodb/mongo_collections_view.dart new file mode 100644 index 00000000..071cede1 --- /dev/null +++ b/lib/features/mongodb/mongo_collections_view.dart @@ -0,0 +1,453 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/mongodb_connection.dart'; +import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; + +/// Displays the list of collections in a MongoDB database. +class MongoCollectionsView extends material.StatefulWidget { + const MongoCollectionsView({ + super.key, + required this.connection, + required this.database, + this.onCollectionTap, + }); + + final MongoConnection connection; + final String database; + final ValueChanged? onCollectionTap; + + @override + material.State createState() => + _MongoCollectionsViewState(); +} + +class _MongoCollectionsViewState extends material.State { + List<_CollectionInfo> _collections = []; + bool _loading = true; + String? _error; + + final _newCollController = material.TextEditingController(); + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _newCollController.dispose(); + super.dispose(); + } + + Future _load() async { + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final names = + await widget.connection.listCollections(widget.database); + final collections = <_CollectionInfo>[]; + for (final name in names) { + int? count; + int? size; + try { + final stats = await MongoService.instance.getCollectionStats( + widget.connection, + widget.database, + name, + ); + count = _toInt(stats['count']); + size = _toInt(stats['size']); + } catch (_) {} + collections.add( + _CollectionInfo(name: name, documentCount: count, size: size)); + } + if (!mounted) return; + setState(() { + _collections = collections; + _loading = false; + }); + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + } + + /// Safely converts a BSON/Dart value to [int]. + static int? _toInt(dynamic v) { + if (v == null) return null; + if (v is int) return v; + if (v is num) return v.toInt(); + return int.tryParse(v.toString()); + } + + Future _createCollection() async { + final name = _newCollController.text.trim(); + if (name.isEmpty) return; + try { + await MongoService.instance.createCollection( + widget.connection, + widget.database, + name, + ); + _newCollController.clear(); + await _load(); + } catch (e) { + if (mounted) { + setState(() { + _error = 'Failed to create collection: $e'; + }); + } + } + } + + Future _dropCollection(String name) async { + try { + await MongoService.instance.dropCollection( + widget.connection, + widget.database, + name, + ); + await _load(); + } catch (e) { + if (mounted) { + setState(() { + _error = 'Failed to drop collection: $e'; + }); + } + } + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + + if (_loading) { + return material.Center( + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + const material.SizedBox( + width: 32, + height: 32, + child: material.CircularProgressIndicator(strokeWidth: 2), + ), + const Gap(16), + const Text('Loading collections...').muted().small(), + ], + ), + ); + } + + if (_error != null) { + return material.Center( + child: material.Padding( + padding: const material.EdgeInsets.all(32), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon(material.Icons.error_outline_rounded, + size: 48, color: cs.destructive), + const Gap(16), + const Text('Error').large().semiBold(), + const Gap(8), + material.SelectableText(_error!, + style: material.TextStyle( + color: cs.mutedForeground, fontSize: 13)), + const Gap(24), + OutlineButton( + onPressed: _load, + leading: const material.Icon(material.Icons.refresh_rounded, + size: 18), + child: const Text('Retry'), + ), + ], + ), + ), + ); + } + + return material.SingleChildScrollView( + padding: const material.EdgeInsets.all(24), + child: _buildCard(cs), + ); + } + + Widget _buildCard(ColorScheme cs) { + final shadcnCs = shadcn.Theme.of(context).colorScheme; + return material.Container( + decoration: material.BoxDecoration( + color: cs.card, + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: cs.border.withValues(alpha: 0.4), width: 1), + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + // Card header + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 20, vertical: 14), + decoration: material.BoxDecoration( + color: shadcnCs.muted.withValues(alpha: 0.3), + borderRadius: const material.BorderRadius.only( + topLeft: Radius.circular(8), + topRight: Radius.circular(8), + ), + ), + child: Row( + children: [ + material.Icon(material.Icons.folder_rounded, + size: 18, color: shadcnCs.primary), + const Gap(10), + Text('${widget.database} — Collections (${_collections.length})') + .semiBold(), + const Spacer(), + material.SizedBox( + width: 180, + child: TextField( + controller: _newCollController, + placeholder: const Text('New collection...'), + ), + ), + const Gap(8), + PrimaryButton( + onPressed: _createCollection, + size: ButtonSize.small, + leading: const material.Icon(material.Icons.add_rounded, + size: 16), + child: const Text('Create'), + ), + ], + ), + ), + // Table header + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 20, vertical: 10), + decoration: material.BoxDecoration( + color: shadcnCs.muted.withValues(alpha: 0.15), + ), + child: Row( + children: [ + const material.SizedBox(width: 80), + material.Expanded( + child: + const Text('Collection Name').semiBold().xSmall()), + material.SizedBox( + width: 100, + child: const Text('Documents').semiBold().xSmall()), + material.SizedBox( + width: 100, + child: const Text('Size').semiBold().xSmall()), + const material.SizedBox(width: 60), + ], + ), + ), + // Collection rows + for (var i = 0; i < _collections.length; i++) ...[ + if (i > 0) + Divider( + height: 1, + color: cs.border.withValues(alpha: 0.15)), + _CollectionRow( + collection: _collections[i], + colorScheme: cs, + onView: () => + widget.onCollectionTap?.call(_collections[i].name), + onDrop: () => _dropCollection(_collections[i].name), + ), + ], + if (_collections.isEmpty) + material.Padding( + padding: const material.EdgeInsets.all(24), + child: material.Center( + child: const Text('No collections found').muted(), + ), + ), + ], + ), + ); + } +} + +// ─── Row widget ────────────────────────────────────────────────────────────── + +class _CollectionRow extends StatefulWidget { + const _CollectionRow({ + required this.collection, + required this.colorScheme, + required this.onView, + required this.onDrop, + }); + + final _CollectionInfo collection; + final ColorScheme colorScheme; + final VoidCallback onView; + final VoidCallback onDrop; + + @override + State<_CollectionRow> createState() => _CollectionRowState(); +} + +class _CollectionRowState extends State<_CollectionRow> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + final cs = widget.colorScheme; + return material.MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: material.AnimatedContainer( + duration: const Duration(milliseconds: 120), + curve: material.Curves.easeOut, + color: _hovered + ? cs.muted.withValues(alpha: 0.15) + : Colors.transparent, + padding: const material.EdgeInsets.symmetric( + horizontal: 20, vertical: 10), + child: Row( + children: [ + _ActionButton( + label: 'View', + icon: material.Icons.visibility_rounded, + color: const Color(0xFF4CAF50), + onTap: widget.onView, + ), + const Gap(16), + material.Expanded( + child: material.InkWell( + onTap: widget.onView, + child: Text( + widget.collection.name, + style: material.TextStyle( + color: cs.primary, + fontSize: 14, + fontWeight: material.FontWeight.w500, + ), + ), + ), + ), + material.SizedBox( + width: 100, + child: Text(widget.collection.documentCount?.toString() ?? '—') + .muted() + .small(), + ), + material.SizedBox( + width: 100, + child: Text(_formatSize(widget.collection.size)) + .muted() + .small(), + ), + _ActionButton( + label: 'Del', + icon: material.Icons.delete_rounded, + color: const Color(0xFFEF5350), + onTap: widget.onDrop, + ), + ], + ), + ), + ); + } + + String _formatSize(int? bytes) { + if (bytes == null) return '—'; + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) { + return '${(bytes / 1024).toStringAsFixed(1)} KB'; + } + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } +} + +class _ActionButton extends StatefulWidget { + const _ActionButton({ + required this.label, + required this.icon, + required this.color, + required this.onTap, + }); + + final String label; + final material.IconData icon; + final Color color; + final VoidCallback onTap; + + @override + State<_ActionButton> createState() => _ActionButtonState(); +} + +class _ActionButtonState extends State<_ActionButton> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + return material.MouseRegion( + cursor: material.SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: material.InkWell( + onTap: widget.onTap, + borderRadius: material.BorderRadius.circular(6), + child: material.AnimatedContainer( + duration: const Duration(milliseconds: 120), + curve: material.Curves.easeOut, + padding: const material.EdgeInsets.symmetric( + horizontal: 12, vertical: 6), + decoration: material.BoxDecoration( + color: _hovered + ? widget.color.withValues(alpha: 0.9) + : widget.color.withValues(alpha: 0.75), + borderRadius: material.BorderRadius.circular(6), + ), + child: Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon(widget.icon, + size: 14, color: material.Colors.white), + const Gap(5), + Text( + widget.label, + style: const material.TextStyle( + color: material.Colors.white, + fontSize: 12, + fontWeight: material.FontWeight.w500, + ), + ), + ], + ), + ), + ), + ); + } +} + +// ─── Data model ────────────────────────────────────────────────────────────── + +class _CollectionInfo { + const _CollectionInfo({ + required this.name, + this.documentCount, + this.size, + }); + + final String name; + final int? documentCount; + final int? size; +} diff --git a/lib/features/mongodb/mongo_databases_view.dart b/lib/features/mongodb/mongo_databases_view.dart index 74f101be..cdbddae1 100644 --- a/lib/features/mongodb/mongo_databases_view.dart +++ b/lib/features/mongodb/mongo_databases_view.dart @@ -1,48 +1,28 @@ -import 'package:flutter/material.dart' as material - show - Padding, - EdgeInsets, - Container, - BoxDecoration, - Border, - BorderRadius, - Icon, - IconData, - Icons, - Center, - CrossAxisAlignment, - MainAxisSize, - Column, - SizedBox, - CircularProgressIndicator, - Colors, - FontWeight, - TextStyle, - Expanded, - SingleChildScrollView, - InkWell, - MouseRegion, - SystemMouseCursors, - AnimatedContainer, - Curves, - SelectableText, - TextEditingController, - DefaultTextStyle; +import 'package:flutter/material.dart' as material; import 'package:querya_desktop/core/database/mongodb_connection.dart'; import 'package:querya_desktop/core/database/mongodb_service.dart'; import 'package:querya_desktop/core/storage/local_db.dart'; import 'package:querya_desktop/shared/widgets/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; /// View that displays MongoDB databases list and server status. -/// Inspired by Mongo Express UI. class MongoDatabasesView extends StatefulWidget { const MongoDatabasesView({ super.key, required this.connectionRow, + this.connection, + this.onDatabaseTap, }); final ConnectionRow connectionRow; + /// An already-open [MongoConnection]. When provided the view re-uses it + /// instead of creating (and potentially killing) a new one. + final MongoConnection? connection; + + /// Called when the user taps a database row to browse it. + final ValueChanged? onDatabaseTap; + @override State createState() => _MongoDatabasesViewState(); } @@ -50,7 +30,6 @@ class MongoDatabasesView extends StatefulWidget { class _MongoDatabasesViewState extends State { MongoConnection? _connection; List<_DatabaseInfo> _databases = []; - _ServerStatus? _serverStatus; bool _isLoading = true; String? _error; @@ -65,7 +44,8 @@ class _MongoDatabasesViewState extends State { @override void didUpdateWidget(covariant MongoDatabasesView oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.connectionRow.id != widget.connectionRow.id) { + if (oldWidget.connectionRow.id != widget.connectionRow.id || + oldWidget.connection != widget.connection) { _connectAndLoad(); } } @@ -83,11 +63,17 @@ class _MongoDatabasesViewState extends State { }); try { - final conn = MongoService.instance.createConnection(widget.connectionRow); - await conn.connect(); + // Re-use the connection supplied by the parent (MongoExplorerView) when + // available so we don't create a second connection that replaces the + // shared one in MongoService. + final conn = widget.connection ?? + MongoService.instance.createConnection(widget.connectionRow); + if (!conn.isConnected) { + await conn.connect(); + } + if (!mounted) return; _connection = conn; await _loadDatabases(); - await _loadServerStatus(); } catch (e) { if (mounted) { setState(() { @@ -104,9 +90,31 @@ class _MongoDatabasesViewState extends State { try { final dbNames = await _connection!.listDatabases(); final databases = <_DatabaseInfo>[]; + + // Try to fetch sizes via listDatabases command + Map? dbListResult; + try { + dbListResult = await MongoService.instance.executeCommand( + _connection!, + 'admin', + {'listDatabases': 1}, + ); + } catch (_) {} + + final dbList = + dbListResult?['databases'] as List? ?? []; + for (final name in dbNames) { - databases.add(_DatabaseInfo(name: name)); + int? sizeOnDisk; + for (final entry in dbList) { + if (entry is Map && entry['name'] == name) { + sizeOnDisk = _toInt(entry['sizeOnDisk']); + break; + } + } + databases.add(_DatabaseInfo(name: name, sizeOnDisk: sizeOnDisk)); } + if (mounted) { setState(() { _databases = databases; @@ -123,24 +131,14 @@ class _MongoDatabasesViewState extends State { } } - Future _loadServerStatus() async { - if (_connection == null || !_connection!.isConnected) return; - - try { - final result = await MongoService.instance.executeCommand( - _connection!, - 'admin', - {'serverStatus': 1}, - ); - - if (mounted) { - setState(() { - _serverStatus = _ServerStatus.fromMap(result); - }); - } - } catch (_) { - // Server status is optional — don't fail the whole view - } + /// Safely converts a BSON/Dart value to [int]. + /// Handles [int], [num], and bson `Int64` (which is not a [num]). + static int? _toInt(dynamic v) { + if (v == null) return null; + if (v is int) return v; + if (v is num) return v.toInt(); + // bson Int64 has a toInt() method but is not a Dart num. + return int.tryParse(v.toString()); } Future _createDatabase() async { @@ -148,7 +146,6 @@ class _MongoDatabasesViewState extends State { if (name.isEmpty || _connection == null) return; try { - // Creating a collection in a new database effectively creates the database await MongoService.instance.executeCommand( _connection!, name, @@ -186,8 +183,7 @@ class _MongoDatabasesViewState extends State { @override Widget build(BuildContext context) { - final theme = Theme.of(context); - final cs = theme.colorScheme; + final cs = Theme.of(context).colorScheme; if (_isLoading) { return material.Center( @@ -200,7 +196,7 @@ class _MongoDatabasesViewState extends State { child: material.CircularProgressIndicator(strokeWidth: 2), ), const Gap(16), - Text('Connecting to ${widget.connectionRow.name}...').muted().small(), + const Text('Loading databases...').muted().small(), ], ), ); @@ -213,25 +209,21 @@ class _MongoDatabasesViewState extends State { child: material.Column( mainAxisSize: material.MainAxisSize.min, children: [ - material.Icon( - material.Icons.error_outline_rounded, - size: 48, - color: cs.destructive, - ), + material.Icon(material.Icons.error_outline_rounded, + size: 48, color: cs.destructive), const Gap(16), - const Text('Connection Error').large().semiBold(), + const Text('Error').large().semiBold(), const Gap(8), material.SelectableText( _error!, style: material.TextStyle( - color: cs.mutedForeground, - fontSize: 13, - ), + color: cs.mutedForeground, fontSize: 13), ), const Gap(24), OutlineButton( onPressed: _connectAndLoad, - leading: const material.Icon(material.Icons.refresh_rounded, size: 18), + leading: const material.Icon(material.Icons.refresh_rounded, + size: 18), child: const Text('Retry'), ), ], @@ -240,63 +232,25 @@ class _MongoDatabasesViewState extends State { ); } - return material.Container( - color: cs.background, - child: material.SingleChildScrollView( - padding: const material.EdgeInsets.all(24), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - children: [ - // Header - _buildHeader(cs), - const Gap(24), - // Databases card - _buildDatabasesCard(cs), - const Gap(24), - // Server status card - if (_serverStatus != null) _buildServerStatusCard(cs), - ], - ), + return material.SingleChildScrollView( + padding: const material.EdgeInsets.all(24), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _buildDatabasesCard(cs), + ], ), ); } - Widget _buildHeader(ColorScheme cs) { - return Row( - children: [ - material.Icon(material.Icons.eco_rounded, size: 28, color: cs.primary), - const Gap(12), - material.Expanded( - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - mainAxisSize: material.MainAxisSize.min, - children: [ - Text(widget.connectionRow.name).large().semiBold(), - const Gap(4), - Text( - '${widget.connectionRow.host ?? 'localhost'}:${widget.connectionRow.port ?? 27017}', - ).muted().small(), - ], - ), - ), - OutlineButton( - onPressed: _connectAndLoad, - leading: const material.Icon(material.Icons.refresh_rounded, size: 18), - child: const Text('Refresh'), - ), - ], - ); - } - Widget _buildDatabasesCard(ColorScheme cs) { + final shadcnCs = shadcn.Theme.of(context).colorScheme; return material.Container( decoration: material.BoxDecoration( color: cs.card, borderRadius: material.BorderRadius.circular(8), border: material.Border.all( - color: cs.border.withValues(alpha: 0.4), - width: 1, - ), + color: cs.border.withValues(alpha: 0.4), width: 1), ), child: material.Column( crossAxisAlignment: material.CrossAxisAlignment.stretch, @@ -304,9 +258,10 @@ class _MongoDatabasesViewState extends State { children: [ // Card header material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 20, vertical: 16), + padding: const material.EdgeInsets.symmetric( + horizontal: 20, vertical: 14), decoration: material.BoxDecoration( - color: cs.muted.withValues(alpha: 0.3), + color: shadcnCs.muted.withValues(alpha: 0.3), borderRadius: const material.BorderRadius.only( topLeft: Radius.circular(8), topRight: Radius.circular(8), @@ -314,37 +269,58 @@ class _MongoDatabasesViewState extends State { ), child: Row( children: [ - const Text('Databases').semiBold(), + material.Icon(material.Icons.storage_rounded, + size: 18, color: shadcnCs.primary), + const Gap(10), + Text('Databases (${_databases.length})').semiBold(), const Spacer(), material.SizedBox( - width: 200, + width: 180, child: TextField( controller: _newDbController, - placeholder: const Text('Database Name'), + placeholder: const Text('New database...'), ), ), const Gap(8), PrimaryButton( onPressed: _createDatabase, - leading: const material.Icon(material.Icons.add_rounded, size: 18), - child: const Text('Create Database'), + size: ButtonSize.small, + leading: const material.Icon(material.Icons.add_rounded, + size: 16), + child: const Text('Create'), ), ], ), ), + // Table header + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 20, vertical: 10), + decoration: material.BoxDecoration( + color: shadcnCs.muted.withValues(alpha: 0.15), + ), + child: Row( + children: [ + const material.SizedBox(width: 80), + material.Expanded( + child: const Text('Database Name').semiBold().xSmall()), + material.SizedBox( + width: 120, + child: const Text('Size').semiBold().xSmall()), + const material.SizedBox(width: 60), + ], + ), + ), // Database rows for (var i = 0; i < _databases.length; i++) ...[ if (i > 0) Divider( - height: 1, - color: cs.border.withValues(alpha: 0.2), - ), + height: 1, + color: cs.border.withValues(alpha: 0.15)), _DatabaseRow( database: _databases[i], colorScheme: cs, - onView: () { - // TODO: navigate to collections view - }, + onView: () => widget.onDatabaseTap?.call(_databases[i].name), onDrop: () => _dropDatabase(_databases[i].name), ), ], @@ -359,82 +335,6 @@ class _MongoDatabasesViewState extends State { ), ); } - - Widget _buildServerStatusCard(ColorScheme cs) { - final s = _serverStatus!; - return material.Container( - decoration: material.BoxDecoration( - color: cs.card, - borderRadius: material.BorderRadius.circular(8), - border: material.Border.all( - color: cs.border.withValues(alpha: 0.4), - width: 1, - ), - ), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - mainAxisSize: material.MainAxisSize.min, - children: [ - // Card header - material.Container( - padding: const material.EdgeInsets.symmetric(horizontal: 20, vertical: 16), - decoration: material.BoxDecoration( - color: cs.muted.withValues(alpha: 0.3), - borderRadius: const material.BorderRadius.only( - topLeft: Radius.circular(8), - topRight: Radius.circular(8), - ), - ), - child: const Text('Server Status').semiBold(), - ), - // Status rows - material.Padding( - padding: const material.EdgeInsets.all(20), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.stretch, - mainAxisSize: material.MainAxisSize.min, - children: [ - // Server info - _StatusSection( - rows: [ - if (s.host != null) _StatusRow('Hostname', s.host!), - if (s.version != null) _StatusRow('MongoDB Version', s.version!), - if (s.uptime != null) _StatusRow('Uptime', '${s.uptime} seconds'), - ], - ), - const Gap(16), - // Connections - _StatusSection( - rows: [ - if (s.currentConnections != null) - _StatusRow('Current Connections', '${s.currentConnections}'), - if (s.availableConnections != null) - _StatusRow('Available Connections', '${s.availableConnections}'), - if (s.activeClients != null) - _StatusRow('Active Clients', '${s.activeClients}'), - ], - ), - const Gap(16), - // Operations - _StatusSection( - rows: [ - if (s.totalInserts != null) - _StatusRow('Total Inserts', '${s.totalInserts}'), - if (s.totalQueries != null) - _StatusRow('Total Queries', '${s.totalQueries}'), - if (s.totalUpdates != null) - _StatusRow('Total Updates', '${s.totalUpdates}'), - if (s.totalDeletes != null) - _StatusRow('Total Deletes', '${s.totalDeletes}'), - ], - ), - ], - ), - ), - ], - ), - ); - } } // ─── Helper widgets ────────────────────────────────────────────────────────── @@ -468,8 +368,11 @@ class _DatabaseRowState extends State<_DatabaseRow> { child: material.AnimatedContainer( duration: const Duration(milliseconds: 120), curve: material.Curves.easeOut, - color: _hovered ? cs.muted.withValues(alpha: 0.15) : Colors.transparent, - padding: const material.EdgeInsets.symmetric(horizontal: 20, vertical: 12), + color: _hovered + ? cs.muted.withValues(alpha: 0.15) + : Colors.transparent, + padding: const material.EdgeInsets.symmetric( + horizontal: 20, vertical: 10), child: Row( children: [ // View button @@ -484,16 +387,23 @@ class _DatabaseRowState extends State<_DatabaseRow> { material.Expanded( child: material.InkWell( onTap: widget.onView, - child: material.DefaultTextStyle( - style: const material.TextStyle( - color: Color(0xFF42A5F5), - fontSize: 15, + child: Text( + widget.database.name, + style: material.TextStyle( + color: cs.primary, + fontSize: 14, fontWeight: material.FontWeight.w500, ), - child: Text(widget.database.name), ), ), ), + // Size + material.SizedBox( + width: 120, + child: Text(_formatSize(widget.database.sizeOnDisk)) + .muted() + .small(), + ), // Delete button _ActionButton( label: 'Del', @@ -506,6 +416,18 @@ class _DatabaseRowState extends State<_DatabaseRow> { ), ); } + + String _formatSize(int? bytes) { + if (bytes == null) return '—'; + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) { + return '${(bytes / 1024).toStringAsFixed(1)} KB'; + } + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } } class _ActionButton extends StatefulWidget { @@ -540,7 +462,8 @@ class _ActionButtonState extends State<_ActionButton> { child: material.AnimatedContainer( duration: const Duration(milliseconds: 120), curve: material.Curves.easeOut, - padding: const material.EdgeInsets.symmetric(horizontal: 14, vertical: 8), + padding: const material.EdgeInsets.symmetric( + horizontal: 12, vertical: 6), decoration: material.BoxDecoration( color: _hovered ? widget.color.withValues(alpha: 0.9) @@ -550,15 +473,16 @@ class _ActionButtonState extends State<_ActionButton> { child: Row( mainAxisSize: material.MainAxisSize.min, children: [ - material.Icon(widget.icon, size: 16, color: material.Colors.white), - const Gap(6), - material.DefaultTextStyle( + material.Icon(widget.icon, + size: 14, color: material.Colors.white), + const Gap(5), + Text( + widget.label, style: const material.TextStyle( color: material.Colors.white, - fontSize: 13, + fontSize: 12, fontWeight: material.FontWeight.w500, ), - child: Text(widget.label), ), ], ), @@ -568,108 +492,10 @@ class _ActionButtonState extends State<_ActionButton> { } } -class _StatusSection extends StatelessWidget { - const _StatusSection({required this.rows}); - - final List<_StatusRow> rows; - - @override - Widget build(BuildContext context) { - if (rows.isEmpty) return const material.SizedBox.shrink(); - final cs = Theme.of(context).colorScheme; - return material.Container( - decoration: material.BoxDecoration( - border: material.Border.all( - color: cs.border.withValues(alpha: 0.2), - width: 1, - ), - borderRadius: material.BorderRadius.circular(6), - ), - child: material.Column( - mainAxisSize: material.MainAxisSize.min, - children: [ - for (var i = 0; i < rows.length; i++) ...[ - if (i > 0) - Divider( - height: 1, - color: cs.border.withValues(alpha: 0.15), - ), - material.Padding( - padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 10), - child: Row( - children: [ - material.SizedBox( - width: 200, - child: Text(rows[i].label).semiBold().small(), - ), - material.Expanded( - child: Text(rows[i].value).muted().small(), - ), - ], - ), - ), - ], - ], - ), - ); - } -} - -class _StatusRow { - const _StatusRow(this.label, this.value); - final String label; - final String value; -} - // ─── Data models ───────────────────────────────────────────────────────────── class _DatabaseInfo { - const _DatabaseInfo({required this.name}); + const _DatabaseInfo({required this.name, this.sizeOnDisk}); final String name; -} - -class _ServerStatus { - const _ServerStatus({ - this.host, - this.version, - this.uptime, - this.currentConnections, - this.availableConnections, - this.activeClients, - this.totalInserts, - this.totalQueries, - this.totalUpdates, - this.totalDeletes, - }); - - final String? host; - final String? version; - final int? uptime; - final int? currentConnections; - final int? availableConnections; - final int? activeClients; - final int? totalInserts; - final int? totalQueries; - final int? totalUpdates; - final int? totalDeletes; - - factory _ServerStatus.fromMap(Map m) { - final connections = m['connections'] as Map?; - final globalLock = m['globalLock'] as Map?; - final activeClientsMap = globalLock?['activeClients'] as Map?; - final opcounters = m['opcounters'] as Map?; - - return _ServerStatus( - host: m['host'] as String?, - version: m['version'] as String?, - uptime: m['uptime'] as int?, - currentConnections: connections?['current'] as int?, - availableConnections: connections?['available'] as int?, - activeClients: activeClientsMap?['total'] as int?, - totalInserts: opcounters?['insert'] as int?, - totalQueries: opcounters?['query'] as int?, - totalUpdates: opcounters?['update'] as int?, - totalDeletes: opcounters?['delete'] as int?, - ); - } + final int? sizeOnDisk; } diff --git a/lib/features/mongodb/mongo_document_editor.dart b/lib/features/mongodb/mongo_document_editor.dart new file mode 100644 index 00000000..beffba39 --- /dev/null +++ b/lib/features/mongodb/mongo_document_editor.dart @@ -0,0 +1,326 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/mongodb_connection.dart'; +import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; + +/// Full-screen JSON editor for a single MongoDB document. +class MongoDocumentEditor extends material.StatefulWidget { + const MongoDocumentEditor({ + super.key, + required this.connection, + required this.database, + required this.collection, + required this.document, + this.onBack, + this.onDocumentUpdated, + this.onDocumentDeleted, + }); + + final MongoConnection connection; + final String database; + final String collection; + final Map document; + final VoidCallback? onBack; + final VoidCallback? onDocumentUpdated; + final VoidCallback? onDocumentDeleted; + + @override + material.State createState() => + _MongoDocumentEditorState(); +} + +class _MongoDocumentEditorState extends material.State { + late material.TextEditingController _controller; + bool _saving = false; + bool _deleting = false; + String? _error; + String? _success; + bool _dirty = false; + + @override + void initState() { + super.initState(); + _controller = material.TextEditingController( + text: _prettyJson(widget.document), + ); + _controller.addListener(_onTextChanged); + } + + @override + void dispose() { + _controller.removeListener(_onTextChanged); + _controller.dispose(); + super.dispose(); + } + + void _onTextChanged() { + if (!_dirty) { + setState(() => _dirty = true); + } + } + + void _format() { + try { + final parsed = json.decode(_controller.text) as Map; + _controller.text = _prettyJson(parsed); + setState(() => _error = null); + } catch (e) { + setState(() => _error = 'Invalid JSON: $e'); + } + } + + Future _save() async { + final id = widget.document['_id']; + if (id == null) { + setState(() => _error = 'Document has no _id field'); + return; + } + + Map parsed; + try { + parsed = json.decode(_controller.text) as Map; + } catch (e) { + setState(() => _error = 'Invalid JSON: $e'); + return; + } + + // Remove _id from the update payload (can't change _id) + final updateDoc = Map.from(parsed); + updateDoc.remove('_id'); + + setState(() { + _saving = true; + _error = null; + _success = null; + }); + + try { + await MongoService.instance.updateDocument( + widget.connection, + widget.database, + widget.collection, + {'_id': id}, + {r'$set': updateDoc}, + ); + if (!mounted) return; + setState(() { + _saving = false; + _dirty = false; + _success = 'Document saved successfully'; + }); + // Clear success after a delay + Future.delayed(const Duration(seconds: 3), () { + if (mounted) setState(() => _success = null); + }); + } catch (e) { + if (mounted) { + setState(() { + _saving = false; + _error = 'Failed to save: $e'; + }); + } + } + } + + Future _delete() async { + final id = widget.document['_id']; + if (id == null) return; + + setState(() { + _deleting = true; + _error = null; + }); + + try { + await MongoService.instance.deleteDocument( + widget.connection, + widget.database, + widget.collection, + {'_id': id}, + ); + if (!mounted) return; + widget.onDocumentDeleted?.call(); + } catch (e) { + if (mounted) { + setState(() { + _deleting = false; + _error = 'Failed to delete: $e'; + }); + } + } + } + + String _prettyJson(Map doc) { + try { + return const JsonEncoder.withIndent(' ').convert(doc); + } catch (_) { + return doc.toString(); + } + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final shadcnCs = shadcn.Theme.of(context).colorScheme; + final idStr = widget.document['_id']?.toString() ?? 'New Document'; + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Toolbar + material.Container( + padding: + const material.EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: material.BoxDecoration( + color: shadcnCs.muted.withValues(alpha: 0.15), + ), + child: Row( + children: [ + material.InkWell( + onTap: widget.onBack, + borderRadius: material.BorderRadius.circular(6), + child: material.Padding( + padding: const material.EdgeInsets.all(4), + child: material.Icon(material.Icons.arrow_back_rounded, + size: 18, color: shadcnCs.foreground), + ), + ), + const Gap(10), + material.Icon(material.Icons.description_rounded, + size: 16, color: shadcnCs.mutedForeground), + const Gap(8), + material.Expanded( + child: Text(idStr).semiBold().small(), + ), + // Format button + OutlineButton( + onPressed: _format, + size: ButtonSize.small, + leading: const material.Icon( + material.Icons.format_align_left_rounded, + size: 14), + child: const Text('Format'), + ), + const Gap(8), + // Save button + PrimaryButton( + onPressed: _saving ? null : _save, + size: ButtonSize.small, + leading: _saving + ? const material.SizedBox( + width: 14, + height: 14, + child: material.CircularProgressIndicator( + strokeWidth: 2), + ) + : const material.Icon(material.Icons.save_rounded, + size: 14), + child: Text(_saving ? 'Saving...' : 'Save'), + ), + const Gap(8), + // Delete button + DestructiveButton( + onPressed: _deleting ? null : _delete, + size: ButtonSize.small, + leading: const material.Icon(material.Icons.delete_rounded, + size: 14), + child: const Text('Delete'), + ), + ], + ), + ), + // Status banners + if (_error != null) + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + color: cs.destructive.withValues(alpha: 0.1), + child: Row( + children: [ + material.Icon(material.Icons.error_outline_rounded, + size: 14, color: cs.destructive), + const Gap(8), + material.Expanded( + child: Text(_error!, + style: material.TextStyle( + color: cs.destructive, fontSize: 12)), + ), + material.InkWell( + onTap: () => setState(() => _error = null), + child: material.Icon(material.Icons.close_rounded, + size: 14, color: cs.destructive), + ), + ], + ), + ), + if (_success != null) + material.Container( + padding: const material.EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + color: const Color(0xFF4CAF50).withValues(alpha: 0.1), + child: Row( + children: [ + const material.Icon(material.Icons.check_circle_rounded, + size: 14, color: Color(0xFF4CAF50)), + const Gap(8), + material.Expanded( + child: Text(_success!, + style: const material.TextStyle( + color: Color(0xFF4CAF50), fontSize: 12)), + ), + ], + ), + ), + // Editor + material.Expanded( + child: material.Container( + color: cs.card, + child: material.TextField( + controller: _controller, + maxLines: null, + expands: true, + style: material.TextStyle( + fontFamily: 'monospace', + fontSize: 13, + color: shadcnCs.foreground, + height: 1.5, + ), + decoration: const material.InputDecoration( + border: material.InputBorder.none, + contentPadding: material.EdgeInsets.all(16), + ), + ), + ), + ), + // Status bar + material.Container( + height: 30, + padding: const material.EdgeInsets.symmetric(horizontal: 16), + decoration: material.BoxDecoration( + color: shadcnCs.muted.withValues(alpha: 0.15), + border: material.Border( + top: material.BorderSide( + color: cs.border.withValues(alpha: 0.2), width: 1), + ), + ), + child: Row( + children: [ + Text('${widget.database} / ${widget.collection}') + .muted() + .xSmall(), + const Spacer(), + if (_dirty) + Text('Modified', + style: material.TextStyle( + color: cs.primary, fontSize: 11)) + .xSmall(), + ], + ), + ), + ], + ); + } +} diff --git a/lib/features/mongodb/mongo_documents_view.dart b/lib/features/mongodb/mongo_documents_view.dart new file mode 100644 index 00000000..0ed8782e --- /dev/null +++ b/lib/features/mongodb/mongo_documents_view.dart @@ -0,0 +1,561 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/mongodb_connection.dart'; +import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; + +const _defaultLimit = 25; + +/// Paginated document browser for a MongoDB collection. +class MongoDocumentsView extends material.StatefulWidget { + const MongoDocumentsView({ + super.key, + required this.connection, + required this.database, + required this.collection, + this.onDocumentTap, + }); + + final MongoConnection connection; + final String database; + final String collection; + final ValueChanged>? onDocumentTap; + + @override + material.State createState() => + _MongoDocumentsViewState(); +} + +class _MongoDocumentsViewState extends material.State { + List> _documents = []; + int _totalCount = 0; + int _skip = 0; + final int _limit = _defaultLimit; + bool _loading = true; + String? _error; + + final _filterController = material.TextEditingController(); + Map? _activeFilter; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void dispose() { + _filterController.dispose(); + super.dispose(); + } + + Future _load() async { + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + }); + try { + final count = await MongoService.instance.countDocuments( + widget.connection, + widget.database, + widget.collection, + filter: _activeFilter, + ); + final docs = await MongoService.instance.find( + widget.connection, + widget.database, + widget.collection, + filter: _activeFilter, + limit: _limit, + skip: _skip, + ); + if (!mounted) return; + setState(() { + _totalCount = count; + _documents = docs; + _loading = false; + }); + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + } + + void _applyFilter() { + final text = _filterController.text.trim(); + if (text.isEmpty) { + _activeFilter = null; + } else { + try { + _activeFilter = json.decode(text) as Map; + } catch (e) { + setState(() { + _error = 'Invalid JSON filter: $e'; + }); + return; + } + } + _skip = 0; + _load(); + } + + void _clearFilter() { + _filterController.clear(); + _activeFilter = null; + _skip = 0; + _load(); + } + + void _goNextPage() { + if (_skip + _limit < _totalCount) { + _skip += _limit; + _load(); + } + } + + void _goPrevPage() { + if (_skip > 0) { + _skip = (_skip - _limit).clamp(0, _totalCount); + _load(); + } + } + + Future _addDocument() async { + try { + await MongoService.instance.insertDocument( + widget.connection, + widget.database, + widget.collection, + {}, + ); + await _load(); + } catch (e) { + if (mounted) { + setState(() { + _error = 'Failed to insert: $e'; + }); + } + } + } + + Future _deleteDocument(Map doc) async { + final id = doc['_id']; + if (id == null) return; + try { + await MongoService.instance.deleteDocument( + widget.connection, + widget.database, + widget.collection, + {'_id': id}, + ); + await _load(); + } catch (e) { + if (mounted) { + setState(() { + _error = 'Failed to delete: $e'; + }); + } + } + } + + // ─── Build ────────────────────────────────────────────────────────────── + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + + if (_loading && _documents.isEmpty) { + return material.Center( + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + const material.SizedBox( + width: 32, + height: 32, + child: material.CircularProgressIndicator(strokeWidth: 2), + ), + const Gap(16), + const Text('Loading documents...').muted().small(), + ], + ), + ); + } + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Filter bar + _buildFilterBar(cs), + const Divider(height: 1), + // Error banner + if (_error != null) _buildErrorBanner(cs), + // Document list + material.Expanded( + child: material.SingleChildScrollView( + padding: const material.EdgeInsets.all(16), + child: _buildDocumentCards(cs), + ), + ), + // Pagination bar + _buildPaginationBar(cs), + ], + ); + } + + Widget _buildFilterBar(ColorScheme cs) { + final shadcnCs = shadcn.Theme.of(context).colorScheme; + return material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 10), + decoration: material.BoxDecoration( + color: shadcnCs.muted.withValues(alpha: 0.15), + ), + child: Row( + children: [ + material.Icon(material.Icons.filter_list_rounded, + size: 18, color: shadcnCs.mutedForeground), + const Gap(10), + material.Expanded( + child: TextField( + controller: _filterController, + placeholder: const Text('Filter (JSON) e.g. {"name": "John"}'), + onSubmitted: (_) => _applyFilter(), + ), + ), + const Gap(8), + OutlineButton( + onPressed: _applyFilter, + size: ButtonSize.small, + child: const Text('Apply'), + ), + const Gap(4), + GhostButton( + onPressed: _clearFilter, + size: ButtonSize.small, + child: const Text('Clear'), + ), + const Gap(12), + PrimaryButton( + onPressed: _addDocument, + size: ButtonSize.small, + leading: + const material.Icon(material.Icons.add_rounded, size: 16), + child: const Text('Add Document'), + ), + ], + ), + ); + } + + Widget _buildErrorBanner(ColorScheme cs) { + return material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 8), + color: cs.destructive.withValues(alpha: 0.1), + child: Row( + children: [ + material.Icon(material.Icons.error_outline_rounded, + size: 16, color: cs.destructive), + const Gap(8), + material.Expanded( + child: Text( + _error!, + style: material.TextStyle(color: cs.destructive, fontSize: 13), + ), + ), + material.InkWell( + onTap: () => setState(() => _error = null), + child: material.Icon(material.Icons.close_rounded, + size: 16, color: cs.destructive), + ), + ], + ), + ); + } + + Widget _buildDocumentCards(ColorScheme cs) { + final shadcnCs = shadcn.Theme.of(context).colorScheme; + if (_documents.isEmpty) { + return material.Center( + child: material.Padding( + padding: const material.EdgeInsets.all(48), + child: const Text('No documents found').muted(), + ), + ); + } + + return material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + for (var i = 0; i < _documents.length; i++) ...[ + if (i > 0) const Gap(8), + _DocumentCard( + document: _documents[i], + index: _skip + i, + colorScheme: cs, + shadcnCs: shadcnCs, + onView: () => + widget.onDocumentTap?.call(_documents[i]), + onDelete: () => _deleteDocument(_documents[i]), + ), + ], + ], + ); + } + + Widget _buildPaginationBar(ColorScheme cs) { + final shadcnCs = shadcn.Theme.of(context).colorScheme; + final currentPage = (_skip / _limit).floor() + 1; + final totalPages = (_totalCount / _limit).ceil(); + final from = _totalCount == 0 ? 0 : _skip + 1; + final to = (_skip + _limit).clamp(0, _totalCount); + + return material.Container( + height: 44, + padding: const material.EdgeInsets.symmetric(horizontal: 16), + decoration: material.BoxDecoration( + color: shadcnCs.muted.withValues(alpha: 0.15), + border: material.Border( + top: material.BorderSide( + color: cs.border.withValues(alpha: 0.2), width: 1), + ), + ), + child: Row( + children: [ + Text('$_totalCount documents').muted().small(), + const Spacer(), + Text('$from – $to').muted().small(), + const Gap(16), + material.InkWell( + onTap: _skip > 0 ? _goPrevPage : null, + child: material.Padding( + padding: const material.EdgeInsets.all(4), + child: material.Icon( + material.Icons.chevron_left_rounded, + size: 20, + color: _skip > 0 + ? shadcnCs.foreground + : shadcnCs.mutedForeground, + ), + ), + ), + const Gap(8), + Text('$currentPage / $totalPages').small(), + const Gap(8), + material.InkWell( + onTap: _skip + _limit < _totalCount ? _goNextPage : null, + child: material.Padding( + padding: const material.EdgeInsets.all(4), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 20, + color: _skip + _limit < _totalCount + ? shadcnCs.foreground + : shadcnCs.mutedForeground, + ), + ), + ), + ], + ), + ); + } +} + +// ─── Document card widget ─────────────────────────────────────────────────── + +class _DocumentCard extends StatefulWidget { + const _DocumentCard({ + required this.document, + required this.index, + required this.colorScheme, + required this.shadcnCs, + required this.onView, + required this.onDelete, + }); + + final Map document; + final int index; + final ColorScheme colorScheme; + final shadcn.ColorScheme shadcnCs; + final VoidCallback onView; + final VoidCallback onDelete; + + @override + State<_DocumentCard> createState() => _DocumentCardState(); +} + +class _DocumentCardState extends State<_DocumentCard> { + bool _hovered = false; + bool _expanded = false; + + @override + Widget build(BuildContext context) { + final cs = widget.colorScheme; + final scs = widget.shadcnCs; + final idStr = widget.document['_id']?.toString() ?? '—'; + final keysPreview = _keysPreview(widget.document); + + return material.MouseRegion( + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: material.AnimatedContainer( + duration: const Duration(milliseconds: 120), + decoration: material.BoxDecoration( + color: _hovered + ? scs.muted.withValues(alpha: 0.15) + : cs.card, + borderRadius: material.BorderRadius.circular(8), + border: material.Border.all( + color: cs.border.withValues(alpha: 0.3), width: 1), + ), + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + mainAxisSize: material.MainAxisSize.min, + children: [ + // Header row + material.InkWell( + onTap: widget.onView, + borderRadius: material.BorderRadius.circular(8), + child: material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 16, vertical: 10), + child: Row( + children: [ + material.Icon(material.Icons.description_rounded, + size: 16, color: scs.mutedForeground), + const Gap(8), + Text( + idStr, + style: material.TextStyle( + color: cs.primary, + fontSize: 13, + fontWeight: material.FontWeight.w500), + ), + const Spacer(), + // Expand toggle + material.InkWell( + onTap: () => setState(() => _expanded = !_expanded), + child: material.Padding( + padding: const material.EdgeInsets.all(4), + child: material.Icon( + _expanded + ? material.Icons.expand_less_rounded + : material.Icons.expand_more_rounded, + size: 18, + color: scs.mutedForeground, + ), + ), + ), + const Gap(8), + // View + _SmallActionButton( + icon: material.Icons.edit_rounded, + color: const Color(0xFF42A5F5), + onTap: widget.onView, + ), + const Gap(4), + // Delete + _SmallActionButton( + icon: material.Icons.delete_rounded, + color: const Color(0xFFEF5350), + onTap: widget.onDelete, + ), + ], + ), + ), + ), + // Preview / expanded JSON + material.Padding( + padding: const material.EdgeInsets.only( + left: 16, right: 16, bottom: 10), + child: _expanded + ? material.SelectableText( + _prettyJson(widget.document), + style: material.TextStyle( + fontSize: 12, + fontFamily: 'monospace', + color: scs.mutedForeground, + ), + ) + : Text( + keysPreview, + overflow: TextOverflow.ellipsis, + maxLines: 1, + style: material.TextStyle( + fontSize: 12, + fontFamily: 'monospace', + color: scs.mutedForeground, + ), + ), + ), + ], + ), + ), + ); + } + + /// Returns a compact list of top-level keys (excluding _id). + String _keysPreview(Map doc) { + final keys = doc.keys.where((k) => k != '_id').toList(); + if (keys.isEmpty) return '{ }'; + return keys.join(', '); + } + + String _prettyJson(Map doc) { + try { + return const JsonEncoder.withIndent(' ').convert(doc); + } catch (_) { + return doc.toString(); + } + } +} + +// ─── Small icon-only action button ────────────────────────────────────────── + +class _SmallActionButton extends StatefulWidget { + const _SmallActionButton({ + required this.icon, + required this.color, + required this.onTap, + }); + + final material.IconData icon; + final Color color; + final VoidCallback onTap; + + @override + State<_SmallActionButton> createState() => _SmallActionButtonState(); +} + +class _SmallActionButtonState extends State<_SmallActionButton> { + bool _hovered = false; + + @override + Widget build(BuildContext context) { + return material.MouseRegion( + cursor: material.SystemMouseCursors.click, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: material.InkWell( + onTap: widget.onTap, + borderRadius: material.BorderRadius.circular(4), + child: material.AnimatedContainer( + duration: const Duration(milliseconds: 120), + padding: const material.EdgeInsets.all(5), + decoration: material.BoxDecoration( + color: _hovered + ? widget.color.withValues(alpha: 0.15) + : material.Colors.transparent, + borderRadius: material.BorderRadius.circular(4), + ), + child: material.Icon(widget.icon, size: 15, color: widget.color), + ), + ), + ); + } +} diff --git a/lib/features/mongodb/mongo_explorer_view.dart b/lib/features/mongodb/mongo_explorer_view.dart new file mode 100644 index 00000000..abc3ddc6 --- /dev/null +++ b/lib/features/mongodb/mongo_explorer_view.dart @@ -0,0 +1,466 @@ +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/mongodb_connection.dart'; +import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; + +import 'mongo_collections_view.dart'; +import 'mongo_databases_view.dart'; +import 'mongo_documents_view.dart'; +import 'mongo_document_editor.dart'; +import 'mongo_stats_view.dart'; + +// ─── Navigation path model ────────────────────────────────────────────────── + +/// A breadcrumb segment in the MongoDB explorer. +class _Crumb { + const _Crumb(this.label, this.level); + final String label; + final _Level level; +} + +enum _Level { databases, collections, documents, document } + +// ─── Main explorer widget ─────────────────────────────────────────────────── + +/// 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}); + final ConnectionRow connectionRow; + + @override + material.State createState() => _MongoExplorerViewState(); +} + +class _MongoExplorerViewState extends material.State { + MongoConnection? _connection; + bool _connecting = true; + String? _error; + + // View mode + bool _showStats = false; + + // Navigation state + String? _selectedDatabase; + String? _selectedCollection; + Map? _selectedDocument; + + @override + void initState() { + super.initState(); + _connect(); + } + + @override + void didUpdateWidget(covariant MongoExplorerView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.connectionRow.id != widget.connectionRow.id) { + _disconnectCurrent(); + _connect(); + } + } + + @override + void dispose() { + _disconnectCurrent(); + super.dispose(); + } + + void _disconnectCurrent() { + final conn = _connection; + _connection = null; + if (conn != null) { + conn.disconnect(); + } + } + + Future _connect() async { + _disconnectCurrent(); + if (!mounted) return; + setState(() { + _connecting = true; + _error = null; + _selectedDatabase = null; + _selectedCollection = null; + _selectedDocument = null; + }); + try { + final conn = + MongoService.instance.createConnection(widget.connectionRow); + await conn.connect(); + if (!mounted) { + conn.disconnect(); + return; + } + setState(() { + _connection = conn; + _connecting = false; + }); + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _connecting = false; + }); + } + } + } + + // ─── Navigation helpers ───────────────────────────────────────────────── + + void _navigateToDatabase(String dbName) { + setState(() { + _selectedDatabase = dbName; + _selectedCollection = null; + _selectedDocument = null; + }); + } + + void _navigateToCollection(String collName) { + setState(() { + _selectedCollection = collName; + _selectedDocument = null; + }); + } + + void _navigateToDocument(Map doc) { + setState(() { + _selectedDocument = doc; + }); + } + + void _navigateToDatabases() { + setState(() { + _selectedDatabase = null; + _selectedCollection = null; + _selectedDocument = null; + }); + } + + void _navigateToCollections() { + setState(() { + _selectedCollection = null; + _selectedDocument = null; + }); + } + + void _navigateToDocuments() { + setState(() { + _selectedDocument = null; + }); + } + + // ─── Breadcrumbs ──────────────────────────────────────────────────────── + + List<_Crumb> get _crumbs { + final list = <_Crumb>[ + _Crumb(widget.connectionRow.name, _Level.databases), + ]; + if (_selectedDatabase != null) { + list.add(_Crumb(_selectedDatabase!, _Level.collections)); + } + if (_selectedCollection != null) { + list.add(_Crumb(_selectedCollection!, _Level.documents)); + } + if (_selectedDocument != null) { + final id = _selectedDocument!['_id']?.toString() ?? 'Document'; + list.add(_Crumb(id, _Level.document)); + } + return list; + } + + void _onCrumbTap(_Crumb crumb) { + switch (crumb.level) { + case _Level.databases: + _navigateToDatabases(); + case _Level.collections: + _navigateToCollections(); + case _Level.documents: + _navigateToDocuments(); + case _Level.document: + break; // Already on the document + } + } + + // ─── Build ────────────────────────────────────────────────────────────── + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + + // Loading state + if (_connecting) { + return material.Center( + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + const material.SizedBox( + width: 32, + height: 32, + child: material.CircularProgressIndicator(strokeWidth: 2), + ), + const Gap(16), + const Text('Connecting...').muted().small(), + ], + ), + ); + } + + // Error state + final err = _error; + if (err != null) { + return material.Center( + child: material.Padding( + padding: const material.EdgeInsets.all(32), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon(material.Icons.error_outline_rounded, + size: 48, color: cs.destructive), + const Gap(16), + const Text('Connection Error').large().semiBold(), + const Gap(8), + material.SelectableText(err, + style: material.TextStyle( + color: cs.mutedForeground, fontSize: 13)), + const Gap(24), + OutlineButton( + onPressed: _connect, + leading: const material.Icon(material.Icons.refresh_rounded, + size: 18), + child: const Text('Retry'), + ), + ], + ), + ), + ); + } + + final conn = _connection; + if (conn == null) return const material.SizedBox.shrink(); + + // Statistics mode — render MongoStatsView full-screen + if (_showStats) { + return MongoStatsView( + key: ValueKey('stats_${widget.connectionRow.id}'), + connectionRow: widget.connectionRow, + connection: conn, + onBack: () => setState(() => _showStats = false), + ); + } + + return material.Container( + color: cs.background, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + // Breadcrumb bar + _BreadcrumbBar( + crumbs: _crumbs, + onCrumbTap: _onCrumbTap, + onRefresh: () { + // Force rebuild of current child + setState(() {}); + }, + onStats: () => setState(() => _showStats = true), + ), + const Divider(height: 1), + // Content + material.Expanded(child: _buildContent(conn)), + ], + ), + ); + } + + material.Widget _buildContent(MongoConnection conn) { + // Document editor + if (_selectedDocument != null && + _selectedDatabase != null && + _selectedCollection != null) { + return MongoDocumentEditor( + key: ValueKey('doc_${_selectedDocument!['_id']}'), + connection: conn, + database: _selectedDatabase!, + collection: _selectedCollection!, + document: _selectedDocument!, + onBack: _navigateToDocuments, + onDocumentUpdated: _navigateToDocuments, + onDocumentDeleted: _navigateToDocuments, + ); + } + + // Documents list + if (_selectedCollection != null && _selectedDatabase != null) { + return MongoDocumentsView( + key: ValueKey('docs_${_selectedDatabase}_$_selectedCollection'), + connection: conn, + database: _selectedDatabase!, + collection: _selectedCollection!, + onDocumentTap: _navigateToDocument, + ); + } + + // Collections list + if (_selectedDatabase != null) { + return MongoCollectionsView( + key: ValueKey('colls_$_selectedDatabase'), + connection: conn, + database: _selectedDatabase!, + onCollectionTap: _navigateToCollection, + ); + } + + // Databases list + return MongoDatabasesView( + key: ValueKey(widget.connectionRow.id), + connection: conn, + connectionRow: widget.connectionRow, + onDatabaseTap: _navigateToDatabase, + ); + } +} + +// ─── Breadcrumb bar ───────────────────────────────────────────────────────── + +class _BreadcrumbBar extends StatelessWidget { + const _BreadcrumbBar({ + required this.crumbs, + required this.onCrumbTap, + required this.onRefresh, + required this.onStats, + }); + + final List<_Crumb> crumbs; + final void Function(_Crumb) onCrumbTap; + final VoidCallback onRefresh; + final VoidCallback onStats; + + @override + material.Widget build(material.BuildContext context) { + final cs = shadcn.Theme.of(context).colorScheme; + return material.Container( + height: 44, + padding: const material.EdgeInsets.symmetric(horizontal: 16), + decoration: material.BoxDecoration( + color: cs.muted.withValues(alpha: 0.3), + ), + child: material.Row( + children: [ + material.Icon(material.Icons.eco_rounded, + size: 18, color: cs.primary), + const Gap(10), + material.Expanded( + child: material.SingleChildScrollView( + scrollDirection: material.Axis.horizontal, + child: material.Row( + mainAxisSize: material.MainAxisSize.min, + children: [ + for (var i = 0; i < crumbs.length; i++) ...[ + if (i > 0) ...[ + material.Padding( + padding: const material.EdgeInsets.symmetric( + horizontal: 6), + child: material.Icon( + material.Icons.chevron_right_rounded, + size: 16, + color: cs.mutedForeground), + ), + ], + _CrumbChip( + label: crumbs[i].label, + isLast: i == crumbs.length - 1, + onTap: i < crumbs.length - 1 + ? () => onCrumbTap(crumbs[i]) + : null, + ), + ], + ], + ), + ), + ), + const Gap(8), + material.Tooltip( + message: 'Statistics', + child: material.InkWell( + onTap: onStats, + borderRadius: material.BorderRadius.circular(6), + child: material.Padding( + padding: const material.EdgeInsets.all(6), + child: material.Icon(material.Icons.bar_chart_rounded, + size: 18, color: cs.mutedForeground), + ), + ), + ), + const Gap(4), + material.Tooltip( + message: 'Refresh', + child: material.InkWell( + onTap: onRefresh, + borderRadius: material.BorderRadius.circular(6), + child: material.Padding( + padding: const material.EdgeInsets.all(6), + child: material.Icon(material.Icons.refresh_rounded, + size: 18, color: cs.mutedForeground), + ), + ), + ), + ], + ), + ); + } +} + +class _CrumbChip extends StatefulWidget { + const _CrumbChip({ + required this.label, + required this.isLast, + this.onTap, + }); + + final String label; + final bool isLast; + final VoidCallback? onTap; + + @override + material.State<_CrumbChip> createState() => _CrumbChipState(); +} + +class _CrumbChipState extends material.State<_CrumbChip> { + bool _hovered = false; + + @override + material.Widget build(material.BuildContext context) { + final cs = shadcn.Theme.of(context).colorScheme; + return material.MouseRegion( + cursor: widget.onTap != null + ? material.SystemMouseCursors.click + : material.SystemMouseCursors.basic, + onEnter: (_) => setState(() => _hovered = true), + onExit: (_) => setState(() => _hovered = false), + child: material.GestureDetector( + onTap: widget.onTap, + child: material.AnimatedContainer( + duration: const Duration(milliseconds: 120), + padding: + const material.EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: material.BoxDecoration( + color: _hovered && widget.onTap != null + ? cs.primary.withValues(alpha: 0.1) + : material.Colors.transparent, + borderRadius: material.BorderRadius.circular(4), + ), + child: widget.isLast + ? Text(widget.label).semiBold().small() + : Text(widget.label, + style: material.TextStyle( + color: cs.primary, + fontSize: 13, + fontWeight: material.FontWeight.w500)) + .small(), + ), + ), + ); + } +} diff --git a/lib/features/mongodb/mongo_stats_view.dart b/lib/features/mongodb/mongo_stats_view.dart new file mode 100644 index 00000000..f3927e7a --- /dev/null +++ b/lib/features/mongodb/mongo_stats_view.dart @@ -0,0 +1,575 @@ +import 'dart:async'; + +import 'package:flutter/material.dart' as material; +import 'package:querya_desktop/core/database/mongodb_connection.dart'; +import 'package:querya_desktop/core/database/mongodb_service.dart'; +import 'package:querya_desktop/core/storage/local_db.dart'; +import 'package:querya_desktop/shared/widgets/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shadcn; + +const _pollInterval = Duration(seconds: 3); +const _summaryChipHeight = 72.0; +const _gridCardHeight = 220.0; + +class MongoStatsView extends material.StatefulWidget { + const MongoStatsView({ + super.key, + required this.connectionRow, + this.connection, + this.onBack, + }); + + final ConnectionRow connectionRow; + + /// An already-open [MongoConnection]. When provided the view re-uses it + /// instead of creating (and potentially killing) a shared one. + final MongoConnection? connection; + + /// Called when the user taps the "back to explorer" button. + final material.VoidCallback? onBack; + + @override + material.State createState() => _MongoStatsViewState(); +} + +class _MongoStatsViewState extends material.State { + MongoConnection? _connection; + Map? _serverStatus; + bool _loading = true; + String? _error; + Timer? _timer; + + @override + void initState() { + super.initState(); + _load(); + } + + @override + void didUpdateWidget(covariant MongoStatsView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.connectionRow.id != widget.connectionRow.id) { + _timer?.cancel(); + _disconnectCurrent(); + _load(); + } + } + + @override + void dispose() { + _timer?.cancel(); + _disconnectCurrent(); + super.dispose(); + } + + /// Whether this view owns its connection (created it itself). + bool _ownsConnection = false; + + /// Safely disconnects and clears the current MongoDB connection. + void _disconnectCurrent() { + final conn = _connection; + _connection = null; + if (conn != null && _ownsConnection) { + conn.disconnect(); // fire-and-forget; disconnect handles errors + } + _ownsConnection = false; + } + + Future _load() async { + _timer?.cancel(); + _disconnectCurrent(); + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + _serverStatus = null; + }); + try { + // Re-use the connection supplied by the parent when available. + final supplied = widget.connection; + MongoConnection conn; + if (supplied != null && supplied.isConnected) { + conn = supplied; + _ownsConnection = false; + } else { + conn = MongoService.instance.createConnection(widget.connectionRow); + await conn.connect(); + _ownsConnection = true; + } + if (!mounted) { + if (_ownsConnection) conn.disconnect(); + return; + } + _connection = conn; + await _fetch(); + if (mounted) _startTimer(); + } catch (e) { + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } + } + } + + Future _fetch() async { + final c = _connection; + if (c == null || !c.isConnected) return; + try { + final status = await MongoService.instance.executeCommand( + c, + 'admin', + {'serverStatus': 1}, + ); + if (!mounted) return; + setState(() { + _serverStatus = status; + _loading = false; + }); + } catch (e) { + if (mounted) setState(() { _error = e.toString(); _loading = false; }); + } + } + + void _startTimer() { + _timer?.cancel(); + _timer = Timer.periodic(_pollInterval, (_) async { + final c = _connection; + if (c == null || !c.isConnected) return; + try { + final status = await MongoService.instance.executeCommand( + c, + 'admin', + {'serverStatus': 1}, + ); + if (!mounted) return; + setState(() => _serverStatus = status); + } catch (_) {} + }); + } + + @override + material.Widget build(material.BuildContext context) { + final cs = Theme.of(context).colorScheme; + final width = MediaQuery.sizeOf(context).width; + + if (_loading) { + return material.Center( + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + const material.SizedBox( + width: 32, + height: 32, + child: material.CircularProgressIndicator(strokeWidth: 2), + ), + const Gap(16), + const Text('Connecting...').muted().small(), + ], + ), + ); + } + + final err = _error; + if (err != null) { + return material.Center( + child: material.Padding( + padding: const material.EdgeInsets.all(32), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + children: [ + material.Icon(material.Icons.error_outline_rounded, size: 48, color: cs.destructive), + const Gap(16), + const Text('Connection Error').large().semiBold(), + const Gap(8), + material.SelectableText(err, style: material.TextStyle(color: cs.mutedForeground, fontSize: 13)), + const Gap(24), + OutlineButton( + onPressed: _load, + leading: const material.Icon(material.Icons.refresh_rounded, size: 18), + child: const Text('Retry'), + ), + ], + ), + ), + ); + } + + final status = _serverStatus; + if (status == null) return material.Container(color: cs.background); + + return material.Container( + color: cs.background, + child: material.RefreshIndicator( + onRefresh: _fetch, + child: material.SingleChildScrollView( + physics: const material.AlwaysScrollableScrollPhysics(), + padding: const material.EdgeInsets.all(24), + child: material.SizedBox( + width: width, + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _header(context), + const Gap(24), + _summaryChips(context, status), + const Gap(24), + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded(child: _memoryCard(context, status)), + const Gap(16), + material.Expanded(child: _operationsCard(context, status)), + ], + ), + const Gap(16), + material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Expanded(child: _connectionsCard(context, status)), + const Gap(16), + material.Expanded(child: _networkCard(context, status)), + ], + ), + const Gap(24), + _sectionCard(context, 'Server', _extractServerInfo(status)), + const Gap(12), + _sectionCard(context, 'Storage', _extractStorageInfo(status)), + const Gap(12), + _sectionCard(context, 'Replication', _extractReplicationInfo(status)), + const Gap(12), + _sectionCard(context, 'WiredTiger', _extractWiredTigerInfo(status)), + ], + ), + ), + ), + ), + ); + } + + material.Widget _header(material.BuildContext context) { + final cs = shadcn.Theme.of(context).colorScheme; + return material.Row( + children: [ + material.Container( + padding: const material.EdgeInsets.all(10), + decoration: material.BoxDecoration( + color: cs.primary.withValues(alpha: 0.12), + borderRadius: material.BorderRadius.circular(12), + ), + child: material.Icon(material.Icons.eco_rounded, size: 28, color: cs.primary), + ), + const Gap(16), + material.Expanded( + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + Text(widget.connectionRow.name).large().semiBold(), + const Gap(4), + Text('${widget.connectionRow.host ?? 'localhost'}:${widget.connectionRow.port ?? 27017}') + .muted().small(), + ], + ), + ), + if (widget.onBack != null) ...[ + OutlineButton( + onPressed: widget.onBack, + leading: const material.Icon( + material.Icons.grid_view_rounded, + size: 18), + child: const Text('Explorer'), + ), + const Gap(8), + ], + OutlineButton( + onPressed: _load, + leading: const material.Icon( + material.Icons.refresh_rounded, size: 18), + child: const Text('Refresh'), + ), + ], + ); + } + + material.Widget _summaryChips(material.BuildContext context, Map status) { + final cs = shadcn.Theme.of(context).colorScheme; + final version = _getString(status, 'version') ?? '—'; + final uptime = _getInt(status, 'uptime') ?? 0; + final uptimeDays = (uptime / 86400).toStringAsFixed(1); + final connections = _getNestedInt(status, 'connections', 'current') ?? 0; + final maxConnections = _getNestedInt(status, 'connections', 'available') ?? 0; + final ops = _getNestedInt(status, 'opcounters', 'query') ?? 0; + material.Widget chip(String label, String value, material.IconData icon) { + return material.Expanded( + child: material.SizedBox( + height: _summaryChipHeight, + child: material.Container( + padding: const material.EdgeInsets.symmetric(horizontal: 16, vertical: 12), + decoration: material.BoxDecoration( + color: cs.card, + borderRadius: material.BorderRadius.circular(10), + border: material.Border.all(color: cs.border.withValues(alpha: 0.5)), + ), + child: material.Row( + children: [ + material.Icon(icon, size: 20, color: cs.primary), + const Gap(12), + material.Expanded( + child: material.Column( + mainAxisAlignment: material.MainAxisAlignment.center, + crossAxisAlignment: material.CrossAxisAlignment.start, + mainAxisSize: material.MainAxisSize.min, + children: [ + Text(label).muted().xSmall(), + const Gap(2), + Text(value).semiBold().small(), + ], + ), + ), + ], + ), + ), + ), + ); + } + return material.Row( + children: [ + chip('Version', version, material.Icons.tag_rounded), + const Gap(12), + chip('Uptime', '$uptimeDays days', material.Icons.schedule_rounded), + const Gap(12), + chip('Connections', '$connections / $maxConnections', material.Icons.people_outline_rounded), + const Gap(12), + chip('Queries', '$ops', material.Icons.speed_rounded), + ], + ); + } + + material.Widget _card(material.BuildContext context, String title, material.Widget body, {double? height}) { + final cs = shadcn.Theme.of(context).colorScheme; + return material.Container( + width: double.infinity, + height: height, + padding: const material.EdgeInsets.all(20), + decoration: material.BoxDecoration( + color: cs.card, + borderRadius: material.BorderRadius.circular(12), + border: material.Border.all(color: cs.border.withValues(alpha: 0.4)), + ), + child: material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + Text(title).semiBold(), + const Gap(12), + body, + ], + ), + ); + } + + material.Widget _memoryCard(material.BuildContext context, Map status) { + final mem = status['mem'] as Map?; + final resident = _getInt(mem, 'resident') ?? 0; + final virtual = _getInt(mem, 'virtual') ?? 0; + final mapped = _getInt(mem, 'mapped') ?? 0; + final mappedWithJournal = _getInt(mem, 'mappedWithJournal') ?? 0; + return _card( + context, + 'Memory', + material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _row(context, 'Resident', _formatBytes(resident)), + _row(context, 'Virtual', _formatBytes(virtual)), + _row(context, 'Mapped', _formatBytes(mapped)), + _row(context, 'Mapped + Journal', _formatBytes(mappedWithJournal)), + ], + ), + height: _gridCardHeight, + ); + } + + material.Widget _operationsCard(material.BuildContext context, Map status) { + final opcounters = status['opcounters'] as Map?; + final inserts = _getInt(opcounters, 'insert') ?? 0; + final queries = _getInt(opcounters, 'query') ?? 0; + final updates = _getInt(opcounters, 'update') ?? 0; + final deletes = _getInt(opcounters, 'delete') ?? 0; + return _card( + context, + 'Operations', + material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _row(context, 'Inserts', '$inserts'), + _row(context, 'Queries', '$queries'), + _row(context, 'Updates', '$updates'), + _row(context, 'Deletes', '$deletes'), + ], + ), + height: _gridCardHeight, + ); + } + + material.Widget _connectionsCard(material.BuildContext context, Map status) { + final connections = status['connections'] as Map?; + final current = _getInt(connections, 'current') ?? 0; + final available = _getInt(connections, 'available') ?? 0; + final active = _getNestedInt(status, 'globalLock', 'activeClients', 'total') ?? 0; + return _card( + context, + 'Connections', + material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _row(context, 'Current', '$current'), + _row(context, 'Available', '$available'), + _row(context, 'Active clients', '$active'), + ], + ), + height: _gridCardHeight, + ); + } + + material.Widget _networkCard(material.BuildContext context, Map status) { + final network = status['network'] as Map?; + final bytesIn = _getInt(network, 'bytesIn') ?? 0; + final bytesOut = _getInt(network, 'bytesOut') ?? 0; + final numRequests = _getInt(network, 'numRequests') ?? 0; + return _card( + context, + 'Network', + material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [ + _row(context, 'Bytes in', _formatBytes(bytesIn)), + _row(context, 'Bytes out', _formatBytes(bytesOut)), + _row(context, 'Requests', '$numRequests'), + ], + ), + height: _gridCardHeight, + ); + } + + material.Widget _sectionCard(material.BuildContext context, String title, Map? data) { + if (data == null || data.isEmpty) return const material.SizedBox.shrink(); + return _card( + context, + title, + material.Column( + mainAxisSize: material.MainAxisSize.min, + crossAxisAlignment: material.CrossAxisAlignment.stretch, + children: [for (final e in data.entries) _row(context, e.key, e.value)], + ), + ); + } + + material.Widget _row(material.BuildContext context, String key, String value) { + final cs = shadcn.Theme.of(context).colorScheme; + return material.Padding( + padding: const material.EdgeInsets.symmetric(vertical: 4), + child: material.Row( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.SizedBox(width: 160, child: Text(key).muted().small()), + material.Expanded(child: material.SelectableText(value, style: material.TextStyle(fontSize: 13, color: cs.foreground))), + ], + ), + ); + } + + // Helper methods to extract data from serverStatus + String? _getString(Map? map, String key) { + final v = map?[key]; + return v?.toString(); + } + + int? _getInt(Map? map, String key) { + final v = map?[key]; + if (v is int) return v; + if (v is num) return v.toInt(); + return null; + } + + int? _getNestedInt(Map? map, String key1, String key2, [String? key3]) { + final m1 = map?[key1] as Map?; + if (m1 == null) return null; + if (key3 != null) { + final m2 = m1[key2] as Map?; + if (m2 == null) return null; + final v = m2[key3]; + if (v is int) return v; + if (v is num) return v.toInt(); + return null; + } + final v = m1[key2]; + if (v is int) return v; + if (v is num) return v.toInt(); + return null; + } + + Map _extractServerInfo(Map status) { + final result = {}; + if (status['host'] != null) result['Host'] = status['host'].toString(); + if (status['version'] != null) result['Version'] = status['version'].toString(); + if (status['process'] != null) result['Process'] = status['process'].toString(); + final uptime = _getInt(status, 'uptime'); + if (uptime != null) { + final days = (uptime / 86400).toStringAsFixed(1); + result['Uptime'] = '$days days ($uptime seconds)'; + } + return result; + } + + Map _extractStorageInfo(Map status) { + final result = {}; + final dur = status['dur'] as Map?; + if (dur != null) { + if (dur['commitsInWriteLock'] != null) { + result['Commits in write lock'] = dur['commitsInWriteLock'].toString(); + } + } + return result; + } + + Map _extractReplicationInfo(Map status) { + final result = {}; + final repl = status['repl'] as Map?; + if (repl != null) { + if (repl['setName'] != null) result['Replica set'] = repl['setName'].toString(); + if (repl['ismaster'] != null) result['Is master'] = repl['ismaster'].toString(); + if (repl['secondary'] != null) result['Secondary'] = repl['secondary'].toString(); + } + return result; + } + + Map _extractWiredTigerInfo(Map status) { + final result = {}; + final wiredTiger = status['wiredTiger'] as Map?; + if (wiredTiger != null) { + final cache = wiredTiger['cache'] as Map?; + if (cache != null) { + final maxSize = _getInt(cache, 'maximum bytes configured'); + if (maxSize != null) result['Max cache size'] = _formatBytes(maxSize); + final usedSize = _getInt(cache, 'bytes currently in the cache'); + if (usedSize != null) result['Cache used'] = _formatBytes(usedSize); + } + } + return result; + } + + String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } +} diff --git a/test/core/database/mongodb_uri_replacement_test.dart b/test/core/database/mongodb_uri_replacement_test.dart new file mode 100644 index 00000000..f98fc459 --- /dev/null +++ b/test/core/database/mongodb_uri_replacement_test.dart @@ -0,0 +1,192 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:querya_desktop/core/database/mongodb_connection.dart'; + +void main() { + group('MongoDB URI database replacement', () { + test('replaces database in URI without existing database', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: '127.0.0.1', + username: 'root', + password: 'root', + ); + final adminUri = conn.buildUriForDatabase('admin'); + expect(adminUri, contains('/admin')); + expect(adminUri, contains('root:root')); + }); + + test('replaces database in URI with existing database', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: '127.0.0.1', + username: 'root', + password: 'root', + database: 'mydb', + ); + final adminUri = conn.buildUriForDatabase('admin'); + expect(adminUri, contains('/admin')); + // Should have authSource=mydb because original db was mydb + expect(adminUri, contains('authSource=mydb')); + }); + + test('preserves explicit authSource when replacing database', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: '127.0.0.1', + username: 'root', + password: 'root', + authSource: 'admin', + ); + final otherUri = conn.buildUriForDatabase('mydb'); + expect(otherUri, contains('/mydb')); + expect(otherUri, contains('authSource=admin')); + }); + + test('preserves multiple query parameters when replacing database', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: '127.0.0.1', + username: 'root', + password: 'root', + authSource: 'admin', + replicaSet: 'rs0', + useSSL: true, + ); + final otherUri = conn.buildUriForDatabase('testdb'); + expect(otherUri, contains('/testdb')); + expect(otherUri, contains('authSource=admin')); + expect(otherUri, contains('replicaSet=rs0')); + expect(otherUri, contains('ssl=true')); + }); + + test('replaces database with custom database name', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: 'localhost', + database: 'olddb', + ); + final newDbUri = conn.buildUriForDatabase('newdb'); + expect(newDbUri, contains('/newdb')); + // No credentials → no authSource added + expect(newDbUri, isNot(contains('authSource'))); + }); + + test('handles URI without authentication', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: 'localhost', + ); + final adminUri = conn.buildUriForDatabase('admin'); + expect(adminUri, contains('/admin')); + // No credentials → no authSource added + expect(adminUri, isNot(contains('authSource'))); + }); + + test('handles URI with custom port', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: '127.0.0.1', + port: 27018, + username: 'root', + password: 'root', + ); + final adminUri = conn.buildUriForDatabase('admin'); + expect(adminUri, contains(':27018')); + expect(adminUri, contains('/admin')); + }); + + test('handles connectionString URI replacement', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: 'localhost', + connectionString: + 'mongodb://user:pass@host:27017/mydb?authSource=admin', + ); + final otherUri = conn.buildUriForDatabase('otherdb'); + expect(otherUri, contains('/otherdb')); + // Explicit authSource in connection string is preserved + expect(otherUri, contains('authSource=admin')); + }); + + test('ensures no literal dollar sign appears in final URI', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: '127.0.0.1', + username: 'root', + password: 'root', + ); + final adminUri = conn.buildUriForDatabase('admin'); + expect(adminUri, isNot(contains(r'$1'))); + expect(adminUri, isNot(contains('admin\$1'))); + expect(adminUri, isNot(contains(r'admin$1'))); + expect(adminUri, contains('/admin')); + }); + + // ─── authSource auto-injection tests ─────────────────────────────── + + test('auto-adds authSource=admin when credentials present and no db', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: '127.0.0.1', + username: 'root', + password: 'root', + ); + // Original URI has no database path → authSource should default to admin + final uri = conn.buildUriForDatabase('testdb'); + expect(uri, contains('/testdb')); + expect(uri, contains('authSource=admin')); + }); + + test('auto-adds authSource=origDb when credentials and db are present', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: '127.0.0.1', + username: 'root', + password: 'root', + database: 'mydb', + ); + // Original URI has /mydb → authSource should be mydb + final uri = conn.buildUriForDatabase('otherdb'); + expect(uri, contains('/otherdb')); + expect(uri, contains('authSource=mydb')); + }); + + test('does not override explicit authSource', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: '127.0.0.1', + username: 'root', + password: 'root', + database: 'mydb', + authSource: 'admin', + ); + // Explicit authSource=admin should be preserved, NOT overridden to mydb + final uri = conn.buildUriForDatabase('otherdb'); + expect(uri, contains('/otherdb')); + expect(uri, contains('authSource=admin')); + }); + + test('no authSource added when no credentials', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: '127.0.0.1', + ); + final uri = conn.buildUriForDatabase('testdb'); + expect(uri, contains('/testdb')); + expect(uri, isNot(contains('authSource'))); + }); + }); +}