From 88c78e633a9c08b6837679fa39e923df61b211bc Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 6 Mar 2026 15:55:55 +0300 Subject: [PATCH 1/2] fix: MongoDB auth failed on non-admin databases - add buildUriForDatabase() with auto authSource injection - reuse parent connection in MongoDatabasesView to prevent disconnection - refactor mongodb_service to use _withDb helper consistently - update URI replacement tests for authSource scenarios --- lib/core/database/mongodb_connection.dart | 43 +- lib/core/database/mongodb_service.dart | 161 ++++- lib/features/main_screen/workspace_panel.dart | 6 +- .../mongodb/mongo_collections_view.dart | 453 ++++++++++++++ .../mongodb/mongo_databases_view.dart | 462 +++++---------- .../mongodb/mongo_document_editor.dart | 326 +++++++++++ .../mongodb/mongo_documents_view.dart | 552 ++++++++++++++++++ lib/features/mongodb/mongo_explorer_view.dart | 433 ++++++++++++++ .../mongodb_uri_replacement_test.dart | 161 ++--- 9 files changed, 2172 insertions(+), 425 deletions(-) create mode 100644 lib/features/mongodb/mongo_collections_view.dart create mode 100644 lib/features/mongodb/mongo_document_editor.dart create mode 100644 lib/features/mongodb/mongo_documents_view.dart create mode 100644 lib/features/mongodb/mongo_explorer_view.dart diff --git a/lib/core/database/mongodb_connection.dart b/lib/core/database/mongodb_connection.dart index 5cf067c5..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) { @@ -122,9 +157,7 @@ class MongoConnection { try { // Switch to admin database to list all databases - final baseUri = buildConnectionUri(); - final uri = Uri.parse(baseUri); - final adminUri = uri.replace(path: '/admin').toString(); + final adminUri = buildUriForDatabase('admin'); final adminDb = await Db.create(adminUri); await adminDb.open(); try { @@ -152,9 +185,7 @@ class MongoConnection { try { // Create a new Db connection to the specified database - final baseUri = buildConnectionUri(); - final uri = Uri.parse(baseUri); - final dbUri = uri.replace(path: '/$databaseName').toString(); + 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 35e6c409..8a7df0eb 100644 --- a/lib/core/database/mongodb_service.dart +++ b/lib/core/database/mongodb_service.dart @@ -75,18 +75,10 @@ class MongoService { throw StateError('Not connected to MongoDB'); } - // Create a new Db connection to the specified database - final baseUri = connection.buildConnectionUri(); - final uri = Uri.parse(baseUri); - final dbUri = uri.replace(path: '/$database').toString(); - 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. @@ -104,16 +96,10 @@ class MongoService { throw StateError('Not connected to MongoDB'); } - // Create a new Db connection to the specified database - final baseUri = connection.buildConnectionUri(); - final uri = Uri.parse(baseUri); - final dbUri = uri.replace(path: '/$database').toString(); - 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; @@ -129,9 +115,7 @@ class MongoService { count++; } return results; - } finally { - await db.close(); - } + }); } /// Executes an aggregation pipeline. @@ -145,20 +129,139 @@ class MongoService { throw StateError('Not connected to MongoDB'); } - // Create a new Db connection to the specified database - final baseUri = connection.buildConnectionUri(); - final uri = Uri.parse(baseUri); - final dbUri = uri.replace(path: '/$database').toString(); - 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/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index fc96d0f1..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_stats_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,13 +30,13 @@ class _WorkspacePanelState extends State { Widget build(BuildContext context) { final theme = Theme.of(context); - // If a MongoDB connection is selected, show the stats view + // If a MongoDB connection is selected, show the MongoDB explorer if (widget.activeConnection != null && widget.activeConnection!.type == 'mongodb') { return material.Container( color: theme.colorScheme.background, child: material.SizedBox.expand( - child: MongoStatsView( + 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..c785a6fe --- /dev/null +++ b/lib/features/mongodb/mongo_documents_view.dart @@ -0,0 +1,552 @@ +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 preview = _compactJson(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: material.SelectableText( + _expanded ? _prettyJson(widget.document) : preview, + style: material.TextStyle( + fontSize: 12, + fontFamily: 'monospace', + color: scs.mutedForeground, + ), + maxLines: _expanded ? null : 2, + ), + ), + ], + ), + ), + ); + } + + String _compactJson(Map doc) { + try { + return json.encode(doc); + } catch (_) { + return doc.toString(); + } + } + + 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..91e79f8f --- /dev/null +++ b/lib/features/mongodb/mongo_explorer_view.dart @@ -0,0 +1,433 @@ +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'; + +// ─── 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; + + // 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(); + + 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(() {}); + }, + ), + 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, + }); + + final List<_Crumb> crumbs; + final void Function(_Crumb) onCrumbTap; + final VoidCallback onRefresh; + + @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.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/test/core/database/mongodb_uri_replacement_test.dart b/test/core/database/mongodb_uri_replacement_test.dart index bcb2e060..f98fc459 100644 --- a/test/core/database/mongodb_uri_replacement_test.dart +++ b/test/core/database/mongodb_uri_replacement_test.dart @@ -11,13 +11,9 @@ void main() { username: 'root', password: 'root', ); - final baseUri = conn.buildConnectionUri(); - expect(baseUri, 'mongodb://root:root@127.0.0.1'); - - // Simulate the replacement logic used in listDatabases - final uri = Uri.parse(baseUri); - final adminUri = uri.replace(path: '/admin').toString(); - expect(adminUri, 'mongodb://root:root@127.0.0.1/admin'); + final adminUri = conn.buildUriForDatabase('admin'); + expect(adminUri, contains('/admin')); + expect(adminUri, contains('root:root')); }); test('replaces database in URI with existing database', () { @@ -29,16 +25,13 @@ void main() { password: 'root', database: 'mydb', ); - final baseUri = conn.buildConnectionUri(); - expect(baseUri, 'mongodb://root:root@127.0.0.1/mydb'); - - // Simulate the replacement logic - final uri = Uri.parse(baseUri); - final adminUri = uri.replace(path: '/admin').toString(); - expect(adminUri, 'mongodb://root:root@127.0.0.1/admin'); + 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 query parameters when replacing database', () { + test('preserves explicit authSource when replacing database', () { final conn = MongoConnection( id: 1, name: 'test', @@ -47,15 +40,9 @@ void main() { password: 'root', authSource: 'admin', ); - final baseUri = conn.buildConnectionUri(); - expect(baseUri, contains('?authSource=admin')); - - // Simulate the replacement logic - final uri = Uri.parse(baseUri); - final adminUri = uri.replace(path: '/admin').toString(); - expect(adminUri, contains('/admin')); - expect(adminUri, contains('?authSource=admin')); - expect(adminUri, 'mongodb://root:root@127.0.0.1/admin?authSource=admin'); + final otherUri = conn.buildUriForDatabase('mydb'); + expect(otherUri, contains('/mydb')); + expect(otherUri, contains('authSource=admin')); }); test('preserves multiple query parameters when replacing database', () { @@ -69,15 +56,11 @@ void main() { replicaSet: 'rs0', useSSL: true, ); - final baseUri = conn.buildConnectionUri(); - - // Simulate the replacement logic - final uri = Uri.parse(baseUri); - final adminUri = uri.replace(path: '/admin').toString(); - expect(adminUri, contains('/admin')); - expect(adminUri, contains('authSource=admin')); - expect(adminUri, contains('replicaSet=rs0')); - expect(adminUri, contains('ssl=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', () { @@ -87,13 +70,10 @@ void main() { host: 'localhost', database: 'olddb', ); - final baseUri = conn.buildConnectionUri(); - expect(baseUri, 'mongodb://localhost/olddb'); - - // Simulate the replacement logic used in listCollections - final uri = Uri.parse(baseUri); - final newDbUri = uri.replace(path: '/newdb').toString(); - expect(newDbUri, 'mongodb://localhost/newdb'); + final newDbUri = conn.buildUriForDatabase('newdb'); + expect(newDbUri, contains('/newdb')); + // No credentials → no authSource added + expect(newDbUri, isNot(contains('authSource'))); }); test('handles URI without authentication', () { @@ -102,13 +82,10 @@ void main() { name: 'test', host: 'localhost', ); - final baseUri = conn.buildConnectionUri(); - expect(baseUri, 'mongodb://localhost'); - - // Simulate the replacement logic - final uri = Uri.parse(baseUri); - final adminUri = uri.replace(path: '/admin').toString(); - expect(adminUri, 'mongodb://localhost/admin'); + 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', () { @@ -120,13 +97,9 @@ void main() { username: 'root', password: 'root', ); - final baseUri = conn.buildConnectionUri(); - expect(baseUri, 'mongodb://root:root@127.0.0.1:27018'); - - // Simulate the replacement logic - final uri = Uri.parse(baseUri); - final adminUri = uri.replace(path: '/admin').toString(); - expect(adminUri, 'mongodb://root:root@127.0.0.1:27018/admin'); + final adminUri = conn.buildUriForDatabase('admin'); + expect(adminUri, contains(':27018')); + expect(adminUri, contains('/admin')); }); test('handles connectionString URI replacement', () { @@ -134,15 +107,13 @@ void main() { id: 1, name: 'test', host: 'localhost', - connectionString: 'mongodb://user:pass@host:27017/mydb?authSource=admin', + connectionString: + 'mongodb://user:pass@host:27017/mydb?authSource=admin', ); - final baseUri = conn.buildConnectionUri(); - expect(baseUri, 'mongodb://user:pass@host:27017/mydb?authSource=admin'); - - // Simulate the replacement logic - final uri = Uri.parse(baseUri); - final adminUri = uri.replace(path: '/admin').toString(); - expect(adminUri, 'mongodb://user:pass@host:27017/admin?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', () { @@ -153,17 +124,69 @@ void main() { username: 'root', password: 'root', ); - final baseUri = conn.buildConnectionUri(); - - // Simulate the replacement logic - final uri = Uri.parse(baseUri); - final adminUri = uri.replace(path: '/admin').toString(); - - // Critical: ensure no literal $1 appears (the bug we fixed) + 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'))); + }); }); } From 26715b76c490492c82529b29c2784612c0e932b5 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 6 Mar 2026 15:57:59 +0300 Subject: [PATCH 2/2] feat: add Explorer/Statistics toggle in MongoDB breadcrumb bar - stats button (bar_chart icon) next to refresh in breadcrumb bar - MongoStatsView reuses parent connection, shows Explorer back button - seamless switching between data browsing and server statistics --- lib/features/mongodb/mongo_explorer_view.dart | 47 ++++++++++++--- lib/features/mongodb/mongo_stats_view.dart | 57 ++++++++++++++++--- 2 files changed, 89 insertions(+), 15 deletions(-) diff --git a/lib/features/mongodb/mongo_explorer_view.dart b/lib/features/mongodb/mongo_explorer_view.dart index 91e79f8f..abc3ddc6 100644 --- a/lib/features/mongodb/mongo_explorer_view.dart +++ b/lib/features/mongodb/mongo_explorer_view.dart @@ -9,6 +9,7 @@ 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 ────────────────────────────────────────────────── @@ -38,6 +39,9 @@ class _MongoExplorerViewState extends material.State { bool _connecting = true; String? _error; + // View mode + bool _showStats = false; + // Navigation state String? _selectedDatabase; String? _selectedCollection; @@ -237,6 +241,16 @@ class _MongoExplorerViewState extends material.State { 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( @@ -250,6 +264,7 @@ class _MongoExplorerViewState extends material.State { // Force rebuild of current child setState(() {}); }, + onStats: () => setState(() => _showStats = true), ), const Divider(height: 1), // Content @@ -314,11 +329,13 @@ class _BreadcrumbBar extends StatelessWidget { 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) { @@ -364,13 +381,29 @@ class _BreadcrumbBar extends StatelessWidget { ), ), const Gap(8), - 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), + 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), + ), ), ), ], diff --git a/lib/features/mongodb/mongo_stats_view.dart b/lib/features/mongodb/mongo_stats_view.dart index 308489ae..f3927e7a 100644 --- a/lib/features/mongodb/mongo_stats_view.dart +++ b/lib/features/mongodb/mongo_stats_view.dart @@ -12,9 +12,22 @@ const _summaryChipHeight = 72.0; const _gridCardHeight = 220.0; class MongoStatsView extends material.StatefulWidget { - const MongoStatsView({super.key, required this.connectionRow}); + 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(); } @@ -49,13 +62,17 @@ class _MongoStatsViewState extends material.State { 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) { + if (conn != null && _ownsConnection) { conn.disconnect(); // fire-and-forget; disconnect handles errors } + _ownsConnection = false; } Future _load() async { @@ -68,18 +85,31 @@ class _MongoStatsViewState extends material.State { _serverStatus = null; }); try { - final conn = MongoService.instance.createConnection(widget.connectionRow); - await conn.connect(); + // 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) { - // Widget was disposed while connecting — clean up immediately. - conn.disconnect(); + if (_ownsConnection) conn.disconnect(); return; } _connection = conn; await _fetch(); if (mounted) _startTimer(); } catch (e) { - if (mounted) setState(() { _error = e.toString(); _loading = false; }); + if (mounted) { + setState(() { + _error = e.toString(); + _loading = false; + }); + } } } @@ -244,9 +274,20 @@ class _MongoStatsViewState extends material.State { ], ), ), + 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), + leading: const material.Icon( + material.Icons.refresh_rounded, size: 18), child: const Text('Refresh'), ), ],