From b02cff5728822b4a8a63b5efecf6728c55bbc39f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 6 Mar 2026 00:03:18 +0300 Subject: [PATCH 1/9] fix: use Uri.parse() for MongoDB database replacement Replace regex-based URI manipulation with Uri.parse() and uri.replace() to correctly handle database path replacement. This fixes the bug where \ was interpreted as a literal string instead of a regex capture group, causing errors like 'Could not connect to admin\'. - mongodb_connection.dart: Use Uri.parse() in listDatabases() and listCollections() - mongodb_service.dart: Use Uri.parse() in executeCommand(), find(), and aggregate() - Preserves query parameters (authSource, replicaSet, ssl) when replacing database --- lib/core/database/mongodb_connection.dart | 19 +++++++++++------ lib/core/database/mongodb_service.dart | 25 ++++++++++++++++++----- 2 files changed, 33 insertions(+), 11 deletions(-) diff --git a/lib/core/database/mongodb_connection.dart b/lib/core/database/mongodb_connection.dart index 254076aa..5cf067c5 100644 --- a/lib/core/database/mongodb_connection.dart +++ b/lib/core/database/mongodb_connection.dart @@ -98,10 +98,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 +122,9 @@ class MongoConnection { try { // Switch to admin database to list all databases - final adminUri = buildConnectionUri().replaceAll(RegExp(r'/[^/?]*(\?|$)'), '/admin\$1'); + final baseUri = buildConnectionUri(); + final uri = Uri.parse(baseUri); + final adminUri = uri.replace(path: '/admin').toString(); final adminDb = await Db.create(adminUri); await adminDb.open(); try { @@ -147,7 +152,9 @@ class MongoConnection { try { // Create a new Db connection to the specified database - final dbUri = buildConnectionUri().replaceAll(RegExp(r'/[^/?]*(\?|$)'), '/$databaseName\$1'); + final baseUri = buildConnectionUri(); + final uri = Uri.parse(baseUri); + final dbUri = uri.replace(path: '/$databaseName').toString(); 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..35e6c409 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, @@ -67,7 +76,9 @@ class MongoService { } // Create a new Db connection to the specified database - final dbUri = connection.buildConnectionUri().replaceAll(RegExp(r'/[^/?]*(\?|$)'), '/$database\$1'); + 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 { @@ -94,7 +105,9 @@ class MongoService { } // Create a new Db connection to the specified database - final dbUri = connection.buildConnectionUri().replaceAll(RegExp(r'/[^/?]*(\?|$)'), '/$database\$1'); + 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 { @@ -133,7 +146,9 @@ class MongoService { } // Create a new Db connection to the specified database - final dbUri = connection.buildConnectionUri().replaceAll(RegExp(r'/[^/?]*(\?|$)'), '/$database\$1'); + 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 { From 6800c7a674ed0e998657f7605a212e38b7dbbe54 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 6 Mar 2026 00:03:21 +0300 Subject: [PATCH 2/9] feat: add MongoDB stats view similar to Redis stats Create MongoStatsView widget that displays MongoDB server statistics in a card-based UI similar to RedisView. Features: - Auto-refresh every 3 seconds - Summary chips (Version, Uptime, Connections, Queries) - Memory, Operations, Connections, Network cards - Server, Storage, Replication, WiredTiger sections - Safe connection management with proper cleanup on dispose --- lib/features/mongodb/mongo_stats_view.dart | 534 +++++++++++++++++++++ 1 file changed, 534 insertions(+) create mode 100644 lib/features/mongodb/mongo_stats_view.dart diff --git a/lib/features/mongodb/mongo_stats_view.dart b/lib/features/mongodb/mongo_stats_view.dart new file mode 100644 index 00000000..308489ae --- /dev/null +++ b/lib/features/mongodb/mongo_stats_view.dart @@ -0,0 +1,534 @@ +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}); + final ConnectionRow connectionRow; + + @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(); + } + + /// Safely disconnects and clears the current MongoDB connection. + void _disconnectCurrent() { + final conn = _connection; + _connection = null; + if (conn != null) { + conn.disconnect(); // fire-and-forget; disconnect handles errors + } + } + + Future _load() async { + _timer?.cancel(); + _disconnectCurrent(); + if (!mounted) return; + setState(() { + _loading = true; + _error = null; + _serverStatus = null; + }); + try { + final conn = MongoService.instance.createConnection(widget.connectionRow); + await conn.connect(); + if (!mounted) { + // Widget was disposed while connecting — clean up immediately. + 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(), + ], + ), + ), + 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'; + } +} From 1619648f46be9127413569691aac5308237faa95 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 6 Mar 2026 00:03:24 +0300 Subject: [PATCH 3/9] feat: show MongoStatsView for MongoDB connections Update workspace_panel to display MongoStatsView instead of MongoDatabasesView when a MongoDB connection is selected, providing server statistics similar to Redis connections. --- lib/features/main_screen/workspace_panel.dart | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/lib/features/main_screen/workspace_panel.dart b/lib/features/main_screen/workspace_panel.dart index f2345118..fc96d0f1 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_stats_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 stats view 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: MongoStatsView( + key: ValueKey(widget.activeConnection!.id), + connectionRow: widget.activeConnection!, + ), + ), ); } From 26b0a0237c32ee37d5ac8e29394d98b08f1ae458 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 6 Mar 2026 00:03:26 +0300 Subject: [PATCH 4/9] test: add comprehensive tests for MongoDB URI replacement Add unit tests to verify correct database path replacement in MongoDB URIs: - Replaces database in URI without existing database - Replaces database in URI with existing database - Preserves query parameters (authSource, replicaSet, ssl) - Handles various URI formats (with/without auth, custom ports, connection strings) - Ensures no literal \ appears in final URI (the bug we fixed) --- .../mongodb_uri_replacement_test.dart | 169 ++++++++++++++++++ 1 file changed, 169 insertions(+) create mode 100644 test/core/database/mongodb_uri_replacement_test.dart 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..bcb2e060 --- /dev/null +++ b/test/core/database/mongodb_uri_replacement_test.dart @@ -0,0 +1,169 @@ +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 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'); + }); + + 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 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'); + }); + + test('preserves query parameters when replacing database', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: '127.0.0.1', + username: 'root', + 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'); + }); + + 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 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')); + }); + + test('replaces database with custom database name', () { + final conn = MongoConnection( + id: 1, + name: 'test', + 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'); + }); + + test('handles URI without authentication', () { + final conn = MongoConnection( + id: 1, + 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'); + }); + + 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 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'); + }); + + test('handles connectionString URI replacement', () { + final conn = MongoConnection( + id: 1, + name: 'test', + host: 'localhost', + 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'); + }); + + 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 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) + expect(adminUri, isNot(contains(r'$1'))); + expect(adminUri, isNot(contains('admin\$1'))); + expect(adminUri, isNot(contains(r'admin$1'))); + expect(adminUri, contains('/admin')); + }); + }); +} From f5dbccde9bf44e94cbdcf68f8449d83d13907ac7 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 6 Mar 2026 15:29:10 +0300 Subject: [PATCH 5/9] Fix MongoDB URI database replacement bug and update workspace panel - Fix critical bug where database path replacement used regex incorrectly, causing literal '' to appear in connection URIs - Replace regex-based URI manipulation with Uri.parse() and uri.replace() for reliable path replacement while preserving query parameters - Update workspace_panel to use MongoStatsView for MongoDB connections - Update .flutter-plugins-dependencies --- .flutter-plugins-dependencies | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies index f1aa1ebf..a2697a14 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-05 23:51:56.159118","version":"3.38.5","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file From 88c78e633a9c08b6837679fa39e923df61b211bc Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Fri, 6 Mar 2026 15:55:55 +0300 Subject: [PATCH 6/9] 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 7/9] 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'), ), ], From 4c8c396e023cb69ddce40a6b0a9d0620da961d5f Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 7 Mar 2026 10:36:44 +0300 Subject: [PATCH 8/9] fix: UI text overflow and document preview improvements - connection panel: ellipsis on long name/host text - folder name: ellipsis on overflow - document cards: show only keys when collapsed, full JSON when expanded - disable horizontal scroll on collapsed document preview --- .flutter-plugins-dependencies | 1 - .../connections/connections_panel.dart | 20 +++++++-- .../mongodb/mongo_documents_view.dart | 41 +++++++++++-------- 3 files changed, 42 insertions(+), 20 deletions(-) delete mode 100644 .flutter-plugins-dependencies diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies deleted file mode 100644 index a2697a14..00000000 --- a/.flutter-plugins-dependencies +++ /dev/null @@ -1 +0,0 @@ -{"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 23:51:56.159118","version":"3.38.5","swift_package_manager_enabled":{"ios":false,"macos":false}} \ No newline at end of file diff --git a/lib/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 46e504ea..4b5673ff 100644 --- a/lib/features/connections/connections_panel.dart +++ b/lib/features/connections/connections_panel.dart @@ -342,9 +342,17 @@ class _ConnectionTile extends StatelessWidget { crossAxisAlignment: material.CrossAxisAlignment.start, mainAxisSize: material.MainAxisSize.min, children: [ - Text(connection.name).small(), + Text( + connection.name, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ).small(), if (connection.host != null) - Text('${connection.host}:${connection.port ?? ''}').muted().xSmall(), + Text( + '${connection.host}:${connection.port ?? ''}', + overflow: TextOverflow.ellipsis, + maxLines: 1, + ).muted().xSmall(), ], ), ), @@ -424,7 +432,13 @@ 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()), + Expanded( + child: Text( + name, + overflow: TextOverflow.ellipsis, + maxLines: 1, + ).small(), + ), ], ), ), diff --git a/lib/features/mongodb/mongo_documents_view.dart b/lib/features/mongodb/mongo_documents_view.dart index c785a6fe..0ed8782e 100644 --- a/lib/features/mongodb/mongo_documents_view.dart +++ b/lib/features/mongodb/mongo_documents_view.dart @@ -398,7 +398,7 @@ class _DocumentCardState extends State<_DocumentCard> { final cs = widget.colorScheme; final scs = widget.shadcnCs; final idStr = widget.document['_id']?.toString() ?? '—'; - final preview = _compactJson(widget.document); + final keysPreview = _keysPreview(widget.document); return material.MouseRegion( onEnter: (_) => setState(() => _hovered = true), @@ -473,15 +473,25 @@ class _DocumentCardState extends State<_DocumentCard> { 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, - ), + 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, + ), + ), ), ], ), @@ -489,12 +499,11 @@ class _DocumentCardState extends State<_DocumentCard> { ); } - String _compactJson(Map doc) { - try { - return json.encode(doc); - } catch (_) { - return doc.toString(); - } + /// 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) { From 390cf39959259dcdd8d5be0d30035db7bbc19cb7 Mon Sep 17 00:00:00 2001 From: ZhuchkaTriplesix Date: Sat, 7 Mar 2026 10:40:06 +0300 Subject: [PATCH 9/9] fix: text overflow in connections panel - use material.Text directly to ensure TextOverflow.ellipsis works - shadcn extensions (.small()/.muted()) were wrapping and losing overflow --- .flutter-plugins-dependencies | 1 + .../connections/connections_panel.dart | 38 ++++++++++++------- 2 files changed, 26 insertions(+), 13 deletions(-) create mode 100644 .flutter-plugins-dependencies diff --git a/.flutter-plugins-dependencies b/.flutter-plugins-dependencies new file mode 100644 index 00000000..18d87a5c --- /dev/null +++ b/.flutter-plugins-dependencies @@ -0,0 +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-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/features/connections/connections_panel.dart b/lib/features/connections/connections_panel.dart index 4b5673ff..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,22 +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( + material.Text( connection.name, - overflow: TextOverflow.ellipsis, + overflow: material.TextOverflow.ellipsis, maxLines: 1, - ).small(), + style: material.TextStyle( + fontSize: 13, + color: theme.colorScheme.foreground, + ), + ), if (connection.host != null) - Text( + material.Text( '${connection.host}:${connection.port ?? ''}', - overflow: TextOverflow.ellipsis, + overflow: material.TextOverflow.ellipsis, maxLines: 1, - ).muted().xSmall(), + style: material.TextStyle( + fontSize: 11, + color: theme.colorScheme.mutedForeground, + ), + ), ], ), ), @@ -432,12 +440,16 @@ 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( + material.Expanded( + child: material.Text( name, - overflow: TextOverflow.ellipsis, + overflow: material.TextOverflow.ellipsis, maxLines: 1, - ).small(), + style: material.TextStyle( + fontSize: 13, + color: theme.colorScheme.foreground, + ), + ), ), ], ),